From 7c651e206435aedff19ac65e65a150e437d8bc53 Mon Sep 17 00:00:00 2001 From: Brendan Szymanski Date: Tue, 11 Aug 2026 18:17:01 -0400 Subject: [PATCH] Add breakpoint modifiers and condition DSL --- Sources/Example/BreakpointDemo.swift | 46 +++ Sources/Example/ExampleApp.swift | 3 +- .../Extensions/BreakpointBin+Extras.swift | 268 ++++++++++++++++++ Sources/Portico/Generated/Button.swift | 34 +++ Sources/Portico/Generated/CheckButton.swift | 28 ++ Sources/Portico/Generated/ComboBox.swift | 44 +++ Sources/Portico/Generated/Expander.swift | 38 +++ .../Portico/Generated/FontDialogButton.swift | 68 +---- Sources/Portico/Generated/Label.swift | 75 +++++ Sources/Portico/Generated/ToggleButton.swift | 37 +++ Sources/Portico/Reexports.swift | 9 + Sources/Portico/State/Condition.swift | 82 ++++++ Tests/PorticoTests/BreakpointTests.swift | 100 +++++++ 13 files changed, 764 insertions(+), 68 deletions(-) create mode 100644 Sources/Example/BreakpointDemo.swift create mode 100644 Sources/Portico/Extensions/BreakpointBin+Extras.swift create mode 100644 Sources/Portico/State/Condition.swift create mode 100644 Tests/PorticoTests/BreakpointTests.swift diff --git a/Sources/Example/BreakpointDemo.swift b/Sources/Example/BreakpointDemo.swift new file mode 100644 index 0000000..0f0ffd0 --- /dev/null +++ b/Sources/Example/BreakpointDemo.swift @@ -0,0 +1,46 @@ +import Adw +import Portico + +/// Demonstrates breakpoint conditions, typed setters, and apply handlers. +struct BreakpointDemoPage: View { + @State private var isCompact = false + @State private var appliedMessage = "No manual breakpoint applied" + @State private var currentBreakpoint: Breakpoint? + @WidgetRef private var carousel: Adw.Carousel? + + var body: some View { + BreakpointBin { + VStack(spacing: 12) { + Label("Breakpoint demo") + .title1() + Label { isCompact ? "Compact layout" : "Wide layout" } + Label { appliedMessage } + .dimmed() + Carousel { + Label("Carousel content") + } + .ref(_carousel) + .hexpand(true) + .vexpand(true) + } + .margin(24) + .hexpand(true) + .vexpand(true) + } + .breakpoint("max-width: 700px", isActive: $isCompact) + .breakpoint(.maxWidth(500)) { + Setter(_carousel, "spacing", double: 0) + } + .breakpoint("max-width: 400px") { configuration in + configuration.onApply { + appliedMessage = "Manual breakpoint applied" + } + configuration.onUnapply { + appliedMessage = "Manual breakpoint unapplied" + } + } + .currentBreakpoint($currentBreakpoint) + .hexpand(true) + .vexpand(true) + } +} diff --git a/Sources/Example/ExampleApp.swift b/Sources/Example/ExampleApp.swift index 107c2d1..4407bec 100644 --- a/Sources/Example/ExampleApp.swift +++ b/Sources/Example/ExampleApp.swift @@ -31,6 +31,7 @@ struct ExampleApp: App { settingsPage AsyncDemoPage() EnvironmentDemoPage() + BreakpointDemoPage() navigationDemoPage PresentationDemoPage() conveniencePage @@ -42,7 +43,7 @@ struct ExampleApp: App { .carousel($pager) .halign(.center) - Label("Uptime: \(uptime)s, Page \(currentPage + 1) of 8") + Label("Uptime: \(uptime)s, Page \(currentPage + 1) of 9") .dimmed() .halign(.center) .margin(8) diff --git a/Sources/Portico/Extensions/BreakpointBin+Extras.swift b/Sources/Portico/Extensions/BreakpointBin+Extras.swift new file mode 100644 index 0000000..81f7122 --- /dev/null +++ b/Sources/Portico/Extensions/BreakpointBin+Extras.swift @@ -0,0 +1,268 @@ +import Adw +import GObject +import Gtk + +/// A typed property setter installed on an ``Adw.Breakpoint``. +/// +/// The target is resolved from a mounted ``WidgetRef`` or its projected binding. +/// Libadwaita restores the target property when the breakpoint is unapplied. +public struct Setter { + fileprivate let target: () -> GLibObject? + fileprivate let property: String + fileprivate let addTo: (Adw.Breakpoint, GLibObject, String) -> Void + + private init( + target: @escaping () -> GLibObject?, + property: String, + addTo: @escaping (Adw.Breakpoint, GLibObject, String) -> Void + ) { + self.target = target + self.property = property + self.addTo = addTo + } + + /// Creates a boolean setter targeting a widget reference. + public init(_ ref: WidgetRef, _ property: String, bool: Bool) { + self.init( + target: { ref.projectedValue.untrackedValue }, + property: property, + addTo: { breakpoint, object, property in + breakpoint.addSetter(object: object, property: property, bool: bool) + } + ) + } + + /// Creates a boolean setter targeting a widget binding. + public init(_ ref: Binding, _ property: String, bool: Bool) { + self.init( + target: { ref.untrackedValue }, + property: property, + addTo: { breakpoint, object, property in + breakpoint.addSetter(object: object, property: property, bool: bool) + } + ) + } + + /// Creates a string setter targeting a widget reference. + public init(_ ref: WidgetRef, _ property: String, string: String) { + self.init( + target: { ref.projectedValue.untrackedValue }, + property: property, + addTo: { breakpoint, object, property in + breakpoint.addSetter(object: object, property: property, string: string) + } + ) + } + + /// Creates a string setter targeting a widget binding. + public init(_ ref: Binding, _ property: String, string: String) { + self.init( + target: { ref.untrackedValue }, + property: property, + addTo: { breakpoint, object, property in + breakpoint.addSetter(object: object, property: property, string: string) + } + ) + } + + /// Creates a signed integer setter targeting a widget reference. + public init(_ ref: WidgetRef, _ property: String, int: Int32) { + self.init( + target: { ref.projectedValue.untrackedValue }, + property: property, + addTo: { breakpoint, object, property in + breakpoint.addSetter(object: object, property: property, int: int) + } + ) + } + + /// Creates an unsigned integer setter targeting a widget reference. + public init(_ ref: WidgetRef, _ property: String, uint: UInt32) { + self.init( + target: { ref.projectedValue.untrackedValue }, + property: property, + addTo: { breakpoint, object, property in + breakpoint.addSetter(object: object, property: property, uint: uint) + } + ) + } + + /// Creates a double setter targeting a widget reference. + public init(_ ref: WidgetRef, _ property: String, double: Double) { + self.init( + target: { ref.projectedValue.untrackedValue }, + property: property, + addTo: { breakpoint, object, property in + breakpoint.addSetter(object: object, property: property, double: double) + } + ) + } + + /// Creates an object setter targeting a widget reference. + public init(_ ref: WidgetRef, _ property: String, widget: GLibObject?) { + self.init( + target: { ref.projectedValue.untrackedValue }, + property: property, + addTo: { breakpoint, object, property in + breakpoint.addSetter(object: object, property: property, widget: widget) + } + ) + } +} + +/// Collects typed setters in a breakpoint modifier closure. +@resultBuilder +public struct BreakpointBuilder { + /// Combines the setters in a single builder block. + public static func buildBlock(_ components: [Setter]...) -> [Setter] { + components.flatMap { $0 } + } + + /// Lifts a single setter into the builder's component type. + public static func buildExpression(_ setter: Setter) -> [Setter] { + [setter] + } + + /// Combines setters produced by a loop. + public static func buildArray(_ components: [[Setter]]) -> [Setter] { + components.flatMap { $0 } + } + + /// Handles an optional setter branch. + public static func buildOptional(_ component: [Setter]?) -> [Setter] { + component ?? [] + } + + /// Selects the first conditional branch. + public static func buildEither(first component: [Setter]) -> [Setter] { + component + } + + /// Selects the second conditional branch. + public static func buildEither(second component: [Setter]) -> [Setter] { + component + } +} + +/// Context for registering breakpoint apply and unapply handlers. +public struct BreakpointConfiguration { + /// The breakpoint being configured. + public let breakpoint: Adw.Breakpoint + let registry: NodeRegistry + + init(breakpoint: Adw.Breakpoint, registry: NodeRegistry) { + self.breakpoint = breakpoint + self.registry = registry + } + + /// Registers a handler that runs after breakpoint setters are applied. + public func onApply(_ handler: @escaping () -> Void) { + registry.add(breakpoint.connectApply { _ in handler() }) + } + + /// Registers a handler that runs before breakpoint setters are reset. + public func onUnapply(_ handler: @escaping () -> Void) { + registry.add(breakpoint.connectUnapply { _ in handler() }) + } +} + +extension WidgetView where Target: Adw.BreakpointBin { + /// Adds a breakpoint that writes `true` while its condition is active. + public func breakpoint(_ condition: Condition, isActive: Binding) -> Self { + appending { widget, context in + let breakpoint = Adw.Breakpoint(condition: condition.condition) + context.registry.add(breakpoint.connectApply { _ in + isActive.wrappedValue = true + }) + context.registry.add(breakpoint.connectUnapply { _ in + isActive.wrappedValue = false + }) + widget.addBreakpoint(breakpoint: breakpoint) + } + } + + /// Adds a breakpoint from a libadwaita condition string. + public func breakpoint(_ condition: String, isActive: Binding) -> Self { + breakpoint(Condition(stringLiteral: condition), isActive: isActive) + } + + /// Adds a breakpoint that identifies its matching layout value. + public func breakpoint( + _ condition: Condition, + isActive: Binding, + matches: Layout + ) -> Self { + appending { widget, context in + let breakpoint = Adw.Breakpoint(condition: condition.condition) + context.registry.add(breakpoint.connectApply { _ in + isActive.wrappedValue = matches + }) + context.registry.add(breakpoint.connectUnapply { _ in + // A later breakpoint may already have replaced this value. + _ = isActive.untrackedValue == matches + }) + widget.addBreakpoint(breakpoint: breakpoint) + } + } + + /// Adds a breakpoint with typed widget-property setters. + public func breakpoint( + _ condition: Condition, + @BreakpointBuilder _ setters: () -> [Setter] + ) -> Self { + let setterValues = setters() + return appending { widget, context in + let breakpoint = Adw.Breakpoint(condition: condition.condition) + for setter in setterValues { + guard let target = setter.target() else { + preconditionFailure( + "Setter target \(setter.property): WidgetRef is nil. " + + "Ensure the widget is published with .ref() before the breakpoint mounts." + ) + } + setter.addTo(breakpoint, target, setter.property) + } + widget.addBreakpoint(breakpoint: breakpoint) + } + } + + /// Adds typed widget-property setters from a libadwaita condition string. + public func breakpoint( + _ condition: String, + @BreakpointBuilder _ setters: () -> [Setter] + ) -> Self { + breakpoint(Condition(stringLiteral: condition), setters) + } + + /// Adds a breakpoint with manually managed apply and unapply handlers. + public func breakpoint( + _ condition: Condition, + configure: @escaping (BreakpointConfiguration) -> Void + ) -> Self { + appending { widget, context in + let breakpoint = Adw.Breakpoint(condition: condition.condition) + configure(BreakpointConfiguration(breakpoint: breakpoint, registry: context.registry)) + widget.addBreakpoint(breakpoint: breakpoint) + } + } + + /// Adds manually managed handlers from a libadwaita condition string. + public func breakpoint( + _ condition: String, + configure: @escaping (BreakpointConfiguration) -> Void + ) -> Self { + breakpoint(Condition(stringLiteral: condition), configure: configure) + } + + /// Binds the currently active breakpoint, or `nil` when none match. + public func currentBreakpoint(_ binding: Binding) -> Self { + appending { widget, context in + context.registry.add( + widget.connectNotify(detail: "current-breakpoint") { _, _ in + binding.wrappedValue = widget.getCurrentBreakpoint() + } + ) + binding.wrappedValue = widget.getCurrentBreakpoint() + } + } +} diff --git a/Sources/Portico/Generated/Button.swift b/Sources/Portico/Generated/Button.swift index 6af9c34..959f9a2 100644 --- a/Sources/Portico/Generated/Button.swift +++ b/Sources/Portico/Generated/Button.swift @@ -349,6 +349,40 @@ import Gdk } } + // PorticoGen: generateInits(static) | source: Gtk.Button.init(withMnemonicLabel:) + /// Creates a new `GtkButton` containing a label. + /// + /// If characters in `label` are preceded by an underscore, they are underlined. + /// If you need a literal underscore character in a label, use “__” (two + /// underscores). The first underlined character represents a keyboard + /// accelerator called a mnemonic. Pressing Alt and that key + /// activates the button. + /// + /// Applied once at mount; use the `Binding` or closure overload for values that change. + /// Each closure is evaluated once; children are added in order and slot closures mount their first view. + /// An empty closure adds no children and leaves slots unset. + /// Optional value parameters are applied only when non-`nil`; a `nil` argument leaves the widget's own default in place and cannot clear a nullable property - use the matching modifier for that. + /// + /// - Parameter withMnemonicLabel: The `withMnemonicLabel` value forwarded to `Gtk.Button`. + /// - Parameter canShrink: Whether the size of the button can be made smaller than the natural size of its contents. + /// - Parameter hasFrame: Whether the button has a frame. + /// - Parameter useUnderline: If set, an underline in the text indicates that the following character is to be used as mnemonic. + /// - Parameter child: A `ViewBuilder` closure whose first view is mounted into the `child` slot. + /// - Parameter onActivate: Invoked when the widget emits the `activate` signal. + /// - Parameter onClicked: Invoked when the widget emits the `clicked` signal. + public init(withMnemonicLabel: String, canShrink: Bool? = nil, hasFrame: Bool? = nil, useUnderline: Bool? = nil, @ViewBuilder child: () -> [AnyView] = { [] }, onActivate: (() -> Void)? = nil, onClicked: (() -> Void)? = nil) { + let childViews = child() + make = { _ in Gtk.Button(withMnemonicLabel: withMnemonicLabel) } + configure.append { w, ctx in + if let canShrink { w.setCanShrink(canShrink: canShrink) } + if let hasFrame { w.setHasFrame(hasFrame: hasFrame) } + if let useUnderline { w.setUseUnderline(useUnderline: useUnderline) } + if let v = childViews.first { w.setChild(child: v.makeWidget(ctx)) } + if let onActivate { ctx.registry.add(w.connectActivate { _ in onActivate() }) } + if let onClicked { ctx.registry.add(w.connectClicked { _ in onClicked() }) } + } + } + } extension Button: WidgetView { diff --git a/Sources/Portico/Generated/CheckButton.swift b/Sources/Portico/Generated/CheckButton.swift index a5536db..71d8179 100644 --- a/Sources/Portico/Generated/CheckButton.swift +++ b/Sources/Portico/Generated/CheckButton.swift @@ -236,6 +236,34 @@ import Gdk } } + // PorticoGen: generateInits(static) | source: Gtk.CheckButton.init(withMnemonicLabel:) + /// Creates a new `GtkCheckButton` with the given text and a mnemonic. + /// + /// Applied once at mount; use the `Binding` or closure overload for values that change. + /// Each closure is evaluated once; children are added in order and slot closures mount their first view. + /// An empty closure adds no children and leaves slots unset. + /// Optional value parameters are applied only when non-`nil`; a `nil` argument leaves the widget's own default in place and cannot clear a nullable property - use the matching modifier for that. + /// + /// - Parameter withMnemonicLabel: The `withMnemonicLabel` value forwarded to `Gtk.CheckButton`. + /// - Parameter active: If the check button is active. + /// - Parameter inconsistent: If the check button is in an “in between” state. + /// - Parameter useUnderline: If set, an underline in the text indicates that the following character is to be used as mnemonic. + /// - Parameter child: A `ViewBuilder` closure whose first view is mounted into the `child` slot. + /// - Parameter onActivate: Invoked when the widget emits the `activate` signal. + /// - Parameter onToggled: Invoked when the widget emits the `toggled` signal. + public init(withMnemonicLabel: String?, active: Bool? = nil, inconsistent: Bool? = nil, useUnderline: Bool? = nil, @ViewBuilder child: () -> [AnyView] = { [] }, onActivate: (() -> Void)? = nil, onToggled: (() -> Void)? = nil) { + let childViews = child() + make = { _ in Gtk.CheckButton(withMnemonicLabel: withMnemonicLabel) } + configure.append { w, ctx in + if let active { w.setActive(setting: active) } + if let inconsistent { w.setInconsistent(inconsistent: inconsistent) } + if let useUnderline { w.setUseUnderline(setting: useUnderline) } + if let v = childViews.first { w.setChild(child: v.makeWidget(ctx)) } + if let onActivate { ctx.registry.add(w.connectActivate { _ in onActivate() }) } + if let onToggled { ctx.registry.add(w.connectToggled { _ in onToggled() }) } + } + } + } extension CheckButton: WidgetView { diff --git a/Sources/Portico/Generated/ComboBox.swift b/Sources/Portico/Generated/ComboBox.swift index 095f123..d7b5aae 100644 --- a/Sources/Portico/Generated/ComboBox.swift +++ b/Sources/Portico/Generated/ComboBox.swift @@ -160,6 +160,50 @@ import Gdk } } + // PorticoGen: generateInits(static) | source: Gtk.ComboBox.init(withModelAndEntryModel:) + /// Creates a new empty `GtkComboBox` with an entry and a model. + /// + /// See also [ctor`Gtk`.ComboBox.new_with_entry]. + /// + /// Applied once at mount; use the `Binding` or closure overload for values that change. + /// Each closure is evaluated once; children are added in order and slot closures mount their first view. + /// An empty closure adds no children and leaves slots unset. + /// Optional value parameters are applied only when non-`nil`; a `nil` argument leaves the widget's own default in place and cannot clear a nullable property - use the matching modifier for that. + /// + /// - Parameter withModelAndEntryModel: The `withModelAndEntryModel` value forwarded to `Gtk.ComboBox`. + /// - Parameter active: The item which is currently active. + /// - Parameter activeId: The value of the ID column of the active row. + /// - Parameter buttonSensitivity: Whether the dropdown button is sensitive when the model is empty. + /// - Parameter entryTextColumn: The model column to associate with strings from the entry. + /// - Parameter idColumn: The model column that provides string IDs for the values in the model, if != -1. + /// - Parameter popupFixedWidth: Whether the popup's width should be a fixed width matching the allocated width of the combo box. + /// - Parameter child: A `ViewBuilder` closure whose first view is mounted into the `child` slot. + /// - Parameter onActivate: Invoked when the widget emits the `activate` signal. + /// - Parameter onChanged: Invoked when the widget emits the `changed` signal. + /// - Parameter onFormatEntryText: Invoked when the widget emits the `format-entry-text` signal. The closure receives the signal's arguments in order. Its return value is forwarded to GTK as the signal's result. + /// - Parameter onMoveActive: Invoked when the widget emits the `move-active` signal. The closure receives the signal's arguments in order. + /// - Parameter onPopdown: Invoked when the widget emits the `popdown` signal. Its return value is forwarded to GTK as the signal's result. + /// - Parameter onPopup: Invoked when the widget emits the `popup` signal. + public init(withModelAndEntryModel: Gtk.TreeModel, active: Int32? = nil, activeId: String? = nil, buttonSensitivity: Gtk.SensitivityType? = nil, entryTextColumn: Int32? = nil, idColumn: Int32? = nil, popupFixedWidth: Bool? = nil, @ViewBuilder child: () -> [AnyView] = { [] }, onActivate: (() -> Void)? = nil, onChanged: (() -> Void)? = nil, onFormatEntryText: ((String) -> String)? = nil, onMoveActive: ((Gtk.ScrollType) -> Void)? = nil, onPopdown: (() -> Bool)? = nil, onPopup: (() -> Void)? = nil) { + let childViews = child() + make = { _ in Gtk.ComboBox(withModelAndEntryModel: withModelAndEntryModel) } + configure.append { w, ctx in + if let active { w.setActive(index: active) } + if let activeId { w.setActiveId(activeId: activeId) } + if let buttonSensitivity { w.setButtonSensitivity(sensitivity: buttonSensitivity) } + if let entryTextColumn { w.setEntryTextColumn(textColumn: entryTextColumn) } + if let idColumn { w.setIdColumn(idColumn: idColumn) } + if let popupFixedWidth { w.setPopupFixedWidth(fixed: popupFixedWidth) } + if let v = childViews.first { w.setChild(child: v.makeWidget(ctx)) } + if let onActivate { ctx.registry.add(w.connectActivate { _ in onActivate() }) } + if let onChanged { ctx.registry.add(w.connectChanged { _ in onChanged() }) } + if let onFormatEntryText { ctx.registry.add(w.connectFormatEntryText { _, a0 in onFormatEntryText(a0) }) } + if let onMoveActive { ctx.registry.add(w.connectMoveActive { _, a0 in onMoveActive(a0) }) } + if let onPopdown { ctx.registry.add(w.connectPopdown { _ in onPopdown() }) } + if let onPopup { ctx.registry.add(w.connectPopup { _ in onPopup() }) } + } + } + } extension ComboBox: WidgetView { diff --git a/Sources/Portico/Generated/Expander.swift b/Sources/Portico/Generated/Expander.swift index 93b025f..561f3df 100644 --- a/Sources/Portico/Generated/Expander.swift +++ b/Sources/Portico/Generated/Expander.swift @@ -243,6 +243,44 @@ import Gdk } } + // PorticoGen: generateInits(static) | source: Gtk.Expander.init(withMnemonicLabel:) + /// Creates a new expander using `label` as the text of the label. + /// + /// If characters in `label` are preceded by an underscore, they are + /// underlined. If you need a literal underscore character in a label, + /// use “__” (two underscores). The first underlined character represents + /// a keyboard accelerator called a mnemonic. + /// + /// Pressing Alt and that key activates the button. + /// + /// Applied once at mount; use the `Binding` or closure overload for values that change. + /// Each closure is evaluated once; children are added in order and slot closures mount their first view. + /// An empty closure adds no children and leaves slots unset. + /// Optional value parameters are applied only when non-`nil`; a `nil` argument leaves the widget's own default in place and cannot clear a nullable property - use the matching modifier for that. + /// + /// - Parameter withMnemonicLabel: The `withMnemonicLabel` value forwarded to `Gtk.Expander`. + /// - Parameter expanded: Whether the expander has been opened to reveal the child. + /// - Parameter resizeToplevel: When this property is `true`, the expander will resize the toplevel widget containing the expander upon expanding and collapsing. + /// - Parameter useMarkup: Whether the text in the label is Pango markup. + /// - Parameter useUnderline: Whether an underline in the text indicates a mnemonic. + /// - Parameter child: A `ViewBuilder` closure whose first view is mounted into the `child` slot. + /// - Parameter labelWidget: A `ViewBuilder` closure whose first view is mounted into the `labelWidget` slot. + /// - Parameter onActivate: Invoked when the widget emits the `activate` signal. + public init(withMnemonicLabel: String?, expanded: Bool? = nil, resizeToplevel: Bool? = nil, useMarkup: Bool? = nil, useUnderline: Bool? = nil, @ViewBuilder child: () -> [AnyView] = { [] }, @ViewBuilder labelWidget: () -> [AnyView] = { [] }, onActivate: (() -> Void)? = nil) { + let childViews = child() + let labelWidgetViews = labelWidget() + make = { _ in Gtk.Expander(withMnemonicLabel: withMnemonicLabel) } + configure.append { w, ctx in + if let expanded { w.setExpanded(expanded: expanded) } + if let resizeToplevel { w.setResizeToplevel(resizeToplevel: resizeToplevel) } + if let useMarkup { w.setUseMarkup(useMarkup: useMarkup) } + if let useUnderline { w.setUseUnderline(useUnderline: useUnderline) } + if let v = childViews.first { w.setChild(child: v.makeWidget(ctx)) } + if let v = labelWidgetViews.first { w.setLabelWidget(labelWidget: v.makeWidget(ctx)) } + if let onActivate { ctx.registry.add(w.connectActivate { _ in onActivate() }) } + } + } + } extension Expander: WidgetView { diff --git a/Sources/Portico/Generated/FontDialogButton.swift b/Sources/Portico/Generated/FontDialogButton.swift index 419dcff..2910717 100644 --- a/Sources/Portico/Generated/FontDialogButton.swift +++ b/Sources/Portico/Generated/FontDialogButton.swift @@ -46,17 +46,15 @@ import Gdk /// - Parameter dialog: The `GtkFontDialog` that contains parameters for the font chooser dialog. /// - Parameter fontDesc: The selected font. /// - Parameter fontFeatures: The selected font features. - /// - Parameter language: The selected language for font features. /// - Parameter level: The level of detail for the font chooser dialog. /// - Parameter useFont: Whether the buttons label will be drawn in the selected font. /// - Parameter useSize: Whether the buttons label will use the selected font size. /// - Parameter onActivate: Invoked when the widget emits the `activate` signal. - public init(dialog: Gtk.FontDialog?, fontDesc: Gtk.FontDescription? = nil, fontFeatures: String? = nil, language: Gtk.Language? = nil, level: Gtk.FontLevel? = nil, useFont: Bool? = nil, useSize: Bool? = nil, onActivate: (() -> Void)? = nil) { + public init(dialog: Gtk.FontDialog?, fontDesc: Gtk.FontDescription? = nil, fontFeatures: String? = nil, level: Gtk.FontLevel? = nil, useFont: Bool? = nil, useSize: Bool? = nil, onActivate: (() -> Void)? = nil) { make = { _ in Gtk.FontDialogButton(dialog: dialog) } configure.append { w, ctx in if let fontDesc { w.setFontDesc(fontDesc: fontDesc) } if let fontFeatures { w.setFontFeatures(fontFeatures: fontFeatures) } - if let language { w.setLanguage(language: language) } if let level { w.setLevel(level: level) } if let useFont { w.setUseFont(useFont: useFont) } if let useSize { w.setUseSize(useSize: useSize) } @@ -294,70 +292,6 @@ extension WidgetView where Target: Gtk.FontDialogButton { } } - // PorticoGen: generateModifierExtension -> generatePropertyModifiers(static) | source: Gtk.FontDialogButton.setLanguage(language:) - /// Sets the language to use for font features. - /// - /// Applied once at mount; use the `Binding` or closure overload for a value that changes. - /// - /// - Parameter language: The selected language for font features. - /// - Returns: A copy of this view with the modifier applied. - public func language(_ language: Gtk.Language?) -> Self { - appending { w, _ in - w.setLanguage(language: language) - } - } - - // PorticoGen: generateModifierExtension -> generatePropertyModifiers -> bindingModifier(twoWay) | source: Gtk.FontDialogButton.setLanguage(language:), GObject.Object.connectNotify(detail:_:), Gtk.FontDialogButton.getLanguage() - /// Sets the language to use for font features. - /// - /// Applied at mount and re-applied on every change the binding publishes. - /// When `Gtk.Language?` conforms to `Equatable` this binds in both directions: the widget's `notify` signal writes its current value back into the binding, so changes made in the UI propagate to the bound state. Each direction compares before writing, which terminates the echo after one hop. A value type that is not `Equatable` binds one way only, because the echo cannot be broken. - /// - /// - Returns: A copy of this view with the modifier applied. - public func language(_ language: Portico.Binding) -> Self { - appending { w, ctx in - Portico.bindProperty( - w, language, registry: ctx.registry, notifyDetail: "language", - read: { [w] in w.getLanguage() }, - write: { [w] v in w.setLanguage(language: v) } - ) - } - } - - // PorticoGen: generateModifierExtension -> generatePropertyModifiers -> bindingModifier(lifted,twoWay) | source: Gtk.FontDialogButton.setLanguage(language:), GObject.Object.connectNotify(detail:_:), Gtk.FontDialogButton.getLanguage() - /// Sets the language to use for font features. - /// - /// Applied at mount and re-applied on every change the binding publishes. - /// `Binding` is invariant, so a `Binding` is not accepted by the nullable overload; this one takes it and promotes each value. Pass a `Binding` to be able to clear the property. - /// When `Gtk.Language` conforms to `Equatable` this binds in both directions: the widget's `notify` signal writes its current value back into the binding, so changes made in the UI propagate to the bound state. Each direction compares before writing, which terminates the echo after one hop. A value type that is not `Equatable` binds one way only, because the echo cannot be broken. - /// A `nil` widget value is never written back into the binding. - /// - /// - Returns: A copy of this view with the modifier applied. - public func language(_ language: Portico.Binding) -> Self { - appending { w, ctx in - Portico.bindProperty( - w, language, registry: ctx.registry, notifyDetail: "language", - read: { [w] in w.getLanguage() }, - write: { [w] v in w.setLanguage(language: v) } - ) - } - } - - // PorticoGen: generateModifierExtension -> generatePropertyModifiers(closure) | source: Gtk.FontDialogButton.setLanguage(language:) - /// Sets the language to use for font features. - /// - /// The closure runs inside a `DependencyTracker`, so any `@State` it reads re-runs it and pushes the new value through `Gtk.FontDialogButton.setLanguage(language:)`. - /// - /// - Parameter language: The selected language for font features. - /// - Returns: A copy of this view with the modifier applied. - public func language(_ language: @escaping () -> Gtk.Language?) -> Self { - appending { w, ctx in - let tracker = DependencyTracker { [w] in w.setLanguage(language: language()) } - tracker.run() - ctx.registry.add(tracker) - } - } - // PorticoGen: generateModifierExtension -> generatePropertyModifiers(static) | source: Gtk.FontDialogButton.setLevel(level:) /// Sets the level of detail at which this dialog /// lets the user select fonts. diff --git a/Sources/Portico/Generated/Label.swift b/Sources/Portico/Generated/Label.swift index b32e0b0..5ab00fe 100644 --- a/Sources/Portico/Generated/Label.swift +++ b/Sources/Portico/Generated/Label.swift @@ -294,6 +294,81 @@ import Gdk } } + // PorticoGen: generateInits(static) | source: Gtk.Label.init(withMnemonicStr:) + /// Creates a new label with the given text inside it, and a mnemonic. + /// + /// If characters in `str` are preceded by an underscore, they are + /// underlined. If you need a literal underscore character in a label, use + /// '__' (two underscores). The first underlined character represents a + /// keyboard accelerator called a mnemonic. The mnemonic key can be used + /// to activate another widget, chosen automatically, or explicitly using + /// [method`Gtk`.Label.set_mnemonic_widget]. + /// + /// If [method`Gtk`.Label.set_mnemonic_widget] is not called, then the first + /// activatable ancestor of the label will be chosen as the mnemonic + /// widget. For instance, if the label is inside a button or menu item, + /// the button or menu item will automatically become the mnemonic widget + /// and be activated by the mnemonic. + /// + /// Applied once at mount; use the `Binding` or closure overload for values that change. + /// Each closure is evaluated once; children are added in order and slot closures mount their first view. + /// An empty closure adds no children and leaves slots unset. + /// Optional value parameters are applied only when non-`nil`; a `nil` argument leaves the widget's own default in place and cannot clear a nullable property - use the matching modifier for that. + /// + /// - Parameter withMnemonicStr: The `withMnemonicStr` value forwarded to `Gtk.Label`. + /// - Parameter attributes: A list of style attributes to apply to the text of the label. + /// - Parameter ellipsize: The preferred place to ellipsize the string, if the label does not have enough room to display the entire string. + /// - Parameter extraMenu: A menu model whose contents will be appended to the context menu. + /// - Parameter justify: The alignment of the lines in the text of the label, relative to each other. + /// - Parameter label: The contents of the label. + /// - Parameter lines: The number of lines to which an ellipsized, wrapping label should display before it gets ellipsized. This both prevents the label from ellipsizing before this many lines are displayed, and limits the height request of the label to this many lines. + /// - Parameter maxWidthChars: The desired maximum width of the label, in characters. + /// - Parameter naturalWrapMode: Select the line wrapping for the natural size request. + /// - Parameter selectable: Whether the label text can be selected with the mouse. + /// - Parameter singleLineMode: Whether the label is in single line mode. + /// - Parameter tabs: Custom tabs for this label. + /// - Parameter useMarkup: True if the text of the label includes Pango markup. + /// - Parameter useUnderline: True if the text of the label indicates a mnemonic with an `_` before the mnemonic character. + /// - Parameter widthChars: The desired width of the label, in characters. + /// - Parameter wrap: True if the label text will wrap if it gets too wide. + /// - Parameter wrapMode: Controls how the line wrapping is done. + /// - Parameter xalign: The horizontal alignment of the label text inside its size allocation. + /// - Parameter yalign: The vertical alignment of the label text inside its size allocation. + /// - Parameter mnemonicWidget: A `ViewBuilder` closure whose first view is mounted into the `mnemonicWidget` slot. + /// - Parameter onActivateCurrentLink: Invoked when the widget emits the `activate-current-link` signal. + /// - Parameter onActivateLink: Invoked when the widget emits the `activate-link` signal. The closure receives the signal's arguments in order. Its return value is forwarded to GTK as the signal's result. + /// - Parameter onCopyClipboard: Invoked when the widget emits the `copy-clipboard` signal. + /// - Parameter onMoveCursor: Invoked when the widget emits the `move-cursor` signal. The closure receives the signal's arguments in order. + public init(withMnemonicStr: String?, attributes: Gtk.AttrList? = nil, ellipsize: Gtk.EllipsizeMode? = nil, extraMenu: Gtk.MenuModel? = nil, justify: Gtk.Justification? = nil, label: String? = nil, lines: Int32? = nil, maxWidthChars: Int32? = nil, naturalWrapMode: Gtk.NaturalWrapMode? = nil, selectable: Bool? = nil, singleLineMode: Bool? = nil, tabs: Gtk.TabArray? = nil, useMarkup: Bool? = nil, useUnderline: Bool? = nil, widthChars: Int32? = nil, wrap: Bool? = nil, wrapMode: Pango.WrapMode? = nil, xalign: Float? = nil, yalign: Float? = nil, @ViewBuilder mnemonicWidget: () -> [AnyView] = { [] }, onActivateCurrentLink: (() -> Void)? = nil, onActivateLink: ((String) -> Bool)? = nil, onCopyClipboard: (() -> Void)? = nil, onMoveCursor: ((Gtk.MovementStep, Int32, Bool) -> Void)? = nil) { + let mnemonicWidgetViews = mnemonicWidget() + make = { _ in Gtk.Label(withMnemonicStr: withMnemonicStr) } + configure.append { w, ctx in + if let attributes { w.setAttributes(attrs: attributes) } + if let ellipsize { w.setEllipsize(mode: ellipsize) } + if let extraMenu { w.setExtraMenu(model: extraMenu) } + if let justify { w.setJustify(jtype: justify) } + if let label { w.setLabel(str: label) } + if let lines { w.setLines(lines: lines) } + if let maxWidthChars { w.setMaxWidthChars(nChars: maxWidthChars) } + if let naturalWrapMode { w.setNaturalWrapMode(wrapMode: naturalWrapMode) } + if let selectable { w.setSelectable(setting: selectable) } + if let singleLineMode { w.setSingleLineMode(singleLineMode: singleLineMode) } + if let tabs { w.setTabs(tabs: tabs) } + if let useMarkup { w.setUseMarkup(setting: useMarkup) } + if let useUnderline { w.setUseUnderline(setting: useUnderline) } + if let widthChars { w.setWidthChars(nChars: widthChars) } + if let wrap { w.setWrap(wrap: wrap) } + if let wrapMode { w.setWrapMode(wrapMode: wrapMode) } + if let xalign { w.setXalign(xalign: xalign) } + if let yalign { w.setYalign(yalign: yalign) } + if let v = mnemonicWidgetViews.first { w.setMnemonicWidget(widget: v.makeWidget(ctx)) } + if let onActivateCurrentLink { ctx.registry.add(w.connectActivateCurrentLink { _ in onActivateCurrentLink() }) } + if let onActivateLink { ctx.registry.add(w.connectActivateLink { _, a0 in onActivateLink(a0) }) } + if let onCopyClipboard { ctx.registry.add(w.connectCopyClipboard { _ in onCopyClipboard() }) } + if let onMoveCursor { ctx.registry.add(w.connectMoveCursor { _, a0, a1, a2 in onMoveCursor(a0, a1, a2) }) } + } + } + } extension Label: WidgetView { diff --git a/Sources/Portico/Generated/ToggleButton.swift b/Sources/Portico/Generated/ToggleButton.swift index 7966513..17a81f2 100644 --- a/Sources/Portico/Generated/ToggleButton.swift +++ b/Sources/Portico/Generated/ToggleButton.swift @@ -279,6 +279,43 @@ import Gdk } } + // PorticoGen: generateInits(static) | source: Gtk.ToggleButton.init(withMnemonicLabel:) + /// Creates a new `GtkToggleButton` containing a label. + /// + /// The label will be created using [ctor`Gtk`.Label.new_with_mnemonic], + /// so underscores in `label` indicate the mnemonic for the button. + /// + /// Applied once at mount; use the `Binding` or closure overload for values that change. + /// Each closure is evaluated once; children are added in order and slot closures mount their first view. + /// An empty closure adds no children and leaves slots unset. + /// Optional value parameters are applied only when non-`nil`; a `nil` argument leaves the widget's own default in place and cannot clear a nullable property - use the matching modifier for that. + /// + /// - Parameter withMnemonicLabel: The `withMnemonicLabel` value forwarded to `Gtk.ToggleButton`. + /// - Parameter active: If the toggle button should be pressed in. + /// - Parameter canShrink: Whether the size of the button can be made smaller than the natural size of its contents. + /// - Parameter hasFrame: Whether the button has a frame. + /// - Parameter iconName: The name of the icon used to automatically populate the button. + /// - Parameter useUnderline: If set, an underline in the text indicates that the following character is to be used as mnemonic. + /// - Parameter child: A `ViewBuilder` closure whose first view is mounted into the `child` slot. + /// - Parameter onToggled: Invoked when the widget emits the `toggled` signal. + /// - Parameter onActivate: Invoked when the widget emits the `activate` signal. + /// - Parameter onClicked: Invoked when the widget emits the `clicked` signal. + public init(withMnemonicLabel: String, active: Bool? = nil, canShrink: Bool? = nil, hasFrame: Bool? = nil, iconName: String? = nil, useUnderline: Bool? = nil, @ViewBuilder child: () -> [AnyView] = { [] }, onToggled: (() -> Void)? = nil, onActivate: (() -> Void)? = nil, onClicked: (() -> Void)? = nil) { + let childViews = child() + make = { _ in Gtk.ToggleButton(withMnemonicLabel: withMnemonicLabel) } + configure.append { w, ctx in + if let active { w.setActive(isActive: active) } + if let canShrink { w.setCanShrink(canShrink: canShrink) } + if let hasFrame { w.setHasFrame(hasFrame: hasFrame) } + if let iconName { w.setIconName(iconName: iconName) } + if let useUnderline { w.setUseUnderline(useUnderline: useUnderline) } + if let v = childViews.first { w.setChild(child: v.makeWidget(ctx)) } + if let onToggled { ctx.registry.add(w.connectToggled { _ in onToggled() }) } + if let onActivate { ctx.registry.add(w.connectActivate { _ in onActivate() }) } + if let onClicked { ctx.registry.add(w.connectClicked { _ in onClicked() }) } + } + } + } extension ToggleButton: WidgetView { diff --git a/Sources/Portico/Reexports.swift b/Sources/Portico/Reexports.swift index 236f6ce..20feb31 100644 --- a/Sources/Portico/Reexports.swift +++ b/Sources/Portico/Reexports.swift @@ -32,6 +32,15 @@ public typealias ResponseAppearance = Adw.ResponseAppearance /// Whether a dialog presents itself as a floating window or a bottom sheet. public typealias DialogPresentationMode = Adw.DialogPresentationMode +/// A unit used by a breakpoint length condition. +public typealias LengthUnit = Adw.LengthUnit + +/// A libadwaita breakpoint condition. +public typealias BreakpointCondition = Adw.BreakpointCondition + +/// A libadwaita breakpoint. +public typealias Breakpoint = Adw.Breakpoint + /// The value/range model behind ``SpinRow`` and other range widgets. /// diff --git a/Sources/Portico/State/Condition.swift b/Sources/Portico/State/Condition.swift new file mode 100644 index 0000000..d604e86 --- /dev/null +++ b/Sources/Portico/State/Condition.swift @@ -0,0 +1,82 @@ +import Adw + +/// A type-safe builder for libadwaita breakpoint conditions. +/// +/// Use a condition with ``WidgetView/breakpoint(_:isActive:)`` or one of the +/// typed setter breakpoint modifiers. String literals are parsed by libadwaita. +public enum Condition: ExpressibleByStringLiteral { + /// Matches when the width is at most the supplied value. + case maxWidth(Double, LengthUnit = .px) + /// Matches when the width is at least the supplied value. + case minWidth(Double, LengthUnit = .px) + /// Matches when the height is at most the supplied value. + case maxHeight(Double, LengthUnit = .px) + /// Matches when the height is at least the supplied value. + case minHeight(Double, LengthUnit = .px) + /// Matches when the aspect ratio is at most `width / height`. + case maxAspectRatio(width: Int, height: Int = 1) + /// Matches when the aspect ratio is at least `width / height`. + case minAspectRatio(width: Int, height: Int = 1) + /// Matches when both nested conditions match. + indirect case and(Condition, Condition) + /// Matches when either nested condition matches. + indirect case or(Condition, Condition) + /// Wraps an already constructed libadwaita condition. + case raw(BreakpointCondition) + + /// Creates a condition from a libadwaita condition string. + public init(stringLiteral value: String) { + self = .raw(BreakpointCondition.parse(str: value)) + } + + /// The libadwaita condition represented by this value. + public var condition: BreakpointCondition { + switch self { + case let .maxWidth(value, unit): + return BreakpointCondition( + type: .maxWidth, + value: value, + unit: unit + ) + case let .minWidth(value, unit): + return BreakpointCondition( + type: .minWidth, + value: value, + unit: unit + ) + case let .maxHeight(value, unit): + return BreakpointCondition( + type: .maxHeight, + value: value, + unit: unit + ) + case let .minHeight(value, unit): + return BreakpointCondition( + type: .minHeight, + value: value, + unit: unit + ) + case let .maxAspectRatio(width, height): + return BreakpointCondition( + type: .maxAspectRatio, + width: Int32(width), + height: Int32(height) + ) + case let .minAspectRatio(width, height): + return BreakpointCondition( + type: .minAspectRatio, + width: Int32(width), + height: Int32(height) + ) + case let .and(first, second): + return BreakpointCondition(condition1: first.condition, condition2: second.condition) + case let .or(first, second): + return BreakpointCondition( + orCondition1: first.condition, + orCondition2: second.condition + ) + case let .raw(condition): + return condition + } + } +} diff --git a/Tests/PorticoTests/BreakpointTests.swift b/Tests/PorticoTests/BreakpointTests.swift new file mode 100644 index 0000000..d507d31 --- /dev/null +++ b/Tests/PorticoTests/BreakpointTests.swift @@ -0,0 +1,100 @@ +import Testing +import Adw +@_spi(SGTKInternal) import Gtk +@_spi(Portico) import Portico + +/// Tests the Portico breakpoint DSL and mount-time wiring. +@MainActor @Suite(.serialized) struct BreakpointTests { + @Test func conditionDSLBuildsTypedAndStringConditions() { + guard Gtk.initCheck() else { return } + + let typed = Condition.maxWidth(400).condition + #expect(typed.toString().contains("max-width")) + + let composed = Condition.and(.maxWidth(400), .maxHeight(300)).condition + #expect(composed.toString().contains("and")) + + let parsed: Condition = "min-width: 200px" + #expect(parsed.condition.toString().contains("min-width")) + } + + @Test func typedBreakpointSettersAcceptSupportedGValues() { + guard Gtk.initCheck() else { return } + + let label = Gtk.Label(str: "wide") + let breakpoint = Adw.Breakpoint(condition: Condition.maxWidth(400).condition) + breakpoint.addSetter(object: label, property: "label", string: "compact") + breakpoint.addSetter(object: label, property: "visible", bool: false) + breakpoint.addSetter(object: label, property: "opacity", double: 0.5) + + #expect(breakpoint.getCondition() != nil) + } + + @Test func reactiveBreakpointMountRegistersApplyHandlers() { + guard Gtk.initCheck() else { return } + + let active = StateBox(false) + let context = MountContext() + let view = BreakpointBin { Label(str: "content") } + .breakpoint("max-width: 400px", isActive: Binding(active)) + let bin = AnyView(view).makeWidget(context) as! Adw.BreakpointBin + + #expect(bin.getCurrentBreakpoint() == nil) + #expect(context.registry.isEmpty == false) + context.registry.teardown() + #expect(context.registry.isEmpty) + } + + @Test func typedSetterResolvesWidgetRefBeforeBreakpointMount() { + guard Gtk.initCheck() else { return } + + let labelRef = WidgetRef() + let context = MountContext() + let view = BreakpointBin { + Label(str: "wide").ref(labelRef) + } + .breakpoint("max-width: 400px") { + Setter(labelRef, "label", string: "compact") + } + let bin = AnyView(view).makeWidget(context) as! Adw.BreakpointBin + + #expect(labelRef.wrappedValue != nil) + #expect(bin.getCurrentBreakpoint() == nil) + context.registry.teardown() + #expect(context.registry.isEmpty) + } + + @Test func configurationRegistersApplyAndUnapplyHandlers() { + guard Gtk.initCheck() else { return } + + var configured = false + let context = MountContext() + let view = BreakpointBin { Label(str: "content") } + .breakpoint("max-width: 400px") { configuration in + configured = configuration.breakpoint.getCondition() != nil + configuration.onApply { } + configuration.onUnapply { } + } + _ = AnyView(view).makeWidget(context) as! Adw.BreakpointBin + + #expect(configured) + #expect(context.registry.isEmpty == false) + context.registry.teardown() + #expect(context.registry.isEmpty) + } + + @Test func currentBreakpointBindingInitializesAndTearsDown() { + guard Gtk.initCheck() else { return } + + let current = StateBox(nil) + let context = MountContext() + let view = BreakpointBin { Label(str: "content") } + .currentBreakpoint(Binding(current)) + let bin = AnyView(view).makeWidget(context) as! Adw.BreakpointBin + + #expect(bin.getCurrentBreakpoint() == nil) + #expect(current.peek() == nil) + context.registry.teardown() + #expect(context.registry.isEmpty) + } +}