diff --git a/Sources/Adwaita/View/Dialogs/AboutDialog.swift b/Sources/Adwaita/View/Dialogs/AboutDialog.swift index e2088e6..7def381 100644 --- a/Sources/Adwaita/View/Dialogs/AboutDialog.swift +++ b/Sources/Adwaita/View/Dialogs/AboutDialog.swift @@ -91,11 +91,15 @@ struct AboutDialog: Widget { extension View { - /// Add a dialog to the parent window. + /// Add an about dialog to the parent window. /// - Parameters: /// - visible: Whether the dialog is presented. - /// - title: The dialog's title. - /// - content: The dialog's content. + /// - app: The app's name. + /// - developer: The developer's name. + /// - version: The version string. + /// - icon: The app icon. + /// - website: The app's website. + /// - issues: Website for reporting issues. public func aboutDialog( visible: Binding, app: String? = nil, diff --git a/Sources/Adwaita/View/Dialogs/AlertDialog.swift b/Sources/Adwaita/View/Dialogs/AlertDialog.swift new file mode 100644 index 0000000..d6afd70 --- /dev/null +++ b/Sources/Adwaita/View/Dialogs/AlertDialog.swift @@ -0,0 +1,255 @@ +// +// AlertDialog.swift +// Adwaita +// +// Created by david-swift on 05.04.24. +// + +import CAdw +import LevenshteinTransformations + +/// The message dialog widget. +public struct AlertDialog: Widget { + + /// The ID for the dialog's storage. + static let dialogID = "alert-dialog" + /// The ID for the dialog's responses' storage. + static let responsesID = "responses" + /// The ID for the visibility binding. + static let visibleID = "visible" + /// The ID for the callbacks. + static let callbacks = "callbacks" + + /// Whether the dialog is visible. + @Binding var visible: Bool + /// An identifier used if multiple dialogs are on one view. + var id: String + /// The dialog's title. + var heading: String + /// The body text. + var body: String + /// The available responses. + var responses: [Response] = [] + /// The child view. + var child: View + + /// Information about a response. + struct Response: Identifiable { + + /// The title. + var title: String + /// The identifier. + var id: String { title } + /// The appearance. + var appearance: ResponseAppearance + /// The function for the keyboard shortcut, or no shortcut. + var role: ResponseRole? + /// The callback. + var action: () -> Void + + } + + /// The appearance of the response. + public enum ResponseAppearance { + + /// The regular appearance. + case `default` + /// The suggested appearance. + case suggested + /// The destructive appearance. + case destructive + + } + + /// The role of the response, determining a keyboard shortcut. + public enum ResponseRole { + + /// The close role. + case close + /// The default role. + case `default` + + } + + /// Get the container of the child. + /// - Parameter modifiers: Modify views before being updated. + /// - Returns: The view storage. + public func container(modifiers: [(View) -> View]) -> ViewStorage { + let storage = child.storage(modifiers: modifiers) + storage.fields[Self.visibleID + id] = _visible + update(storage, modifiers: modifiers, updateProperties: true) + return storage + } + + /// Update the view storage of the child, dialog, and dialog content. + /// - Parameters: + /// - storage: The view storage. + /// - modifiers: Modify views before being updated. + /// - updateProperties: Whether to update properties. + public func update(_ storage: ViewStorage, modifiers: [(View) -> View], updateProperties: Bool) { + child.widget(modifiers: modifiers).update(storage, modifiers: modifiers, updateProperties: updateProperties) + guard updateProperties else { + return + } + if visible { + var present = false + if storage.content[Self.dialogID + id]?.first == nil { + createDialog(storage: storage, modifiers: modifiers) + present = true + } + let pointer = storage.content[Self.dialogID + id]?.first?.pointer + adw_alert_dialog_set_heading(pointer?.cast(), heading) + adw_alert_dialog_set_body(pointer?.cast(), body) + let old = storage.fields[Self.responsesID + id] as? [Response] ?? [] + old.identifiableTransform( + to: responses, + functions: .init { index, element in + adw_alert_dialog_remove_response(pointer?.cast(), responseID(old[safe: index]?.id)) + adw_alert_dialog_add_response(pointer?.cast(), responseID(element.id), element.title) + } delete: { index in + adw_alert_dialog_remove_response(pointer?.cast(), responseID(old[safe: index]?.id)) + } insert: { _, element in + adw_alert_dialog_add_response(pointer?.cast(), responseID(element.id), element.title) + } + ) + storage.fields[Self.responsesID + id] = responses + var handlers: [String: () -> Void] = [:] + for response in responses { + handlers[responseID(response.id) ?? ""] = response.action + } + storage.fields[Self.callbacks + id] = handlers + responsesCosmetics(pointer: pointer) + if present { + gtui_alertdialog_choose( + .init(Int(bitPattern: pointer)), + unsafeBitCast(storage, to: UInt64.self), + .init(Int(bitPattern: storage.pointer)) + ) + } + } else { + if storage.content[Self.dialogID + id]?.first != nil { + adw_dialog_close(storage.content[Self.dialogID + id]?.first?.pointer?.cast()) + } + } + } + + /// Style the responses and add shortcuts if required. + /// - Parameter pointer: The pointer. + func responsesCosmetics(pointer: OpaquePointer?) { + for element in responses { + switch element.appearance { + case .default: + adw_alert_dialog_set_response_appearance( + pointer?.cast(), + responseID(element.id), + ADW_RESPONSE_DEFAULT + ) + case .suggested: + adw_alert_dialog_set_response_appearance( + pointer?.cast(), + responseID(element.id), + ADW_RESPONSE_SUGGESTED + ) + case .destructive: + adw_alert_dialog_set_response_appearance( + pointer?.cast(), + responseID(element.id), + ADW_RESPONSE_DESTRUCTIVE + ) + } + } + if let closeResponse = responses.first(where: { $0.role == .close }) ?? responses.first { + adw_alert_dialog_set_close_response(pointer?.cast(), responseID(closeResponse.id)) + } + if let defaultResponse = responses.first(where: { $0.role == .default }) { + adw_alert_dialog_set_default_response(pointer?.cast(), responseID(defaultResponse.id)) + } + } + + /// Create a new instance of the dialog. + /// - Parameters: + /// - storage: The wrapped view's storage. + /// - modifiers: The view modifiers. + func createDialog(storage: ViewStorage, modifiers: [(View) -> View]) { + let pointer = adw_alert_dialog_new(nil, nil) + let dialog = ViewStorage(pointer?.opaque()) + storage.content[Self.dialogID + id] = [dialog] + } + + /// Get the identifier of a response which is combined with the dialog's id. + /// - Parameter id: The response identifier. + /// - Returns: The new identifier. + func responseID(_ id: String?) -> String? { + if let id { + return self.id + "...." + id + } + return nil + } + + /// Add a response to the alert dialog. + /// - Parameters: + /// - title: The response. + /// - appearance: The response's appearance. + /// - role: The response's shortcut, if any. + /// - action: The + public func response( + _ title: String, + appearance: ResponseAppearance = .default, + role: ResponseRole? = nil, + action: @escaping () -> Void + ) -> Self { + var newSelf = self + newSelf.responses.append(.init(title: title, appearance: appearance, role: role, action: action)) + return newSelf + } + +} + +extension View { + + /// Add an alert dialog to the parent window. + /// - Parameters: + /// - visible: Whether the dialog is presented. + /// - heading: The heading. + /// - body: The body text. + public func alertDialog( + visible: Binding, + heading: String, + body: String = "", + id: String? = nil + ) -> AlertDialog { + .init( + visible: visible, + id: id ?? "no-id", + heading: heading, + body: body, + child: self + ) + } + +} + +/// Run when an alert dialog closes. +/// - Parameters: +/// - ptr: The pointer. +/// - answer: The identifier of the answer. +/// - userData: The alert dialog data. +@_cdecl("alertdialog_on_close_cb") +func alertdialog_on_close_cb( + ptr: UnsafeMutableRawPointer, + answer: UnsafePointer?, + userData: UnsafeMutableRawPointer +) { + let storage = Unmanaged.fromOpaque(userData).takeUnretainedValue() + var id = "" + if let answer { + let answer = String(cString: answer) + id = .init(answer.split(separator: "....").first ?? "") + (storage.fields[AlertDialog.callbacks + id] as? [String: () -> Void])?[answer]?() + } + storage.content[AlertDialog.dialogID + id] = [] + storage.fields[AlertDialog.responsesID + id] = [] + if let visible = storage.fields[AlertDialog.visibleID + id] as? Binding, visible.wrappedValue { + visible.wrappedValue = false + } +} diff --git a/Sources/CAdw/shim.h b/Sources/CAdw/shim.h index 0ea21e3..33f2be2 100644 --- a/Sources/CAdw/shim.h +++ b/Sources/CAdw/shim.h @@ -5,6 +5,8 @@ static void filedialog_on_open_cb (void *, void *, void *); static void filedialog_on_save_cb (void *, void *, void *); +static void +alertdialog_on_close_cb (void *, void *, void *); static void gtui_filedialog_save_finish (uint64_t dialog, uint64_t result, uint64_t data) @@ -35,3 +37,17 @@ gtui_filedialog_open (uint64_t dialog, uint64_t data, uint64_t window) swift_retain (data); gtk_file_dialog_open (dialog, window, NULL, G_CALLBACK (gtui_filedialog_open_finish), (void *)data); } + +static void +gtui_alertdialog_cb (uint64_t dialog, uint64_t result, uint64_t data) +{ + const char *response = adw_alert_dialog_choose_finish (dialog, result); + alertdialog_on_close_cb (dialog, response, data); +} + +static void +gtui_alertdialog_choose (uint64_t dialog, uint64_t data, uint64_t parent) +{ + adw_alert_dialog_choose (dialog, parent, NULL, gtui_alertdialog_cb, data); +} + diff --git a/Tests/AlertDialogDemo.swift b/Tests/AlertDialogDemo.swift new file mode 100644 index 0000000..348c8ab --- /dev/null +++ b/Tests/AlertDialogDemo.swift @@ -0,0 +1,37 @@ +// +// AlertDialogDemo.swift +// Adwaita +// +// Created by david-swift on 05.04.24. +// + +// swiftlint:disable missing_docs + +import Adwaita + +struct AlertDialogDemo: View { + + @State private var dialog = false + let padding = 20 + + var view: Body { + VStack { + Button("Show Dialog") { + dialog = true + } + .style("pill") + .frame(maxSize: 100) + .padding() + } + .alertDialog(visible: $dialog, heading: "Alert Dialog", body: "This is an alert dialog") + .response("Cancel", role: .close) { + print("Cancel") + } + .response("Done", appearance: .suggested, role: .default) { + print("Done") + } + } + +} + +// swiftlint:enable missing_docs diff --git a/Tests/Page.swift b/Tests/Page.swift index 473517c..7b42b5a 100644 --- a/Tests/Page.swift +++ b/Tests/Page.swift @@ -18,6 +18,7 @@ enum Page: String, Identifiable, CaseIterable, Codable { case transition case dice case dialog + case alertDialog case toast case list case carousel @@ -39,6 +40,8 @@ enum Page: String, Identifiable, CaseIterable, Codable { return "Flow Box" case .navigationView: return "Navigation View" + case .alertDialog: + return "Alert Dialog" default: return rawValue.capitalized } @@ -69,6 +72,8 @@ enum Page: String, Identifiable, CaseIterable, Codable { return "Roll the dice" case .dialog: return "A window on top of another window" + case .alertDialog: + return "A dialog presenting a message or question" case .toast: return "Show a notification inside of your app" case .list: @@ -106,6 +111,8 @@ enum Page: String, Identifiable, CaseIterable, Codable { DiceDemo() case .dialog: DialogDemo() + case .alertDialog: + AlertDialogDemo() case .toast: ToastDemo(toast: toast) case .list: