diff --git a/Examples/Controls/Controls.swift b/Examples/Controls/Controls.swift index 901c876a..53c9f457 100644 --- a/Examples/Controls/Controls.swift +++ b/Examples/Controls/Controls.swift @@ -4,6 +4,7 @@ import SwiftCrossUI class ControlsState: Observable { @Observed var count = 0 @Observed var exampleButtonState = false + @Observed var exampleSwitchState = false } @main @@ -14,20 +15,39 @@ struct ControlsApp: App { let state = ControlsState() - let windowProperties = WindowProperties(title: "ControlsApp", resizable: true) + let windowProperties = WindowProperties( + title: "ControlsApp", + resizable: true + ) var body: some View { - VStack(spacing: 5) { - Text("Button") - Button("Click me!") { - state.count += 1 + HStack { + VStack { + Text("Button") + Button("Click me!") { + state.count += 1 + } + Text("Count: \(state.count)", wrap: false) + Spacer() } - Text("Count: \(state.count)", wrap: false) Spacer() - .padding(.bottom, 15) - Text("Toggle (Button Style)") - Toggle("Toggle me!", active: state.$exampleButtonState) - Text("Currently enabled: \(state.exampleButtonState)") + VStack { + Text("Toggle (Button Style)") + Toggle("Toggle me!", active: state.$exampleButtonState) + .toggleStyle(.button) + Text("Currently enabled: \(state.exampleButtonState)") + Spacer() + .padding(.bottom, 10) + Text("Toggle (Switch Style)") + HStack { + Toggle("Toggle me:", active: state.$exampleSwitchState) + .toggleStyle(.switch) + Spacer() + .padding(.bottom, 10) + } + Text("Currently enabled: \(state.exampleSwitchState)") + Spacer() + } } .padding(10) } diff --git a/Sources/Gtk/Generated/Switch.swift b/Sources/Gtk/Generated/Switch.swift new file mode 100644 index 00000000..ddcef457 --- /dev/null +++ b/Sources/Gtk/Generated/Switch.swift @@ -0,0 +1,153 @@ +import CGtk + +/// `GtkSwitch` is a "light switch" that has two states: on or off. +/// +/// ![An example GtkSwitch](switch.png) +/// +/// The user can control which state should be active by clicking the +/// empty area, or by dragging the handle. +/// +/// `GtkSwitch` can also handle situations where the underlying state +/// changes with a delay. In this case, the slider position indicates +/// the user's recent change (as indicated by the [property@Gtk.Switch:active] +/// property), and the color indicates whether the underlying state (represented +/// by the [property@Gtk.Switch:state] property) has been updated yet. +/// +/// ![GtkSwitch with delayed state change](switch-state.png) +/// +/// See [signal@Gtk.Switch::state-set] for details. +/// +/// # CSS nodes +/// +/// ``` +/// switch +/// ├── image +/// ├── image +/// ╰── slider +/// ``` +/// +/// `GtkSwitch` has four css nodes, the main node with the name switch and +/// subnodes for the slider and the on and off images. Neither of them is +/// using any style classes. +/// +/// # Accessibility +/// +/// `GtkSwitch` uses the %GTK_ACCESSIBLE_ROLE_SWITCH role. +public class Switch: Widget, Actionable { + /// Creates a new `GtkSwitch` widget. + override public init() { + super.init() + widgetPointer = gtk_switch_new() + } + + override func didMoveToParent() { + removeSignals() + + super.didMoveToParent() + + addSignal(name: "activate") { [weak self] () in + guard let self = self else { return } + self.activate?(self) + } + + let handler1: + @convention(c) (UnsafeMutableRawPointer, Bool, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "state-set", handler: gCallback(handler1)) { [weak self] (_: Bool) in + guard let self = self else { return } + self.stateSet?(self) + } + + let handler2: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::active", handler: gCallback(handler2)) { + [weak self] (_: OpaquePointer) in + guard let self = self else { return } + self.notifyActive?(self) + } + + let handler3: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::state", handler: gCallback(handler3)) { + [weak self] (_: OpaquePointer) in + guard let self = self else { return } + self.notifyState?(self) + } + + let handler4: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::action-name", handler: gCallback(handler4)) { + [weak self] (_: OpaquePointer) in + guard let self = self else { return } + self.notifyActionName?(self) + } + + let handler5: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::action-target", handler: gCallback(handler5)) { + [weak self] (_: OpaquePointer) in + guard let self = self else { return } + self.notifyActionTarget?(self) + } + } + + /// Whether the `GtkSwitch` widget is in its on or off state. + @GObjectProperty(named: "active") public var active: Bool + + /// The backend state that is controlled by the switch. + /// + /// See [signal@Gtk.Switch::state-set] for details. + @GObjectProperty(named: "state") public var state: Bool + + @GObjectProperty(named: "action-name") public var actionName: String? + + /// Emitted to animate the switch. + /// + /// Applications should never connect to this signal, + /// but use the [property@Gtk.Switch:active] property. + public var activate: ((Switch) -> Void)? + + /// Emitted to change the underlying state. + /// + /// The ::state-set signal is emitted when the user changes the switch + /// position. The default handler keeps the state in sync with the + /// [property@Gtk.Switch:active] property. + /// + /// To implement delayed state change, applications can connect to this + /// signal, initiate the change of the underlying state, and call + /// [method@Gtk.Switch.set_state] when the underlying state change is + /// complete. The signal handler should return %TRUE to prevent the + /// default handler from running. + /// + /// Visually, the underlying state is represented by the trough color of + /// the switch, while the [property@Gtk.Switch:active] property is + /// represented by the position of the switch. + public var stateSet: ((Switch) -> Void)? + + public var notifyActive: ((Switch) -> Void)? + + public var notifyState: ((Switch) -> Void)? + + public var notifyActionName: ((Switch) -> Void)? + + public var notifyActionTarget: ((Switch) -> Void)? +} diff --git a/Sources/GtkBackend/GtkBackend.swift b/Sources/GtkBackend/GtkBackend.swift index 33ddfcea..7866f390 100644 --- a/Sources/GtkBackend/GtkBackend.swift +++ b/Sources/GtkBackend/GtkBackend.swift @@ -168,8 +168,8 @@ public struct GtkBackend: AppBackend { } public func createToggle( - label: String, - active: Bool, + label: String, + active: Bool, onChange: @escaping (Bool) -> Void ) -> Widget { let toggle = ToggleButton() @@ -188,6 +188,25 @@ public struct GtkBackend: AppBackend { } } + public func createSwitch(active: Bool, onChange: @escaping (Bool) -> Void) -> Widget { + let switchWidget = Switch() + switchWidget.active = active + switchWidget.notifyActive = { widget in + onChange(widget.active) + } + return switchWidget + } + + public func setIsActive(ofSwitch switchWidget: Widget, to active: Bool) { + (switchWidget as! Gtk.Switch).active = active + } + + public func setOnChange(ofSwitch switchWidget: Widget, to onChange: @escaping (Bool) -> Void) { + (switchWidget as! Gtk.Switch).notifyActive = { widget in + onChange(widget.active) + } + } + public func createTextView(content: String, shouldWrap: Bool) -> Widget { let label = Label(string: content) label.lineWrapMode = .wordCharacter diff --git a/Sources/GtkCodeGen/GtkCodeGen.swift b/Sources/GtkCodeGen/GtkCodeGen.swift index 5b4f909c..0f8e6f00 100644 --- a/Sources/GtkCodeGen/GtkCodeGen.swift +++ b/Sources/GtkCodeGen/GtkCodeGen.swift @@ -49,7 +49,7 @@ struct GtkCodeGen { static func generateSources(for gir: GIR, to directory: URL) throws { let allowListedClasses = [ "Button", "Entry", "Label", "TextView", "Range", "Scale", "Image", "DropDown", - "Picture", + "Picture", "Switch", ] for class_ in gir.namespace.classes where allowListedClasses.contains(class_.name) { let source = generateClass(class_, namespace: gir.namespace) diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index 5c9d5c1f..008f9a81 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -106,6 +106,14 @@ public protocol AppBackend { /// The change handler is called whenever the button is toggled on or off. func setOnChange(ofToggle toggle: Widget, to onChange: @escaping (Bool) -> Void) + /// Creates a switch that is either on or off. Predominantly used by ``Switch`` + func createSwitch(active: Bool, onChange: @escaping (Bool) -> Void) -> Widget + /// Sets the state of the switch to active or not. + func setIsActive(ofSwitch switchWidget: Widget, to active: Bool) + /// Sets the change handler of a switch (replaces any existing change handlers). + /// The change handler is called whenever the switch is turned on or off. + func setOnChange(ofSwitch switchWidget: Widget, to onChange: @escaping (Bool) -> Void) + /// Creates a non-editable text view with optional text wrapping. Predominantly used /// by ``Text``.` func createTextView(content: String, shouldWrap: Bool) -> Widget @@ -386,8 +394,8 @@ extension AppBackend { } public func createToggle( - label: String, - active: Bool, + label: String, + active: Bool, onChange: @escaping (Bool) -> Void ) -> Widget { todo("createToggle not implemented") @@ -399,6 +407,16 @@ extension AppBackend { todo("setOnChange not implemented") } + public func createSwitch(active: Bool, onChange: @escaping (Bool) -> Void) -> Widget { + todo("createSwitch not implemented") + } + public func setIsActive(ofSwitch switchWidget: Widget, to active: Bool) { + todo("setIsActive not implemented") + } + public func setOnChange(ofSwitch switchWidget: Widget, to onChange: @escaping (Bool) -> Void) { + todo("setOnChange not implemented") + } + public func createTextView(content: String, shouldWrap: Bool) -> Widget { todo("createTextView not implemented") } diff --git a/Sources/SwiftCrossUI/Views/Toggle.swift b/Sources/SwiftCrossUI/Views/Toggle.swift index ec83f932..b9e1b252 100644 --- a/Sources/SwiftCrossUI/Views/Toggle.swift +++ b/Sources/SwiftCrossUI/Views/Toggle.swift @@ -1,36 +1,41 @@ -/// A control that is either on or off. -public struct Toggle: ElementaryView, View { - /// The label to show on the toggle button. - private var label: String - /// Whether the button is active or not. - private var active: Binding +public struct Toggle: View { + /// The style of toggle shown. + var selectedToggleStyle: ToggleStyle + /// The label to be shown on or beside the toggle. + var label: String + /// Whether the toggle is active or not. + var active: Binding - /// Creates a toggle button that displays a custom label. + /// Creates a toggle that displays a custom label. public init(_ label: String, active: Binding) { + self.selectedToggleStyle = .button self.label = label self.active = active } - public func asWidget( - backend: Backend - ) -> Backend.Widget { - return backend.createToggle( - label: label, - active: active.wrappedValue, - onChange: { newValue in - self.active.wrappedValue = newValue - } - ) + public var body: some View { + switch selectedToggleStyle { + case .switch: + HStack { + Text(label) + ToggleSwitch(active: active) + } + case .button: + ToggleButton(label, active: active) + } } +} - public func update( - _ widget: Backend.Widget, - backend: Backend - ) { - backend.setLabel(ofButton: widget, to: label) - backend.setIsActive(ofToggle: widget, to: active.wrappedValue) - backend.setOnChange(ofToggle: widget) { newActiveState in - active.wrappedValue = newActiveState - } +extension Toggle { + /// Sets the style of the toggle. + public func toggleStyle(_ selectedToggleStyle: ToggleStyle) -> Toggle { + var toggle = self + toggle.selectedToggleStyle = selectedToggleStyle + return toggle } } + +public enum ToggleStyle { + case `switch` + case button +} diff --git a/Sources/SwiftCrossUI/Views/ToggleButton.swift b/Sources/SwiftCrossUI/Views/ToggleButton.swift new file mode 100644 index 00000000..7fafa653 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/ToggleButton.swift @@ -0,0 +1,36 @@ +/// A button style control that is either on or off. +struct ToggleButton: ElementaryView, View { + /// The label to show on the toggle button. + private var label: String + /// Whether the button is active or not. + private var active: Binding + + /// Creates a toggle button that displays a custom label. + public init(_ label: String, active: Binding) { + self.label = label + self.active = active + } + + public func asWidget( + backend: Backend + ) -> Backend.Widget { + return backend.createToggle( + label: label, + active: active.wrappedValue, + onChange: { newValue in + self.active.wrappedValue = newValue + } + ) + } + + public func update( + _ widget: Backend.Widget, + backend: Backend + ) { + backend.setLabel(ofButton: widget, to: label) + backend.setIsActive(ofToggle: widget, to: active.wrappedValue) + backend.setOnChange(ofToggle: widget) { newActiveState in + active.wrappedValue = newActiveState + } + } +} diff --git a/Sources/SwiftCrossUI/Views/ToggleSwitch.swift b/Sources/SwiftCrossUI/Views/ToggleSwitch.swift new file mode 100644 index 00000000..2c905b55 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/ToggleSwitch.swift @@ -0,0 +1,31 @@ +/// A light switch style control that is either on or off. +struct ToggleSwitch: ElementaryView, View { + /// Whether the switch is active or not. + private var active: Binding + + /// Creates a switch. + public init(active: Binding) { + self.active = active + } + + public func asWidget( + backend: Backend + ) -> Backend.Widget { + return backend.createSwitch( + active: active.wrappedValue, + onChange: { newValue in + self.active.wrappedValue = newValue + } + ) + } + + public func update( + _ widget: Backend.Widget, + backend: Backend + ) { + backend.setIsActive(ofSwitch: widget, to: active.wrappedValue) + backend.setOnChange(ofSwitch: widget) { newActiveState in + active.wrappedValue = newActiveState + } + } +}