diff --git a/Benchmarks/LayoutPerformanceBenchmark/LayoutPerformanceBenchmark.swift b/Benchmarks/LayoutPerformanceBenchmark/LayoutPerformanceBenchmark.swift index 539f533013e..9ccc3277753 100644 --- a/Benchmarks/LayoutPerformanceBenchmark/LayoutPerformanceBenchmark.swift +++ b/Benchmarks/LayoutPerformanceBenchmark/LayoutPerformanceBenchmark.swift @@ -10,10 +10,13 @@ protocol TestCaseView: View { #if BENCHMARK_VIZ import DefaultBackend + @MainActor + var visualizationSize: (width: Int?, height: Int?) = (nil, nil) + struct VizApp: App { var body: some Scene { - WindowGroup("Benchmark visualisation") { - V() + WindowGroup("Benchmark visualization") { + V().frame(width: visualizationSize.width, height: visualizationSize.height) } } } @@ -34,36 +37,39 @@ struct Benchmarks { } @MainActor - func updateNode(_ node: ViewGraphNode, _ size: SIMD2) { - _ = node.update(proposedSize: size, environment: environment, dryRun: true) - _ = node.update(proposedSize: size, environment: environment, dryRun: false) + func updateNode(_ node: ViewGraphNode, _ size: ProposedViewSize) { + _ = node.computeLayout(proposedSize: size, environment: environment) + _ = node.commit() } #if BENCHMARK_VIZ - var benchmarkVisualizations: [(name: String, main: () -> Never)] = [] + var benchmarkVisualizations: [ + (name: String, size: ProposedViewSize, main: () -> Never) + ] = [] #endif @MainActor - func benchmarkLayout(of viewType: V.Type, _ size: SIMD2, _ label: String) { + func benchmarkLayout(of viewType: V.Type, _ size: ProposedViewSize, _ label: String) { #if BENCHMARK_VIZ benchmarkVisualizations.append(( label, + size, { VizApp.main() exit(0) } )) #else - let node = makeNode(V()) benchmark(label) { @MainActor in + let node = makeNode(V()) updateNode(node, size) } #endif } // Register benchmarks - benchmarkLayout(of: GridView.self, SIMD2(800, 800), "grid") - benchmarkLayout(of: ScrollableMessageListView.self, SIMD2(800, 800), "message list") + benchmarkLayout(of: GridView.self, ProposedViewSize(800, 800), "grid") + benchmarkLayout(of: ScrollableMessageListView.self, ProposedViewSize(800, 800), "message list") #if BENCHMARK_VIZ let names = benchmarkVisualizations.map(\.name).joined(separator: " | ") @@ -82,6 +88,11 @@ struct Benchmarks { exit(1) } + visualizationSize = ( + benchmark.size.width.map(Int.init), + benchmark.size.height.map(Int.init) + ) + benchmark.main() #else await Benchmark.main() diff --git a/Examples/Sources/NotesExample/ContentView.swift b/Examples/Sources/NotesExample/ContentView.swift index 05f74d7c5d7..23b36df3caf 100644 --- a/Examples/Sources/NotesExample/ContentView.swift +++ b/Examples/Sources/NotesExample/ContentView.swift @@ -130,21 +130,25 @@ struct ContentView: View { } .frame(minWidth: 200) } detail: { - ScrollView { - VStack(alignment: .center) { - if let selectedNote = selectedNote { - HStack(spacing: 4) { - Text("Title") - TextField("Title", text: selectedNote.title) - } + GeometryReader { proxy in + ScrollView { + VStack(alignment: .center) { + if let selectedNote = selectedNote { + HStack(spacing: 4) { + Text("Title") + TextField("Title", text: selectedNote.title) + } - TextEditor(text: selectedNote.content) - .padding() - .background(textEditorBackground) - .cornerRadius(4) + TextEditor(text: selectedNote.content) + .padding() + .background(textEditorBackground) + .cornerRadius(4) + .frame(maxHeight: .infinity) + } } + .padding() + .frame(minHeight: Int(proxy.size.height)) } - .padding() } } } diff --git a/Examples/Sources/PathsExample/PathsApp.swift b/Examples/Sources/PathsExample/PathsApp.swift index 1f657eff3cb..754ff2bad46 100644 --- a/Examples/Sources/PathsExample/PathsApp.swift +++ b/Examples/Sources/PathsExample/PathsApp.swift @@ -23,17 +23,8 @@ struct ArcShape: StyledShape { } func size(fitting proposal: SIMD2) -> ViewSize { - let diameter = max(11, min(proposal.x, proposal.y)) - return ViewSize( - size: SIMD2(x: diameter, y: diameter), - idealSize: SIMD2(x: 100, y: 100), - idealWidthForProposedHeight: proposal.y, - idealHeightForProposedWidth: proposal.x, - minimumWidth: 11, - minimumHeight: 11, - maximumWidth: nil, - maximumHeight: nil - ) + let diameter = Double(max(11, min(proposal.x, proposal.y))) + return ViewSize(diameter, diameter) } } diff --git a/Examples/Sources/WebViewExample/WebViewApp.swift b/Examples/Sources/WebViewExample/WebViewApp.swift index fb11609c57e..a1510c7dff6 100644 --- a/Examples/Sources/WebViewExample/WebViewApp.swift +++ b/Examples/Sources/WebViewExample/WebViewApp.swift @@ -13,18 +13,26 @@ struct WebViewApp: App { @State var url = URL(string: "https://stackotter.dev")! + func go(_ url: String) { + guard let url = URL(string: urlInput) else { + return + } + + self.url = url + } + var body: some Scene { WindowGroup("WebViewExample") { #hotReloadable { VStack { HStack { TextField("URL", text: $urlInput) - Button("Go") { - guard let url = URL(string: urlInput) else { - return // disabled + .onSubmit { + go(urlInput) } - self.url = url + Button("Go") { + go(urlInput) }.disabled(URL(string: urlInput) == nil) } .padding() @@ -36,5 +44,6 @@ struct WebViewApp: App { } } } + .defaultSize(width: 800, height: 800) } } diff --git a/Examples/Sources/WindowingExample/WindowingApp.swift b/Examples/Sources/WindowingExample/WindowingApp.swift index 40491519239..29056b06053 100644 --- a/Examples/Sources/WindowingExample/WindowingApp.swift +++ b/Examples/Sources/WindowingExample/WindowingApp.swift @@ -177,6 +177,8 @@ struct WindowingApp: App { } Image(Bundle.module.bundleURL.appendingPathComponent("Banner.png")) + .resizable() + .aspectRatio(contentMode: .fit) Divider() diff --git a/Package.swift b/Package.swift index 6d49268f6f2..2bc9c38117b 100644 --- a/Package.swift +++ b/Package.swift @@ -163,6 +163,7 @@ let package = Package( name: "SwiftCrossUITests", dependencies: [ "SwiftCrossUI", + "DummyBackend", .target(name: "AppKitBackend", condition: .when(platforms: [.macOS])), ] ), diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index 32adf784ebc..1d2d2e7487e 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -31,7 +31,7 @@ public final class AppKitBackend: AppBackend { // We assume that all scrollers have their controlSize set to `.regular` by default. // The internet seems to indicate that this is true regardless of any system wide // preferences etc. - Int( + return Int( NSScroller.scrollerWidth( for: .regular, scrollerStyle: NSScroller.preferredScrollerStyle @@ -523,32 +523,17 @@ public final class AppKitBackend: AppBackend { public func size( of text: String, whenDisplayedIn widget: Widget, - proposedFrame: SIMD2?, + proposedWidth: Int?, + proposedHeight: Int?, environment: EnvironmentValues ) -> SIMD2 { - if let proposedFrame, proposedFrame.x == 0 { - // We want the text to have the same height as it would have if it were - // one pixel wide so that the layout doesn't suddely jump when the text - // reaches zero width. - let size = size( - of: text, - whenDisplayedIn: widget, - proposedFrame: SIMD2(1, proposedFrame.y), - environment: environment - ) - return SIMD2( - 0, - size.y - ) - } - let proposedSize = NSSize( - width: (proposedFrame?.x).map(CGFloat.init) ?? 0, - height: .greatestFiniteMagnitude + width: proposedWidth.map(Double.init) ?? .greatestFiniteMagnitude, + height: proposedHeight.map(Double.init) ?? .greatestFiniteMagnitude ) let rect = NSString(string: text).boundingRect( with: proposedSize, - options: [.usesLineFragmentOrigin], + options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine], attributes: Self.attributes(forTextIn: environment) ) return SIMD2( @@ -564,6 +549,7 @@ public final class AppKitBackend: AppBackend { // styles when clicked (yeah that happens...) field.allowsEditingTextAttributes = true field.isSelectable = false + field.cell?.truncatesLastVisibleLine = true return field } @@ -2253,7 +2239,9 @@ public class NSCustomWindow: NSWindow { } let contentSize = sender.contentRect( - forFrameRect: NSRect(x: 0, y: 0, width: frameSize.width, height: frameSize.height) + forFrameRect: NSRect( + x: sender.frame.origin.x, y: sender.frame.origin.y, width: frameSize.width, + height: frameSize.height) ) resizeHandler( diff --git a/Sources/AppKitBackend/NSViewRepresentable.swift b/Sources/AppKitBackend/NSViewRepresentable.swift index 115b323a8a3..498518f09ac 100644 --- a/Sources/AppKitBackend/NSViewRepresentable.swift +++ b/Sources/AppKitBackend/NSViewRepresentable.swift @@ -55,7 +55,7 @@ public protocol NSViewRepresentable: View where Content == Never { /// for the maximum width/height if the view has no maximum size (and /// therefore may occupy the entire screen). func determineViewSize( - for proposal: SIMD2, + for proposal: ProposedViewSize, nsView: NSViewType, context: NSViewRepresentableContext ) -> ViewSize @@ -76,34 +76,16 @@ extension NSViewRepresentable { } public func determineViewSize( - for proposal: SIMD2, nsView: NSViewType, + for proposal: ProposedViewSize, + nsView: NSViewType, context _: NSViewRepresentableContext ) -> ViewSize { let intrinsicSize = nsView.intrinsicContentSize let sizeThatFits = nsView.fittingSize - let roundedSizeThatFits = SIMD2( - Int(sizeThatFits.width.rounded(.up)), - Int(sizeThatFits.height.rounded(.up))) - let roundedIntrinsicSize = SIMD2( - Int(intrinsicSize.width.rounded(.awayFromZero)), - Int(intrinsicSize.height.rounded(.awayFromZero))) - return ViewSize( - size: SIMD2( - intrinsicSize.width < 0.0 ? proposal.x : roundedSizeThatFits.x, - intrinsicSize.height < 0.0 ? proposal.y : roundedSizeThatFits.y - ), - // The 10 here is a somewhat arbitrary constant value so that it's always the same. - // See also `Color` and `Picker`, which use the same constant. - idealSize: SIMD2( - intrinsicSize.width < 0.0 ? 10 : roundedIntrinsicSize.x, - intrinsicSize.height < 0.0 ? 10 : roundedIntrinsicSize.y - ), - minimumWidth: max(0, roundedIntrinsicSize.x), - minimumHeight: max(0, roundedIntrinsicSize.x), - maximumWidth: nil, - maximumHeight: nil + intrinsicSize.width < 0 ? (proposal.width ?? 10) : sizeThatFits.width, + intrinsicSize.height < 0 ? (proposal.height ?? 10) : sizeThatFits.height ) } } @@ -139,15 +121,14 @@ extension View where Self: NSViewRepresentable { } } - public func update( + public func computeLayout( _ widget: Backend.Widget, children: any ViewGraphNodeChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - guard let backend = backend as? AppKitBackend else { + backend: Backend + ) -> ViewLayoutResult { + guard backend is AppKitBackend else { fatalError("NSViewRepresentable updated by \(Backend.self)") } @@ -160,11 +141,17 @@ extension View where Self: NSViewRepresentable { context: representingWidget.context! ) - if !dryRun { - backend.setSize(of: representingWidget, to: size.size) - } + return ViewLayoutResult.leafView(size: size) + } - return ViewUpdateResult.leafView(size: size) + public func commit( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + backend.setSize(of: widget, to: layout.size.vector) } } diff --git a/Sources/DummyBackend/DummyBackend.swift b/Sources/DummyBackend/DummyBackend.swift index ccc6cb9f729..28dbabab667 100644 --- a/Sources/DummyBackend/DummyBackend.swift +++ b/Sources/DummyBackend/DummyBackend.swift @@ -1,5 +1,5 @@ -import SwiftCrossUI import Foundation +import SwiftCrossUI public final class DummyBackend: AppBackend { public class Window { @@ -17,6 +17,23 @@ public final class DummyBackend: AppBackend { } } + public class BreadthFirstWidgetIterator: IteratorProtocol { + var queue: [Widget] + + init(for widget: Widget) { + queue = [widget] + } + + public func next() -> Widget? { + guard let next = queue.first else { + return nil + } + queue.removeFirst() + queue.append(contentsOf: next.getChildren()) + return next + } + } + public class Widget { public var tag: String? public var cornerRadius = 0 @@ -24,6 +41,22 @@ public final class DummyBackend: AppBackend { public var naturalSize: SIMD2 { SIMD2.zero } + + public func getChildren() -> [Widget] { + [] + } + + /// Finds the first widget of type `T` in the hierarchy defined by this + /// widget (including the widget itself). + public func firstWidget(ofType type: T.Type) -> T? { + let iterator = BreadthFirstWidgetIterator(for: self) + while let child = iterator.next() { + if let child = child as? T { + return child + } + } + return nil + } } public class Button: Widget { @@ -64,7 +97,7 @@ public final class DummyBackend: AppBackend { public var maximumValue: Double = 100 public var decimalPlaces = 1 public var changeHandler: ((Double) -> Void)? - + override public var naturalSize: SIMD2 { SIMD2(20, 10) } @@ -95,10 +128,18 @@ public final class DummyBackend: AppBackend { public var columnLabels: [String] = [] public var cells: [Widget] = [] public var rowHeights: [Int] = [] + + public override func getChildren() -> [Widget] { + cells + } } public class Container: Widget { public var children: [(widget: Widget, position: SIMD2)] = [] + + public override func getChildren() -> [Widget] { + children.map(\.widget) + } } public class ScrollContainer: Widget { @@ -109,6 +150,10 @@ public final class DummyBackend: AppBackend { public init(child: Widget) { self.child = child } + + public override func getChildren() -> [Widget] { + [child] + } } public class SelectableListView: Widget { @@ -116,6 +161,10 @@ public final class DummyBackend: AppBackend { public var rowHeights: [Int] = [] public var selectionHandler: ((Int) -> Void)? public var selectedIndex: Int? + + public override func getChildren() -> [Widget] { + items + } } public class Rectangle: Widget { @@ -175,6 +224,10 @@ public final class DummyBackend: AppBackend { self.leadingChild = leadingChild self.trailingChild = trailingChild } + + public override func getChildren() -> [Widget] { + [leadingChild, trailingChild] + } } public class Menu {} @@ -195,6 +248,8 @@ public final class DummyBackend: AppBackend { public var deviceClass = DeviceClass.desktop public var canRevealFiles = false + public var incomingURLHandler: ((URL) -> Void)? + public init() {} public func runMainLoop(_ callback: @escaping @MainActor () -> Void) { @@ -233,7 +288,8 @@ public final class DummyBackend: AppBackend { window.minimumSize = minimumSize } - public func setResizeHandler(ofWindow window: Window, to action: @escaping (SIMD2) -> Void) { + public func setResizeHandler(ofWindow window: Window, to action: @escaping (SIMD2) -> Void) + { window.resizeHandler = action } @@ -253,11 +309,19 @@ public final class DummyBackend: AppBackend { public func setRootEnvironmentChangeHandler(to action: @escaping () -> Void) {} - public func computeWindowEnvironment(window: Window, rootEnvironment: EnvironmentValues) -> EnvironmentValues { + public func computeWindowEnvironment(window: Window, rootEnvironment: EnvironmentValues) + -> EnvironmentValues + { rootEnvironment } - public func setWindowEnvironmentChangeHandler(of window: Window, to action: @escaping () -> Void) {} + public func setWindowEnvironmentChangeHandler( + of window: Window, to action: @escaping () -> Void + ) {} + + public func setIncomingURLHandler(to action: @escaping (URL) -> Void) { + incomingURLHandler = action + } public func show(widget: Widget) {} @@ -317,7 +381,10 @@ public final class DummyBackend: AppBackend { public func updateScrollContainer(_ scrollView: Widget, environment: EnvironmentValues) {} - public func setScrollBarPresence(ofScrollContainer scrollView: Widget, hasVerticalScrollBar: Bool, hasHorizontalScrollBar: Bool) { + public func setScrollBarPresence( + ofScrollContainer scrollView: Widget, hasVerticalScrollBar: Bool, + hasHorizontalScrollBar: Bool + ) { let scrollContainer = scrollView as! ScrollContainer scrollContainer.hasVerticalScrollBar = hasVerticalScrollBar scrollContainer.hasHorizontalScrollBar = hasHorizontalScrollBar @@ -335,13 +402,17 @@ public final class DummyBackend: AppBackend { .zero } - public func setItems(ofSelectableListView listView: Widget, to items: [Widget], withRowHeights rowHeights: [Int]) { + public func setItems( + ofSelectableListView listView: Widget, to items: [Widget], withRowHeights rowHeights: [Int] + ) { let selectableListView = listView as! SelectableListView selectableListView.items = items selectableListView.rowHeights = rowHeights } - public func setSelectionHandler(forSelectableListView listView: Widget, to action: @escaping (Int) -> Void) { + public func setSelectionHandler( + forSelectableListView listView: Widget, to action: @escaping (Int) -> Void + ) { (listView as! SelectableListView).selectionHandler = action } @@ -364,29 +435,41 @@ public final class DummyBackend: AppBackend { (splitView as! SplitView).sidebarWidth } - public func setSidebarWidthBounds(ofSplitView splitView: Widget, minimum minimumWidth: Int, maximum maximumWidth: Int) { + public func setSidebarWidthBounds( + ofSplitView splitView: Widget, minimum minimumWidth: Int, maximum maximumWidth: Int + ) { let splitView = splitView as! SplitView splitView.minimumSidebarWidth = minimumWidth splitView.maximumSidebarWidth = maximumWidth } - public func size(of text: String, whenDisplayedIn widget: Widget, proposedFrame: SIMD2?, environment: EnvironmentValues) -> SIMD2 { + public func size( + of text: String, + whenDisplayedIn widget: Widget, + proposedWidth: Int?, + proposedHeight: Int?, + environment: EnvironmentValues + ) -> SIMD2 { let resolvedFont = environment.resolvedFont let lineHeight = Int(resolvedFont.lineHeight) let characterHeight = Int(resolvedFont.pointSize) let characterWidth = characterHeight * 2 / 3 - guard let proposedFrame else { + guard let proposedWidth else { return SIMD2( characterWidth * text.count, lineHeight ) } - let charactersPerLine = max(1, proposedFrame.x / characterWidth) - let lineCount = (text.count + charactersPerLine - 1) / charactersPerLine + let charactersPerLine = max(1, proposedWidth / characterWidth) + var lineCount = (text.count + charactersPerLine - 1) / charactersPerLine + if let proposedHeight { + lineCount = min(max(1, proposedHeight / lineHeight), lineCount) + } + return SIMD2( - characterWidth * charactersPerLine, + characterWidth * min(charactersPerLine, text.count), lineHeight * lineCount ) } @@ -395,7 +478,8 @@ public final class DummyBackend: AppBackend { TextView() } - public func updateTextView(_ textView: Widget, content: String, environment: EnvironmentValues) { + public func updateTextView(_ textView: Widget, content: String, environment: EnvironmentValues) + { let textView = textView as! TextView textView.content = content textView.color = environment.suggestedForegroundColor @@ -406,7 +490,10 @@ public final class DummyBackend: AppBackend { ImageView() } - public func updateImageView(_ imageView: Widget, rgbaData: [UInt8], width: Int, height: Int, targetWidth: Int, targetHeight: Int, dataHasChanged: Bool, environment: EnvironmentValues) { + public func updateImageView( + _ imageView: Widget, rgbaData: [UInt8], width: Int, height: Int, targetWidth: Int, + targetHeight: Int, dataHasChanged: Bool, environment: EnvironmentValues + ) { let imageView = imageView as! ImageView imageView.rgbaData = rgbaData imageView.pixelWidth = width @@ -421,11 +508,15 @@ public final class DummyBackend: AppBackend { (table as! Table).rowCount = rows } - public func setColumnLabels(ofTable table: Widget, to labels: [String], environment: EnvironmentValues) { + public func setColumnLabels( + ofTable table: Widget, to labels: [String], environment: EnvironmentValues + ) { (table as! Table).columnLabels = labels } - public func setCells(ofTable table: Widget, to cells: [Widget], withRowHeights rowHeights: [Int]) { + public func setCells( + ofTable table: Widget, to cells: [Widget], withRowHeights rowHeights: [Int] + ) { let table = table as! Table table.cells = cells table.rowHeights = rowHeights @@ -435,13 +526,18 @@ public final class DummyBackend: AppBackend { Button() } - public func updateButton(_ button: Widget, label: String, environment: EnvironmentValues, action: @escaping () -> Void) { + public func updateButton( + _ button: Widget, label: String, environment: EnvironmentValues, + action: @escaping () -> Void + ) { let button = button as! Button button.label = label button.action = action } - public func updateButton(_ button: Widget, label: String, menu: Menu, environment: EnvironmentValues) { + public func updateButton( + _ button: Widget, label: String, menu: Menu, environment: EnvironmentValues + ) { let button = button as! Button button.label = label button.menu = menu @@ -452,7 +548,10 @@ public final class DummyBackend: AppBackend { ToggleButton() } - public func updateToggle(_ toggle: Widget, label: String, environment: EnvironmentValues, onChange: @escaping (Bool) -> Void) { + public func updateToggle( + _ toggle: Widget, label: String, environment: EnvironmentValues, + onChange: @escaping (Bool) -> Void + ) { let toggle = toggle as! ToggleButton toggle.label = label toggle.toggleHandler = onChange @@ -467,7 +566,10 @@ public final class DummyBackend: AppBackend { ToggleSwitch() } - public func updateSwitch(_ switchWidget: Widget, environment: SwiftCrossUI.EnvironmentValues, onChange: @escaping (Bool) -> Void) { + public func updateSwitch( + _ switchWidget: Widget, environment: SwiftCrossUI.EnvironmentValues, + onChange: @escaping (Bool) -> Void + ) { (switchWidget as! ToggleSwitch).toggleHandler = onChange } @@ -479,7 +581,10 @@ public final class DummyBackend: AppBackend { Checkbox() } - public func updateCheckbox(_ checkboxWidget: Widget, environment: SwiftCrossUI.EnvironmentValues, onChange: @escaping (Bool) -> Void) { + public func updateCheckbox( + _ checkboxWidget: Widget, environment: SwiftCrossUI.EnvironmentValues, + onChange: @escaping (Bool) -> Void + ) { (checkboxWidget as! Checkbox).toggleHandler = onChange } @@ -491,7 +596,10 @@ public final class DummyBackend: AppBackend { Slider() } - public func updateSlider(_ slider: Widget, minimum: Double, maximum: Double, decimalPlaces: Int, environment: SwiftCrossUI.EnvironmentValues, onChange: @escaping (Double) -> Void) { + public func updateSlider( + _ slider: Widget, minimum: Double, maximum: Double, decimalPlaces: Int, + environment: SwiftCrossUI.EnvironmentValues, onChange: @escaping (Double) -> Void + ) { let slider = slider as! Slider slider.minimumValue = minimum slider.maximumValue = maximum @@ -507,7 +615,10 @@ public final class DummyBackend: AppBackend { TextField() } - public func updateTextField(_ textField: Widget, placeholder: String, environment: SwiftCrossUI.EnvironmentValues, onChange: @escaping (String) -> Void, onSubmit: @escaping () -> Void) { + public func updateTextField( + _ textField: Widget, placeholder: String, environment: SwiftCrossUI.EnvironmentValues, + onChange: @escaping (String) -> Void, onSubmit: @escaping () -> Void + ) { let textField = textField as! TextField textField.placeholder = placeholder textField.font = environment.resolvedFont @@ -524,142 +635,142 @@ public final class DummyBackend: AppBackend { } // public func createTextEditor() -> Widget { - + // } // public func updateTextEditor(_ textEditor: Widget, environment: SwiftCrossUI.EnvironmentValues, onChange: @escaping (String) -> Void) { - + // } // public func setContent(ofTextEditor textEditor: Widget, to content: String) { - + // } // public func getContent(ofTextEditor textEditor: Widget) -> String { - + // } // public func createPicker() -> Widget { - + // } // public func updatePicker(_ picker: Widget, options: [String], environment: SwiftCrossUI.EnvironmentValues, onChange: @escaping (Int?) -> Void) { - + // } // public func setSelectedOption(ofPicker picker: Widget, to selectedOption: Int?) { - + // } // public func createProgressSpinner() -> Widget { - + // } // public func createProgressBar() -> Widget { - + // } // public func updateProgressBar(_ widget: Widget, progressFraction: Double?, environment: SwiftCrossUI.EnvironmentValues) { - + // } // public func createPopoverMenu() -> Menu { - + // } // public func updatePopoverMenu(_ menu: Menu, content: SwiftCrossUI.ResolvedMenu, environment: SwiftCrossUI.EnvironmentValues) { - + // } // public func showPopoverMenu(_ menu: Menu, at position: SIMD2, relativeTo widget: Widget, closeHandler handleClose: @escaping () -> Void) { - + // } // public func createAlert() -> Alert { - + // } // public func updateAlert(_ alert: Alert, title: String, actionLabels: [String], environment: SwiftCrossUI.EnvironmentValues) { - + // } // public func showAlert(_ alert: Alert, window: Window?, responseHandler handleResponse: @escaping (Int) -> Void) { - + // } // public func dismissAlert(_ alert: Alert, window: Window?) { - + // } // public func createSheet(content: Widget) -> Sheet { - + // } // public func updateSheet(_ sheet: Sheet, window: Window, environment: SwiftCrossUI.EnvironmentValues, size: SIMD2, onDismiss: @escaping () -> Void, cornerRadius: Double?, detents: [SwiftCrossUI.PresentationDetent], dragIndicatorVisibility: SwiftCrossUI.Visibility, backgroundColor: SwiftCrossUI.Color?, interactiveDismissDisabled: Bool) { - + // } // public func presentSheet(_ sheet: Sheet, window: Window, parentSheet: Sheet?) { - + // } // public func dismissSheet(_ sheet: Sheet, window: Window, parentSheet: Sheet?) { - + // } // public func size(ofSheet sheet: Sheet) -> SIMD2 { - + // } // public func showOpenDialog(fileDialogOptions: SwiftCrossUI.FileDialogOptions, openDialogOptions: SwiftCrossUI.OpenDialogOptions, window: Window?, resultHandler handleResult: @escaping (SwiftCrossUI.DialogResult<[URL]>) -> Void) { - + // } // public func showSaveDialog(fileDialogOptions: SwiftCrossUI.FileDialogOptions, saveDialogOptions: SwiftCrossUI.SaveDialogOptions, window: Window?, resultHandler handleResult: @escaping (SwiftCrossUI.DialogResult) -> Void) { - + // } // public func createTapGestureTarget(wrapping child: Widget, gesture: SwiftCrossUI.TapGesture) -> Widget { - + // } // public func updateTapGestureTarget(_ tapGestureTarget: Widget, gesture: SwiftCrossUI.TapGesture, environment: SwiftCrossUI.EnvironmentValues, action: @escaping () -> Void) { - + // } // public func createHoverTarget(wrapping child: Widget) -> Widget { - + // } // public func updateHoverTarget(_ hoverTarget: Widget, environment: SwiftCrossUI.EnvironmentValues, action: @escaping (Bool) -> Void) { - + // } // public func createPathWidget() -> Widget { - + // } // public func createPath() -> Path { - + // } // public func updatePath(_ path: Path, _ source: SwiftCrossUI.Path, bounds: SwiftCrossUI.Path.Rect, pointsChanged: Bool, environment: SwiftCrossUI.EnvironmentValues) { - + // } // public func renderPath(_ path: Path, container: Widget, strokeColor: SwiftCrossUI.Color, fillColor: SwiftCrossUI.Color, overrideStrokeStyle: SwiftCrossUI.StrokeStyle?) { - + // } // public func createWebView() -> Widget { - + // } // public func updateWebView(_ webView: Widget, environment: SwiftCrossUI.EnvironmentValues, onNavigate: @escaping (URL) -> Void) { - + // } // public func navigateWebView(_ webView: Widget, to url: URL) { - + // } } diff --git a/Sources/Gtk/Datatypes/EllipsizeMode.swift b/Sources/Gtk/Datatypes/EllipsizeMode.swift new file mode 100644 index 00000000000..a19050d8ee8 --- /dev/null +++ b/Sources/Gtk/Datatypes/EllipsizeMode.swift @@ -0,0 +1,42 @@ +import CGtk + +public enum EllipsizeMode: GValueRepresentableEnum { + public typealias GtkEnum = PangoEllipsizeMode + + case none + case start + case middle + case end + + public static var type: GType { + pango_ellipsize_mode_get_type() + } + + public init(from gtkEnum: PangoEllipsizeMode) { + switch gtkEnum { + case PANGO_ELLIPSIZE_NONE: + self = .none + case PANGO_ELLIPSIZE_START: + self = .start + case PANGO_ELLIPSIZE_MIDDLE: + self = .middle + case PANGO_ELLIPSIZE_END: + self = .end + default: + fatalError("Unsupported PangoEllipsizeMode enum value: \(gtkEnum.rawValue)") + } + } + + public func toGtk() -> PangoEllipsizeMode { + switch self { + case .none: + PANGO_ELLIPSIZE_NONE + case .start: + PANGO_ELLIPSIZE_START + case .middle: + PANGO_ELLIPSIZE_MIDDLE + case .end: + PANGO_ELLIPSIZE_END + } + } +} diff --git a/Sources/Gtk/Generated/Button.swift b/Sources/Gtk/Generated/Button.swift index b932a6ac929..b67ba671279 100644 --- a/Sources/Gtk/Generated/Button.swift +++ b/Sources/Gtk/Generated/Button.swift @@ -76,7 +76,7 @@ open class Button: Widget, Actionable { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() addSignal(name: "activate") { [weak self] () in diff --git a/Sources/Gtk/Generated/CheckButton.swift b/Sources/Gtk/Generated/CheckButton.swift index 287ea926a3c..1a2c80f5a30 100644 --- a/Sources/Gtk/Generated/CheckButton.swift +++ b/Sources/Gtk/Generated/CheckButton.swift @@ -85,7 +85,7 @@ open class CheckButton: Widget, Actionable { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() addSignal(name: "activate") { [weak self] () in diff --git a/Sources/Gtk/Generated/DrawingArea.swift b/Sources/Gtk/Generated/DrawingArea.swift index 4fa422c1dcb..7e4b0abe0c1 100644 --- a/Sources/Gtk/Generated/DrawingArea.swift +++ b/Sources/Gtk/Generated/DrawingArea.swift @@ -86,7 +86,7 @@ open class DrawingArea: Widget { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() let handler0: diff --git a/Sources/Gtk/Generated/DropDown.swift b/Sources/Gtk/Generated/DropDown.swift index 5ff024cbf5b..ffa69800d69 100644 --- a/Sources/Gtk/Generated/DropDown.swift +++ b/Sources/Gtk/Generated/DropDown.swift @@ -58,7 +58,7 @@ open class DropDown: Widget { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() addSignal(name: "activate") { [weak self] () in diff --git a/Sources/Gtk/Generated/Entry.swift b/Sources/Gtk/Generated/Entry.swift index 26b74f887b1..7f1b0ef2df1 100644 --- a/Sources/Gtk/Generated/Entry.swift +++ b/Sources/Gtk/Generated/Entry.swift @@ -97,7 +97,7 @@ open class Entry: Widget, CellEditable, Editable { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() addSignal(name: "activate") { [weak self] () in @@ -336,439 +336,415 @@ open class Entry: Widget, CellEditable, Editable { SignalBox1.run(data, value1) } - addSignal(name: "notify::menu-entry-icon-primary-text", handler: gCallback(handler21)) { - [weak self] (param0: OpaquePointer) in - guard let self = self else { return } - self.notifyMenuEntryIconPrimaryText?(self, param0) - } - - let handler22: - @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = - { _, value1, data in - SignalBox1.run(data, value1) - } - - addSignal(name: "notify::menu-entry-icon-secondary-text", handler: gCallback(handler22)) { - [weak self] (param0: OpaquePointer) in - guard let self = self else { return } - self.notifyMenuEntryIconSecondaryText?(self, param0) - } - - let handler23: - @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = - { _, value1, data in - SignalBox1.run(data, value1) - } - - addSignal(name: "notify::overwrite-mode", handler: gCallback(handler23)) { + addSignal(name: "notify::overwrite-mode", handler: gCallback(handler21)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyOverwriteMode?(self, param0) } - let handler24: + let handler22: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::placeholder-text", handler: gCallback(handler24)) { + addSignal(name: "notify::placeholder-text", handler: gCallback(handler22)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyPlaceholderText?(self, param0) } - let handler25: + let handler23: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::primary-icon-activatable", handler: gCallback(handler25)) { + addSignal(name: "notify::primary-icon-activatable", handler: gCallback(handler23)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyPrimaryIconActivatable?(self, param0) } - let handler26: + let handler24: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::primary-icon-gicon", handler: gCallback(handler26)) { + addSignal(name: "notify::primary-icon-gicon", handler: gCallback(handler24)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyPrimaryIconGicon?(self, param0) } - let handler27: + let handler25: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::primary-icon-name", handler: gCallback(handler27)) { + addSignal(name: "notify::primary-icon-name", handler: gCallback(handler25)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyPrimaryIconName?(self, param0) } - let handler28: + let handler26: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::primary-icon-paintable", handler: gCallback(handler28)) { + addSignal(name: "notify::primary-icon-paintable", handler: gCallback(handler26)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyPrimaryIconPaintable?(self, param0) } - let handler29: + let handler27: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::primary-icon-sensitive", handler: gCallback(handler29)) { + addSignal(name: "notify::primary-icon-sensitive", handler: gCallback(handler27)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyPrimaryIconSensitive?(self, param0) } - let handler30: + let handler28: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::primary-icon-storage-type", handler: gCallback(handler30)) { + addSignal(name: "notify::primary-icon-storage-type", handler: gCallback(handler28)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyPrimaryIconStorageType?(self, param0) } - let handler31: + let handler29: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::primary-icon-tooltip-markup", handler: gCallback(handler31)) { + addSignal(name: "notify::primary-icon-tooltip-markup", handler: gCallback(handler29)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyPrimaryIconTooltipMarkup?(self, param0) } - let handler32: + let handler30: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::primary-icon-tooltip-text", handler: gCallback(handler32)) { + addSignal(name: "notify::primary-icon-tooltip-text", handler: gCallback(handler30)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyPrimaryIconTooltipText?(self, param0) } - let handler33: + let handler31: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::progress-fraction", handler: gCallback(handler33)) { + addSignal(name: "notify::progress-fraction", handler: gCallback(handler31)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyProgressFraction?(self, param0) } - let handler34: + let handler32: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::progress-pulse-step", handler: gCallback(handler34)) { + addSignal(name: "notify::progress-pulse-step", handler: gCallback(handler32)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyProgressPulseStep?(self, param0) } - let handler35: + let handler33: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::scroll-offset", handler: gCallback(handler35)) { + addSignal(name: "notify::scroll-offset", handler: gCallback(handler33)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyScrollOffset?(self, param0) } - let handler36: + let handler34: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::secondary-icon-activatable", handler: gCallback(handler36)) { + addSignal(name: "notify::secondary-icon-activatable", handler: gCallback(handler34)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifySecondaryIconActivatable?(self, param0) } - let handler37: + let handler35: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::secondary-icon-gicon", handler: gCallback(handler37)) { + addSignal(name: "notify::secondary-icon-gicon", handler: gCallback(handler35)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifySecondaryIconGicon?(self, param0) } - let handler38: + let handler36: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::secondary-icon-name", handler: gCallback(handler38)) { + addSignal(name: "notify::secondary-icon-name", handler: gCallback(handler36)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifySecondaryIconName?(self, param0) } - let handler39: + let handler37: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::secondary-icon-paintable", handler: gCallback(handler39)) { + addSignal(name: "notify::secondary-icon-paintable", handler: gCallback(handler37)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifySecondaryIconPaintable?(self, param0) } - let handler40: + let handler38: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::secondary-icon-sensitive", handler: gCallback(handler40)) { + addSignal(name: "notify::secondary-icon-sensitive", handler: gCallback(handler38)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifySecondaryIconSensitive?(self, param0) } - let handler41: + let handler39: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::secondary-icon-storage-type", handler: gCallback(handler41)) { + addSignal(name: "notify::secondary-icon-storage-type", handler: gCallback(handler39)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifySecondaryIconStorageType?(self, param0) } - let handler42: + let handler40: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::secondary-icon-tooltip-markup", handler: gCallback(handler42)) { + addSignal(name: "notify::secondary-icon-tooltip-markup", handler: gCallback(handler40)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifySecondaryIconTooltipMarkup?(self, param0) } - let handler43: + let handler41: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::secondary-icon-tooltip-text", handler: gCallback(handler43)) { + addSignal(name: "notify::secondary-icon-tooltip-text", handler: gCallback(handler41)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifySecondaryIconTooltipText?(self, param0) } - let handler44: + let handler42: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::show-emoji-icon", handler: gCallback(handler44)) { + addSignal(name: "notify::show-emoji-icon", handler: gCallback(handler42)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyShowEmojiIcon?(self, param0) } - let handler45: + let handler43: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::tabs", handler: gCallback(handler45)) { + addSignal(name: "notify::tabs", handler: gCallback(handler43)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyTabs?(self, param0) } - let handler46: + let handler44: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::text-length", handler: gCallback(handler46)) { + addSignal(name: "notify::text-length", handler: gCallback(handler44)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyTextLength?(self, param0) } - let handler47: + let handler45: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::truncate-multiline", handler: gCallback(handler47)) { + addSignal(name: "notify::truncate-multiline", handler: gCallback(handler45)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyTruncateMultiline?(self, param0) } - let handler48: + let handler46: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::visibility", handler: gCallback(handler48)) { + addSignal(name: "notify::visibility", handler: gCallback(handler46)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyVisibility?(self, param0) } - let handler49: + let handler47: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::editing-canceled", handler: gCallback(handler49)) { + addSignal(name: "notify::editing-canceled", handler: gCallback(handler47)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyEditingCanceled?(self, param0) } - let handler50: + let handler48: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::cursor-position", handler: gCallback(handler50)) { + addSignal(name: "notify::cursor-position", handler: gCallback(handler48)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyCursorPosition?(self, param0) } - let handler51: + let handler49: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::editable", handler: gCallback(handler51)) { + addSignal(name: "notify::editable", handler: gCallback(handler49)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyEditable?(self, param0) } - let handler52: + let handler50: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::enable-undo", handler: gCallback(handler52)) { + addSignal(name: "notify::enable-undo", handler: gCallback(handler50)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyEnableUndo?(self, param0) } - let handler53: + let handler51: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::max-width-chars", handler: gCallback(handler53)) { + addSignal(name: "notify::max-width-chars", handler: gCallback(handler51)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyMaxWidthChars?(self, param0) } - let handler54: + let handler52: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::selection-bound", handler: gCallback(handler54)) { + addSignal(name: "notify::selection-bound", handler: gCallback(handler52)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifySelectionBound?(self, param0) } - let handler55: + let handler53: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::text", handler: gCallback(handler55)) { + addSignal(name: "notify::text", handler: gCallback(handler53)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyText?(self, param0) } - let handler56: + let handler54: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::width-chars", handler: gCallback(handler56)) { + addSignal(name: "notify::width-chars", handler: gCallback(handler54)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyWidthChars?(self, param0) } - let handler57: + let handler55: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::xalign", handler: gCallback(handler57)) { + addSignal(name: "notify::xalign", handler: gCallback(handler55)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyXalign?(self, param0) @@ -937,10 +913,6 @@ open class Entry: Widget, CellEditable, Editable { public var notifyMaxLength: ((Entry, OpaquePointer) -> Void)? - public var notifyMenuEntryIconPrimaryText: ((Entry, OpaquePointer) -> Void)? - - public var notifyMenuEntryIconSecondaryText: ((Entry, OpaquePointer) -> Void)? - public var notifyOverwriteMode: ((Entry, OpaquePointer) -> Void)? public var notifyPlaceholderText: ((Entry, OpaquePointer) -> Void)? diff --git a/Sources/Gtk/Generated/EventController.swift b/Sources/Gtk/Generated/EventController.swift index c29ce1254ae..77009b50586 100644 --- a/Sources/Gtk/Generated/EventController.swift +++ b/Sources/Gtk/Generated/EventController.swift @@ -14,7 +14,7 @@ import CGtk /// phases of event propagation. open class EventController: GObject { - public override func registerSignals() { + open override func registerSignals() { super.registerSignals() let handler0: diff --git a/Sources/Gtk/Generated/EventControllerKey.swift b/Sources/Gtk/Generated/EventControllerKey.swift index 99ffe0301a7..759ed79694d 100644 --- a/Sources/Gtk/Generated/EventControllerKey.swift +++ b/Sources/Gtk/Generated/EventControllerKey.swift @@ -9,7 +9,7 @@ open class EventControllerKey: EventController { ) } - public override func registerSignals() { + open override func registerSignals() { super.registerSignals() addSignal(name: "im-update") { [weak self] () in diff --git a/Sources/Gtk/Generated/EventControllerMotion.swift b/Sources/Gtk/Generated/EventControllerMotion.swift index 152ca94e59e..2e31c87d055 100644 --- a/Sources/Gtk/Generated/EventControllerMotion.swift +++ b/Sources/Gtk/Generated/EventControllerMotion.swift @@ -16,7 +16,7 @@ open class EventControllerMotion: EventController { ) } - public override func registerSignals() { + open override func registerSignals() { super.registerSignals() let handler0: diff --git a/Sources/Gtk/Generated/FileChooserNative.swift b/Sources/Gtk/Generated/FileChooserNative.swift index ad788b8891d..4e6560b21ab 100644 --- a/Sources/Gtk/Generated/FileChooserNative.swift +++ b/Sources/Gtk/Generated/FileChooserNative.swift @@ -157,7 +157,7 @@ open class FileChooserNative: NativeDialog, FileChooser { ) } - public override func registerSignals() { + open override func registerSignals() { super.registerSignals() let handler0: diff --git a/Sources/Gtk/Generated/FilterChange.swift b/Sources/Gtk/Generated/FilterChange.swift index ef229a31489..39c9f528a09 100644 --- a/Sources/Gtk/Generated/FilterChange.swift +++ b/Sources/Gtk/Generated/FilterChange.swift @@ -6,8 +6,6 @@ import CGtk /// If you are writing an implementation and are not sure which /// value to pass, `GTK_FILTER_CHANGE_DIFFERENT` is always a correct /// choice. -/// -/// New values may be added in the future. public enum FilterChange: GValueRepresentableEnum { public typealias GtkEnum = GtkFilterChange diff --git a/Sources/Gtk/Generated/GLArea.swift b/Sources/Gtk/Generated/GLArea.swift index 5247e75482a..e22ffbf9afd 100644 --- a/Sources/Gtk/Generated/GLArea.swift +++ b/Sources/Gtk/Generated/GLArea.swift @@ -41,13 +41,6 @@ import CGtk /// glClearColor (0, 0, 0, 0); /// glClear (GL_COLOR_BUFFER_BIT); /// -/// // record the active framebuffer ID, so we can return to it -/// // with `glBindFramebuffer (GL_FRAMEBUFFER, screen_fb)` should -/// // we, for instance, intend on utilizing the results of an -/// // intermediate render texture pass -/// GLuint screen_fb = 0; -/// glGetIntegerv (GL_FRAMEBUFFER_BINDING, &screen_fb); -/// /// // draw your object /// // draw_an_object (); /// @@ -121,7 +114,7 @@ open class GLArea: Widget { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() addSignal(name: "create-context") { [weak self] () in diff --git a/Sources/Gtk/Generated/Gesture.swift b/Sources/Gtk/Generated/Gesture.swift index 9b9b702cbf5..7eb196783d7 100644 --- a/Sources/Gtk/Generated/Gesture.swift +++ b/Sources/Gtk/Generated/Gesture.swift @@ -91,7 +91,7 @@ import CGtk /// %GDK_TOUCHPAD_SWIPE and %GDK_TOUCHPAD_PINCH are handled by the `GtkGesture` open class Gesture: EventController { - public override func registerSignals() { + open override func registerSignals() { super.registerSignals() let handler0: diff --git a/Sources/Gtk/Generated/GestureClick.swift b/Sources/Gtk/Generated/GestureClick.swift index f71c8f6c848..be3e7d36cca 100644 --- a/Sources/Gtk/Generated/GestureClick.swift +++ b/Sources/Gtk/Generated/GestureClick.swift @@ -16,7 +16,7 @@ open class GestureClick: GestureSingle { ) } - public override func registerSignals() { + open override func registerSignals() { super.registerSignals() let handler0: diff --git a/Sources/Gtk/Generated/GestureLongPress.swift b/Sources/Gtk/Generated/GestureLongPress.swift index ee0eaaa4dff..2a412ad8012 100644 --- a/Sources/Gtk/Generated/GestureLongPress.swift +++ b/Sources/Gtk/Generated/GestureLongPress.swift @@ -23,7 +23,7 @@ open class GestureLongPress: GestureSingle { ) } - public override func registerSignals() { + open override func registerSignals() { super.registerSignals() addSignal(name: "cancelled") { [weak self] () in diff --git a/Sources/Gtk/Generated/GestureSingle.swift b/Sources/Gtk/Generated/GestureSingle.swift index c131329c8ab..2c9f0a009c5 100644 --- a/Sources/Gtk/Generated/GestureSingle.swift +++ b/Sources/Gtk/Generated/GestureSingle.swift @@ -15,7 +15,7 @@ import CGtk /// [method@Gtk.GestureSingle.get_current_button]. open class GestureSingle: Gesture { - public override func registerSignals() { + open override func registerSignals() { super.registerSignals() let handler0: diff --git a/Sources/Gtk/Generated/Image.swift b/Sources/Gtk/Generated/Image.swift index be138278de5..f0325ce114e 100644 --- a/Sources/Gtk/Generated/Image.swift +++ b/Sources/Gtk/Generated/Image.swift @@ -15,9 +15,9 @@ import CGtk /// If the file isn’t loaded successfully, the image will contain a /// “broken image” icon similar to that used in many web browsers. /// -/// If you want to handle errors in loading the file yourself, for example -/// by displaying an error message, then load the image with an image -/// loading framework such as libglycin, then create the `GtkImage` with +/// If you want to handle errors in loading the file yourself, +/// for example by displaying an error message, then load the image with +/// [ctor@Gdk.Texture.new_from_file], then create the `GtkImage` with /// [ctor@Gtk.Image.new_from_paintable]. /// /// Sometimes an application will want to avoid depending on external data @@ -53,9 +53,9 @@ open class Image: Widget { /// will display a “broken image” icon. This function never returns %NULL, /// it always returns a valid `GtkImage` widget. /// - /// If you need to detect failures to load the file, use an - /// image loading framework such as libglycin to load the file - /// yourself, then create the `GtkImage` from the texture. + /// If you need to detect failures to load the file, use + /// [ctor@Gdk.Texture.new_from_file] to load the file yourself, + /// then create the `GtkImage` from the texture. /// /// The storage type (see [method@Gtk.Image.get_storage_type]) /// of the returned image is not defined, it will be whatever @@ -96,13 +96,6 @@ open class Image: Widget { /// /// The `GtkImage` will track changes to the @paintable and update /// its size and contents in response to it. - /// - /// Note that paintables are still subject to the icon size that is - /// set on the image. If you want to display a paintable at its intrinsic - /// size, use [class@Gtk.Picture] instead. - /// - /// If @paintable is a [iface@Gtk.SymbolicPaintable], then it will be - /// recolored with the symbolic palette from the theme. public convenience init(paintable: OpaquePointer) { self.init( gtk_image_new_from_paintable(paintable) @@ -115,9 +108,9 @@ open class Image: Widget { /// display a “broken image” icon. This function never returns %NULL, /// it always returns a valid `GtkImage` widget. /// - /// If you need to detect failures to load the file, use an - /// image loading framework such as libglycin to load the file - /// yourself, then create the `GtkImage` from the texture. + /// If you need to detect failures to load the file, use + /// [ctor@GdkPixbuf.Pixbuf.new_from_file] to load the file yourself, + /// then create the `GtkImage` from the pixbuf. /// /// The storage type (see [method@Gtk.Image.get_storage_type]) of /// the returned image is not defined, it will be whatever is @@ -128,7 +121,7 @@ open class Image: Widget { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() let handler0: diff --git a/Sources/Gtk/Generated/Label.swift b/Sources/Gtk/Generated/Label.swift index f1e759cca08..576c3885974 100644 --- a/Sources/Gtk/Generated/Label.swift +++ b/Sources/Gtk/Generated/Label.swift @@ -238,7 +238,7 @@ open class Label: Widget { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() addSignal(name: "activate-current-link") { [weak self] () in @@ -507,6 +507,19 @@ open class Label: Widget { } } + /// The preferred place to ellipsize the string, if the label does + /// not have enough room to display the entire string. + /// + /// Note that setting this property to a value other than + /// [enum.Pango.EllipsizeMode.none] has the side-effect that the label requests + /// only enough space to display the ellipsis "...". In particular, this + /// means that ellipsizing labels do not work well in notebook tabs, unless + /// the [property@Gtk.NotebookPage:tab-expand] child property is set to true. + /// + /// Other ways to set a label's width are [method@Gtk.Widget.set_size_request] + /// and [method@Gtk.Label.set_width_chars]. + @GObjectProperty(named: "ellipsize") public var ellipsize: EllipsizeMode + /// The alignment of the lines in the text of the label, relative to each other. /// /// This does *not* affect the alignment of the label within its allocation. diff --git a/Sources/Gtk/Generated/ListBox.swift b/Sources/Gtk/Generated/ListBox.swift index bc5762d6c77..2f9baae1b25 100644 --- a/Sources/Gtk/Generated/ListBox.swift +++ b/Sources/Gtk/Generated/ListBox.swift @@ -72,7 +72,7 @@ open class ListBox: Widget { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() addSignal(name: "activate-cursor-row") { [weak self] () in diff --git a/Sources/Gtk/Generated/NativeDialog.swift b/Sources/Gtk/Generated/NativeDialog.swift index 1f7e967650a..2e77c0795a3 100644 --- a/Sources/Gtk/Generated/NativeDialog.swift +++ b/Sources/Gtk/Generated/NativeDialog.swift @@ -19,7 +19,7 @@ import CGtk /// object. open class NativeDialog: GObject { - public override func registerSignals() { + open override func registerSignals() { super.registerSignals() let handler0: diff --git a/Sources/Gtk/Generated/Picture.swift b/Sources/Gtk/Generated/Picture.swift index 641cd3ecf31..fd211b5a2d6 100644 --- a/Sources/Gtk/Generated/Picture.swift +++ b/Sources/Gtk/Generated/Picture.swift @@ -16,8 +16,8 @@ import CGtk /// “broken image” icon similar to that used in many web browsers. /// If you want to handle errors in loading the file yourself, /// for example by displaying an error message, then load the image with -/// and image loading framework such as libglycin, then create the `GtkPicture` -/// with [ctor@Gtk.Picture.new_for_paintable]. +/// [ctor@Gdk.Texture.new_from_file], then create the `GtkPicture` with +/// [ctor@Gtk.Picture.new_for_paintable]. /// /// Sometimes an application will want to avoid depending on external data /// files, such as image files. See the documentation of `GResource` for details. @@ -84,7 +84,7 @@ open class Picture: Widget { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() let handler0: diff --git a/Sources/Gtk/Generated/Popover.swift b/Sources/Gtk/Generated/Popover.swift index 0a68d9b281c..ab97f6ca02a 100644 --- a/Sources/Gtk/Generated/Popover.swift +++ b/Sources/Gtk/Generated/Popover.swift @@ -5,17 +5,12 @@ import CGtk /// An example GtkPopover /// /// It is primarily meant to provide context-dependent information -/// or options. Popovers are attached to a parent widget. The parent widget -/// must support popover children, as [class@Gtk.MenuButton] and -/// [class@Gtk.PopoverMenuBar] do. If you want to make a custom widget that -/// has an attached popover, you need to call [method@Gtk.Popover.present] -/// in your [vfunc@Gtk.Widget.size_allocate] vfunc, in order to update the -/// positioning of the popover. +/// or options. Popovers are attached to a parent widget. By default, +/// they point to the whole widget area, although this behavior can be +/// changed with [method@Gtk.Popover.set_pointing_to]. /// /// The position of a popover relative to the widget it is attached to -/// can also be changed with [method@Gtk.Popover.set_position]. By default, -/// it points to the whole widget area, but it can be made to point to -/// a specific area using [method@Gtk.Popover.set_pointing_to]. +/// can also be changed with [method@Gtk.Popover.set_position] /// /// By default, `GtkPopover` performs a grab, in order to ensure input /// events get redirected to it while it is shown, and also so the popover @@ -84,7 +79,7 @@ open class Popover: Widget, Native, ShortcutManager { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() addSignal(name: "activate-default") { [weak self] () in diff --git a/Sources/Gtk/Generated/ProgressBar.swift b/Sources/Gtk/Generated/ProgressBar.swift index 65796c43966..6634e8bf4fb 100644 --- a/Sources/Gtk/Generated/ProgressBar.swift +++ b/Sources/Gtk/Generated/ProgressBar.swift @@ -53,7 +53,7 @@ open class ProgressBar: Widget, Orientable { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() let handler0: @@ -141,6 +141,17 @@ open class ProgressBar: Widget, Orientable { } } + /// The preferred place to ellipsize the string. + /// + /// The text will be ellipsized if the progress bar does not have enough room + /// to display the entire string, specified as a `PangoEllipsizeMode`. + /// + /// Note that setting this property to a value other than + /// %PANGO_ELLIPSIZE_NONE has the side-effect that the progress bar requests + /// only enough space to display the ellipsis ("..."). Another means to set a + /// progress bar's width is [method@Gtk.Widget.set_size_request]. + @GObjectProperty(named: "ellipsize") public var ellipsize: EllipsizeMode + /// The fraction of total work that has been completed. @GObjectProperty(named: "fraction") public var fraction: Double diff --git a/Sources/Gtk/Generated/Range.swift b/Sources/Gtk/Generated/Range.swift index 8bb7e472c14..270bf988883 100644 --- a/Sources/Gtk/Generated/Range.swift +++ b/Sources/Gtk/Generated/Range.swift @@ -16,7 +16,7 @@ import CGtk /// fine-tuning mode. open class Range: Widget, Orientable { - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() let handler0: diff --git a/Sources/Gtk/Generated/Scale.swift b/Sources/Gtk/Generated/Scale.swift index 7d9d13b9649..5451bc69505 100644 --- a/Sources/Gtk/Generated/Scale.swift +++ b/Sources/Gtk/Generated/Scale.swift @@ -118,7 +118,7 @@ open class Scale: Range { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() let handler0: diff --git a/Sources/Gtk/Generated/Spinner.swift b/Sources/Gtk/Generated/Spinner.swift index 3ba1db393ca..968c619a7d4 100644 --- a/Sources/Gtk/Generated/Spinner.swift +++ b/Sources/Gtk/Generated/Spinner.swift @@ -23,7 +23,7 @@ open class Spinner: Widget { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() let handler0: diff --git a/Sources/Gtk/Generated/Switch.swift b/Sources/Gtk/Generated/Switch.swift index e4145d8e1e3..d953218c162 100644 --- a/Sources/Gtk/Generated/Switch.swift +++ b/Sources/Gtk/Generated/Switch.swift @@ -45,7 +45,7 @@ open class Switch: Widget, Actionable { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() addSignal(name: "activate") { [weak self] () in diff --git a/Sources/Gtk/Utility/Pango.swift b/Sources/Gtk/Utility/Pango.swift index 8c83b15d6bf..1597bf524cd 100644 --- a/Sources/Gtk/Utility/Pango.swift +++ b/Sources/Gtk/Utility/Pango.swift @@ -21,12 +21,14 @@ public class Pango { /// Uses the `PANGO_WRAP_WORD_CHAR` text wrapping mode. public func getTextSize( _ text: String, + ellipsize: EllipsizeMode, proposedWidth: Double? = nil, proposedHeight: Double? = nil ) -> (width: Int, height: Int) { let layout = pango_layout_new(pangoContext)! pango_layout_set_text(layout, text, Int32(text.count)) pango_layout_set_wrap(layout, PANGO_WRAP_WORD_CHAR) + pango_layout_set_ellipsize(layout, ellipsize.toGtk()) if let proposedWidth { pango_layout_set_width( diff --git a/Sources/Gtk/Widgets/Box.swift b/Sources/Gtk/Widgets/Box.swift index 2111d4f1a20..75e0b8d1ca8 100644 --- a/Sources/Gtk/Widgets/Box.swift +++ b/Sources/Gtk/Widgets/Box.swift @@ -11,7 +11,7 @@ open class Box: Widget, Orientable { self.init(gtk_box_new(orientation.toGtk(), gint(spacing))) } - override func didMoveToParent() { + open override func didMoveToParent() { for widget in children { widget.didMoveToParent() } diff --git a/Sources/Gtk/Widgets/Paned.swift b/Sources/Gtk/Widgets/Paned.swift index 6f41447cbc1..faa196e2ea7 100644 --- a/Sources/Gtk/Widgets/Paned.swift +++ b/Sources/Gtk/Widgets/Paned.swift @@ -21,7 +21,7 @@ open class Paned: Widget, Orientable { } } - override func didMoveToParent() { + open override func didMoveToParent() { startChild?.didMoveToParent() endChild?.didMoveToParent() diff --git a/Sources/Gtk/Widgets/PopoverMenu.swift b/Sources/Gtk/Widgets/PopoverMenu.swift index a62842c710c..66ceb724f73 100644 --- a/Sources/Gtk/Widgets/PopoverMenu.swift +++ b/Sources/Gtk/Widgets/PopoverMenu.swift @@ -184,7 +184,7 @@ public class PopoverMenu: Popover { } } - override func didMoveToParent() { + open override func didMoveToParent() { removeSignals() super.didMoveToParent() diff --git a/Sources/Gtk/Widgets/ScrolledWindow.swift b/Sources/Gtk/Widgets/ScrolledWindow.swift index f66a7cba296..7c89ae973f5 100644 --- a/Sources/Gtk/Widgets/ScrolledWindow.swift +++ b/Sources/Gtk/Widgets/ScrolledWindow.swift @@ -7,7 +7,7 @@ public class ScrolledWindow: Widget { self.init(gtk_scrolled_window_new()) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() } diff --git a/Sources/Gtk/Widgets/TextView.swift b/Sources/Gtk/Widgets/TextView.swift index 80bc00b53c9..7fb353ba2b5 100644 --- a/Sources/Gtk/Widgets/TextView.swift +++ b/Sources/Gtk/Widgets/TextView.swift @@ -90,7 +90,7 @@ open class TextView: Widget, Scrollable { open var buffer: TextBuffer - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() addSignal(name: "backspace") { [weak self] () in diff --git a/Sources/Gtk/Widgets/ToggleButton.swift b/Sources/Gtk/Widgets/ToggleButton.swift index ea732f4a2cf..c168c908b65 100644 --- a/Sources/Gtk/Widgets/ToggleButton.swift +++ b/Sources/Gtk/Widgets/ToggleButton.swift @@ -19,7 +19,7 @@ public class ToggleButton: Button { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() addSignal(name: "toggled") { [weak self] in diff --git a/Sources/Gtk/Widgets/Widget.swift b/Sources/Gtk/Widgets/Widget.swift index 1ad5a93ee52..946707145e2 100644 --- a/Sources/Gtk/Widgets/Widget.swift +++ b/Sources/Gtk/Widgets/Widget.swift @@ -61,7 +61,7 @@ open class Widget: GObject { gtk_widget_set_visible(widgetPointer, false.toGBoolean()) } - public func setSizeRequest(width: Int, height: Int) { + open func setSizeRequest(width: Int, height: Int) { gtk_widget_set_size_request(widgetPointer, Int32(width), Int32(height)) } @@ -98,6 +98,8 @@ open class Widget: GObject { @GObjectProperty(named: "name") public var name: String? + @GObjectProperty(named: "overflow") public var overflow: Overflow + @GObjectProperty(named: "sensitive") public var sensitive: Bool @GObjectProperty(named: "opacity") public var opacity: Double diff --git a/Sources/Gtk3/Datatypes/EllipsizeMode.swift b/Sources/Gtk3/Datatypes/EllipsizeMode.swift new file mode 100644 index 00000000000..f171825122f --- /dev/null +++ b/Sources/Gtk3/Datatypes/EllipsizeMode.swift @@ -0,0 +1,42 @@ +import CGtk3 + +public enum EllipsizeMode: GValueRepresentableEnum { + public typealias GtkEnum = PangoEllipsizeMode + + case none + case start + case middle + case end + + public static var type: GType { + pango_ellipsize_mode_get_type() + } + + public init(from gtkEnum: PangoEllipsizeMode) { + switch gtkEnum { + case PANGO_ELLIPSIZE_NONE: + self = .none + case PANGO_ELLIPSIZE_START: + self = .start + case PANGO_ELLIPSIZE_MIDDLE: + self = .middle + case PANGO_ELLIPSIZE_END: + self = .end + default: + fatalError("Unsupported PangoEllipsizeMode enum value: \(gtkEnum.rawValue)") + } + } + + public func toGtk() -> PangoEllipsizeMode { + switch self { + case .none: + PANGO_ELLIPSIZE_NONE + case .start: + PANGO_ELLIPSIZE_START + case .middle: + PANGO_ELLIPSIZE_MIDDLE + case .end: + PANGO_ELLIPSIZE_END + } + } +} diff --git a/Sources/Gtk3/Generated/Button.swift b/Sources/Gtk3/Generated/Button.swift index 59a967469c1..671e3315511 100644 --- a/Sources/Gtk3/Generated/Button.swift +++ b/Sources/Gtk3/Generated/Button.swift @@ -65,7 +65,7 @@ open class Button: Bin, Activatable { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() addSignal(name: "activate") { [weak self] () in diff --git a/Sources/Gtk3/Generated/Entry.swift b/Sources/Gtk3/Generated/Entry.swift index a7982980d92..4faa7a2cb6b 100644 --- a/Sources/Gtk3/Generated/Entry.swift +++ b/Sources/Gtk3/Generated/Entry.swift @@ -87,7 +87,7 @@ open class Entry: Widget, CellEditable, Editable { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() addSignal(name: "activate") { [weak self] () in @@ -201,11 +201,6 @@ open class Entry: Widget, CellEditable, Editable { self.preeditChanged?(self, param0) } - addSignal(name: "toggle-direction") { [weak self] () in - guard let self = self else { return } - self.toggleDirection?(self) - } - addSignal(name: "toggle-overwrite") { [weak self] () in guard let self = self else { return } self.toggleOverwrite?(self) @@ -226,19 +221,19 @@ open class Entry: Widget, CellEditable, Editable { self.changed?(self) } - let handler17: + let handler16: @convention(c) (UnsafeMutableRawPointer, Int, Int, UnsafeMutableRawPointer) -> Void = { _, value1, value2, data in SignalBox2.run(data, value1, value2) } - addSignal(name: "delete-text", handler: gCallback(handler17)) { + addSignal(name: "delete-text", handler: gCallback(handler16)) { [weak self] (param0: Int, param1: Int) in guard let self = self else { return } self.deleteText?(self, param0, param1) } - let handler18: + let handler17: @convention(c) ( UnsafeMutableRawPointer, UnsafePointer, Int, gpointer, UnsafeMutableRawPointer @@ -248,631 +243,631 @@ open class Entry: Widget, CellEditable, Editable { data, value1, value2, value3) } - addSignal(name: "insert-text", handler: gCallback(handler18)) { + addSignal(name: "insert-text", handler: gCallback(handler17)) { [weak self] (param0: UnsafePointer, param1: Int, param2: gpointer) in guard let self = self else { return } self.insertText?(self, param0, param1, param2) } - let handler19: + let handler18: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::activates-default", handler: gCallback(handler19)) { + addSignal(name: "notify::activates-default", handler: gCallback(handler18)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyActivatesDefault?(self, param0) } - let handler20: + let handler19: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::attributes", handler: gCallback(handler20)) { + addSignal(name: "notify::attributes", handler: gCallback(handler19)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyAttributes?(self, param0) } - let handler21: + let handler20: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::buffer", handler: gCallback(handler21)) { + addSignal(name: "notify::buffer", handler: gCallback(handler20)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyBuffer?(self, param0) } - let handler22: + let handler21: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::caps-lock-warning", handler: gCallback(handler22)) { + addSignal(name: "notify::caps-lock-warning", handler: gCallback(handler21)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyCapsLockWarning?(self, param0) } - let handler23: + let handler22: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::completion", handler: gCallback(handler23)) { + addSignal(name: "notify::completion", handler: gCallback(handler22)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyCompletion?(self, param0) } - let handler24: + let handler23: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::cursor-position", handler: gCallback(handler24)) { + addSignal(name: "notify::cursor-position", handler: gCallback(handler23)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyCursorPosition?(self, param0) } - let handler25: + let handler24: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::editable", handler: gCallback(handler25)) { + addSignal(name: "notify::editable", handler: gCallback(handler24)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyEditable?(self, param0) } - let handler26: + let handler25: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::enable-emoji-completion", handler: gCallback(handler26)) { + addSignal(name: "notify::enable-emoji-completion", handler: gCallback(handler25)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyEnableEmojiCompletion?(self, param0) } - let handler27: + let handler26: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::has-frame", handler: gCallback(handler27)) { + addSignal(name: "notify::has-frame", handler: gCallback(handler26)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyHasFrame?(self, param0) } - let handler28: + let handler27: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::im-module", handler: gCallback(handler28)) { + addSignal(name: "notify::im-module", handler: gCallback(handler27)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyImModule?(self, param0) } - let handler29: + let handler28: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::inner-border", handler: gCallback(handler29)) { + addSignal(name: "notify::inner-border", handler: gCallback(handler28)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyInnerBorder?(self, param0) } - let handler30: + let handler29: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::input-hints", handler: gCallback(handler30)) { + addSignal(name: "notify::input-hints", handler: gCallback(handler29)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyInputHints?(self, param0) } - let handler31: + let handler30: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::input-purpose", handler: gCallback(handler31)) { + addSignal(name: "notify::input-purpose", handler: gCallback(handler30)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyInputPurpose?(self, param0) } - let handler32: + let handler31: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::invisible-char", handler: gCallback(handler32)) { + addSignal(name: "notify::invisible-char", handler: gCallback(handler31)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyInvisibleCharacter?(self, param0) } - let handler33: + let handler32: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::invisible-char-set", handler: gCallback(handler33)) { + addSignal(name: "notify::invisible-char-set", handler: gCallback(handler32)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyInvisibleCharacterSet?(self, param0) } - let handler34: + let handler33: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::max-length", handler: gCallback(handler34)) { + addSignal(name: "notify::max-length", handler: gCallback(handler33)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyMaxLength?(self, param0) } - let handler35: + let handler34: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::max-width-chars", handler: gCallback(handler35)) { + addSignal(name: "notify::max-width-chars", handler: gCallback(handler34)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyMaxWidthChars?(self, param0) } - let handler36: + let handler35: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::overwrite-mode", handler: gCallback(handler36)) { + addSignal(name: "notify::overwrite-mode", handler: gCallback(handler35)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyOverwriteMode?(self, param0) } - let handler37: + let handler36: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::placeholder-text", handler: gCallback(handler37)) { + addSignal(name: "notify::placeholder-text", handler: gCallback(handler36)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyPlaceholderText?(self, param0) } - let handler38: + let handler37: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::populate-all", handler: gCallback(handler38)) { + addSignal(name: "notify::populate-all", handler: gCallback(handler37)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyPopulateAll?(self, param0) } - let handler39: + let handler38: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::primary-icon-activatable", handler: gCallback(handler39)) { + addSignal(name: "notify::primary-icon-activatable", handler: gCallback(handler38)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyPrimaryIconActivatable?(self, param0) } - let handler40: + let handler39: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::primary-icon-gicon", handler: gCallback(handler40)) { + addSignal(name: "notify::primary-icon-gicon", handler: gCallback(handler39)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyPrimaryIconGicon?(self, param0) } - let handler41: + let handler40: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::primary-icon-name", handler: gCallback(handler41)) { + addSignal(name: "notify::primary-icon-name", handler: gCallback(handler40)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyPrimaryIconName?(self, param0) } - let handler42: + let handler41: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::primary-icon-pixbuf", handler: gCallback(handler42)) { + addSignal(name: "notify::primary-icon-pixbuf", handler: gCallback(handler41)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyPrimaryIconPixbuf?(self, param0) } - let handler43: + let handler42: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::primary-icon-sensitive", handler: gCallback(handler43)) { + addSignal(name: "notify::primary-icon-sensitive", handler: gCallback(handler42)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyPrimaryIconSensitive?(self, param0) } - let handler44: + let handler43: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::primary-icon-stock", handler: gCallback(handler44)) { + addSignal(name: "notify::primary-icon-stock", handler: gCallback(handler43)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyPrimaryIconStock?(self, param0) } - let handler45: + let handler44: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::primary-icon-storage-type", handler: gCallback(handler45)) { + addSignal(name: "notify::primary-icon-storage-type", handler: gCallback(handler44)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyPrimaryIconStorageType?(self, param0) } - let handler46: + let handler45: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::primary-icon-tooltip-markup", handler: gCallback(handler46)) { + addSignal(name: "notify::primary-icon-tooltip-markup", handler: gCallback(handler45)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyPrimaryIconTooltipMarkup?(self, param0) } - let handler47: + let handler46: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::primary-icon-tooltip-text", handler: gCallback(handler47)) { + addSignal(name: "notify::primary-icon-tooltip-text", handler: gCallback(handler46)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyPrimaryIconTooltipText?(self, param0) } - let handler48: + let handler47: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::progress-fraction", handler: gCallback(handler48)) { + addSignal(name: "notify::progress-fraction", handler: gCallback(handler47)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyProgressFraction?(self, param0) } - let handler49: + let handler48: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::progress-pulse-step", handler: gCallback(handler49)) { + addSignal(name: "notify::progress-pulse-step", handler: gCallback(handler48)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyProgressPulseStep?(self, param0) } - let handler50: + let handler49: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::scroll-offset", handler: gCallback(handler50)) { + addSignal(name: "notify::scroll-offset", handler: gCallback(handler49)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyScrollOffset?(self, param0) } - let handler51: + let handler50: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::secondary-icon-activatable", handler: gCallback(handler51)) { + addSignal(name: "notify::secondary-icon-activatable", handler: gCallback(handler50)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifySecondaryIconActivatable?(self, param0) } - let handler52: + let handler51: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::secondary-icon-gicon", handler: gCallback(handler52)) { + addSignal(name: "notify::secondary-icon-gicon", handler: gCallback(handler51)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifySecondaryIconGicon?(self, param0) } - let handler53: + let handler52: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::secondary-icon-name", handler: gCallback(handler53)) { + addSignal(name: "notify::secondary-icon-name", handler: gCallback(handler52)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifySecondaryIconName?(self, param0) } - let handler54: + let handler53: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::secondary-icon-pixbuf", handler: gCallback(handler54)) { + addSignal(name: "notify::secondary-icon-pixbuf", handler: gCallback(handler53)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifySecondaryIconPixbuf?(self, param0) } - let handler55: + let handler54: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::secondary-icon-sensitive", handler: gCallback(handler55)) { + addSignal(name: "notify::secondary-icon-sensitive", handler: gCallback(handler54)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifySecondaryIconSensitive?(self, param0) } - let handler56: + let handler55: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::secondary-icon-stock", handler: gCallback(handler56)) { + addSignal(name: "notify::secondary-icon-stock", handler: gCallback(handler55)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifySecondaryIconStock?(self, param0) } - let handler57: + let handler56: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::secondary-icon-storage-type", handler: gCallback(handler57)) { + addSignal(name: "notify::secondary-icon-storage-type", handler: gCallback(handler56)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifySecondaryIconStorageType?(self, param0) } - let handler58: + let handler57: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::secondary-icon-tooltip-markup", handler: gCallback(handler58)) { + addSignal(name: "notify::secondary-icon-tooltip-markup", handler: gCallback(handler57)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifySecondaryIconTooltipMarkup?(self, param0) } - let handler59: + let handler58: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::secondary-icon-tooltip-text", handler: gCallback(handler59)) { + addSignal(name: "notify::secondary-icon-tooltip-text", handler: gCallback(handler58)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifySecondaryIconTooltipText?(self, param0) } - let handler60: + let handler59: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::selection-bound", handler: gCallback(handler60)) { + addSignal(name: "notify::selection-bound", handler: gCallback(handler59)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifySelectionBound?(self, param0) } - let handler61: + let handler60: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::shadow-type", handler: gCallback(handler61)) { + addSignal(name: "notify::shadow-type", handler: gCallback(handler60)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyShadowType?(self, param0) } - let handler62: + let handler61: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::show-emoji-icon", handler: gCallback(handler62)) { + addSignal(name: "notify::show-emoji-icon", handler: gCallback(handler61)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyShowEmojiIcon?(self, param0) } - let handler63: + let handler62: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::tabs", handler: gCallback(handler63)) { + addSignal(name: "notify::tabs", handler: gCallback(handler62)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyTabs?(self, param0) } - let handler64: + let handler63: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::text", handler: gCallback(handler64)) { + addSignal(name: "notify::text", handler: gCallback(handler63)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyText?(self, param0) } - let handler65: + let handler64: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::text-length", handler: gCallback(handler65)) { + addSignal(name: "notify::text-length", handler: gCallback(handler64)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyTextLength?(self, param0) } - let handler66: + let handler65: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::truncate-multiline", handler: gCallback(handler66)) { + addSignal(name: "notify::truncate-multiline", handler: gCallback(handler65)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyTruncateMultiline?(self, param0) } - let handler67: + let handler66: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::visibility", handler: gCallback(handler67)) { + addSignal(name: "notify::visibility", handler: gCallback(handler66)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyVisibility?(self, param0) } - let handler68: + let handler67: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::width-chars", handler: gCallback(handler68)) { + addSignal(name: "notify::width-chars", handler: gCallback(handler67)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyWidthChars?(self, param0) } - let handler69: + let handler68: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::xalign", handler: gCallback(handler69)) { + addSignal(name: "notify::xalign", handler: gCallback(handler68)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyXalign?(self, param0) } - let handler70: + let handler69: @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = { _, value1, data in SignalBox1.run(data, value1) } - addSignal(name: "notify::editing-canceled", handler: gCallback(handler70)) { + addSignal(name: "notify::editing-canceled", handler: gCallback(handler69)) { [weak self] (param0: OpaquePointer) in guard let self = self else { return } self.notifyEditingCanceled?(self, param0) @@ -999,8 +994,6 @@ open class Entry: Widget, CellEditable, Editable { /// connect to this signal. public var preeditChanged: ((Entry, UnsafePointer) -> Void)? - public var toggleDirection: ((Entry) -> Void)? - /// The ::toggle-overwrite signal is a /// [keybinding signal][GtkBindingSignal] /// which gets emitted to toggle the overwrite mode of the entry. diff --git a/Sources/Gtk3/Generated/EventBox.swift b/Sources/Gtk3/Generated/EventBox.swift index a8a6766047b..2a38117b020 100644 --- a/Sources/Gtk3/Generated/EventBox.swift +++ b/Sources/Gtk3/Generated/EventBox.swift @@ -11,7 +11,7 @@ open class EventBox: Bin { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() let handler0: diff --git a/Sources/Gtk3/Generated/EventController.swift b/Sources/Gtk3/Generated/EventController.swift index f5897da2abb..748ae26332d 100644 --- a/Sources/Gtk3/Generated/EventController.swift +++ b/Sources/Gtk3/Generated/EventController.swift @@ -5,7 +5,7 @@ import CGtk3 /// actions as a consequence of those. open class EventController: GObject { - public override func registerSignals() { + open override func registerSignals() { super.registerSignals() let handler0: diff --git a/Sources/Gtk3/Generated/FileChooserNative.swift b/Sources/Gtk3/Generated/FileChooserNative.swift index afdf0862ca5..014ff736a04 100644 --- a/Sources/Gtk3/Generated/FileChooserNative.swift +++ b/Sources/Gtk3/Generated/FileChooserNative.swift @@ -167,7 +167,7 @@ open class FileChooserNative: NativeDialog, FileChooser { ) } - public override func registerSignals() { + open override func registerSignals() { super.registerSignals() addSignal(name: "confirm-overwrite") { [weak self] () in diff --git a/Sources/Gtk3/Generated/GLArea.swift b/Sources/Gtk3/Generated/GLArea.swift index 0dec451dc67..7378d9f0378 100644 --- a/Sources/Gtk3/Generated/GLArea.swift +++ b/Sources/Gtk3/Generated/GLArea.swift @@ -107,7 +107,7 @@ open class GLArea: Widget { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() addSignal(name: "create-context") { [weak self] () in diff --git a/Sources/Gtk3/Generated/Gesture.swift b/Sources/Gtk3/Generated/Gesture.swift index fc44d23709c..991de668c8f 100644 --- a/Sources/Gtk3/Generated/Gesture.swift +++ b/Sources/Gtk3/Generated/Gesture.swift @@ -91,7 +91,7 @@ import CGtk3 /// %GDK_TOUCHPAD_SWIPE and %GDK_TOUCHPAD_PINCH are handled by the #GtkGesture open class Gesture: EventController { - public override func registerSignals() { + open override func registerSignals() { super.registerSignals() let handler0: diff --git a/Sources/Gtk3/Generated/GestureLongPress.swift b/Sources/Gtk3/Generated/GestureLongPress.swift index 2a975cae634..38c84a63db7 100644 --- a/Sources/Gtk3/Generated/GestureLongPress.swift +++ b/Sources/Gtk3/Generated/GestureLongPress.swift @@ -15,7 +15,7 @@ open class GestureLongPress: GestureSingle { ) } - public override func registerSignals() { + open override func registerSignals() { super.registerSignals() addSignal(name: "cancelled") { [weak self] () in diff --git a/Sources/Gtk3/Generated/GestureSingle.swift b/Sources/Gtk3/Generated/GestureSingle.swift index f51f30dc779..09dc1cb6eba 100644 --- a/Sources/Gtk3/Generated/GestureSingle.swift +++ b/Sources/Gtk3/Generated/GestureSingle.swift @@ -14,7 +14,7 @@ import CGtk3 /// currently pressed can be known through gtk_gesture_single_get_current_button(). open class GestureSingle: Gesture { - public override func registerSignals() { + open override func registerSignals() { super.registerSignals() let handler0: diff --git a/Sources/Gtk3/Generated/Image.swift b/Sources/Gtk3/Generated/Image.swift index f9570e99592..201930f3c60 100644 --- a/Sources/Gtk3/Generated/Image.swift +++ b/Sources/Gtk3/Generated/Image.swift @@ -156,7 +156,7 @@ open class Image: Misc { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() let handler0: @@ -316,6 +316,15 @@ open class Image: Misc { } } + /// The name of the icon in the icon theme. If the icon theme is + /// changed, the image will be updated automatically. + @GObjectProperty(named: "icon-name") public var iconName: String + + /// The "pixel-size" property can be used to specify a fixed size + /// overriding the #GtkImage:icon-size property for images of type + /// %GTK_IMAGE_ICON_NAME. + @GObjectProperty(named: "pixel-size") public var pixelSize: Int + @GObjectProperty(named: "stock") public var stock: String @GObjectProperty(named: "storage-type") public var storageType: ImageType diff --git a/Sources/Gtk3/Generated/Label.swift b/Sources/Gtk3/Generated/Label.swift index fbb6155cd52..1f2f556b7cd 100644 --- a/Sources/Gtk3/Generated/Label.swift +++ b/Sources/Gtk3/Generated/Label.swift @@ -200,7 +200,7 @@ open class Label: Misc { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() addSignal(name: "activate-current-link") { [weak self] () in @@ -493,6 +493,25 @@ open class Label: Misc { } } + /// The angle that the baseline of the label makes with the horizontal, + /// in degrees, measured counterclockwise. An angle of 90 reads from + /// from bottom to top, an angle of 270, from top to bottom. Ignored + /// if the label is selectable. + @GObjectProperty(named: "angle") public var angle: Double + + /// The preferred place to ellipsize the string, if the label does + /// not have enough room to display the entire string, specified as a + /// #PangoEllipsizeMode. + /// + /// Note that setting this property to a value other than + /// %PANGO_ELLIPSIZE_NONE has the side-effect that the label requests + /// only enough space to display the ellipsis "...". In particular, this + /// means that ellipsizing labels do not work well in notebook tabs, unless + /// the #GtkNotebook tab-expand child property is set to %TRUE. Other ways + /// to set a label's width are gtk_widget_set_size_request() and + /// gtk_label_set_width_chars(). + @GObjectProperty(named: "ellipsize") public var ellipsize: EllipsizeMode + @GObjectProperty(named: "justify") public var justify: Justification /// The contents of the label. @@ -508,14 +527,37 @@ open class Label: Misc { /// to display them. @GObjectProperty(named: "label") public var label: String + /// The desired maximum width of the label, in characters. If this property + /// is set to -1, the width will be calculated automatically. + /// + /// See the section on [text layout][label-text-layout] + /// for details of how #GtkLabel:width-chars and #GtkLabel:max-width-chars + /// determine the width of ellipsized and wrapped labels. + @GObjectProperty(named: "max-width-chars") public var maxWidthChars: Int + @GObjectProperty(named: "mnemonic-keyval") public var mnemonicKeyval: UInt @GObjectProperty(named: "selectable") public var selectable: Bool + /// Whether the label is in single line mode. In single line mode, + /// the height of the label does not depend on the actual text, it + /// is always set to ascent + descent of the font. This can be an + /// advantage in situations where resizing the label because of text + /// changes would be distracting, e.g. in a statusbar. + @GObjectProperty(named: "single-line-mode") public var singleLineMode: Bool + @GObjectProperty(named: "use-markup") public var useMarkup: Bool @GObjectProperty(named: "use-underline") public var useUnderline: Bool + /// The desired width of the label, in characters. If this property is set to + /// -1, the width will be calculated automatically. + /// + /// See the section on [text layout][label-text-layout] + /// for details of how #GtkLabel:width-chars and #GtkLabel:max-width-chars + /// determine the width of ellipsized and wrapped labels. + @GObjectProperty(named: "width-chars") public var widthChars: Int + /// A [keybinding signal][GtkBindingSignal] /// which gets emitted when the user activates a link in the label. /// diff --git a/Sources/Gtk3/Generated/MenuShell.swift b/Sources/Gtk3/Generated/MenuShell.swift index 1fb3f71e87d..26c8b535a57 100644 --- a/Sources/Gtk3/Generated/MenuShell.swift +++ b/Sources/Gtk3/Generated/MenuShell.swift @@ -29,7 +29,7 @@ import CGtk3 /// grab and receive all key presses. open class MenuShell: Container { - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() let handler0: diff --git a/Sources/Gtk3/Generated/NativeDialog.swift b/Sources/Gtk3/Generated/NativeDialog.swift index df63a37b594..752473ee9e9 100644 --- a/Sources/Gtk3/Generated/NativeDialog.swift +++ b/Sources/Gtk3/Generated/NativeDialog.swift @@ -17,7 +17,7 @@ import CGtk3 /// similar to gtk_dialog_run(). open class NativeDialog: GObject { - public override func registerSignals() { + open override func registerSignals() { super.registerSignals() let handler0: diff --git a/Sources/Gtk3/Generated/ProgressBar.swift b/Sources/Gtk3/Generated/ProgressBar.swift index 9b3eb446a44..3f3c6948928 100644 --- a/Sources/Gtk3/Generated/ProgressBar.swift +++ b/Sources/Gtk3/Generated/ProgressBar.swift @@ -47,7 +47,7 @@ open class ProgressBar: Widget, Orientable { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() let handler0: @@ -135,6 +135,16 @@ open class ProgressBar: Widget, Orientable { } } + /// The preferred place to ellipsize the string, if the progress bar does + /// not have enough room to display the entire string, specified as a + /// #PangoEllipsizeMode. + /// + /// Note that setting this property to a value other than + /// %PANGO_ELLIPSIZE_NONE has the side-effect that the progress bar requests + /// only enough space to display the ellipsis ("..."). Another means to set a + /// progress bar's width is gtk_widget_set_size_request(). + @GObjectProperty(named: "ellipsize") public var ellipsize: EllipsizeMode + @GObjectProperty(named: "fraction") public var fraction: Double @GObjectProperty(named: "inverted") public var inverted: Bool diff --git a/Sources/Gtk3/Generated/Range.swift b/Sources/Gtk3/Generated/Range.swift index b93bddbbe79..cf558bb830b 100644 --- a/Sources/Gtk3/Generated/Range.swift +++ b/Sources/Gtk3/Generated/Range.swift @@ -9,7 +9,7 @@ import CGtk3 /// “fill level” on range widgets. See gtk_range_set_fill_level(). open class Range: Widget, Orientable { - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() let handler0: diff --git a/Sources/Gtk3/Generated/Scale.swift b/Sources/Gtk3/Generated/Scale.swift index b6970ca14b1..e1f3fcfe623 100644 --- a/Sources/Gtk3/Generated/Scale.swift +++ b/Sources/Gtk3/Generated/Scale.swift @@ -99,7 +99,7 @@ open class Scale: Range { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() let handler0: diff --git a/Sources/Gtk3/Generated/Spinner.swift b/Sources/Gtk3/Generated/Spinner.swift index c65f02682d5..48c6878ea90 100644 --- a/Sources/Gtk3/Generated/Spinner.swift +++ b/Sources/Gtk3/Generated/Spinner.swift @@ -19,7 +19,7 @@ open class Spinner: Widget { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() let handler0: diff --git a/Sources/Gtk3/Generated/Switch.swift b/Sources/Gtk3/Generated/Switch.swift index 3ce717d9350..d93fa5d4a7a 100644 --- a/Sources/Gtk3/Generated/Switch.swift +++ b/Sources/Gtk3/Generated/Switch.swift @@ -24,7 +24,7 @@ open class Switch: Widget, Activatable { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() addSignal(name: "activate") { [weak self] () in diff --git a/Sources/Gtk3/Utility/Pango.swift b/Sources/Gtk3/Utility/Pango.swift index 8a46cfd47ca..2bd0d79255b 100644 --- a/Sources/Gtk3/Utility/Pango.swift +++ b/Sources/Gtk3/Utility/Pango.swift @@ -17,14 +17,18 @@ public class Pango { /// acts as a suggested width. The text will attempt to take up less than or equal to the proposed /// width but if the text wrapping strategy doesn't allow the text to become as small as required /// than it may take up more the proposed width. + /// + /// Uses the `PANGO_WRAP_WORD_CHAR` text wrapping mode. public func getTextSize( _ text: String, + ellipsize: EllipsizeMode, proposedWidth: Double? = nil, proposedHeight: Double? = nil ) -> (width: Int, height: Int) { let layout = pango_layout_new(pangoContext)! pango_layout_set_text(layout, text, Int32(text.count)) pango_layout_set_wrap(layout, PANGO_WRAP_WORD_CHAR) + pango_layout_set_ellipsize(layout, ellipsize.toGtk()) if let proposedWidth { pango_layout_set_width( diff --git a/Sources/Gtk3/Widgets/Box.swift b/Sources/Gtk3/Widgets/Box.swift index f51ac656bf7..d6c21e3f95f 100644 --- a/Sources/Gtk3/Widgets/Box.swift +++ b/Sources/Gtk3/Widgets/Box.swift @@ -14,7 +14,7 @@ open class Box: Widget, Orientable { self.init(gtk_box_new(orientation.toGtk(), gint(spacing))) } - override func didMoveToParent() { + open override func didMoveToParent() { for widget in children { widget.didMoveToParent() } diff --git a/Sources/Gtk3/Widgets/ListBox.swift b/Sources/Gtk3/Widgets/ListBox.swift index e93cc94180f..0ec82a89c83 100644 --- a/Sources/Gtk3/Widgets/ListBox.swift +++ b/Sources/Gtk3/Widgets/ListBox.swift @@ -46,7 +46,7 @@ open class ListBox: Container { ) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() addSignal(name: "activate-cursor-row") { [weak self] () in diff --git a/Sources/Gtk3/Widgets/Paned.swift b/Sources/Gtk3/Widgets/Paned.swift index 9c1d2660344..d9bd29ad5d0 100644 --- a/Sources/Gtk3/Widgets/Paned.swift +++ b/Sources/Gtk3/Widgets/Paned.swift @@ -29,7 +29,7 @@ open class Paned: Container, Orientable { } } - override func didMoveToParent() { + open override func didMoveToParent() { startChild?.didMoveToParent() endChild?.didMoveToParent() diff --git a/Sources/Gtk3/Widgets/ScrolledWindow.swift b/Sources/Gtk3/Widgets/ScrolledWindow.swift index 96d2ad0fc20..a244c53dac3 100644 --- a/Sources/Gtk3/Widgets/ScrolledWindow.swift +++ b/Sources/Gtk3/Widgets/ScrolledWindow.swift @@ -5,7 +5,7 @@ public class ScrolledWindow: Bin { self.init(gtk_scrolled_window_new(nil, nil)) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() } diff --git a/Sources/Gtk3/Widgets/TextView.swift b/Sources/Gtk3/Widgets/TextView.swift index 33e7a1dff66..1cff0a9e1d6 100644 --- a/Sources/Gtk3/Widgets/TextView.swift +++ b/Sources/Gtk3/Widgets/TextView.swift @@ -46,7 +46,7 @@ open class TextView: Container, Scrollable { open var buffer: TextBuffer - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() addSignal(name: "backspace") { [weak self] () in diff --git a/Sources/Gtk3/Widgets/ToggleButton.swift b/Sources/Gtk3/Widgets/ToggleButton.swift index adc1a753340..ed69749d6df 100644 --- a/Sources/Gtk3/Widgets/ToggleButton.swift +++ b/Sources/Gtk3/Widgets/ToggleButton.swift @@ -13,7 +13,7 @@ open class ToggleButton: Button { self.init(gtk_toggle_button_new_with_mnemonic(label)) } - override func didMoveToParent() { + open override func didMoveToParent() { super.didMoveToParent() addSignal(name: "toggled") { [weak self] in diff --git a/Sources/Gtk3/Widgets/Widget.swift b/Sources/Gtk3/Widgets/Widget.swift index fadce434880..881e9bbf6df 100644 --- a/Sources/Gtk3/Widgets/Widget.swift +++ b/Sources/Gtk3/Widgets/Widget.swift @@ -23,7 +23,7 @@ open class Widget: GObject { } } - func didMoveToParent() { + open func didMoveToParent() { // The Gtk3 docs claim that this handler should take GdkEventButton as a // value, but that leads to crashes on Rocky Linux. These crashes are // fixed by instead taking the event as a pointer. I've confirmed that @@ -54,8 +54,10 @@ open class Widget: GObject { UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer - ) -> Void = { _, cairo, data in + ) -> Bool = { _, cairo, data in SignalBox1.run(data, cairo) + // Propagate event to next handler + return false } addSignal( @@ -65,6 +67,39 @@ open class Widget: GObject { guard let self = self else { return } self.doDraw?(cairo) } + + let handler3: + @convention(c) ( + UnsafeMutableRawPointer, + OpaquePointer, + UnsafeMutableRawPointer + ) -> Void = { _, screen, data in + SignalBox1.run(data, screen) + } + + addSignal( + name: "screen-changed", + handler: gCallback(handler3) + ) { [weak self] (previousScreen: OpaquePointer) in + guard let self = self else { return } + self.screenChanged?() + } + + let handler4: + @convention(c) ( + UnsafeMutableRawPointer, + UnsafeMutableRawPointer + ) -> Void = { _, data in + SignalBox1.run(data, ()) + } + + addSignal( + name: "style-updated", + handler: gCallback(handler4) + ) { [weak self] (_: Void) in + guard let self = self else { return } + self.styleUpdated?() + } } open func didMoveFromParent() {} @@ -110,7 +145,7 @@ open class Widget: GObject { gtk_widget_show(widgetPointer) } - public func setSizeRequest(width: Int, height: Int) { + open func setSizeRequest(width: Int, height: Int) { gtk_widget_set_size_request(widgetPointer, Int32(width), Int32(height)) } @@ -143,6 +178,10 @@ open class Widget: GObject { public var doDraw: ((_ cairo: OpaquePointer) -> Void)? + public var screenChanged: (() -> Void)? + + public var styleUpdated: (() -> Void)? + @GObjectProperty(named: "name") public var name: String? @GObjectProperty(named: "sensitive") public var sensitive: Bool diff --git a/Sources/Gtk3Backend/Gtk3Backend.swift b/Sources/Gtk3Backend/Gtk3Backend.swift index a8e412896b9..d64d0234949 100644 --- a/Sources/Gtk3Backend/Gtk3Backend.swift +++ b/Sources/Gtk3Backend/Gtk3Backend.swift @@ -614,9 +614,12 @@ public final class Gtk3Backend: AppBackend { // MARK: Passive views public func createTextView() -> Widget { - let label = Label(string: "") - label.horizontalAlignment = .start - return label + let textView = CustomLabel(string: "") + textView.horizontalAlignment = .start + textView.wrap = true + textView.lineWrapMode = .wordCharacter + textView.ellipsize = .end + return textView } public func updateTextView( @@ -624,10 +627,8 @@ public final class Gtk3Backend: AppBackend { content: String, environment: EnvironmentValues ) { - let textView = textView as! Label + let textView = textView as! CustomLabel textView.label = content - textView.wrap = true - textView.lineWrapMode = .wordCharacter textView.justify = switch environment.multilineTextAlignment { case .leading: @@ -645,14 +646,16 @@ public final class Gtk3Backend: AppBackend { public func size( of text: String, whenDisplayedIn widget: Widget, - proposedFrame: SIMD2?, + proposedWidth: Int?, + proposedHeight: Int?, environment: EnvironmentValues ) -> SIMD2 { let pango = Pango(for: widget) let (width, height) = pango.getTextSize( text, - proposedWidth: (proposedFrame?.x).map(Double.init), - proposedHeight: nil + ellipsize: (widget as! CustomLabel).ellipsize, + proposedWidth: proposedWidth.map(Double.init), + proposedHeight: proposedHeight.map(Double.init) ) return SIMD2(width, height) } @@ -1517,3 +1520,33 @@ struct Gtk3Error: LocalizedError { "gerror: code=\(code), domain=\(domain), message=\(message)" } } + +/// A custom label subclass that supports ellipsizing multi-line text. Regular +/// `Label`s only display a single line of text when ellipsizing is enabled +/// because they don't pass their size request to their underlying Pango layout. +class CustomLabel: Label { + override func didMoveToParent() { + super.didMoveToParent() + + doDraw = { [weak self] _ in + guard let self else { return } + self.setLayoutHeight(getSizeRequest().height) + } + } + + private func setLayoutHeight(_ height: Int) { + // Override the label's layout height. We do this so that the label grows + // vertically to fill available space even though we have ellipsizing + // enabled (which generally causes labels to limit themselves to a single line). + // + // This code relies on the assumption that the layout won't get recreated + // during rendering. From reading the Gtk 3 source code I believe that's + // unlikely, but note that the docs recommend against mutating + // the layout returned by gtk_label_get_layout. + let layout = gtk_label_get_layout(castedPointer()) + pango_layout_set_height( + layout, + Int32((Double(height) * Double(PANGO_SCALE)).rounded(.towardZero)) + ) + } +} diff --git a/Sources/GtkBackend/GtkBackend.swift b/Sources/GtkBackend/GtkBackend.swift index 49f5fc8d2b3..318b0e7e30b 100644 --- a/Sources/GtkBackend/GtkBackend.swift +++ b/Sources/GtkBackend/GtkBackend.swift @@ -590,9 +590,12 @@ public final class GtkBackend: AppBackend { // MARK: Passive views public func createTextView() -> Widget { - let label = Label(string: "") - label.horizontalAlignment = .start - return label + let textView = CustomLabel(string: "") + textView.horizontalAlignment = .start + textView.wrap = true + textView.lineWrapMode = .wordCharacter + textView.ellipsize = .end + return textView } public func updateTextView( @@ -600,10 +603,8 @@ public final class GtkBackend: AppBackend { content: String, environment: EnvironmentValues ) { - let textView = textView as! Label + let textView = textView as! CustomLabel textView.label = content - textView.wrap = true - textView.lineWrapMode = .wordCharacter textView.justify = switch environment.multilineTextAlignment { case .leading: @@ -621,14 +622,16 @@ public final class GtkBackend: AppBackend { public func size( of text: String, whenDisplayedIn widget: Widget, - proposedFrame: SIMD2?, + proposedWidth: Int?, + proposedHeight: Int?, environment: EnvironmentValues ) -> SIMD2 { let pango = Pango(for: widget) let (width, height) = pango.getTextSize( text, - proposedWidth: (proposedFrame?.x).map(Double.init), - proposedHeight: nil + ellipsize: (widget as! CustomLabel).ellipsize, + proposedWidth: proposedWidth.map(Double.init), + proposedHeight: proposedHeight.map(Double.init) ) return SIMD2(width, height) } @@ -1692,3 +1695,31 @@ extension UnsafeMutablePointer { class CustomListBox: ListBox { var cachedSelection: Int? = nil } + +/// A custom label subclass that supports ellipsizing multi-line text. Regular +/// `Label`s only display a single line of text when ellipsizing is enabled +/// because they don't pass their size request to their underlying Pango layout. +class CustomLabel: Label { + override func setSizeRequest(width: Int, height: Int) { + super.setSizeRequest(width: width, height: height) + + // Override the label's layout height. We do this so that the label grows + // vertically to fill available space even though we have ellipsizing + // enabled (which generally causes labels to limit themselves to a single line). + // + // This code relies on the assumption that the layout won't get recreated + // until after the label gets rendered. The docs recommend against mutating + // the layout returned by gtk_label_get_layout. + // + // Ideally we'd use an Inscription instead, because it has this behavior + // by default, but that's only available from Gtk 4.8, and the predecessor + // CellRendererText isn't a widget. + let layout = gtk_label_get_layout(opaquePointer) + pango_layout_set_height( + layout, + Int32( + (Double(height) * Double(PANGO_SCALE)) + .rounded(.towardZero)) + ) + } +} diff --git a/Sources/GtkCodeGen/GtkCodeGen.swift b/Sources/GtkCodeGen/GtkCodeGen.swift index a1f18b01c29..12187dfd35b 100644 --- a/Sources/GtkCodeGen/GtkCodeGen.swift +++ b/Sources/GtkCodeGen/GtkCodeGen.swift @@ -69,6 +69,7 @@ struct GtkCodeGen { "Gdk.Paintable": "OpaquePointer", "Gdk.Clipboard": "OpaquePointer", "Gdk.ModifierType": "GdkModifierType", + "Pango.EllipsizeMode": "EllipsizeMode", ] static let interfaces: [String] = [ @@ -109,15 +110,16 @@ struct GtkCodeGen { ) throws { let allowListedClasses = [ "Button", "Entry", "Label", "Range", "Scale", "Image", "Switch", "Spinner", - "ProgressBar", "FileChooserNative", "NativeDialog", "GestureClick", "GestureSingle", - "Gesture", "EventController", "GestureLongPress", "GLArea", "DrawingArea", - "CheckButton", + "ProgressBar", "FileChooserNative", "NativeDialog", "GestureClick", + "GestureSingle", "Gesture", "EventController", "GestureLongPress", "GLArea", + "DrawingArea", "CheckButton", ] let gtk3AllowListedClasses = ["MenuShell", "EventBox"] let gtk4AllowListedClasses = [ "Picture", "DropDown", "Popover", "ListBox", "EventControllerMotion", "EventControllerKey", ] + for class_ in gir.namespace.classes { guard allowListedClasses.contains(class_.name) @@ -373,7 +375,7 @@ struct GtkCodeGen { var properties: [DeclSyntax] = [] for (classLike, property) in class_.getAllImplemented(\.properties, namespace: namespace) { guard - property.version == nil || property.version == "3.2", + property.version == nil || property.version == "3.2" || property.version == "2.6", property.name != "child", let decl = generateProperty( property, namespace: namespace, classLike: classLike, forProtocol: false @@ -583,7 +585,7 @@ struct GtkCodeGen { return DeclSyntax( """ - \(raw: isWidget ? "" : "public") override func \(raw: methodName)() { + open override func \(raw: methodName)() { super.\(raw: methodName)() \(raw: exprs.joined(separator: "\n\n")) @@ -666,6 +668,7 @@ struct GtkCodeGen { } if !cTypeReplacements.values.contains(type) + && !typeNameReplacements.values.contains(type) && !namespace.enumerations.contains(where: { $0.name == type }) && type != "OpaquePointer" { diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index 52dfbab1adb..c0209a76e7e 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -334,27 +334,35 @@ public protocol AppBackend: Sendable { // MARK: Passive views /// Gets the size that the given text would have if it were layed out attempting to stay - /// within the proposed frame (most backends only use the proposed width and ignore the - /// proposed height). The size returned by this function will be upheld by the layout + /// within the proposed frame. The given text should be truncated/ellipsized to fit within + /// the proposal if possible. + /// + /// The size returned by this function will be upheld by the layout /// system; child views always get the final say on their own size, parents just choose how - /// the children get layed out. + /// the children get layed out. The text should get truncated to fit within the proposal if + /// possible. + /// + /// SwiftCrossUI will never supply zero as the proposed width or height, because some UI + /// frameworks handle that in special ways. /// /// The target widget is supplied because some backends (such as Gtk) require a /// reference to the target widget to get a text layout context. /// - /// If `proposedFrame` isn't supplied, the text should be layed out on a single line - /// taking up as much width as it needs. - /// /// Used by both ``SwiftCrossUI/Text`` and ``SwiftCrossUI/TextEditor``. func size( of text: String, whenDisplayedIn widget: Widget, - proposedFrame: SIMD2?, + proposedWidth: Int?, + proposedHeight: Int?, environment: EnvironmentValues ) -> SIMD2 /// Creates a non-editable text view with optional text wrapping. Predominantly used - /// by ``Text``.` + /// by ``Text``. + /// + /// The returned widget should truncate and ellipsize its content when given a size + /// which isn't big enough to fit the full content, as per + /// ``size(of:whenDisplayedIn:proposedWidth:proposedHeight:environment)``. func createTextView() -> Widget /// Sets the content and wrapping mode of a non-editable text view. func updateTextView(_ textView: Widget, content: String, environment: EnvironmentValues) @@ -922,7 +930,8 @@ extension AppBackend { public func size( of text: String, whenDisplayedIn widget: Widget, - proposedFrame: SIMD2?, + proposedWidth: Int?, + proposedHeight: Int?, environment: EnvironmentValues ) -> SIMD2 { todo() diff --git a/Sources/SwiftCrossUI/Environment/Actions/DismissAction.swift b/Sources/SwiftCrossUI/Environment/Actions/DismissAction.swift index 1258d67193f..e3c3c8529d2 100644 --- a/Sources/SwiftCrossUI/Environment/Actions/DismissAction.swift +++ b/Sources/SwiftCrossUI/Environment/Actions/DismissAction.swift @@ -20,9 +20,9 @@ /// ``` @MainActor public struct DismissAction { - private let action: () -> Void + private let action: @Sendable @MainActor () -> Void - internal init(action: @escaping () -> Void) { + nonisolated internal init(action: @escaping @Sendable @MainActor () -> Void) { self.action = action } @@ -34,7 +34,6 @@ public struct DismissAction { /// Environment key for the dismiss action. private struct DismissActionKey: EnvironmentKey { - @MainActor static var defaultValue: DismissAction { DismissAction(action: { #if DEBUG diff --git a/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift b/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift index cafe0dea636..f9f5f1d2bec 100644 --- a/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift +++ b/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift @@ -98,9 +98,18 @@ public struct EnvironmentValues { /// Whether the text should be selectable. Set by ``View/textSelectionEnabled(_:)``. public var isTextSelectionEnabled: Bool - // Backing storage for extensible subscript + /// Backing storage for extensible subscript private var extraValues: [ObjectIdentifier: Any] + /// An internal environment value used to control whether layout caching is + /// enabled or not. This is set to true when computing non-final layouts. E.g. + /// when a stack computes the minimum and maximum sizes of its children, it + /// should enable layout caching because those updates are guaranteed to be + /// non-final. The reason that we can't cache on non-final updates is that + /// the last layout proposal received by each view must be its intended final + /// proposal. + var allowLayoutCaching: Bool + public subscript(_ key: T.Type) -> T.Value { get { extraValues[ObjectIdentifier(T.self), default: T.defaultValue] as! T.Value @@ -217,6 +226,7 @@ public struct EnvironmentValues { isEnabled = true scrollDismissesKeyboardMode = .automatic isTextSelectionEnabled = false + allowLayoutCaching = false } /// Returns a copy of the environment with the specified property set to the diff --git a/Sources/SwiftCrossUI/Extensions/Sequence.swift b/Sources/SwiftCrossUI/Extensions/Sequence.swift new file mode 100644 index 00000000000..2d2fbdfc942 --- /dev/null +++ b/Sources/SwiftCrossUI/Extensions/Sequence.swift @@ -0,0 +1,12 @@ +#if swift(<6.0) + extension Sequence { + /// Back-ported implementation of `count(where:)` for pre Swift 6. + func count(where predicate: (Element) -> Bool) -> Int { + var count = 0 + for element in self where predicate(element) { + count += 1 + } + return count + } + } +#endif diff --git a/Sources/SwiftCrossUI/Layout/LayoutSystem.swift b/Sources/SwiftCrossUI/Layout/LayoutSystem.swift index 4618efff1ef..e2473821bf7 100644 --- a/Sources/SwiftCrossUI/Layout/LayoutSystem.swift +++ b/Sources/SwiftCrossUI/Layout/LayoutSystem.swift @@ -1,14 +1,40 @@ public enum LayoutSystem { - static func width(forHeight height: Int, aspectRatio: Double) -> Int { - roundSize(Double(height) * aspectRatio) + static func width(forHeight height: Double, aspectRatio: Double) -> Double { + Double(height) * aspectRatio } - static func height(forWidth width: Int, aspectRatio: Double) -> Int { - roundSize(Double(width) / aspectRatio) + static func height(forWidth width: Double, aspectRatio: Double) -> Double { + Double(width) / aspectRatio } - static func roundSize(_ size: Double) -> Int { - Int(size.rounded(.towardZero)) + package static func roundSize(_ size: Double) -> Int { + if size.isInfinite { + print("warning: LayoutSystem.roundSize called with infinite size") + } + + let size = size.rounded(.towardZero) + return if size >= Double(Int.max) { + Int.max + } else if size <= Double(Int.min) { + Int.min + } else { + Int(size) + } + } + + static func clamp(_ value: Double, minimum: Double?, maximum: Double?) -> Double { + var value = value + if let minimum { + value = max(minimum, value) + } + if let maximum { + value = min(maximum, value) + } + return value + } + + static func aspectRatio(of frame: ViewSize) -> Double { + aspectRatio(of: SIMD2(frame.width, frame.height)) } static func aspectRatio(of frame: SIMD2) -> Double { @@ -23,51 +49,38 @@ public enum LayoutSystem { } } - static func frameSize( - forProposedSize proposedSize: SIMD2, - aspectRatio: Double, - contentMode: ContentMode - ) -> SIMD2 { - let widthForHeight = width(forHeight: proposedSize.y, aspectRatio: aspectRatio) - let heightForWidth = height(forWidth: proposedSize.x, aspectRatio: aspectRatio) - switch contentMode { - case .fill: - return SIMD2( - max(proposedSize.x, widthForHeight), - max(proposedSize.y, heightForWidth) - ) - case .fit: - return SIMD2( - min(proposedSize.x, widthForHeight), - min(proposedSize.y, heightForWidth) - ) - } - } - public struct LayoutableChild { - private var update: + private var computeLayout: @MainActor ( - _ proposedSize: SIMD2, - _ environment: EnvironmentValues, - _ dryRun: Bool - ) -> ViewUpdateResult + _ proposedSize: ProposedViewSize, + _ environment: EnvironmentValues + ) -> ViewLayoutResult + private var _commit: @MainActor () -> ViewLayoutResult var tag: String? public init( - update: @escaping @MainActor (SIMD2, EnvironmentValues, Bool) -> ViewUpdateResult, + computeLayout: @escaping @MainActor (ProposedViewSize, EnvironmentValues) -> + ViewLayoutResult, + commit: @escaping @MainActor () -> ViewLayoutResult, tag: String? = nil ) { - self.update = update + self.computeLayout = computeLayout + self._commit = commit self.tag = tag } @MainActor - public func update( - proposedSize: SIMD2, + public func computeLayout( + proposedSize: ProposedViewSize, environment: EnvironmentValues, dryRun: Bool = false - ) -> ViewUpdateResult { - update(proposedSize, environment, dryRun) + ) -> ViewLayoutResult { + computeLayout(proposedSize, environment) + } + + @MainActor + public func commit() -> ViewLayoutResult { + _commit() } } @@ -77,68 +90,101 @@ public enum LayoutSystem { /// ``Group`` to avoid changing stack layout participation (since ``Group`` /// is meant to appear completely invisible to the layout system). @MainActor - public static func updateStackLayout( + static func computeStackLayout( container: Backend.Widget, children: [LayoutableChild], - proposedSize: SIMD2, + cache: inout StackLayoutCache, + proposedSize: ProposedViewSize, environment: EnvironmentValues, backend: Backend, - dryRun: Bool, inheritStackLayoutParticipation: Bool = false - ) -> ViewUpdateResult { + ) -> ViewLayoutResult { let spacing = environment.layoutSpacing - let alignment = environment.layoutAlignment let orientation = environment.layoutOrientation + let perpendicularOrientation = orientation.perpendicular - var renderedChildren: [ViewUpdateResult] = Array( - repeating: ViewUpdateResult.leafView(size: .empty), + var renderedChildren: [ViewLayoutResult] = Array( + repeating: ViewLayoutResult.leafView(size: .zero), count: children.count ) - // Figure out which views to treat as hidden. This could be the cause - // of issues if a view has some threshold at which it suddenly becomes - // invisible. - var isHidden = [Bool](repeating: false, count: children.count) - for (i, child) in children.enumerated() { - let result = child.update( - proposedSize: proposedSize, - environment: environment, - dryRun: true + let stackLength = proposedSize[component: orientation] + if stackLength == 0 || stackLength == .infinity || stackLength == nil || children.count == 1 + { + var resultLength: Double = 0 + var resultWidth: Double = 0 + var results: [ViewLayoutResult] = [] + for child in children { + let result = child.computeLayout( + proposedSize: proposedSize, + environment: environment + ) + resultLength += result.size[component: orientation] + resultWidth = max(resultWidth, result.size[component: perpendicularOrientation]) + results.append(result) + } + + let visibleChildrenCount = results.count { result in + result.participatesInStackLayouts + } + + let totalSpacing = Double(max(visibleChildrenCount - 1, 0) * spacing) + var size = ViewSize.zero + size[component: orientation] = resultLength + totalSpacing + size[component: perpendicularOrientation] = resultWidth + + // In this case, flexibility doesn't matter. We set the ordering to + // nil to signal to commitStackLayout that it can ignore flexibility. + cache.lastFlexibilityOrdering = nil + cache.lastHiddenChildren = results.map(\.participatesInStackLayouts).map(!) + cache.redistributeSpaceOnCommit = false + + return ViewLayoutResult( + size: size, + childResults: results, + participateInStackLayoutsWhenEmpty: + results.contains(where: \.participateInStackLayoutsWhenEmpty), + preferencesOverlay: nil ) - isHidden[i] = !result.participatesInStackLayouts + } + + guard let stackLength else { + fatalError("unreachable") } // My thanks go to this great article for investigating and explaining // how SwiftUI determines child view 'flexibility': // https://www.objc.io/blog/2020/11/10/hstacks-child-ordering/ + var isHidden = [Bool](repeating: false, count: children.count) + var minimumProposedSize = proposedSize + minimumProposedSize[component: orientation] = 0 + var maximumProposedSize = proposedSize + maximumProposedSize[component: orientation] = .infinity + let flexibilities = children.enumerated().map { i, child in + let minimumResult = child.computeLayout( + proposedSize: minimumProposedSize, + environment: environment.with(\.allowLayoutCaching, true) + ) + let maximumResult = child.computeLayout( + proposedSize: maximumProposedSize, + environment: environment.with(\.allowLayoutCaching, true) + ) + isHidden[i] = !minimumResult.participatesInStackLayouts + let maximum = maximumResult.size[component: orientation] + let minimum = minimumResult.size[component: orientation] + return maximum - minimum + } let visibleChildrenCount = isHidden.filter { hidden in !hidden }.count - let totalSpacing = max(visibleChildrenCount - 1, 0) * spacing - let proposedSizeWithoutSpacing = SIMD2( - proposedSize.x - (orientation == .horizontal ? totalSpacing : 0), - proposedSize.y - (orientation == .vertical ? totalSpacing : 0) - ) - let flexibilities = children.map { child in - let size = child.update( - proposedSize: proposedSizeWithoutSpacing, - environment: environment, - dryRun: true - ).size - return switch orientation { - case .horizontal: - size.maximumWidth - Double(size.minimumWidth) - case .vertical: - size.maximumHeight - Double(size.minimumHeight) - } - } + let totalSpacing = Double(max(visibleChildrenCount - 1, 0) * spacing) let sortedChildren = zip(children.enumerated(), flexibilities) .sorted { first, second in first.1 <= second.1 } .map(\.0) - var spaceUsedAlongStackAxis = 0 + var spaceUsedAlongStackAxis: Double = 0 var childrenRemaining = visibleChildrenCount for (index, child) in sortedChildren { // No need to render visible children. @@ -146,10 +192,9 @@ public enum LayoutSystem { // Update child in case it has just changed from visible to hidden, // and to make sure that the view is still hidden (if it's not then // it's a bug with either the view or the layout system). - let result = child.update( + let result = child.computeLayout( proposedSize: .zero, - environment: environment, - dryRun: dryRun + environment: environment ) if result.participatesInStackLayouts { print( @@ -160,154 +205,133 @@ public enum LayoutSystem { ) } renderedChildren[index] = result - renderedChildren[index].size = .hidden + renderedChildren[index].participateInStackLayoutsWhenEmpty = false + renderedChildren[index].size = .zero continue } - let proposedWidth: Double - let proposedHeight: Double - switch orientation { - case .horizontal: - proposedWidth = - Double(max(proposedSize.x - spaceUsedAlongStackAxis - totalSpacing, 0)) - / Double(childrenRemaining) - proposedHeight = Double(proposedSize.y) - case .vertical: - proposedHeight = - Double(max(proposedSize.y - spaceUsedAlongStackAxis - totalSpacing, 0)) - / Double(childrenRemaining) - proposedWidth = Double(proposedSize.x) - } + var proposedChildSize = proposedSize + proposedChildSize[component: orientation] = + max(stackLength - spaceUsedAlongStackAxis - totalSpacing, 0) + / Double(childrenRemaining) - let childResult = child.update( - proposedSize: SIMD2( - Int(proposedWidth.rounded(.towardZero)), - Int(proposedHeight.rounded(.towardZero)) - ), - environment: environment, - dryRun: dryRun + let childResult = child.computeLayout( + proposedSize: proposedChildSize, + environment: environment ) renderedChildren[index] = childResult childrenRemaining -= 1 - switch orientation { - case .horizontal: - spaceUsedAlongStackAxis += childResult.size.size.x - case .vertical: - spaceUsedAlongStackAxis += childResult.size.size.y - } + spaceUsedAlongStackAxis += childResult.size[component: orientation] } - let size: SIMD2 - let idealSize: SIMD2 - let idealWidthForProposedHeight: Int - let idealHeightForProposedWidth: Int - let minimumWidth: Int - let minimumHeight: Int - let maximumWidth: Double? - let maximumHeight: Double? - switch orientation { - case .horizontal: - size = SIMD2( - renderedChildren.map(\.size.size.x).reduce(0, +) + totalSpacing, - renderedChildren.map(\.size.size.y).max() ?? 0 - ) - idealSize = SIMD2( - renderedChildren.map(\.size.idealSize.x).reduce(0, +) + totalSpacing, - renderedChildren.map(\.size.idealSize.y).max() ?? 0 - ) - minimumWidth = renderedChildren.map(\.size.minimumWidth).reduce(0, +) + totalSpacing - minimumHeight = renderedChildren.map(\.size.minimumHeight).max() ?? 0 - maximumWidth = - renderedChildren.map(\.size.maximumWidth).reduce(0, +) + Double(totalSpacing) - maximumHeight = renderedChildren.map(\.size.maximumHeight).max() - idealWidthForProposedHeight = - renderedChildren.map(\.size.idealWidthForProposedHeight).reduce(0, +) - + totalSpacing - idealHeightForProposedWidth = - renderedChildren.map(\.size.idealHeightForProposedWidth).max() ?? 0 - case .vertical: - size = SIMD2( - renderedChildren.map(\.size.size.x).max() ?? 0, - renderedChildren.map(\.size.size.y).reduce(0, +) + totalSpacing - ) - idealSize = SIMD2( - renderedChildren.map(\.size.idealSize.x).max() ?? 0, - renderedChildren.map(\.size.idealSize.y).reduce(0, +) + totalSpacing - ) - minimumWidth = renderedChildren.map(\.size.minimumWidth).max() ?? 0 - minimumHeight = - renderedChildren.map(\.size.minimumHeight).reduce(0, +) + totalSpacing - maximumWidth = renderedChildren.map(\.size.maximumWidth).max() - maximumHeight = - renderedChildren.map(\.size.maximumHeight).reduce(0, +) + Double(totalSpacing) - idealWidthForProposedHeight = - renderedChildren.map(\.size.idealWidthForProposedHeight).max() ?? 0 - idealHeightForProposedWidth = - renderedChildren.map(\.size.idealHeightForProposedWidth).reduce(0, +) - + totalSpacing - } + var size = ViewSize.zero + size[component: orientation] = + renderedChildren.map(\.size[component: orientation]).reduce(0, +) + totalSpacing + size[component: perpendicularOrientation] = + renderedChildren.map(\.size[component: perpendicularOrientation]).max() ?? 0 - if !dryRun { - backend.setSize(of: container, to: size) + cache.lastFlexibilityOrdering = sortedChildren.map(\.offset) + cache.lastHiddenChildren = isHidden - var x = 0 - var y = 0 - for (index, childSize) in renderedChildren.enumerated() { - // Avoid the whole iteration if the child is hidden. If there - // are weird positioning issues for views that do strange things - // then this could be the cause. - if isHidden[index] { - continue - } + // When the length along the stacking axis is concrete (i.e. flexibility + // matters) and the perpendicular axis is unspecified (nil), then we need + // to re-run the space distribution algorithm with our final size during + // the commit phase. This opens the door to certain edge cases, but SwiftUI + // has them too, and there's not a good general solution to these edge + // cases, even if you assume that you have unlimited compute. + cache.redistributeSpaceOnCommit = + proposedSize[component: orientation] != nil + && proposedSize[component: perpendicularOrientation] == nil - // Compute alignment - switch (orientation, alignment) { - case (.vertical, .leading): - x = 0 - case (.horizontal, .leading): - y = 0 - case (.vertical, .center): - x = (size.x - childSize.size.size.x) / 2 - case (.horizontal, .center): - y = (size.y - childSize.size.size.y) / 2 - case (.vertical, .trailing): - x = (size.x - childSize.size.size.x) - case (.horizontal, .trailing): - y = (size.y - childSize.size.size.y) - } + return ViewLayoutResult( + size: size, + childResults: renderedChildren, + participateInStackLayoutsWhenEmpty: + renderedChildren.contains(where: \.participateInStackLayoutsWhenEmpty) + ) + } - backend.setPosition(ofChildAt: index, in: container, to: SIMD2(x, y)) + @MainActor + static func commitStackLayout( + container: Backend.Widget, + children: [LayoutableChild], + cache: inout StackLayoutCache, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + let size = layout.size + backend.setSize(of: container, to: size.vector) - switch orientation { - case .horizontal: - x += childSize.size.size.x + spacing - case .vertical: - y += childSize.size.size.y + spacing + let alignment = environment.layoutAlignment + let spacing = environment.layoutSpacing + let orientation = environment.layoutOrientation + let perpendicularOrientation = orientation.perpendicular + + if cache.redistributeSpaceOnCommit { + guard let ordering = cache.lastFlexibilityOrdering else { + fatalError( + "Expected flexibility ordering in order to redistribute space during commit") + } + + var spaceUsedAlongStackAxis: Double = 0 + // Avoid a trailing closure here because Swift 5.10 gets confused + let visibleChildrenCount = cache.lastHiddenChildren.count { isHidden in + !isHidden + } + let totalSpacing = Double(max(visibleChildrenCount - 1, 0) * spacing) + var childrenRemaining = visibleChildrenCount + + // TODO: Reuse the corresponding loop from computeStackLayout if + // possible to avoid the possibility for a behaviour mismatch. + for index in ordering { + if cache.lastHiddenChildren[index] { + continue } + + var proposedChildSize = layout.size + proposedChildSize[component: orientation] -= spaceUsedAlongStackAxis + totalSpacing + proposedChildSize[component: orientation] /= Double(childrenRemaining) + let result = children[index].computeLayout( + proposedSize: ProposedViewSize(proposedChildSize), + environment: environment + ) + + spaceUsedAlongStackAxis += result.size[component: orientation] + childrenRemaining -= 1 } } - // If the stack has been told to inherit its stack layout participation - // and all of its children are hidden, then the stack itself also - // shouldn't participate in stack layouts. - let shouldGetIgnoredInStackLayouts = - inheritStackLayoutParticipation && isHidden.allSatisfy { $0 } + let renderedChildren = children.map { $0.commit() } - return ViewUpdateResult( - size: ViewSize( - size: size, - idealSize: idealSize, - idealWidthForProposedHeight: idealWidthForProposedHeight, - idealHeightForProposedWidth: idealHeightForProposedWidth, - minimumWidth: minimumWidth, - minimumHeight: minimumHeight, - maximumWidth: maximumWidth, - maximumHeight: maximumHeight, - participateInStackLayoutsWhenEmpty: !shouldGetIgnoredInStackLayouts - ), - childResults: renderedChildren - ) + var position = Position.zero + for (index, child) in renderedChildren.enumerated() { + // Avoid the whole iteration if the child is hidden. If there + // are weird positioning issues for views that do strange things + // then this could be the cause. + if !child.participatesInStackLayouts { + continue + } + + // Compute alignment + switch alignment { + case .leading: + position[component: perpendicularOrientation] = 0 + case .center: + let outer = size[component: perpendicularOrientation] + let inner = child.size[component: perpendicularOrientation] + position[component: perpendicularOrientation] = (outer - inner) / 2 + case .trailing: + let outer = size[component: perpendicularOrientation] + let inner = child.size[component: perpendicularOrientation] + position[component: perpendicularOrientation] = outer - inner + } + + backend.setPosition(ofChildAt: index, in: container, to: position.vector) + + position[component: orientation] += child.size[component: orientation] + Double(spacing) + } } } diff --git a/Sources/SwiftCrossUI/Layout/Position.swift b/Sources/SwiftCrossUI/Layout/Position.swift new file mode 100644 index 00000000000..1d9682397bb --- /dev/null +++ b/Sources/SwiftCrossUI/Layout/Position.swift @@ -0,0 +1,53 @@ +/// A positon. +struct Position: Hashable, Sendable { + /// The zero position (aka the origin). + static let zero = Self(0, 0) + + /// The position's x component. + var x: Double + /// The position's y component. + var y: Double + + var vector: SIMD2 { + SIMD2( + LayoutSystem.roundSize(x), + LayoutSystem.roundSize(y) + ) + } + + /// Creates a new position. + init(_ x: Double, _ y: Double) { + self.x = x + self.y = y + } + + /// The position component associated with the given orientation's main axis. + public subscript(component orientation: Orientation) -> Double { + get { + switch orientation { + case .horizontal: + x + case .vertical: + y + } + } + set { + switch orientation { + case .horizontal: + x = newValue + case .vertical: + y = newValue + } + } + } + + /// The position component associated with the given axis. + public subscript(component axis: Axis) -> Double { + get { + self[component: axis.orientation] + } + set { + self[component: axis.orientation] = newValue + } + } +} diff --git a/Sources/SwiftCrossUI/Layout/ProposedViewSize.swift b/Sources/SwiftCrossUI/Layout/ProposedViewSize.swift new file mode 100644 index 00000000000..7eae852f078 --- /dev/null +++ b/Sources/SwiftCrossUI/Layout/ProposedViewSize.swift @@ -0,0 +1,78 @@ +/// The proposed size for a view. `nil` signifies an unspecified dimension. +public struct ProposedViewSize: Hashable, Sendable { + /// The zero proposal. + public static let zero = Self(0, 0) + /// The infinite proposal. + public static let infinity = Self(.infinity, .infinity) + /// The unspecified/ideal proposal. + public static let unspecified = Self(nil, nil) + + /// The proposed width (if any). + public var width: Double? + /// The proposed height (if any). + public var height: Double? + + /// The proposal as a concrete view size if both dimensions are specified. + var concrete: ViewSize? { + if let width, let height { + ViewSize(width, height) + } else { + nil + } + } + + /// Creates a view size proposal. + public init(_ width: Double?, _ height: Double?) { + self.width = width + self.height = height + } + + public init(_ viewSize: ViewSize) { + self.width = viewSize.width + self.height = viewSize.height + } + + init(_ vector: SIMD2) { + self.width = Double(vector.x) + self.height = Double(vector.y) + } + + /// Replaces unspecified dimensions of a proposed view size with dimensions + /// from a concrete view size to get a concrete proposal. + public func replacingUnspecifiedDimensions(by size: ViewSize) -> ViewSize { + ViewSize( + width ?? size.width, + height ?? size.height + ) + } + + /// The component associated with the given orientation. + public subscript(component orientation: Orientation) -> Double? { + get { + switch orientation { + case .horizontal: + width + case .vertical: + height + } + } + set { + switch orientation { + case .horizontal: + width = newValue + case .vertical: + height = newValue + } + } + } + + /// The component associated with the given axis. + public subscript(component axis: Axis) -> Double? { + get { + self[component: axis.orientation] + } + set { + self[component: axis.orientation] = newValue + } + } +} diff --git a/Sources/SwiftCrossUI/Layout/ViewLayoutResult.swift b/Sources/SwiftCrossUI/Layout/ViewLayoutResult.swift new file mode 100644 index 00000000000..7dc1e015960 --- /dev/null +++ b/Sources/SwiftCrossUI/Layout/ViewLayoutResult.swift @@ -0,0 +1,53 @@ +/// The result of a call to ``View/computeLayout(_:children:proposedSize:environment:backend:)``. +public struct ViewLayoutResult { + /// The size that the view has chosen for itself based off of the proposed view size. + public var size: ViewSize + /// Whether the view participates in stack layouts when empty (i.e. has its own spacing). + /// + /// This will be removed once we properly support dynamic alignment and spacing. + public var participateInStackLayoutsWhenEmpty: Bool + /// The preference values produced by the view and its children. + public var preferences: PreferenceValues + + public init( + size: ViewSize, + participateInStackLayoutsWhenEmpty: Bool = false, + preferences: PreferenceValues + ) { + self.size = size + self.participateInStackLayoutsWhenEmpty = participateInStackLayoutsWhenEmpty + self.preferences = preferences + } + + /// Creates a layout result by combining a parent view's sizing and its + /// children's preference values. + public init( + size: ViewSize, + childResults: [ViewLayoutResult], + participateInStackLayoutsWhenEmpty: Bool = false, + preferencesOverlay: PreferenceValues? = nil + ) { + self.size = size + self.participateInStackLayoutsWhenEmpty = participateInStackLayoutsWhenEmpty + + preferences = PreferenceValues( + merging: childResults.map(\.preferences) + + [preferencesOverlay].compactMap { $0 } + ) + } + + /// Creates the layout result of a leaf view (one with no children and no + /// special preference behaviour). Uses ``PreferenceValues/default``. + public static func leafView(size: ViewSize) -> Self { + ViewLayoutResult( + size: size, + participateInStackLayoutsWhenEmpty: true, + preferences: .default + ) + } + + /// Whether the view should participate in stack layouts (i.e. get its own spacing). + public var participatesInStackLayouts: Bool { + size != .zero || participateInStackLayoutsWhenEmpty + } +} diff --git a/Sources/SwiftCrossUI/Layout/ViewSize.swift b/Sources/SwiftCrossUI/Layout/ViewSize.swift index cd1a478979b..528f6460b44 100644 --- a/Sources/SwiftCrossUI/Layout/ViewSize.swift +++ b/Sources/SwiftCrossUI/Layout/ViewSize.swift @@ -1,117 +1,60 @@ -/// The size of a view. Includes ideal size, and minimum/maximum width and height -/// along with the size you'd expect. -/// -/// The width and height components of the view's minimum and maximum sizes are -/// stored separately to make it extra clear that they don't always form some -/// sort of achievable minimum/maximum size. The provided minimum/maximum bounds -/// may only be achievable along a single axis at a time. -public struct ViewSize: Equatable, Sendable { - /// The view update result for an empty view. - public static let empty = ViewSize( - size: .zero, - idealSize: .zero, - minimumWidth: 0, - minimumHeight: 0, - maximumWidth: 0, - maximumHeight: 0 - ) +/// The size of a view. +public struct ViewSize: Hashable, Sendable { + /// The zero view size. + public static let zero = Self(0, 0) - /// The view update result for a hidden view. Differs from ``ViewSize/empty`` - /// by stopping hidden views from participating in stack layouts (i.e. - /// getting spacing between the previous child and the hidden child). - public static let hidden = ViewSize( - size: .zero, - idealSize: .zero, - minimumWidth: 0, - minimumHeight: 0, - maximumWidth: 0, - maximumHeight: 0, - participateInStackLayoutsWhenEmpty: false - ) + /// The view's width. + public var width: Double + /// The view's height. + public var height: Double - /// The size that the view now takes up. - public var size: SIMD2 - /// The size that the view ideally wants to take up. - public var idealSize: SIMD2 - /// The width that the view ideally wants to take up assuming that the - /// proposed height doesn't change. Only really differs from `idealSize` for - /// views that have a trade-off between width and height (such as `Text`). - public var idealWidthForProposedHeight: Int - /// The height that the view ideally wants to take up assuming that the - /// proposed width doesn't change. Only really differs from `idealSize` for - /// views that have a trade-off between width and height (such as `Text`). - public var idealHeightForProposedWidth: Int - /// The minimum width that the view can take (if its height remains the same). - public var minimumWidth: Int - /// The minimum height that the view can take (if its width remains the same). - public var minimumHeight: Int - /// The maximum width that the view can take (if its height remains the same). - public var maximumWidth: Double - /// The maximum height that the view can take (if its width remains the same). - public var maximumHeight: Double - /// Whether the view should participate in stack layouts when empty. - /// - /// If `false`, the view won't get any spacing before or after it in stack - /// layouts. For example, this is used by ``OptionalView`` when its - /// underlying view is `nil` to avoid having spacing between views that are - /// semantically 'not present'. - /// - /// Only takes effect when ``ViewSize/size`` is zero, to avoid any ambiguity - /// when the view has non-zero size as this option is really only intended - /// to be used for visually hidden views (what would it mean for a non-empty - /// view to not participate in the layout? would the spacing between the - /// previous view and the next go before or after the view? would the view - /// get forced to zero size?). - public var participateInStackLayoutsWhenEmpty: Bool + /// Creates a view size. + public init(_ width: Double, _ height: Double) { + self.width = width + self.height = height + } + + /// Creates a view size from an integer vector. + init(_ vector: SIMD2) { + width = Double(vector.x) + height = Double(vector.y) + } - /// The view's ideal aspect ratio, computed from ``ViewSize/idealSize``. If - /// either of the view's ideal dimensions are 0, then the aspect ratio - /// defaults to 1. - public var idealAspectRatio: Double { - LayoutSystem.aspectRatio(of: SIMD2(idealSize)) + /// Gets the view size as a vector. + package var vector: SIMD2 { + SIMD2( + LayoutSystem.roundSize(width), + LayoutSystem.roundSize(height) + ) } - public init( - size: SIMD2, - idealSize: SIMD2, - idealWidthForProposedHeight: Int? = nil, - idealHeightForProposedWidth: Int? = nil, - minimumWidth: Int, - minimumHeight: Int, - maximumWidth: Double?, - maximumHeight: Double?, - participateInStackLayoutsWhenEmpty: Bool = true - ) { - self.size = size - self.idealSize = idealSize - self.idealWidthForProposedHeight = idealWidthForProposedHeight ?? idealSize.x - self.idealHeightForProposedWidth = idealHeightForProposedWidth ?? idealSize.y - self.minimumWidth = minimumWidth - self.minimumHeight = minimumHeight - // Using `Double(1 << 53)` as the default allows us to differentiate between views - // with unlimited size and different minimum sizes when calculating view flexibility. - // If we use `Double.infinity` then all views with unlimited size have infinite - // flexibility, meaning that there's no difference when sorting, even though the - // minimum size should still affect view layout. Similarly, if we use - // `Double.greatestFiniteMagnitude` we don't have enough precision to get different results - // when subtracting reasonable minimum dimensions. The chosen value for 'unlimited' - // width/height is in the range where the gap between consecutive Doubles is `1`, which - // I believe is a good compromise. - self.maximumWidth = maximumWidth ?? Double(1 << 53) - self.maximumHeight = maximumHeight ?? Double(1 << 53) - self.participateInStackLayoutsWhenEmpty = - participateInStackLayoutsWhenEmpty + /// The size component associated with the given orientation. + public subscript(component orientation: Orientation) -> Double { + get { + switch orientation { + case .horizontal: + width + case .vertical: + height + } + } + set { + switch orientation { + case .horizontal: + width = newValue + case .vertical: + height = newValue + } + } } - public init(fixedSize: SIMD2) { - size = fixedSize - idealSize = fixedSize - idealWidthForProposedHeight = fixedSize.x - idealHeightForProposedWidth = fixedSize.y - minimumWidth = fixedSize.x - minimumHeight = fixedSize.y - maximumWidth = Double(fixedSize.x) - maximumHeight = Double(fixedSize.y) - participateInStackLayoutsWhenEmpty = true + /// The size component associated with the given axis. + public subscript(component axis: Axis) -> Double { + get { + self[component: axis.orientation] + } + set { + self[component: axis.orientation] = newValue + } } } diff --git a/Sources/SwiftCrossUI/Layout/ViewUpdateResult.swift b/Sources/SwiftCrossUI/Layout/ViewUpdateResult.swift deleted file mode 100644 index bd907bc7281..00000000000 --- a/Sources/SwiftCrossUI/Layout/ViewUpdateResult.swift +++ /dev/null @@ -1,33 +0,0 @@ -public struct ViewUpdateResult { - public var size: ViewSize - public var preferences: PreferenceValues - - public init( - size: ViewSize, - preferences: PreferenceValues - ) { - self.size = size - self.preferences = preferences - } - - public init( - size: ViewSize, - childResults: [ViewUpdateResult], - preferencesOverlay: PreferenceValues? = nil - ) { - self.size = size - - preferences = PreferenceValues( - merging: childResults.map(\.preferences) - + [preferencesOverlay].compactMap { $0 } - ) - } - - public static func leafView(size: ViewSize) -> Self { - ViewUpdateResult(size: size, preferences: .default) - } - - public var participatesInStackLayouts: Bool { - size.size != .zero || size.participateInStackLayoutsWhenEmpty - } -} diff --git a/Sources/SwiftCrossUI/Scenes/WindowGroupNode.swift b/Sources/SwiftCrossUI/Scenes/WindowGroupNode.swift index 717906b4845..fad7cf42f58 100644 --- a/Sources/SwiftCrossUI/Scenes/WindowGroupNode.swift +++ b/Sources/SwiftCrossUI/Scenes/WindowGroupNode.swift @@ -48,9 +48,11 @@ public final class WindowGroupNode: SceneGraphNode { guard let self else { return } + _ = self.update( self.scene, proposedWindowSize: newSize, + needsWindowSizeCommit: false, backend: backend, environment: self.parentEnvironment, windowSizeIsFinal: @@ -62,9 +64,11 @@ public final class WindowGroupNode: SceneGraphNode { guard let self else { return } + _ = self.update( self.scene, proposedWindowSize: backend.size(ofWindow: window), + needsWindowSizeCommit: false, backend: backend, environment: self.parentEnvironment, windowSizeIsFinal: @@ -85,24 +89,50 @@ public final class WindowGroupNode: SceneGraphNode { let isProgramaticallyResizable = backend.isWindowProgrammaticallyResizable(window) + let proposedWindowSize: SIMD2 + let usedDefaultSize: Bool + if isFirstUpdate && isProgramaticallyResizable { + proposedWindowSize = (newScene ?? scene).defaultSize + usedDefaultSize = true + } else { + proposedWindowSize = backend.size(ofWindow: window) + usedDefaultSize = false + } + _ = update( newScene, - proposedWindowSize: isFirstUpdate && isProgramaticallyResizable - ? (newScene ?? scene).defaultSize - : backend.size(ofWindow: window), + proposedWindowSize: proposedWindowSize, + needsWindowSizeCommit: usedDefaultSize, backend: backend, environment: environment, windowSizeIsFinal: !isProgramaticallyResizable ) } + /// Updates the WindowGroupNode. + /// - Parameters: + /// - newScene: The scene's body if recomputed. + /// - proposedWindowSize: The proposed window size. + /// - needsWindowSizeCommit: Whether the proposed window size matches the + /// windows current size (or imminent size in the case of a window + /// resize). We use this parameter instead of comparing to the window's + /// current size to the proposed size, because some backends (such as + /// AppKitBackend) trigger window resize handlers *before* the underlying + /// window gets assigned its new size (allowing us to pre-emptively update the + /// window's content to match the new size). + /// - backend: The backend to use. + /// - environment: The current environment. + /// - windowSizeIsFinal: If true, no further resizes can/will be made. This + /// is true on platforms that don't support programmatic window resizing, + /// and when a window is full screen. public func update( _ newScene: WindowGroup?, proposedWindowSize: SIMD2, + needsWindowSizeCommit: Bool, backend: Backend, environment: EnvironmentValues, windowSizeIsFinal: Bool = false - ) -> ViewUpdateResult { + ) -> ViewLayoutResult { guard let window = window as? Backend.Window else { fatalError("Scene updated with a backend incompatible with the window it was given") } @@ -130,129 +160,75 @@ public final class WindowGroupNode: SceneGraphNode { _ = self.update( self.scene, proposedWindowSize: backend.size(ofWindow: window), + needsWindowSizeCommit: false, backend: backend, environment: environment ) } .with(\.window, window) - let dryRunResult: ViewUpdateResult? - if !windowSizeIsFinal { - // Perform a dry-run update of the root view to check if the window - // needs to change size. - let contentResult = viewGraph.update( + let finalContentResult: ViewLayoutResult + if scene.resizability.isResizable { + let minimumWindowSize = viewGraph.computeLayout( with: newScene?.body, - proposedSize: proposedWindowSize, - environment: environment, - dryRun: true - ) - dryRunResult = contentResult + proposedSize: .zero, + environment: environment.with(\.allowLayoutCaching, true) + ).size - let newWindowSize = computeNewWindowSize( - currentProposedSize: proposedWindowSize, - backend: backend, - contentSize: contentResult.size, - environment: environment + let clampedWindowSize = ViewSize( + max(minimumWindowSize.width, Double(proposedWindowSize.x)), + max(minimumWindowSize.height, Double(proposedWindowSize.y)) ) - // Restart the window update if the content has caused the window to - // change size. To avoid infinite recursion, we take the view's word - // and assume that it will take on the minimum/maximum size it claimed. - if let newWindowSize { + if clampedWindowSize.vector != proposedWindowSize && !windowSizeIsFinal { + // Restart the window update if the content has caused the window to + // change size. return update( scene, - proposedWindowSize: newWindowSize, + proposedWindowSize: clampedWindowSize.vector, + needsWindowSizeCommit: true, backend: backend, environment: environment, - windowSizeIsFinal: false + windowSizeIsFinal: true ) } - } else { - dryRunResult = nil - } - let finalContentResult = viewGraph.update( - with: newScene?.body, - proposedSize: proposedWindowSize, - environment: environment, - dryRun: false - ) + // Set this even if the window isn't programmatically resizable + // because the window may still be user resizable. + backend.setMinimumSize(ofWindow: window, to: minimumWindowSize.vector) - // The Gtk 3 backend has some broken sizing code that can't really be - // fixed due to the design of Gtk 3. Our layout system underestimates - // the size of the new view due to the button not being in the Gtk 3 - // widget hierarchy yet (which prevents Gtk 3 from computing the - // natural sizes of the new buttons). One fix seems to be removing - // view size reuse (currently the second check in ViewGraphNode.update) - // and I'm not exactly sure why, but that makes things awfully slow. - // The other fix is to add an alternative path to - // Gtk3Backend.naturalSize(of:) for buttons that moves non-realized - // buttons to a secondary window before measuring their natural size, - // but that's super janky, easy to break if the button in the real - // window is inheriting styles from its ancestors, and I'm not sure - // how to hide the window (it's probably terrible for performance too). - // - // I still have no clue why this size underestimation (and subsequent - // mis-sizing of the window) had the symptom of all buttons losing - // their labels temporarily; Gtk 3 is a temperamental beast. - // - // Anyway, Gtk3Backend isn't really intended to be a recommended - // backend so I think this is a fine solution for now (people should - // only use Gtk3Backend if they can't use GtkBackend). - if let dryRunResult, finalContentResult.size != dryRunResult.size { - print( - """ - warning: Final window content size didn't match dry-run size. This is a sign that - either view size caching is broken or that backend.naturalSize(of:) is - broken (or both). - -> dryRunResult.size: \(dryRunResult.size) - -> finalContentResult.size: \(finalContentResult.size) - """ + finalContentResult = viewGraph.computeLayout( + proposedSize: ProposedViewSize(proposedWindowSize), + environment: environment ) - - // Give the view graph one more chance to sort itself out to fail - // as gracefully as possible. - let newWindowSize = computeNewWindowSize( - currentProposedSize: proposedWindowSize, - backend: backend, - contentSize: finalContentResult.size, + } else { + let initialContentResult = viewGraph.computeLayout( + with: newScene?.body, + proposedSize: ProposedViewSize(proposedWindowSize), environment: environment ) - - if let newWindowSize { + if initialContentResult.size.vector != proposedWindowSize && !windowSizeIsFinal { return update( scene, - proposedWindowSize: newWindowSize, + proposedWindowSize: initialContentResult.size.vector, + needsWindowSizeCommit: true, backend: backend, environment: environment, windowSizeIsFinal: true ) } + finalContentResult = initialContentResult } - // Set this even if the window isn't programmatically resizable - // because the window may still be user resizable. - if scene.resizability.isResizable { - backend.setMinimumSize( - ofWindow: window, - to: SIMD2( - finalContentResult.size.minimumWidth, - finalContentResult.size.minimumHeight - ) - ) - } + viewGraph.commit() backend.setPosition( ofChildAt: 0, in: containerWidget.into(), - to: SIMD2( - (proposedWindowSize.x - finalContentResult.size.size.x) / 2, - (proposedWindowSize.y - finalContentResult.size.size.y) / 2 - ) + to: (proposedWindowSize &- finalContentResult.size.vector) / 2 ) - let currentWindowSize = backend.size(ofWindow: window) - if currentWindowSize != proposedWindowSize { + if needsWindowSizeCommit { backend.setSize(ofWindow: window, to: proposedWindowSize) } @@ -263,29 +239,4 @@ public final class WindowGroupNode: SceneGraphNode { return finalContentResult } - - public func computeNewWindowSize( - currentProposedSize: SIMD2, - backend: Backend, - contentSize: ViewSize, - environment: EnvironmentValues - ) -> SIMD2? { - if scene.resizability.isResizable { - if currentProposedSize.x < contentSize.minimumWidth - || currentProposedSize.y < contentSize.minimumHeight - { - let newSize = SIMD2( - max(currentProposedSize.x, contentSize.minimumWidth), - max(currentProposedSize.y, contentSize.minimumHeight) - ) - return newSize - } else { - return nil - } - } else if contentSize.idealSize != currentProposedSize { - return contentSize.idealSize - } else { - return nil - } - } } diff --git a/Sources/SwiftCrossUI/State/DynamicKeyPath.swift b/Sources/SwiftCrossUI/State/DynamicKeyPath.swift new file mode 100644 index 00000000000..9671ae4ebbd --- /dev/null +++ b/Sources/SwiftCrossUI/State/DynamicKeyPath.swift @@ -0,0 +1,84 @@ +#if canImport(Darwin) + import func Darwin.memcmp +#elseif canImport(Glibc) + import func Glibc.memcmp +#elseif canImport(WinSDK) + import func WinSDK.memcmp +#elseif canImport(Android) + import func Android.memcmp +#endif + +/// A type similar to KeyPath, but that can be constructed at run time given +/// an instance of a struct, and the value of the desired property. Construction +/// fails if the property's in-memory representation is not unique within the +/// struct. SwiftCrossUI only uses ``DynamicKeyPath`` in situations where it is +/// highly likely for properties to have unique in-memory representations, such +/// as when properties have internal storage pointers. +struct DynamicKeyPath { + /// The property's offset within instances of ``T``. + var offset: Int + + /// Constructs a key path given an instance of the base type, and the + /// value of the desired property. The initializer will search through + /// the base instance's in-memory representation to find the unique offset + /// that matches the representation of the given property value. If such an + /// offset can't be found or isn't unique, then the initialiser returns `nil`. + init?( + forProperty value: Value, + of base: Base, + label: String? = nil + ) { + let propertyAlignment = MemoryLayout.alignment + let propertySize = MemoryLayout.size + let baseStructSize = MemoryLayout.size + + var index = 0 + var matches: [Int] = [] + while index + propertySize <= baseStructSize { + let isMatch = + withUnsafeBytes(of: base) { viewPointer in + withUnsafeBytes(of: value) { valuePointer in + memcmp( + viewPointer.baseAddress!.advanced(by: index), + valuePointer.baseAddress!, + propertySize + ) + } + } == 0 + if isMatch { + matches.append(index) + } + index += propertyAlignment + } + + guard let offset = matches.first else { + print("Warning: No offset found for dynamic property '\(label ?? "")'") + return nil + } + + guard matches.count == 1 else { + print("Warning: Multiple offsets found for dynamic property '\(label ?? "")'") + return nil + } + + self.offset = offset + } + + /// Gets the property's value on the given instance. + func get(_ base: Base) -> Value { + withUnsafeBytes(of: base) { buffer in + buffer.baseAddress!.advanced(by: offset) + .assumingMemoryBound(to: Value.self) + .pointee + } + } + + /// Sets the property's value to a new value on the given instance. + func set(_ base: inout Base, _ newValue: Value) { + withUnsafeMutableBytes(of: &base) { buffer in + buffer.baseAddress!.advanced(by: offset) + .assumingMemoryBound(to: Value.self) + .pointee = newValue + } + } +} diff --git a/Sources/SwiftCrossUI/State/DynamicProperty.swift b/Sources/SwiftCrossUI/State/DynamicProperty.swift index 2ba43c633d9..c5734159363 100644 --- a/Sources/SwiftCrossUI/State/DynamicProperty.swift +++ b/Sources/SwiftCrossUI/State/DynamicProperty.swift @@ -11,82 +11,3 @@ public protocol DynamicProperty { previousValue: Self? ) } - -/// Updates the dynamic properties of a value given a previous instance of the -/// type (if one exists) and the current environment. -func updateDynamicProperties( - of value: T, - previousValue: T?, - environment: EnvironmentValues -) { - let newMirror = Mirror(reflecting: value) - let previousMirror = previousValue.map(Mirror.init(reflecting:)) - if let previousChildren = previousMirror?.children { - let propertySequence = zip(newMirror.children, previousChildren) - for (newProperty, previousProperty) in propertySequence { - guard - let newValue = newProperty.value as? any DynamicProperty, - let previousValue = previousProperty.value as? any DynamicProperty - else { - continue - } - - updateDynamicProperty( - newProperty: newValue, - previousProperty: previousValue, - environment: environment, - enclosingTypeName: "\(T.self)", - propertyName: newProperty.label - ) - } - } else { - for property in newMirror.children { - guard let newValue = property.value as? any DynamicProperty else { - continue - } - - updateDynamicProperty( - newProperty: newValue, - previousProperty: nil, - environment: environment, - enclosingTypeName: "\(T.self)", - propertyName: property.label - ) - } - } -} - -/// Updates a dynamic property. Required to unmask the concrete type of the -/// property. Since the two properties can technically be two different -/// types, Swift correctly wouldn't allow us to assume they're both the -/// same. So we unwrap one and then dynamically check whether the other -/// matches using a type cast. -private func updateDynamicProperty( - newProperty: Property, - previousProperty: (any DynamicProperty)?, - environment: EnvironmentValues, - enclosingTypeName: String, - propertyName: String? -) { - let castedPreviousProperty: Property? - if let previousProperty { - guard let previousProperty = previousProperty as? Property else { - fatalError( - """ - Supposedly unreachable... previous and current types of \ - \(enclosingTypeName).\(propertyName ?? "") \ - don't match. - """ - ) - } - - castedPreviousProperty = previousProperty - } else { - castedPreviousProperty = nil - } - - newProperty.update( - with: environment, - previousValue: castedPreviousProperty - ) -} diff --git a/Sources/SwiftCrossUI/State/DynamicPropertyUpdater.swift b/Sources/SwiftCrossUI/State/DynamicPropertyUpdater.swift new file mode 100644 index 00000000000..d1af87fe389 --- /dev/null +++ b/Sources/SwiftCrossUI/State/DynamicPropertyUpdater.swift @@ -0,0 +1,210 @@ +/// A cache for dynamic property updaters. The keys are the ObjectIdentifiers of +/// various Base types that we have already computed dynamic property updaters +/// for, and the elements are corresponding cached instances of +/// DynamicPropertyUpdater. +/// +/// From some basic testing, this caching seems to reduce layout times by 5-10% +/// (at the time of implementation). +@MainActor +var updaterCache: [ObjectIdentifier: Any] = [:] + +/// A helper for updating the dynamic properties of a stateful struct (e.g. +/// a View or App conforming struct). Dynamic properties are those that conform +/// to ``DynamicProperty``, e.g. properties annotated with `@State`. +/// +/// At initialisation the updater will attempt to determine the byte offset of +/// each stateful property in the struct. This is guaranteed to succeed if every +/// dynamic property in the provided struct instance contains internal mutable +/// storage, because the storage pointers will provide unique byte sequences. +/// Otherwise, offset discovery will fail when two dynamic properties share the +/// same pattern in memory. When offset discovery fails the updater will fall +/// back to using Mirrors each time `update` gets called, which can be 1500x +/// times slower when the view has 0 state properties, and 9x slower when the +/// view has 4 properties, with the factor slowly dropping as the number of +/// properties increases. +struct DynamicPropertyUpdater { + typealias PropertyUpdater = ( + _ old: Base?, + _ new: Base, + _ environment: EnvironmentValues + ) -> Void + + /// The updaters for each of Base's dynamic properties. If `nil`, then we + /// failed to compute + let propertyUpdaters: [PropertyUpdater]? + + /// Creates a new dynamic property updater which can efficiently update + /// all dynamic properties on any value of type Base without creating + /// any mirrors after the initial creation of the updater. Pass in a + /// `mirror` of base if you already have one to save us creating another one. + @MainActor + init(for base: Base, mirror: Mirror? = nil) { + // Unlikely shortcut, but worthwhile when we can. + if MemoryLayout.size == 0 { + self.propertyUpdaters = [] + return + } + + if let cachedUpdater = updaterCache[ObjectIdentifier(Base.self)], + let cachedUpdater = cachedUpdater as? Self + { + self = cachedUpdater + return + } + + var propertyUpdaters: [PropertyUpdater] = [] + + let mirror = mirror ?? Mirror(reflecting: base) + for child in mirror.children { + let label = child.label ?? "" + let value = child.value + + guard let value = value as? any DynamicProperty else { + continue + } + + guard let updater = Self.getUpdater(for: value, base: base, label: label) else { + // We have failed to create the required property updaters. Fallback + // to using Mirrors to update all properties. + print( + """ + warning: Failed to produce DynamicPropertyUpdater for \(Base.self), \ + falling back to slower Mirror-based property updating approach. + """ + ) + self.propertyUpdaters = nil + + // We intentionally return without caching the updaters here so + // that we if this failure is a fluke we can recover on a + // subsequent attempt for the same type. It may turn out that in + // practice types that fail are ones that always fail, in which + // case we should update this code to add the current updater to + // the cache. + return + } + + propertyUpdaters.append(updater) + } + + self.propertyUpdaters = propertyUpdaters + + updaterCache[ObjectIdentifier(Base.self)] = self + } + + /// Updates each dynamic property of the given value. + func update(_ value: Base, with environment: EnvironmentValues, previousValue: Base?) { + guard let propertyUpdaters else { + // Fall back to our old dynamic property updating approach which involves a lot of + // Mirror overhead. This should be rare. + Self.updateFallback(of: value, previousValue: previousValue, environment: environment) + return + } + + for updater in propertyUpdaters { + updater(previousValue, value, environment) + } + } + + /// Gets an updater for the property of base with the given value. If multiple + /// properties exist matching the byte pattern of `value`, then `nil` is returned. + /// + /// The returned updater is reusable and doesn't use Mirror. + private static func getUpdater( + for value: T, + base: Base, + label: String + ) -> PropertyUpdater? { + guard let keyPath = DynamicKeyPath(forProperty: value, of: base, label: label) else { + return nil + } + + let updater = { (old: Base?, new: Base, environment: EnvironmentValues) in + let property = keyPath.get(new) + property.update( + with: environment, + previousValue: old.map(keyPath.get) + ) + } + + return updater + } + + /// Updates the dynamic properties of a value given a previous instance of the + /// type (if one exists) and the current environment. + private static func updateFallback( + of value: T, + previousValue: T?, + environment: EnvironmentValues + ) { + let newMirror = Mirror(reflecting: value) + let previousMirror = previousValue.map(Mirror.init(reflecting:)) + if let previousChildren = previousMirror?.children { + let propertySequence = zip(newMirror.children, previousChildren) + for (newProperty, previousProperty) in propertySequence { + guard + let newValue = newProperty.value as? any DynamicProperty, + let previousValue = previousProperty.value as? any DynamicProperty + else { + continue + } + + updateDynamicPropertyFallback( + newProperty: newValue, + previousProperty: previousValue, + environment: environment, + enclosingTypeName: "\(T.self)", + propertyName: newProperty.label + ) + } + } else { + for property in newMirror.children { + guard let newValue = property.value as? any DynamicProperty else { + continue + } + + updateDynamicPropertyFallback( + newProperty: newValue, + previousProperty: nil, + environment: environment, + enclosingTypeName: "\(T.self)", + propertyName: property.label + ) + } + } + } + + /// Updates a dynamic property. Required to unmask the concrete type of the + /// property. Since the two properties can technically be two different + /// types, Swift correctly wouldn't allow us to assume they're both the + /// same. So we unwrap one and then dynamically check whether the other + /// matches using a type cast. + private static func updateDynamicPropertyFallback( + newProperty: Property, + previousProperty: (any DynamicProperty)?, + environment: EnvironmentValues, + enclosingTypeName: String, + propertyName: String? + ) { + let castedPreviousProperty: Property? + if let previousProperty { + guard let previousProperty = previousProperty as? Property else { + fatalError( + """ + Supposedly unreachable... previous and current types of \ + \(enclosingTypeName).\(propertyName ?? "") \ + don't match. + """ + ) + } + + castedPreviousProperty = previousProperty + } else { + castedPreviousProperty = nil + } + + newProperty.update( + with: environment, + previousValue: castedPreviousProperty + ) + } +} diff --git a/Sources/SwiftCrossUI/Values/Axis.swift b/Sources/SwiftCrossUI/Values/Axis.swift index 16f14a00e62..480aecbd0c8 100644 --- a/Sources/SwiftCrossUI/Values/Axis.swift +++ b/Sources/SwiftCrossUI/Values/Axis.swift @@ -1,12 +1,27 @@ /// An axis in a 2D coordinate system. -public enum Axis: Sendable { +public enum Axis: Sendable, CaseIterable { /// The horizontal axis. case horizontal /// The vertical axis. case vertical + /// Gets the orientation with this axis as its main axis. + var orientation: Orientation { + switch self { + case .horizontal: + .horizontal + case .vertical: + .vertical + } + } + /// A set of axes represented as an efficient bit field. public struct Set: OptionSet, Sendable { + // Required to satisfy older compilers (5.10) due to our custom + // `contains(_:)` overload below which confuses the inference + // of SetAlgebra's Element associated type. + public typealias Element = Self + /// The horizontal axis. public static let horizontal = Set(rawValue: 1) /// The vertical axis. @@ -17,5 +32,15 @@ public enum Axis: Sendable { public init(rawValue: UInt8) { self.rawValue = rawValue } + + /// Gets whether a given member is a member of the option set. + public func contains(_ member: Axis) -> Bool { + switch member { + case .horizontal: + contains(Axis.Set.horizontal) + case .vertical: + contains(Axis.Set.vertical) + } + } } } diff --git a/Sources/SwiftCrossUI/Values/Color.swift b/Sources/SwiftCrossUI/Values/Color.swift index aa9c18f6d02..ef67f26d417 100644 --- a/Sources/SwiftCrossUI/Values/Color.swift +++ b/Sources/SwiftCrossUI/Values/Color.swift @@ -1,5 +1,8 @@ /// An RGBA representation of a color. public struct Color: Sendable, Equatable, Hashable { + /// The ideal size of a color view. + private static let idealSize = ViewSize(10, 10) + /// The red component (from 0 to 1). public var red: Float /// The green component (from 0 to 1). @@ -69,26 +72,24 @@ extension Color: ElementaryView { backend.createColorableRectangle() } - func update( + func computeLayout( _ widget: Backend.Widget, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - if !dryRun { - backend.setSize(of: widget, to: proposedSize) - backend.setColor(ofColorableRectangle: widget, to: self) - } - return ViewUpdateResult.leafView( - size: ViewSize( - size: proposedSize, - idealSize: SIMD2(10, 10), - minimumWidth: 0, - minimumHeight: 0, - maximumWidth: nil, - maximumHeight: nil - ) + backend: Backend + ) -> ViewLayoutResult { + ViewLayoutResult.leafView( + size: proposedSize.replacingUnspecifiedDimensions(by: Self.idealSize) ) } + + func commit( + _ widget: Backend.Widget, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + backend.setSize(of: widget, to: layout.size.vector) + backend.setColor(ofColorableRectangle: widget, to: self) + } } diff --git a/Sources/SwiftCrossUI/Values/GeometryProxy.swift b/Sources/SwiftCrossUI/Values/GeometryProxy.swift index c08bf23f8bd..d9165b16016 100644 --- a/Sources/SwiftCrossUI/Values/GeometryProxy.swift +++ b/Sources/SwiftCrossUI/Values/GeometryProxy.swift @@ -3,5 +3,5 @@ public struct GeometryProxy { /// The size proposed to the view by its parent. In the context of /// ``GeometryReader``, this is the size that the ``GeometryReader`` /// will take on (to prevent feedback loops). - public var size: SIMD2 + public var size: ViewSize } diff --git a/Sources/SwiftCrossUI/Values/Orientation.swift b/Sources/SwiftCrossUI/Values/Orientation.swift index 0139a6d721c..01ac11dac3a 100644 --- a/Sources/SwiftCrossUI/Values/Orientation.swift +++ b/Sources/SwiftCrossUI/Values/Orientation.swift @@ -2,4 +2,14 @@ public enum Orientation: Sendable { case horizontal case vertical + + /// The orientation perpendicular to this one. + var perpendicular: Orientation { + switch self { + case .horizontal: + .vertical + case .vertical: + .horizontal + } + } } diff --git a/Sources/SwiftCrossUI/ViewGraph/AnyViewGraphNode.swift b/Sources/SwiftCrossUI/ViewGraph/AnyViewGraphNode.swift index 2edc517100a..5ade76afb99 100644 --- a/Sources/SwiftCrossUI/ViewGraph/AnyViewGraphNode.swift +++ b/Sources/SwiftCrossUI/ViewGraph/AnyViewGraphNode.swift @@ -12,27 +12,36 @@ public class AnyViewGraphNode { _getWidget() } - /// The node's type-erased update method for update the view. - private var _updateWithNewView: + /// The node's last proposed size. + public var lastProposedSize: ProposedViewSize { + _getLastProposedSize() + } + + /// The node's type-erased layout computing method. + private var _computeLayoutWithNewView: ( _ newView: NodeView?, - _ proposedSize: SIMD2, - _ environment: EnvironmentValues, - _ dryRun: Bool - ) -> ViewUpdateResult + _ proposedSize: ProposedViewSize, + _ environment: EnvironmentValues + ) -> ViewLayoutResult + /// The node's type-erased commit method. + private var _commit: () -> ViewLayoutResult /// The type-erased getter for the node's widget. private var _getWidget: () -> AnyWidget /// The type-erased getter for the node's view. private var _getNodeView: () -> NodeView /// The type-erased getter for the node's children. private var _getNodeChildren: () -> any ViewGraphNodeChildren - /// The underlying erased backend. + /// The type-erased getter for the node's underlying erased backend. private var _getBackend: () -> any AppBackend + /// The type-erased getter for the node's last proposed size. + private var _getLastProposedSize: () -> ProposedViewSize /// Type-erases a view graph node. public init(_ node: ViewGraphNode) { self.node = node - _updateWithNewView = node.update(with:proposedSize:environment:dryRun:) + _computeLayoutWithNewView = node.computeLayout(with:proposedSize:environment:) + _commit = node.commit _getWidget = { AnyWidget(node.widget) } @@ -45,6 +54,9 @@ public class AnyViewGraphNode { _getBackend = { node.backend } + _getLastProposedSize = { + node.lastProposedSize + } } /// Creates a new view graph node and immediately type-erases it. @@ -64,16 +76,20 @@ public class AnyViewGraphNode { ) } - /// Updates the view after it got recomputed (e.g. due to the parent's state changing) - /// or after its own state changed (depending on the presence of `newView`). - /// - Parameter dryRun: If `true`, only compute sizing and don't update the underlying widget. - public func update( + /// Computes a view's layout. Propagates to the view's children unless + /// the given size proposal already has a cached result. + public func computeLayout( with newView: NodeView?, - proposedSize: SIMD2, - environment: EnvironmentValues, - dryRun: Bool - ) -> ViewUpdateResult { - _updateWithNewView(newView, proposedSize, environment, dryRun) + proposedSize: ProposedViewSize, + environment: EnvironmentValues + ) -> ViewLayoutResult { + _computeLayoutWithNewView(newView, proposedSize, environment) + } + + /// Commits the view's most recently computed layout. Propagates to the + /// view's children. Also commits any view state changes. + public func commit() -> ViewLayoutResult { + _commit() } /// Gets the node's wrapped view. diff --git a/Sources/SwiftCrossUI/ViewGraph/ErasedViewGraphNode.swift b/Sources/SwiftCrossUI/ViewGraph/ErasedViewGraphNode.swift index 23bba12d409..0293bd32d92 100644 --- a/Sources/SwiftCrossUI/ViewGraph/ErasedViewGraphNode.swift +++ b/Sources/SwiftCrossUI/ViewGraph/ErasedViewGraphNode.swift @@ -8,13 +8,14 @@ public struct ErasedViewGraphNode { /// value will have `viewTypeMatched` set to `false`, allowing views such as `AnyView` /// to choose how to react to a mismatch. In `AnyView`'s case this means throwing away /// the current view graph node and creating a new one for the new view type. - public var updateWithNewView: + public var computeLayoutWithNewView: ( _ newView: Any?, - _ proposedSize: SIMD2, - _ environment: EnvironmentValues, - _ dryRun: Bool - ) -> (viewTypeMatched: Bool, size: ViewUpdateResult) + _ proposedSize: ProposedViewSize, + _ environment: EnvironmentValues + ) -> (viewTypeMatched: Bool, size: ViewLayoutResult) + /// The underlying view graph node's commit method. + public var commit: () -> ViewLayoutResult public var getWidget: () -> AnyWidget public var viewType: any View.Type @@ -42,28 +43,27 @@ public struct ErasedViewGraphNode { self.node = node backendType = Backend.self viewType = V.self - updateWithNewView = { view, proposedSize, environment, dryRun in + computeLayoutWithNewView = { view, proposedSize, environment in if let view { guard let view = view as? V else { - return (false, ViewUpdateResult.leafView(size: .empty)) + return (false, ViewLayoutResult.leafView(size: .zero)) } - let size = node.update( + let size = node.computeLayout( with: view, proposedSize: proposedSize, - environment: environment, - dryRun: dryRun + environment: environment ) return (true, size) } else { - let size = node.update( + let size = node.computeLayout( with: nil, proposedSize: proposedSize, - environment: environment, - dryRun: dryRun + environment: environment ) return (true, size) } } + commit = node.commit getWidget = { return AnyWidget(node.widget) } diff --git a/Sources/SwiftCrossUI/ViewGraph/ViewGraph.swift b/Sources/SwiftCrossUI/ViewGraph/ViewGraph.swift index b868d9ab7e9..0cdd4a80676 100644 --- a/Sources/SwiftCrossUI/ViewGraph/ViewGraph.swift +++ b/Sources/SwiftCrossUI/ViewGraph/ViewGraph.swift @@ -18,11 +18,13 @@ public class ViewGraph { private var cancellable: Cancellable? /// The root view being managed by this view graph. private var view: Root - /// The most recent size of the window (used when updated the root view due to a state - /// change as opposed to a window resizing event). - private var windowSize: SIMD2 + /// The latest size proposal. + private var latestProposal: ProposedViewSize + /// The latest proposal as of the last commit (used when updated the root + /// view due to a state change as opposed to a window resizing event). + private var committedProposal: ProposedViewSize /// The current size of the root view. - private var currentRootViewResult: ViewUpdateResult + private var currentRootViewResult: ViewLayoutResult /// The environment most recently provided by this node's parent scene. private var parentEnvironment: EnvironmentValues @@ -39,37 +41,47 @@ public class ViewGraph { rootNode = AnyViewGraphNode(for: view, backend: backend, environment: environment) self.view = view - windowSize = .zero + latestProposal = .zero + committedProposal = .zero parentEnvironment = environment - currentRootViewResult = ViewUpdateResult.leafView(size: .empty) + currentRootViewResult = ViewLayoutResult.leafView(size: .zero) setIncomingURLHandler = backend.setIncomingURLHandler(to:) } /// Recomputes the entire UI (e.g. due to the root view's state updating). /// If the update is due to the parent scene getting updated then the view /// is recomputed and passed as `newView`. - public func update( + public func computeLayout( with newView: Root? = nil, - proposedSize: SIMD2, - environment: EnvironmentValues, - dryRun: Bool - ) -> ViewUpdateResult { + proposedSize: ProposedViewSize, + environment: EnvironmentValues + ) -> ViewLayoutResult { parentEnvironment = environment - windowSize = proposedSize - let result = rootNode.update( + latestProposal = proposedSize + + let result = rootNode.computeLayout( with: newView ?? view, proposedSize: proposedSize, - environment: parentEnvironment, - dryRun: dryRun + environment: parentEnvironment ) self.currentRootViewResult = result - if isFirstUpdate, !dryRun { + if let newView { + self.view = newView + } + return result + } + + /// Commits the result of the last computeLayout call to the underlying + /// widget hierarchy. + public func commit() { + committedProposal = latestProposal + self.currentRootViewResult = rootNode.commit() + if isFirstUpdate { setIncomingURLHandler { url in self.currentRootViewResult.preferences.onOpenURL?(url) } isFirstUpdate = false } - return result } public func snapshot() -> ViewGraphSnapshotter.NodeSnapshot { diff --git a/Sources/SwiftCrossUI/ViewGraph/ViewGraphNode.swift b/Sources/SwiftCrossUI/ViewGraph/ViewGraphNode.swift index b80b8486993..15e0296dd27 100644 --- a/Sources/SwiftCrossUI/ViewGraph/ViewGraphNode.swift +++ b/Sources/SwiftCrossUI/ViewGraph/ViewGraphNode.swift @@ -1,6 +1,7 @@ import Foundation -/// A view graph node storing a view, its widget, and its children (likely a collection of more nodes). +/// A view graph node storing a view, its widget, and its children (likely a +/// collection of more nodes). /// /// This is where updates are initiated when a view's state updates, and where state is persisted /// even when a view gets recomputed by its parent. @@ -17,9 +18,9 @@ public class ViewGraphNode: Sendable { /// The view's children (usually just contains more view graph nodes, but can handle extra logic /// such as figuring out how to update variable length array of children efficiently). /// - /// It's type-erased because otherwise complex implementation details would be forced to the user - /// or other compromises would have to be made. I believe that this is the best option with Swift's - /// current generics landscape. + /// It's type-erased because otherwise complex implementation details would + /// be forced to the user or other compromises would have to be made. I + /// believe that this is the best option with Swift's current generics landscape. public var children: any ViewGraphNodeChildren { get { _children! @@ -36,14 +37,17 @@ public class ViewGraphNode: Sendable { /// The backend used to create the view's widget. public var backend: Backend - /// The most recent update result for the wrapped view. - var currentResult: ViewUpdateResult? - /// A cache of update results keyed by the proposed size they were for. Gets cleared before the - /// results' sizes become invalid. - var resultCache: [SIMD2: ViewUpdateResult] + /// The view's most recently computed layout. Doesn't include cached layouts, + /// as this is the layout that is currently 'ready to commit'. + public var currentLayout: ViewLayoutResult? + /// A cache of update results keyed by the proposed size they were for. Gets + /// cleared before the results' sizes become invalid. + var resultCache: [ProposedViewSize: ViewLayoutResult] /// The most recent size proposed by the parent view. Used when updating the wrapped - /// view as a result of a state change rather than the parent view updating. - private var lastProposedSize: SIMD2 + /// view as a result of a state change rather than the parent view updating. Proposals + /// that get cached responses don't update this size, as this size should stay in sync + /// with currentLayout. + private(set) var lastProposedSize: ProposedViewSize /// A cancellable handle to the view's state property observations. private var cancellables: [Cancellable] @@ -51,6 +55,9 @@ public class ViewGraphNode: Sendable { /// The environment most recently provided by this node's parent. private var parentEnvironment: EnvironmentValues + /// The dynamic property updater for this view. + private var dynamicPropertyUpdater: DynamicPropertyUpdater + /// Creates a node for a given view while also creating the nodes for its children, creating /// the view's widget, and starting to observe its state for changes. public init( @@ -70,19 +77,18 @@ public class ViewGraphNode: Sendable { snapshot?.isValid(for: NodeView.self) == true ? snapshot?.children : snapshot.map { [$0] } - currentResult = nil + currentLayout = nil resultCache = [:] lastProposedSize = .zero parentEnvironment = environment cancellables = [] + let mirror = Mirror(reflecting: view) + dynamicPropertyUpdater = DynamicPropertyUpdater(for: view, mirror: mirror) + let viewEnvironment = updateEnvironment(environment) - updateDynamicProperties( - of: view, - previousValue: nil, - environment: viewEnvironment - ) + dynamicPropertyUpdater.update(view, with: viewEnvironment, previousValue: nil) let children = view.children( backend: backend, @@ -102,7 +108,6 @@ public class ViewGraphNode: Sendable { backend.tag(widget: widget, as: tag) // Update the view and its children when state changes (children are always updated first). - let mirror = Mirror(reflecting: view) for property in mirror.children { if property.label == "state" && property.value is ObservableObject { print( @@ -136,34 +141,18 @@ public class ViewGraphNode: Sendable { private func bottomUpUpdate() { // First we compute what size the view will be after the update. If it will change size, // propagate the update to this node's parent instead of updating straight away. - let currentSize = currentResult?.size - let newResult = self.update( + let currentSize = currentLayout?.size + let newLayout = self.computeLayout( proposedSize: lastProposedSize, - environment: parentEnvironment, - dryRun: true + environment: parentEnvironment ) - if newResult.size != currentSize { - self.currentResult = newResult - resultCache[lastProposedSize] = newResult - parentEnvironment.onResize(newResult.size) + self.currentLayout = newLayout + if newLayout.size != currentSize { + resultCache[lastProposedSize] = newLayout + parentEnvironment.onResize(newLayout.size) } else { - let finalResult = self.update( - proposedSize: lastProposedSize, - environment: parentEnvironment, - dryRun: false - ) - if finalResult.size != newResult.size { - print( - """ - warning: State-triggered view update had mismatch \ - between dry-run size and final size. - -> dry-run size: \(newResult.size) - -> final size: \(finalResult.size) - """ - ) - } - self.currentResult = finalResult + _ = self.commit() } } @@ -174,17 +163,15 @@ public class ViewGraphNode: Sendable { } } - /// Recomputes the view's body, and updates its widget accordingly. The view may or may not - /// propagate the update to its children depending on the nature of the update. If `newView` - /// is provided (in the case that the parent's body got updated) then it simply replaces the - /// old view while inheriting the old view's state. - /// - Parameter dryRun: If `true`, only compute sizing and don't update the underlying widget. - public func update( + /// Recomputes the view's body and computes the layout of it and all its children + /// if necessary. If `newView` is provided (in the case that the parent's body got + /// updated) then it simply replaces the old view while inheriting the old view's + /// state. + public func computeLayout( with newView: NodeView? = nil, - proposedSize: SIMD2, - environment: EnvironmentValues, - dryRun: Bool - ) -> ViewUpdateResult { + proposedSize: ProposedViewSize, + environment: EnvironmentValues + ) -> ViewLayoutResult { // Defensively ensure that all future scene implementations obey this // precondition. By putting the check here instead of only in views // that require `environment.window` (such as the alert modifier view), @@ -194,46 +181,24 @@ public class ViewGraphNode: Sendable { "View graph updated without parent window present in environment" ) - if dryRun, let cachedResult = resultCache[proposedSize] { + if proposedSize == lastProposedSize && !resultCache.isEmpty + && (!parentEnvironment.allowLayoutCaching || environment.allowLayoutCaching), + let currentLayout + { + // If the previous proposal is the same as the current one, and our + // cache hasn't been invalidated, then we can reuse the current layout. + // But only if the previous layout was computed without caching, or the + // current layout is being computed with caching, cause otherwise we could + // end up using a layout computed with caching while computing a layout + // without caching. + return currentLayout + } else if environment.allowLayoutCaching, let cachedResult = resultCache[proposedSize] { + // If this layout pass is a probing pass (not a final pass), then we + // can reuse any layouts that we've computed since the cache was last + // cleared. The cache gets cleared on commit. return cachedResult } - // Attempt to cleverly reuse the current size if we can know that it - // won't change. We must of course be in a dry run, have a known - // current size, and must've run at least one proper dry run update - // since the last update cycle (checked via`!sizeCache.isEmpty`) to - // ensure that the view has been updated at least once with the - // current view state. - if dryRun, let currentResult, !resultCache.isEmpty { - // If both the previous and current proposed sizes are larger than - // the view's previously computed maximum size, reuse the previous - // result (currentResult). - if ((Double(lastProposedSize.x) >= currentResult.size.maximumWidth - && Double(proposedSize.x) >= currentResult.size.maximumWidth) - || proposedSize.x == lastProposedSize.x) - && ((Double(lastProposedSize.y) >= currentResult.size.maximumHeight - && Double(proposedSize.y) >= currentResult.size.maximumHeight) - || proposedSize.y == lastProposedSize.y) - { - return currentResult - } - - // If the view has already been updated this update cycle and claims - // to be fixed size (maximumSize == minimumSize) then reuse the current - // result. - let maximumSize = SIMD2( - currentResult.size.maximumWidth, - currentResult.size.maximumHeight - ) - let minimumSize = SIMD2( - Double(currentResult.size.minimumWidth), - Double(currentResult.size.minimumHeight) - ) - if maximumSize == minimumSize { - return currentResult - } - } - parentEnvironment = environment lastProposedSize = proposedSize @@ -247,36 +212,58 @@ public class ViewGraphNode: Sendable { let viewEnvironment = updateEnvironment(environment) - updateDynamicProperties( - of: view, - previousValue: previousView, - environment: viewEnvironment - ) + dynamicPropertyUpdater.update(view, with: viewEnvironment, previousValue: previousView) - if !dryRun { - backend.show(widget: widget) - } - let result = view.update( + let result = view.computeLayout( widget, children: children, proposedSize: proposedSize, environment: viewEnvironment, - backend: backend, - dryRun: dryRun + backend: backend ) - // We assume that the view's sizing behaviour won't change between consecutive dry run updates - // and the following real update because groups of updates following that pattern are assumed to - // be occurring within a single overarching view update. It may seem weird that we set it - // to false after real updates, but that's because it may get invalidated between a real - // update and the next dry-run update. - if !dryRun { - resultCache = [:] - } else { - resultCache[proposedSize] = result - } + // We assume that the view's sizing behaviour won't change between consecutive + // layout computations and the following commit, because groups of updates + // following that pattern are assumed to be occurring within a single overarching + // view update. Under that assumption, we can cache view layout results. + resultCache[proposedSize] = result - currentResult = result + currentLayout = result return result } + + /// Commits the view's most recently computed layout and any view state changes + /// that have occurred since the last update (e.g. text content changes or font + /// size changes). Returns the most recently computed layout for convenience, + /// although it's guaranteed to match the result of the last call to computeLayout. + public func commit() -> ViewLayoutResult { + backend.show(widget: widget) + + guard let currentLayout else { + print("warning: layout committed before being computed, ignoring") + return .leafView(size: .zero) + } + + if parentEnvironment.allowLayoutCaching { + print( + "warning: Committing layout computed with caching enabled. Results may be invalid. NodeView = \(NodeView.self)" + ) + } + if currentLayout.size.height == .infinity || currentLayout.size.width == .infinity { + print( + "warning: \(NodeView.self) has infinite height or width on commit, currentLayout.size: \(currentLayout.size), lastProposedSize: \(lastProposedSize)" + ) + } + + view.commit( + widget, + children: children, + layout: currentLayout, + environment: parentEnvironment, + backend: backend + ) + resultCache = [:] + + return currentLayout + } } diff --git a/Sources/SwiftCrossUI/Views/AnyView.swift b/Sources/SwiftCrossUI/Views/AnyView.swift index 7004374fad1..c83d533404b 100644 --- a/Sources/SwiftCrossUI/Views/AnyView.swift +++ b/Sources/SwiftCrossUI/Views/AnyView.swift @@ -52,19 +52,17 @@ public struct AnyView: TypeSafeView { /// Attempts to update the child. If the initial update fails then it means that the child's /// concrete type has changed and we must recreate the child node and swap out our current /// child widget with the new view's widget. - func update( + func computeLayout( _ widget: Backend.Widget, children: AnyViewChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - var (viewTypesMatched, result) = children.node.updateWithNewView( + backend: Backend + ) -> ViewLayoutResult { + var (viewTypesMatched, result) = children.node.computeLayoutWithNewView( child, proposedSize, - environment, - dryRun + environment ) // If the new view's type doesn't match the old view's type then we need to create a new @@ -79,29 +77,34 @@ public struct AnyView: TypeSafeView { // We can just assume that the update succeeded because we just created the node // a few lines earlier (so it's guaranteed that the view types match). - let (_, newResult) = children.node.updateWithNewView( + let (_, newResult) = children.node.computeLayoutWithNewView( child, proposedSize, - environment, - dryRun + environment ) result = newResult } - // If the child view has changed types and this isn't a dry-run then switch to displaying - // the new child widget. - if !dryRun, let widgetToReplace = children.widgetToReplace { + return result + } + + func commit( + _ widget: Backend.Widget, + children: AnyViewChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + if let widgetToReplace = children.widgetToReplace { backend.removeChild(widgetToReplace.into(), from: widget) backend.addChild(children.node.getWidget().into(), to: widget) backend.setPosition(ofChildAt: 0, in: widget, to: .zero) children.widgetToReplace = nil } - if !dryRun { - backend.setSize(of: widget, to: result.size.size) - } + _ = children.node.commit() - return result + backend.setSize(of: widget, to: layout.size.vector) } } diff --git a/Sources/SwiftCrossUI/Views/Button.swift b/Sources/SwiftCrossUI/Views/Button.swift index 739d1ba407d..dc86fdc6945 100644 --- a/Sources/SwiftCrossUI/Views/Button.swift +++ b/Sources/SwiftCrossUI/Views/Button.swift @@ -29,15 +29,14 @@ extension Button: ElementaryView { return backend.createButton() } - public func update( + public func computeLayout( _ widget: Backend.Widget, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - // TODO: Implement button sizing within SwiftCrossUI so that we can properly implement - // `dryRun`. Relying on the backend for button sizing also makes the Gtk 3 backend + backend: Backend + ) -> ViewLayoutResult { + // TODO: Implement button sizing within SwiftCrossUI so that we can move this to + // commit. Relying on the backend for button sizing also makes the Gtk 3 backend // basically impossible to implement correctly, hence the // `finalContentSize != contentSize` check in WindowGroupNode to catch any weird // behaviour. Without that extra safety net logic, buttons all end up label-less @@ -58,10 +57,15 @@ extension Button: ElementaryView { naturalSize.y ) - if !dryRun { - backend.setSize(of: widget, to: size) - } + return ViewLayoutResult.leafView(size: ViewSize(size)) + } - return ViewUpdateResult.leafView(size: ViewSize(fixedSize: size)) + public func commit( + _ widget: Backend.Widget, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + backend.setSize(of: widget, to: layout.size.vector) } } diff --git a/Sources/SwiftCrossUI/Views/Checkbox.swift b/Sources/SwiftCrossUI/Views/Checkbox.swift index 7c80d354f71..1d9d810f9b2 100644 --- a/Sources/SwiftCrossUI/Views/Checkbox.swift +++ b/Sources/SwiftCrossUI/Views/Checkbox.swift @@ -12,22 +12,26 @@ struct Checkbox: ElementaryView, View { return backend.createCheckbox() } - public func update( + func computeLayout( _ widget: Backend.Widget, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - if !dryRun { - backend.updateCheckbox(widget, environment: environment) { newActiveState in - active.wrappedValue = newActiveState - } - backend.setState(ofCheckbox: widget, to: active.wrappedValue) - } - - return ViewUpdateResult.leafView( - size: ViewSize(fixedSize: backend.naturalSize(of: widget)) + backend: Backend + ) -> ViewLayoutResult { + return ViewLayoutResult.leafView( + size: ViewSize(backend.naturalSize(of: widget)) ) } + + func commit( + _ widget: Backend.Widget, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + backend.updateCheckbox(widget, environment: environment) { newActiveState in + active.wrappedValue = newActiveState + } + backend.setState(ofCheckbox: widget, to: active.wrappedValue) + } } diff --git a/Sources/SwiftCrossUI/Views/EitherView.swift b/Sources/SwiftCrossUI/Views/EitherView.swift index fac32056ff0..685ca682469 100644 --- a/Sources/SwiftCrossUI/Views/EitherView.swift +++ b/Sources/SwiftCrossUI/Views/EitherView.swift @@ -47,25 +47,23 @@ extension EitherView: TypeSafeView { return backend.createContainer() } - func update( + func computeLayout( _ widget: Backend.Widget, children: EitherViewChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - let result: ViewUpdateResult + backend: Backend + ) -> ViewLayoutResult { + let result: ViewLayoutResult let hasSwitchedCase: Bool switch storage { case .a(let a): switch children.node { - case .a(let nodeA): - result = nodeA.update( + case let .a(nodeA): + result = nodeA.computeLayout( with: a, proposedSize: proposedSize, - environment: environment, - dryRun: dryRun + environment: environment ) hasSwitchedCase = false case .b: @@ -75,22 +73,20 @@ extension EitherView: TypeSafeView { environment: environment ) children.node = .a(nodeA) - result = nodeA.update( + result = nodeA.computeLayout( with: a, proposedSize: proposedSize, - environment: environment, - dryRun: dryRun + environment: environment ) hasSwitchedCase = true } case .b(let b): switch children.node { - case .b(let nodeB): - result = nodeB.update( + case let .b(nodeB): + result = nodeB.computeLayout( with: b, proposedSize: proposedSize, - environment: environment, - dryRun: dryRun + environment: environment ) hasSwitchedCase = false case .a: @@ -100,29 +96,36 @@ extension EitherView: TypeSafeView { environment: environment ) children.node = .b(nodeB) - result = nodeB.update( + result = nodeB.computeLayout( with: b, proposedSize: proposedSize, - environment: environment, - dryRun: dryRun + environment: environment ) hasSwitchedCase = true } } children.hasSwitchedCase = children.hasSwitchedCase || hasSwitchedCase - if !dryRun && children.hasSwitchedCase { + return result + } + + func commit( + _ widget: Backend.Widget, + children: EitherViewChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + if children.hasSwitchedCase { backend.removeAllChildren(of: widget) backend.addChild(children.node.widget.into(), to: widget) backend.setPosition(ofChildAt: 0, in: widget, to: .zero) children.hasSwitchedCase = false } - if !dryRun { - backend.setSize(of: widget, to: result.size.size) - } + _ = children.node.erasedNode.commit() - return result + backend.setSize(of: widget, to: layout.size.vector) } } diff --git a/Sources/SwiftCrossUI/Views/ElementaryView.swift b/Sources/SwiftCrossUI/Views/ElementaryView.swift index df835d0e016..3e93b86f22a 100644 --- a/Sources/SwiftCrossUI/Views/ElementaryView.swift +++ b/Sources/SwiftCrossUI/Views/ElementaryView.swift @@ -7,13 +7,19 @@ protocol ElementaryView: View where Content == EmptyView { backend: Backend ) -> Backend.Widget - func update( + func computeLayout( _ widget: Backend.Widget, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult + backend: Backend + ) -> ViewLayoutResult + + func commit( + _ widget: Backend.Widget, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) } extension ElementaryView { @@ -30,20 +36,33 @@ extension ElementaryView { } /// Do not implement yourself, implement ``ElementaryView/update(_:proposedSize:environment:backend:)`` instead. - public func update( + public func computeLayout( _ widget: Backend.Widget, children: any ViewGraphNodeChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - update( + backend: Backend + ) -> ViewLayoutResult { + computeLayout( widget, proposedSize: proposedSize, environment: environment, - backend: backend, - dryRun: dryRun + backend: backend + ) + } + + public func commit( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + commit( + widget, + layout: layout, + environment: environment, + backend: backend ) } } diff --git a/Sources/SwiftCrossUI/Views/EmptyView.swift b/Sources/SwiftCrossUI/Views/EmptyView.swift index b541a72366f..90cc8815073 100644 --- a/Sources/SwiftCrossUI/Views/EmptyView.swift +++ b/Sources/SwiftCrossUI/Views/EmptyView.swift @@ -36,16 +36,23 @@ public struct EmptyView: View, Sendable { backend.createContainer() } - public func update( + public func computeLayout( _ widget: Backend.Widget, children: any ViewGraphNodeChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - ViewUpdateResult.leafView(size: .empty) + backend: Backend + ) -> ViewLayoutResult { + ViewLayoutResult.leafView(size: .zero) } + + public func commit( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) {} } /// The children of a node with no children. diff --git a/Sources/SwiftCrossUI/Views/ForEach.swift b/Sources/SwiftCrossUI/Views/ForEach.swift index 81ad975c1a5..b050be06585 100644 --- a/Sources/SwiftCrossUI/Views/ForEach.swift +++ b/Sources/SwiftCrossUI/Views/ForEach.swift @@ -1,3 +1,5 @@ +import Foundation + /// A view that displays a variable amount of children. public struct ForEach where Items.Index == Int { /// A variable-length collection of elements to display. @@ -76,40 +78,19 @@ extension ForEach: TypeSafeView, View where Child: View { return backend.createContainer() } - func update( + func computeLayout( _ widget: Backend.Widget, children: ForEachViewChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { + backend: Backend + ) -> ViewLayoutResult { func addChild(_ child: Backend.Widget) { - if dryRun { - children.queuedChanges.append(.addChild(AnyWidget(child))) - } else { - backend.addChild(child, to: widget) - } + children.queuedChanges.append(.addChild(AnyWidget(child))) } func removeChild(_ child: Backend.Widget) { - if dryRun { - children.queuedChanges.append(.removeChild(AnyWidget(child))) - } else { - backend.removeChild(child, from: widget) - } - } - - if !dryRun { - for change in children.queuedChanges { - switch change { - case .addChild(let child): - backend.addChild(child.into(), to: widget) - case .removeChild(let child): - backend.removeChild(child.into(), from: widget) - } - } - children.queuedChanges = [] + children.queuedChanges.append(.removeChild(AnyWidget(child))) } // TODO: The way we're reusing nodes for technically different elements means that if @@ -128,14 +109,14 @@ extension ForEach: TypeSafeView, View where Child: View { } layoutableChildren.append( LayoutSystem.LayoutableChild( - update: { proposedSize, environment, dryRun in - node.update( + computeLayout: { proposedSize, environment in + node.computeLayout( with: childContent, proposedSize: proposedSize, - environment: environment, - dryRun: dryRun + environment: environment ) - } + }, + commit: node.commit ) ) } @@ -156,14 +137,14 @@ extension ForEach: TypeSafeView, View where Child: View { addChild(node.widget.into()) layoutableChildren.append( LayoutSystem.LayoutableChild( - update: { proposedSize, environment, dryRun in - node.update( + computeLayout: { proposedSize, environment in + node.computeLayout( with: childContent, proposedSize: proposedSize, - environment: environment, - dryRun: dryRun + environment: environment ) - } + }, + commit: node.commit ) ) } @@ -175,13 +156,51 @@ extension ForEach: TypeSafeView, View where Child: View { children.nodes.removeLast(unused) } - return LayoutSystem.updateStackLayout( + return LayoutSystem.computeStackLayout( container: widget, children: layoutableChildren, + cache: &children.stackLayoutCache, proposedSize: proposedSize, environment: environment, - backend: backend, - dryRun: dryRun + backend: backend + ) + } + + func commit( + _ widget: Backend.Widget, + children: ForEachViewChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + for change in children.queuedChanges { + switch change { + case .addChild(let child): + backend.addChild(child.into(), to: widget) + case .removeChild(let child): + backend.removeChild(child.into(), from: widget) + } + } + children.queuedChanges = [] + + LayoutSystem.commitStackLayout( + container: widget, + children: children.nodes.map { node in + LayoutSystem.LayoutableChild( + computeLayout: { proposedSize, environment in + node.computeLayout( + with: nil, + proposedSize: proposedSize, + environment: environment + ) + }, + commit: node.commit + ) + }, + cache: &children.stackLayoutCache, + layout: layout, + environment: environment, + backend: backend ) } } @@ -220,6 +239,8 @@ class ForEachViewChildren< nodes.map(ErasedViewGraphNode.init(wrapping:)) } + var stackLayoutCache = StackLayoutCache() + /// Gets a variable length view's children as view graph node children. init( from view: ForEach, diff --git a/Sources/SwiftCrossUI/Views/GeometryReader.swift b/Sources/SwiftCrossUI/Views/GeometryReader.swift index 79ff46d3d1d..4a1bd9ea4dd 100644 --- a/Sources/SwiftCrossUI/Views/GeometryReader.swift +++ b/Sources/SwiftCrossUI/Views/GeometryReader.swift @@ -52,15 +52,15 @@ public struct GeometryReader: TypeSafeView, View { return backend.createContainer() } - func update( + func computeLayout( _ widget: Backend.Widget, children: GeometryReaderChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - let view = content(GeometryProxy(size: proposedSize)) + backend: Backend + ) -> ViewLayoutResult { + let size = proposedSize.replacingUnspecifiedDimensions(by: ViewSize(10, 10)) + let view = content(GeometryProxy(size: size)) let environment = environment.with(\.layoutAlignment, .leading) @@ -75,40 +75,32 @@ public struct GeometryReader: TypeSafeView, View { ) children.node = contentNode - // It's ok to add the child here even though it's not a dry run - // because this is guaranteed to only happen once. Dry runs are - // more about 'commit' actions that happen every single update. backend.addChild(contentNode.widget.into(), to: widget) } - // TODO: Look into moving this to the final non-dry run update. In order - // to do so we'd have to give up on preferences being allowed to affect - // layout (which is probably something we don't want to support anyway - // because it sounds like feedback loop central). - let contentResult = contentNode.update( + let contentResult = contentNode.computeLayout( with: view, - proposedSize: proposedSize, - environment: environment, - dryRun: dryRun + proposedSize: ProposedViewSize(size), + environment: environment ) - if !dryRun { - backend.setPosition(ofChildAt: 0, in: widget, to: .zero) - backend.setSize(of: widget, to: proposedSize) - } - - return ViewUpdateResult( - size: ViewSize( - size: proposedSize, - idealSize: SIMD2(10, 10), - minimumWidth: 0, - minimumHeight: 0, - maximumWidth: nil, - maximumHeight: nil - ), + return ViewLayoutResult( + size: size, childResults: [contentResult] ) } + + func commit( + _ widget: Backend.Widget, + children: GeometryReaderChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + _ = children.node?.commit() + backend.setPosition(ofChildAt: 0, in: widget, to: .zero) + backend.setSize(of: widget, to: layout.size.vector) + } } class GeometryReaderChildren: ViewGraphNodeChildren { diff --git a/Sources/SwiftCrossUI/Views/Group.swift b/Sources/SwiftCrossUI/Views/Group.swift index 7d967f91e1d..1e92224e476 100644 --- a/Sources/SwiftCrossUI/Views/Group.swift +++ b/Sources/SwiftCrossUI/Views/Group.swift @@ -23,22 +23,46 @@ public struct Group: View { return container } - public func update( + public func computeLayout( _ widget: Backend.Widget, children: any ViewGraphNodeChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - LayoutSystem.updateStackLayout( + backend: Backend + ) -> ViewLayoutResult { + if !(children is TupleViewChildren) { + print("warning: VStack will not function correctly non-TupleView Content") + } + var cache = (children as? TupleViewChildren)?.stackLayoutCache ?? StackLayoutCache() + let result = LayoutSystem.computeStackLayout( container: widget, children: layoutableChildren(backend: backend, children: children), + cache: &cache, proposedSize: proposedSize, environment: environment, backend: backend, - dryRun: dryRun, inheritStackLayoutParticipation: true ) + (children as? TupleViewChildren)?.stackLayoutCache = cache + return result + } + + public func commit( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + var cache = (children as? TupleViewChildren)?.stackLayoutCache ?? StackLayoutCache() + LayoutSystem.commitStackLayout( + container: widget, + children: layoutableChildren(backend: backend, children: children), + cache: &cache, + layout: layout, + environment: environment, + backend: backend + ) + (children as? TupleViewChildren)?.stackLayoutCache = cache } } diff --git a/Sources/SwiftCrossUI/Views/HStack.swift b/Sources/SwiftCrossUI/Views/HStack.swift index 097df08c0cd..36fe1f788d4 100644 --- a/Sources/SwiftCrossUI/Views/HStack.swift +++ b/Sources/SwiftCrossUI/Views/HStack.swift @@ -29,25 +29,53 @@ public struct HStack: View { return vStack } - public func update( + public func computeLayout( _ widget: Backend.Widget, children: any ViewGraphNodeChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - return LayoutSystem.updateStackLayout( + backend: Backend + ) -> ViewLayoutResult { + if !(children is TupleViewChildren) { + print("warning: VStack will not function correctly non-TupleView Content") + } + var cache = (children as? TupleViewChildren)?.stackLayoutCache ?? StackLayoutCache() + let result = LayoutSystem.computeStackLayout( container: widget, children: layoutableChildren(backend: backend, children: children), + cache: &cache, proposedSize: proposedSize, environment: environment .with(\.layoutOrientation, .horizontal) .with(\.layoutAlignment, alignment.asStackAlignment) .with(\.layoutSpacing, spacing), - backend: backend, - dryRun: dryRun + backend: backend + ) + (children as? TupleViewChildren)?.stackLayoutCache = cache + return result + } + + public func commit( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + var cache = (children as? TupleViewChildren)?.stackLayoutCache ?? StackLayoutCache() + LayoutSystem.commitStackLayout( + container: widget, + children: layoutableChildren(backend: backend, children: children), + cache: &cache, + layout: layout, + environment: + environment + .with(\.layoutOrientation, .horizontal) + .with(\.layoutAlignment, alignment.asStackAlignment) + .with(\.layoutSpacing, spacing), + backend: backend ) + (children as? TupleViewChildren)?.stackLayoutCache = cache } } diff --git a/Sources/SwiftCrossUI/Views/HotReloadableView.swift b/Sources/SwiftCrossUI/Views/HotReloadableView.swift index 5c005057480..e610762bec9 100644 --- a/Sources/SwiftCrossUI/Views/HotReloadableView.swift +++ b/Sources/SwiftCrossUI/Views/HotReloadableView.swift @@ -51,19 +51,17 @@ public struct HotReloadableView: TypeSafeView { /// view graph sub tree's state onto the new view graph sub tree. This is not possible to do /// perfectly by definition, so if we can't successfully transfer the state of the sub tree /// we just fall back on the failing view's default state. - func update( + func computeLayout( _ widget: Backend.Widget, children: HotReloadableViewChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - var (viewTypeMatched, result) = children.node.updateWithNewView( + backend: Backend + ) -> ViewLayoutResult { + var (viewTypeMatched, result) = children.node.computeLayoutWithNewView( child, proposedSize, - environment, - dryRun + environment ) if !viewTypeMatched { @@ -78,28 +76,35 @@ public struct HotReloadableView: TypeSafeView { // We can assume that the view types match since we just recreated the view // on the line above. - let (_, newResult) = children.node.updateWithNewView( + let (_, newResult) = children.node.computeLayoutWithNewView( child, proposedSize, - environment, - dryRun + environment ) result = newResult children.hasChangedChild = true } - if !dryRun { - if children.hasChangedChild { - backend.removeAllChildren(of: widget) - backend.addChild(children.node.getWidget().into(), to: widget) - backend.setPosition(ofChildAt: 0, in: widget, to: .zero) - children.hasChangedChild = false - } + return result + } - backend.setSize(of: widget, to: result.size.size) + func commit( + _ widget: Backend.Widget, + children: HotReloadableViewChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + if children.hasChangedChild { + backend.removeAllChildren(of: widget) + backend.addChild(children.node.getWidget().into(), to: widget) + backend.setPosition(ofChildAt: 0, in: widget, to: .zero) + children.hasChangedChild = false } - return result + _ = children.node.commit() + + backend.setSize(of: widget, to: layout.size.vector) } } diff --git a/Sources/SwiftCrossUI/Views/Image.swift b/Sources/SwiftCrossUI/Views/Image.swift index 2f3be3e6f82..b363c2919d4 100644 --- a/Sources/SwiftCrossUI/Views/Image.swift +++ b/Sources/SwiftCrossUI/Views/Image.swift @@ -66,14 +66,13 @@ extension Image: TypeSafeView { children.container.into() } - func update( + func computeLayout( _ widget: Backend.Widget, children: _ImageChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { + backend: Backend + ) -> ViewLayoutResult { let image: ImageFormats.Image? if source != children.cachedImageSource { switch source { @@ -102,36 +101,44 @@ extension Image: TypeSafeView { image = children.cachedImage } - let idealSize = SIMD2(image?.width ?? 0, image?.height ?? 0) let size: ViewSize - if isResizable { - size = ViewSize( - size: image == nil ? .zero : proposedSize, - idealSize: idealSize, - minimumWidth: 0, - minimumHeight: 0, - maximumWidth: image == nil ? 0 : nil, - maximumHeight: image == nil ? 0 : nil - ) + if let image { + let idealSize = ViewSize(Double(image.width), Double(image.height)) + if isResizable { + size = proposedSize.replacingUnspecifiedDimensions(by: idealSize) + } else { + size = idealSize + } } else { - size = ViewSize(fixedSize: idealSize) + size = .zero } - let hasResized = children.cachedImageDisplaySize != size.size - if !dryRun - && (children.imageChanged - || hasResized - || (backend.requiresImageUpdateOnScaleFactorChange - && children.lastScaleFactor != environment.windowScaleFactor)) + return ViewLayoutResult.leafView(size: size) + } + + func commit( + _ widget: Backend.Widget, + children: _ImageChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + let size = layout.size.vector + let hasResized = children.cachedImageDisplaySize != size + children.cachedImageDisplaySize = size + if children.imageChanged + || hasResized + || (backend.requiresImageUpdateOnScaleFactorChange + && children.lastScaleFactor != environment.windowScaleFactor) { - if let image { + if let image = children.cachedImage { backend.updateImageView( children.imageWidget.into(), rgbaData: image.bytes, width: image.width, height: image.height, - targetWidth: size.size.x, - targetHeight: size.size.y, + targetWidth: size.x, + targetHeight: size.y, dataHasChanged: children.imageChanged, environment: environment ) @@ -147,15 +154,8 @@ extension Image: TypeSafeView { children.imageChanged = false children.lastScaleFactor = environment.windowScaleFactor } - - children.cachedImageDisplaySize = size.size - - if !dryRun { - backend.setSize(of: children.container.into(), to: size.size) - backend.setSize(of: children.imageWidget.into(), to: size.size) - } - - return ViewUpdateResult.leafView(size: size) + backend.setSize(of: children.container.into(), to: size) + backend.setSize(of: children.imageWidget.into(), to: size) } } diff --git a/Sources/SwiftCrossUI/Views/List.swift b/Sources/SwiftCrossUI/Views/List.swift index 4283e5249ed..8253c78c393 100644 --- a/Sources/SwiftCrossUI/Views/List.swift +++ b/Sources/SwiftCrossUI/Views/List.swift @@ -98,14 +98,13 @@ public struct List: TypeSafeView, View backend.createSelectableListView() } - func update( + func computeLayout( _ widget: Backend.Widget, children: Children, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { + backend: Backend + ) -> ViewLayoutResult { // Padding that the backend could not remove (some frameworks have a small // constant amount of required padding within each row). let baseRowPadding = backend.baseItemPadding(ofSelectableListView: widget) @@ -138,82 +137,80 @@ public struct List: TypeSafeView, View children.nodes.removeLast(children.nodes.count - rowCount) } - var childResults: [ViewUpdateResult] = [] + var childResults: [ViewLayoutResult] = [] for (rowView, node) in zip(rowViews, children.nodes) { - let preferredSize = node.update( + let proposedWidth: Double? + if let width = proposedSize.width { + proposedWidth = max( + Double(minimumRowSize.x), + width - Double(baseRowPadding.axisTotals.x) + ) + } else { + proposedWidth = nil + } + + let childResult = node.computeLayout( with: rowView, - proposedSize: SIMD2( - max(proposedSize.x, minimumRowSize.x) - baseRowPadding.axisTotals.x, - max(proposedSize.y, minimumRowSize.y) - baseRowPadding.axisTotals.y + proposedSize: ProposedViewSize( + proposedWidth, + nil ), - environment: environment, - dryRun: true - ).size - let childResult = node.update( - with: nil, - proposedSize: SIMD2( - max(proposedSize.x, minimumRowSize.x) - horizontalBasePadding, - max( - preferredSize.idealHeightForProposedWidth, - minimumRowSize.y - baseRowPadding.axisTotals.y - ) - ), - environment: environment, - dryRun: dryRun + environment: environment ) childResults.append(childResult) } - let size = SIMD2( + let height = childResults.map(\.size.height).map { rowHeight in max( - (childResults.map(\.size.size.x).max() ?? 0) + horizontalBasePadding, - max(minimumRowSize.x, proposedSize.x) - ), - childResults.map(\.size.size.y).map { rowHeight in - max( - rowHeight + verticalBasePadding, - minimumRowSize.y - ) - }.reduce(0, +) + rowHeight + Double(verticalBasePadding), + Double(minimumRowSize.y) + ) + }.reduce(0, +) + let minimumWidth = + (childResults.map(\.size.width).max() ?? 0) + Double(horizontalBasePadding) + let size = ViewSize( + max(proposedSize.width ?? minimumWidth, minimumWidth), + height ) - if !dryRun { - backend.setItems( - ofSelectableListView: widget, - to: children.widgets.map { $0.into() }, - withRowHeights: childResults.map(\.size.size.y).map { height in - height + verticalBasePadding - } - ) - backend.setSize(of: widget, to: size) - backend.setSelectionHandler(forSelectableListView: widget) { selectedIndex in - selection.wrappedValue = associatedSelectionValue(selectedIndex) - } - let selectedIndex: Int? - if let selectedItem = selection.wrappedValue { - selectedIndex = find(selectedItem) - } else { - selectedIndex = nil + return ViewLayoutResult( + size: size, + childResults: childResults + ) + } + + func commit( + _ widget: Backend.Widget, + children: Children, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + let baseRowPadding = backend.baseItemPadding(ofSelectableListView: widget) + let verticalBasePadding = baseRowPadding.axisTotals.y + + let childResults = children.nodes.map { $0.commit() } + backend.setItems( + ofSelectableListView: widget, + to: children.widgets.map { $0.into() }, + withRowHeights: childResults.map(\.size.height).map { height in + LayoutSystem.roundSize(height) + verticalBasePadding } - backend.setSelectedItem(ofSelectableListView: widget, toItemAt: selectedIndex) + ) + + backend.setSize(of: widget, to: layout.size.vector) + backend.setSelectionHandler(forSelectableListView: widget) { selectedIndex in + selection.wrappedValue = associatedSelectionValue(selectedIndex) } - return ViewUpdateResult( - size: ViewSize( - size: size, - idealSize: SIMD2( - (childResults.map(\.size.idealSize.x).max() ?? 0) - + horizontalBasePadding, - size.y - ), - minimumWidth: (childResults.map(\.size.minimumWidth).max() ?? 0) - + horizontalBasePadding, - minimumHeight: size.y, - maximumWidth: nil, - maximumHeight: Double(size.y) - ), - childResults: childResults - ) + let selectedIndex: Int? + if let selectedItem = selection.wrappedValue { + selectedIndex = find(selectedItem) + } else { + selectedIndex = nil + } + + backend.setSelectedItem(ofSelectableListView: widget, toItemAt: selectedIndex) } } diff --git a/Sources/SwiftCrossUI/Views/Menu.swift b/Sources/SwiftCrossUI/Views/Menu.swift index 7773db5215c..cf9a78b80c1 100644 --- a/Sources/SwiftCrossUI/Views/Menu.swift +++ b/Sources/SwiftCrossUI/Views/Menu.swift @@ -64,18 +64,29 @@ extension Menu: TypeSafeView { [] } - func update( + func computeLayout( _ widget: Backend.Widget, children: MenuStorage, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { + backend: Backend + ) -> ViewLayoutResult { // TODO: Store popped menu in view graph node children so that we can // continue updating it even once it's open. var size = backend.naturalSize(of: widget) size.x = buttonWidth ?? size.x + return ViewLayoutResult.leafView(size: ViewSize(size)) + } + + func commit( + _ widget: Backend.Widget, + children: MenuStorage, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + let size = layout.size + backend.setSize(of: widget, to: size.vector) let content = resolve().content switch backend.menuImplementationStyle { @@ -94,7 +105,7 @@ extension Menu: TypeSafeView { ) backend.showPopoverMenu( menu, - at: SIMD2(0, size.y + 2), + at: SIMD2(0, LayoutSystem.roundSize(size.width) + 2), relativeTo: widget ) { children.menu = nil @@ -102,14 +113,11 @@ extension Menu: TypeSafeView { } ) - if !dryRun { - backend.setSize(of: widget, to: size) - children.updateMenuIfShown( - content: content, - environment: environment, - backend: backend - ) - } + children.updateMenuIfShown( + content: content, + environment: environment, + backend: backend + ) case .menuButton: let menu = children.menu as? Backend.Menu ?? backend.createPopoverMenu() children.menu = menu @@ -119,13 +127,7 @@ extension Menu: TypeSafeView { environment: environment ) backend.updateButton(widget, label: label, menu: menu, environment: environment) - - if !dryRun { - backend.setSize(of: widget, to: size) - } } - - return ViewUpdateResult.leafView(size: ViewSize(fixedSize: size)) } /// A temporary button width solution until arbitrary labels are supported. diff --git a/Sources/SwiftCrossUI/Views/Modifiers/AlertModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/AlertModifier.swift index 3f3b9e5dfa9..a001365c195 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/AlertModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/AlertModifier.swift @@ -58,24 +58,35 @@ struct AlertModifierView: TypeSafeView { ) } - func asWidget(_ children: Children, backend: Backend) -> Backend.Widget { + func asWidget( + _ children: Children, + backend: Backend + ) -> Backend.Widget { children.childNode.widget.into() } - func update( + func computeLayout( _ widget: Backend.Widget, children: Children, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - let childResult = children.childNode.update( + backend: Backend + ) -> ViewLayoutResult { + children.childNode.computeLayout( with: child, proposedSize: proposedSize, - environment: environment, - dryRun: dryRun + environment: environment ) + } + + func commit( + _ widget: Backend.Widget, + children: AlertModifierViewChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + _ = children.childNode.commit() if isPresented.wrappedValue && children.alert == nil { let alert = backend.createAlert() @@ -101,8 +112,6 @@ struct AlertModifierView: TypeSafeView { ) children.alert = nil } - - return childResult } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/EnvironmentModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/EnvironmentModifier.swift index 801354c2130..216a2142446 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/EnvironmentModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/EnvironmentModifier.swift @@ -19,21 +19,35 @@ package struct EnvironmentModifier: View { ) } - package func update( + package func computeLayout( _ widget: Backend.Widget, children: any ViewGraphNodeChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - body.update( + backend: Backend + ) -> ViewLayoutResult { + body.computeLayout( widget, children: children, proposedSize: proposedSize, environment: modification(environment), - backend: backend, - dryRun: dryRun + backend: backend + ) + } + + package func commit( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + body.commit( + widget, + children: children, + layout: layout, + environment: modification(environment), + backend: backend ) } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/Handlers/OnChangeModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/Handlers/OnChangeModifier.swift index 9c226532d36..fd5cf309888 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/Handlers/OnChangeModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/Handlers/OnChangeModifier.swift @@ -24,14 +24,14 @@ struct OnChangeModifier: View { var action: () -> Void var initial: Bool - func update( + // TODO: Should this go in computeLayout or commit? + func computeLayout( _ widget: Backend.Widget, children: any ViewGraphNodeChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { + backend: Backend + ) -> ViewLayoutResult { if let previousValue = previousValue, value != previousValue { action() } else if initial && previousValue == nil { @@ -42,13 +42,12 @@ struct OnChangeModifier: View { previousValue = value } - return defaultUpdate( + return defaultComputeLayout( widget, children: children, proposedSize: proposedSize, environment: environment, - backend: backend, - dryRun: dryRun + backend: backend ) } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/Handlers/OnHoverModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/Handlers/OnHoverModifier.swift index 0d1d000f848..4cd346a44e1 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/Handlers/OnHoverModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/Handlers/OnHoverModifier.swift @@ -32,28 +32,33 @@ struct OnHoverModifier: TypeSafeView { backend.createHoverTarget(wrapping: children.child0.widget.into()) } - func update( + func computeLayout( _ widget: Backend.Widget, children: Children, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - let childResult = children.child0.update( + backend: Backend + ) -> ViewLayoutResult { + children.child0.computeLayout( with: body.view0, proposedSize: proposedSize, + environment: environment + ) + } + + func commit( + _ widget: Backend.Widget, + children: Children, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + let size = children.child0.commit().size.vector + backend.setSize(of: widget, to: size) + backend.updateHoverTarget( + widget, environment: environment, - dryRun: dryRun + action: action ) - if !dryRun { - backend.setSize(of: widget, to: childResult.size.size) - backend.updateHoverTarget( - widget, - environment: environment, - action: action - ) - } - return childResult } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/Handlers/OnTapGestureModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/Handlers/OnTapGestureModifier.swift index b8688eabbd4..1feb583a42f 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/Handlers/OnTapGestureModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/Handlers/OnTapGestureModifier.swift @@ -61,29 +61,34 @@ struct OnTapGestureModifier: TypeSafeView { backend.createTapGestureTarget(wrapping: children.child0.widget.into(), gesture: gesture) } - func update( + func computeLayout( _ widget: Backend.Widget, children: Children, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - let childResult = children.child0.update( + backend: Backend + ) -> ViewLayoutResult { + children.child0.computeLayout( with: body.view0, proposedSize: proposedSize, + environment: environment + ) + } + + func commit( + _ widget: Backend.Widget, + children: TupleView1.Children, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + let size = children.child0.commit().size + backend.setSize(of: widget, to: size.vector) + backend.updateTapGestureTarget( + widget, + gesture: gesture, environment: environment, - dryRun: dryRun + action: action ) - if !dryRun { - backend.setSize(of: widget, to: childResult.size.size) - backend.updateTapGestureTarget( - widget, - gesture: gesture, - environment: environment, - action: action - ) - } - return childResult } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/Layout/AspectRatioModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/Layout/AspectRatioModifier.swift index 2fc864a04ef..a2da3d68826 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/Layout/AspectRatioModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/Layout/AspectRatioModifier.swift @@ -1,18 +1,10 @@ extension View { - // TODO: Figure out why SwiftUI's window gets significantly shorter than - // SwiftCrossUI's with the following content; - // - // VStack { - // Text("Hello, World!") - // Divider() - // Color.red - // .aspectRatio(1, contentMode: .fill) - // .frame(maxWidth: 300) - // Divider() - // Text("Footer") - // } - - /// Constrains a view to maintain a specific aspect ratio. + /// Modifies size proposals to match the specified aspect ratio, or the view's + /// ideal aspect ratio if unspecified. + /// + /// This modifier doesn't guarantee that the view's size will maintain a + /// constant aspect ratio, as it only changes the size proposed to the + /// wrapped content. /// - Parameter aspectRatio: The aspect ratio to maintain. Use `nil` to /// maintain the view's ideal aspect ratio. /// - Parameter contentMode: How the view should fill available space. @@ -20,8 +12,11 @@ extension View { AspectRatioView(self, aspectRatio: aspectRatio, contentMode: contentMode) } - /// Constrains a view to maintain an aspect ratio matching that of the - /// provided size. + /// Modifies size proposals to match the aspect ratio of the provided size. + /// + /// This modifier doesn't guarantee that the view's size will maintain a + /// constant aspect ratio, as it only changes the size proposed to the + /// wrapped content. /// - Parameter aspectRatio: The aspect ratio to maintain, specified as a /// size with the desired aspect ratio. /// - Parameter contentMode: How the view should fill available space. @@ -58,91 +53,83 @@ struct AspectRatioView: TypeSafeView { _ children: TupleViewChildren1, backend: Backend ) -> Backend.Widget { - let container = backend.createContainer() - backend.addChild(children.child0.widget.into(), to: container) - return container + children.child0.widget.into() } - func update( + func computeLayout( _ widget: Backend.Widget, children: TupleViewChildren1, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - let evaluatedAspectRatio: Double - if let aspectRatio { - evaluatedAspectRatio = aspectRatio == 0 ? 1 : aspectRatio - } else { - let childResult = children.child0.update( - with: body.view0, - proposedSize: proposedSize, - environment: environment, - dryRun: true - ) - evaluatedAspectRatio = childResult.size.idealAspectRatio + backend: Backend + ) -> ViewLayoutResult { + // We lazily compute the aspect ratio because we don't always need it. + // For example, when both dimensions are specified we pass them unmodified. + func computeAspectRatio() -> Double { + if let aspectRatio { + return aspectRatio == 0 ? 1 : aspectRatio + } else { + let childResult = children.child0.computeLayout( + with: body.view0, + proposedSize: .unspecified, + environment: environment + ) + return LayoutSystem.aspectRatio(of: childResult.size) + } } - let proposedFrameSize = LayoutSystem.frameSize( - forProposedSize: proposedSize, - aspectRatio: evaluatedAspectRatio, - contentMode: contentMode - ) + let proposedFrameSize: ProposedViewSize + switch (proposedSize.width, proposedSize.height) { + case (.some(let width), .some(let height)): + let evaluatedAspectRatio = computeAspectRatio() + let widthForHeight = LayoutSystem.width( + forHeight: height, + aspectRatio: evaluatedAspectRatio + ) + let heightForWidth = LayoutSystem.height( + forWidth: width, + aspectRatio: evaluatedAspectRatio + ) + switch contentMode { + case .fill: + proposedFrameSize = ProposedViewSize( + max(width, widthForHeight), + max(height, heightForWidth) + ) + case .fit: + proposedFrameSize = ProposedViewSize( + min(width, widthForHeight), + min(height, heightForWidth) + ) + } + case (.some(let width), .none): + proposedFrameSize = ProposedViewSize( + width, + LayoutSystem.height(forWidth: width, aspectRatio: computeAspectRatio()) + ) + case (.none, .some(let height)): + proposedFrameSize = ProposedViewSize( + LayoutSystem.width(forHeight: height, aspectRatio: computeAspectRatio()), + height + ) + case (.none, .none): + proposedFrameSize = .unspecified + } - let childResult = children.child0.update( - with: nil, + return children.child0.computeLayout( + with: body.view0, proposedSize: proposedFrameSize, - environment: environment, - dryRun: dryRun - ) - - let frameSize = LayoutSystem.frameSize( - forProposedSize: childResult.size.size, - aspectRatio: evaluatedAspectRatio, - contentMode: contentMode.opposite + environment: environment ) + } - if !dryRun { - // Center child in frame for cases where it's smaller or bigger than - // aspect ratio locked frame (not all views can achieve every aspect - // ratio). - let childPosition = Alignment.center.position( - ofChild: childResult.size.size, - in: frameSize - ) - backend.setPosition(ofChildAt: 0, in: widget, to: childPosition) - backend.setSize(of: widget, to: frameSize) - } - - return ViewUpdateResult( - size: ViewSize( - size: frameSize, - idealSize: LayoutSystem.frameSize( - forProposedSize: childResult.size.idealSize, - aspectRatio: evaluatedAspectRatio, - contentMode: .fill - ), - idealWidthForProposedHeight: LayoutSystem.height( - forWidth: frameSize.x, - aspectRatio: evaluatedAspectRatio - ), - idealHeightForProposedWidth: LayoutSystem.width( - forHeight: frameSize.y, - aspectRatio: evaluatedAspectRatio - ), - // TODO: These minimum and maximum size calculations are - // incorrect. I don't think we have enough information to - // compute these properly at the moment because the `minimumWidth` - // and `minimumHeight` properties are the minimum sizes assuming - // that the other dimension stays constant, which isn't very - // useful when trying to maintain aspect ratio. - minimumWidth: childResult.size.minimumWidth, - minimumHeight: childResult.size.minimumHeight, - maximumWidth: childResult.size.maximumWidth, - maximumHeight: childResult.size.maximumHeight - ), - childResults: [childResult] - ) + func commit( + _ widget: Backend.Widget, + children: TupleViewChildren1, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + _ = children.child0.commit() } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/Layout/BackgroundModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/Layout/BackgroundModifier.swift index 8b34d55c80f..c4088710210 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/Layout/BackgroundModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/Layout/BackgroundModifier.swift @@ -34,65 +34,62 @@ struct BackgroundModifier: TypeSafeView { body.asWidget(children, backend: backend) } - func update( + func computeLayout( _ widget: Backend.Widget, children: TupleView2.Children, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - let foregroundResult = children.child1.update( + backend: Backend + ) -> ViewLayoutResult { + let foregroundResult = children.child1.computeLayout( with: body.view1, proposedSize: proposedSize, - environment: environment, - dryRun: dryRun + environment: environment ) let foregroundSize = foregroundResult.size - let backgroundResult = children.child0.update( + let backgroundResult = children.child0.computeLayout( with: body.view0, - proposedSize: foregroundSize.size, - environment: environment, - dryRun: dryRun + proposedSize: ProposedViewSize(foregroundSize), + environment: environment ) let backgroundSize = backgroundResult.size - let frameSize = SIMD2( - max(backgroundSize.size.x, foregroundSize.size.x), - max(backgroundSize.size.y, foregroundSize.size.y) + let frameSize = ViewSize( + max(backgroundSize.width, foregroundSize.width), + max(backgroundSize.height, foregroundSize.height) ) - if !dryRun { - let backgroundPosition = (frameSize &- backgroundSize.size) / 2 - let foregroundPosition = (frameSize &- foregroundSize.size) / 2 - - backend.setPosition(ofChildAt: 0, in: widget, to: backgroundPosition) - backend.setPosition(ofChildAt: 1, in: widget, to: foregroundPosition) + // TODO: Investigate the ordering of SwiftUI's preference merging for + // the background modifier. + return ViewLayoutResult( + size: frameSize, + childResults: [backgroundResult, foregroundResult] + ) + } - backend.setSize(of: widget, to: frameSize) - } + public func commit( + _ widget: Backend.Widget, + children: TupleView2.Children, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + let frameSize = layout.size + let backgroundSize = children.child0.commit().size + let foregroundSize = children.child1.commit().size - return ViewUpdateResult( - size: ViewSize( - size: frameSize, - idealSize: SIMD2( - max(foregroundSize.idealSize.x, backgroundSize.minimumWidth), - max(foregroundSize.idealSize.y, backgroundSize.minimumHeight) - ), - idealWidthForProposedHeight: max( - foregroundSize.idealWidthForProposedHeight, - backgroundSize.minimumWidth - ), - idealHeightForProposedWidth: max( - foregroundSize.idealHeightForProposedWidth, - backgroundSize.minimumHeight - ), - minimumWidth: max(backgroundSize.minimumWidth, foregroundSize.minimumWidth), - minimumHeight: max(backgroundSize.minimumHeight, foregroundSize.minimumHeight), - maximumWidth: min(backgroundSize.maximumWidth, foregroundSize.maximumWidth), - maximumHeight: min(backgroundSize.maximumHeight, foregroundSize.maximumHeight) - ), - childResults: [backgroundResult, foregroundResult] + let backgroundPosition = Alignment.center.position( + ofChild: backgroundSize.vector, + in: frameSize.vector ) + let foregroundPosition = Alignment.center.position( + ofChild: foregroundSize.vector, + in: frameSize.vector + ) + + backend.setPosition(ofChildAt: 0, in: widget, to: backgroundPosition) + backend.setPosition(ofChildAt: 1, in: widget, to: foregroundPosition) + + backend.setSize(of: widget, to: frameSize.vector) } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/Layout/FixedSizeModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/Layout/FixedSizeModifier.swift index fa6300781c3..9000ff2a184 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/Layout/FixedSizeModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/Layout/FixedSizeModifier.swift @@ -37,58 +37,45 @@ struct FixedSizeModifier: TypeSafeView { return container } - func update( + func computeLayout( _ widget: Backend.Widget, children: TupleViewChildren1, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - let probingChildResult = children.child0.update( + backend: Backend + ) -> ViewLayoutResult { + var childProposal = proposedSize + if horizontal { + childProposal.width = nil + } + if vertical { + childProposal.height = nil + } + let childResult = children.child0.computeLayout( with: body.view0, proposedSize: proposedSize, - environment: environment, - dryRun: true + environment: environment ) - var frameSize = probingChildResult.size.size - if horizontal && vertical { - frameSize = probingChildResult.size.idealSize - } else if horizontal { - frameSize.x = probingChildResult.size.idealWidthForProposedHeight - } else if vertical { - frameSize.y = probingChildResult.size.idealHeightForProposedWidth - } - - let childResult = children.child0.update( - with: body.view0, - proposedSize: frameSize, - environment: environment, - dryRun: dryRun + return ViewLayoutResult( + size: childResult.size, + childResults: [childResult] ) + } - if !dryRun { - let childPosition = Alignment.center.position( - ofChild: childResult.size.size, - in: frameSize - ) - backend.setPosition(ofChildAt: 0, in: widget, to: childPosition) - backend.setSize(of: widget, to: frameSize) - } - - return ViewUpdateResult( - size: ViewSize( - size: frameSize, - idealSize: childResult.size.idealSize, - idealWidthForProposedHeight: childResult.size.idealWidthForProposedHeight, - idealHeightForProposedWidth: childResult.size.idealHeightForProposedWidth, - minimumWidth: horizontal ? frameSize.x : childResult.size.minimumWidth, - minimumHeight: vertical ? frameSize.y : childResult.size.minimumHeight, - maximumWidth: horizontal ? Double(frameSize.x) : childResult.size.maximumWidth, - maximumHeight: vertical ? Double(frameSize.y) : childResult.size.maximumHeight - ), - childResults: [childResult] + func commit( + _ widget: Backend.Widget, + children: TupleViewChildren1, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + let childResult = children.child0.commit() + let childPosition = Alignment.center.position( + ofChild: childResult.size.vector, + in: layout.size.vector ) + backend.setPosition(ofChildAt: 0, in: widget, to: childPosition) + backend.setSize(of: widget, to: layout.size.vector) } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/Layout/FrameModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/Layout/FrameModifier.swift index eebfc350c81..1ebf5e98240 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/Layout/FrameModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/Layout/FrameModifier.swift @@ -72,82 +72,53 @@ struct StrictFrameView: TypeSafeView { return container } - func update( + func computeLayout( _ widget: Backend.Widget, children: TupleViewChildren1, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - let proposedSize = SIMD2( - width ?? proposedSize.x, - height ?? proposedSize.y - ) + backend: Backend + ) -> ViewLayoutResult { + let width = width.map(Double.init) + let height = height.map(Double.init) - let childResult = children.child0.update( + let childResult = children.child0.computeLayout( with: body.view0, - proposedSize: proposedSize, - environment: environment, - dryRun: dryRun + proposedSize: ProposedViewSize( + width ?? proposedSize.width, + height ?? proposedSize.height + ), + environment: environment ) let childSize = childResult.size - let frameSize = SIMD2( - width ?? childSize.size.x, - height ?? childSize.size.y + let frameSize = ViewSize( + width ?? childSize.width, + height ?? childSize.height ) - if !dryRun { - let childPosition = alignment.position( - ofChild: childSize.size, - in: frameSize - ) - backend.setSize(of: widget, to: frameSize) - backend.setPosition(ofChildAt: 0, in: widget, to: childPosition) - } - let idealWidth: Int - let idealHeight: Int - if let width, let height { - idealWidth = width - idealHeight = height - } else if let width, height == nil { - idealWidth = width - idealHeight = childSize.idealHeightForProposedWidth - } else if let height, width == nil { - idealHeight = height - idealWidth = childSize.idealWidthForProposedHeight - } else { - idealWidth = childSize.idealSize.x - idealHeight = childSize.idealSize.y - } + return ViewLayoutResult( + size: frameSize, + childResults: [childResult] + ) + } - let idealWidthForProposedHeight: Int - let idealHeightForProposedWidth: Int - if width == nil && height == nil { - idealWidthForProposedHeight = childSize.idealWidthForProposedHeight - idealHeightForProposedWidth = childSize.idealHeightForProposedWidth - } else { - idealWidthForProposedHeight = idealWidth - idealHeightForProposedWidth = idealHeight - } + func commit( + _ widget: Backend.Widget, + children: TupleViewChildren1, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + let frameSize = layout.size + let childSize = children.child0.commit().size - return ViewUpdateResult( - size: ViewSize( - size: frameSize, - idealSize: SIMD2( - idealWidth, - idealHeight - ), - idealWidthForProposedHeight: idealWidthForProposedHeight, - idealHeightForProposedWidth: idealHeightForProposedWidth, - minimumWidth: width ?? childSize.minimumWidth, - minimumHeight: height ?? childSize.minimumHeight, - maximumWidth: width.map(Double.init) ?? childSize.maximumWidth, - maximumHeight: height.map(Double.init) ?? childSize.maximumHeight - ), - childResults: [childResult] + let childPosition = alignment.position( + ofChild: childSize.vector, + in: frameSize.vector ) + backend.setSize(of: widget, to: frameSize.vector) + backend.setPosition(ofChildAt: 0, in: widget, to: childPosition) } } @@ -202,113 +173,121 @@ struct FlexibleFrameView: TypeSafeView { return container } - func update( + func clampSize(_ size: ViewSize) -> ViewSize { + var size = size + size.width = clampWidth(size.width) + size.height = clampHeight(size.height) + return size + } + + func clampHeight(_ height: Double) -> Double { + LayoutSystem.clamp( + height, + minimum: minHeight.map(Double.init), + maximum: maxHeight + ) + } + + func clampWidth(_ width: Double) -> Double { + LayoutSystem.clamp( + width, + minimum: minWidth.map(Double.init), + maximum: maxWidth + ) + } + + func computeLayout( _ widget: Backend.Widget, children: TupleViewChildren1, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { + backend: Backend + ) -> ViewLayoutResult { var proposedFrameSize = proposedSize - if let minWidth { - proposedFrameSize.x = max(proposedFrameSize.x, minWidth) + + if let proposedWidth = proposedSize.width { + proposedFrameSize.width = clampWidth(proposedWidth) } - if let maxWidth { - proposedFrameSize.x = LayoutSystem.roundSize( - min(Double(proposedFrameSize.x), maxWidth) - ) + + if let proposedHeight = proposedSize.height { + proposedFrameSize.height = clampHeight(proposedHeight) } - if let minHeight { - proposedFrameSize.y = max(proposedFrameSize.y, minHeight) + + if let idealWidth, proposedSize.width == nil { + proposedFrameSize.width = Double(idealWidth) } - if let maxHeight { - proposedFrameSize.y = LayoutSystem.roundSize( - min(Double(proposedFrameSize.y), maxHeight) - ) + + if let idealHeight, proposedSize.height == nil { + proposedFrameSize.height = Double(idealHeight) } - let childResult = children.child0.update( + let childResult = children.child0.computeLayout( with: body.view0, proposedSize: proposedFrameSize, - environment: environment, - dryRun: dryRun + environment: environment ) let childSize = childResult.size - // TODO: Fix idealSize propagation. When idealSize isn't possible, we - // have to use idealWidthForProposedHeight and - // idealHeightForProposedWidth, and sometimes we may also have to - // perform an additional dryRun update to probe the child view. - - var frameSize = childSize - if let minWidth { - frameSize.size.x = max(frameSize.size.x, minWidth) - frameSize.minimumWidth = minWidth - frameSize.idealSize.x = max(frameSize.idealSize.x, minWidth) - frameSize.idealWidthForProposedHeight = max( - frameSize.idealWidthForProposedHeight, - minWidth - ) - } - if let maxWidth { - if maxWidth == .infinity { - frameSize.size.x = proposedSize.x - } else { - frameSize.size.x = min(frameSize.size.x, LayoutSystem.roundSize(maxWidth)) - } - frameSize.idealSize.x = LayoutSystem.roundSize( - min(Double(frameSize.idealSize.x), maxWidth) - ) - frameSize.maximumWidth = min(childSize.maximumWidth, Double(maxWidth)) - frameSize.idealWidthForProposedHeight = LayoutSystem.roundSize( - min(Double(frameSize.idealWidthForProposedHeight), maxWidth) - ) + var frameSize = clampSize(childSize) + if maxWidth == .infinity, let proposedWidth = proposedSize.width { + frameSize.width = max(frameSize.width, proposedWidth) } - if let minHeight { - frameSize.size.y = max(frameSize.size.y, minHeight) - frameSize.minimumHeight = minHeight - frameSize.idealSize.y = max(frameSize.idealSize.y, minHeight) - frameSize.idealHeightForProposedWidth = max( - frameSize.idealHeightForProposedWidth, - minHeight - ) - } - if let maxHeight { - if maxHeight == .infinity { - frameSize.size.y = proposedSize.y - } else { - frameSize.size.y = min(frameSize.size.y, LayoutSystem.roundSize(maxHeight)) - } - frameSize.idealSize.y = LayoutSystem.roundSize( - min(Double(frameSize.idealSize.y), maxHeight) - ) - frameSize.maximumHeight = min(childSize.maximumHeight, Double(maxHeight)) - frameSize.idealHeightForProposedWidth = LayoutSystem.roundSize( - min(Double(frameSize.idealHeightForProposedWidth), maxHeight) - ) + if maxHeight == .infinity, let proposedHeight = proposedSize.height { + frameSize.height = max(frameSize.height, proposedHeight) } - if let idealWidth { - frameSize.idealSize.x = idealWidth - } - if let idealHeight { - frameSize.idealSize.y = idealHeight - } + return ViewLayoutResult( + size: frameSize, + childResults: [childResult] + ) + } + + func commit( + _ widget: Backend.Widget, + children: TupleViewChildren1, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + let frameSize = layout.size - if !dryRun { - let childPosition = alignment.position( - ofChild: childSize.size, - in: frameSize.size + // If the child view has at least one unspecified axis which this frame + // is constraining with a minimum or maximum, then compute its + // layout again with the clamped frame size. This allows the view to + // fill the space that the frame is going to take up anyway. E.g. consider, + // + // ScrollView { + // Color.blue + // .frame(minHeight: 100) + // } + // + // Without this second layout computation, the blue rectangle would + // take on its ideal size of 10 within a frame of height 100, instead + // of using up the min height of the frame as developers may expect. + // + // This doesn't apply to unconstrained axes which we have a corresponding + // ideal length for. + let widthConstrained = minWidth != nil || maxWidth != nil + let heightConstrained = minHeight != nil || maxHeight != nil + let proposedFrameSize = children.child0.lastProposedSize + if (proposedFrameSize.width == nil && widthConstrained) + || (proposedFrameSize.height == nil && heightConstrained) + { + _ = children.child0.computeLayout( + with: nil, + proposedSize: ProposedViewSize(frameSize), + environment: environment ) - backend.setSize(of: widget, to: frameSize.size) - backend.setPosition(ofChildAt: 0, in: widget, to: childPosition) } - return ViewUpdateResult( - size: frameSize, - childResults: [childResult] + let childSize = children.child0.commit().size + + let childPosition = alignment.position( + ofChild: childSize.vector, + in: frameSize.vector ) + backend.setSize(of: widget, to: frameSize.vector) + backend.setPosition(ofChildAt: 0, in: widget, to: childPosition) } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/Layout/OverlayModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/Layout/OverlayModifier.swift index 34edb7438d5..51cc165e641 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/Layout/OverlayModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/Layout/OverlayModifier.swift @@ -38,54 +38,54 @@ struct OverlayModifier: TypeSafeView { body.asWidget(children, backend: backend) } - func update( + func computeLayout( _ widget: Backend.Widget, children: TupleView2.Children, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - let contentResult = children.child0.update( + backend: Backend + ) -> ViewLayoutResult { + let contentResult = children.child0.computeLayout( with: body.view0, proposedSize: proposedSize, - environment: environment, - dryRun: dryRun + environment: environment ) let contentSize = contentResult.size - let overlayResult = children.child1.update( + let overlayResult = children.child1.computeLayout( with: body.view1, - proposedSize: contentSize.size, - environment: environment, - dryRun: dryRun + proposedSize: ProposedViewSize(contentSize), + environment: environment ) let overlaySize = overlayResult.size - let frameSize = SIMD2( - max(contentSize.size.x, overlaySize.size.x), - max(contentSize.size.y, overlaySize.size.y) + let size = ViewSize( + max(contentSize.width, overlaySize.width), + max(contentSize.height, overlaySize.height) + ) + + return ViewLayoutResult( + size: size, + childResults: [contentResult, overlayResult] ) + } - if !dryRun { - let contentPosition = (frameSize &- contentSize.size) / 2 - let overlayPosition = (frameSize &- overlaySize.size) / 2 + func commit( + _ widget: Backend.Widget, + children: TupleView2.Children, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + let frameSize = layout.size.vector + let contentSize = children.child0.commit().size.vector + let overlaySize = children.child1.commit().size.vector - backend.setPosition(ofChildAt: 0, in: widget, to: contentPosition) - backend.setPosition(ofChildAt: 1, in: widget, to: overlayPosition) + let contentPosition = Alignment.center.position(ofChild: contentSize, in: frameSize) + let overlayPosition = Alignment.center.position(ofChild: overlaySize, in: frameSize) - backend.setSize(of: widget, to: frameSize) - } + backend.setPosition(ofChildAt: 0, in: widget, to: contentPosition) + backend.setPosition(ofChildAt: 1, in: widget, to: overlayPosition) - return ViewUpdateResult( - size: ViewSize( - size: frameSize, - idealSize: contentSize.idealSize, - minimumWidth: max(contentSize.minimumWidth, overlaySize.minimumWidth), - minimumHeight: max(contentSize.minimumHeight, overlaySize.minimumHeight), - maximumWidth: min(contentSize.maximumWidth, overlaySize.maximumWidth), - maximumHeight: min(contentSize.maximumHeight, overlaySize.maximumHeight) - ), - childResults: [contentResult, overlayResult] - ) + backend.setSize(of: widget, to: frameSize) } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/Layout/PaddingModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/Layout/PaddingModifier.swift index c5441093ac0..d2429b6942c 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/Layout/PaddingModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/Layout/PaddingModifier.swift @@ -97,50 +97,57 @@ struct PaddingModifierView: TypeSafeView { return container } - func update( + func computeLayout( _ container: Backend.Widget, children: TupleViewChildren1, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { + backend: Backend + ) -> ViewLayoutResult { + // This first block of calculations is somewhat repeated in `commit`, + // make sure to update things in both places. let insets = EdgeInsets(insets, defaultAmount: backend.defaultPaddingAmount) + let horizontalPadding = Double(insets.leading + insets.trailing) + let verticalPadding = Double(insets.top + insets.bottom) + + var childProposal = proposedSize + if let proposedWidth = proposedSize.width { + childProposal.width = max(proposedWidth - horizontalPadding, 0) + } + if let proposedHeight = proposedSize.height { + childProposal.height = max(proposedHeight - verticalPadding, 0) + } - let childResult = children.child0.update( + let childResult = children.child0.computeLayout( with: body.view0, - proposedSize: SIMD2( - max(proposedSize.x - insets.leading - insets.trailing, 0), - max(proposedSize.y - insets.top - insets.bottom, 0) - ), - environment: environment, - dryRun: dryRun + proposedSize: childProposal, + environment: environment ) - let childSize = childResult.size - - let paddingSize = SIMD2(insets.leading + insets.trailing, insets.top + insets.bottom) - let size = - SIMD2( - childSize.size.x, - childSize.size.y - ) &+ paddingSize - if !dryRun { - backend.setSize(of: container, to: size) - backend.setPosition(ofChildAt: 0, in: container, to: SIMD2(insets.leading, insets.top)) - } - return ViewUpdateResult( - size: ViewSize( - size: size, - idealSize: childSize.idealSize &+ paddingSize, - idealWidthForProposedHeight: childSize.idealWidthForProposedHeight + paddingSize.x, - idealHeightForProposedWidth: childSize.idealHeightForProposedWidth + paddingSize.y, - minimumWidth: childSize.minimumWidth + paddingSize.x, - minimumHeight: childSize.minimumHeight + paddingSize.y, - maximumWidth: childSize.maximumWidth + Double(paddingSize.x), - maximumHeight: childSize.maximumHeight + Double(paddingSize.y) - ), + var size = childResult.size + size.width += horizontalPadding + size.height += verticalPadding + + return ViewLayoutResult( + size: size, childResults: [childResult] ) } + + func commit( + _ container: Backend.Widget, + children: TupleViewChildren1, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + _ = children.child0.commit() + + let size = layout.size + backend.setSize(of: container, to: size.vector) + + let insets = EdgeInsets(insets, defaultAmount: backend.defaultPaddingAmount) + let childPosition = SIMD2(insets.leading, insets.top) + backend.setPosition(ofChildAt: 0, in: container, to: childPosition) + } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/Lifecycle/OnDisappearModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/Lifecycle/OnDisappearModifier.swift index 6c9ce558c93..0d214cbef15 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/Lifecycle/OnDisappearModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/Lifecycle/OnDisappearModifier.swift @@ -46,21 +46,35 @@ struct OnDisappearModifier: TypeSafeView { defaultAsWidget(children.wrappedChildren, backend: backend) } - func update( + func computeLayout( _ widget: Backend.Widget, children: OnDisappearModifierChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - defaultUpdate( + backend: Backend + ) -> ViewLayoutResult { + defaultComputeLayout( widget, children: children.wrappedChildren, proposedSize: proposedSize, environment: environment, - backend: backend, - dryRun: dryRun + backend: backend + ) + } + + func commit( + _ widget: Backend.Widget, + children: OnDisappearModifierChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + defaultCommit( + widget, + children: children.wrappedChildren, + layout: layout, + environment: environment, + backend: backend ) } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/Lifecycle/TaskModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/Lifecycle/TaskModifier.swift index 62726cf32a5..d0cd8e797c4 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/Lifecycle/TaskModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/Lifecycle/TaskModifier.swift @@ -47,16 +47,13 @@ extension TaskModifier: View { var body: some View { // Explicitly return to disable result builder (we don't want an extra // layer of views). - return - content - .onChange(of: id, initial: true) { - task?.cancel() - task = Task(priority: priority) { - await action() - } - } - .onDisappear { - task?.cancel() + return content.onChange(of: id, initial: true) { + task?.cancel() + task = Task(priority: priority) { + await action() } + }.onDisappear { + task?.cancel() + } } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/PreferenceModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/PreferenceModifier.swift index 90ec013d161..c4a641bf3ad 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/PreferenceModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/PreferenceModifier.swift @@ -23,21 +23,19 @@ struct PreferenceModifier: View { self.modification = modification } - func update( + func computeLayout( _ widget: Backend.Widget, children: any ViewGraphNodeChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - var result = defaultUpdate( + backend: Backend + ) -> ViewLayoutResult { + var result = defaultComputeLayout( widget, children: children, proposedSize: proposedSize, environment: environment, - backend: backend, - dryRun: dryRun + backend: backend ) result.preferences = modification(result.preferences, environment) return result diff --git a/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift b/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift index 3b5738a6151..0b6d460f852 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/PresentationModifiers.swift @@ -30,7 +30,7 @@ extension View { /// Sets the visibility of the enclosing sheet presentation's drag indicator. /// Drag indicators are only supported on platforms that support sheet /// resizing, and sheet resizing is generally only support on mobile. - /// + /// /// - Supported platforms: iOS & Mac Catalyst 15+ (ignored on unsupported platforms) /// /// - Parameter visibility: The visibility to use for the drag indicator of diff --git a/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift index 00d87b182cd..edfe1c577f8 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/SheetModifier.swift @@ -6,14 +6,14 @@ extension View { /// /// `onDismiss` isn't called when the sheet gets dismissed programmatically /// (i.e. by setting `isPresented` to `false`). - /// + /// /// `onDismiss` gets called *after* the sheet has been dismissed by the /// underlying UI framework, and *before* `isPresented` gets set to false. /// /// - Parameters: /// - isPresented: A binding controlling whether the sheet is presented. /// - onDismiss: An action to perform when the sheet is dismissed - /// by the user. + /// by the user. public func sheet( isPresented: Binding, onDismiss: (() -> Void)? = nil, @@ -64,20 +64,28 @@ struct SheetModifier: TypeSafeView { children.childNode.widget.into() } - func update( + func computeLayout( _ widget: Backend.Widget, children: Children, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - let childResult = children.childNode.update( + backend: Backend + ) -> ViewLayoutResult { + children.childNode.computeLayout( with: body.view0, proposedSize: proposedSize, - environment: environment, - dryRun: dryRun + environment: environment ) + } + + func commit( + _ widget: Backend.Widget, + children: Children, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + _ = children.childNode.commit() if isPresented.wrappedValue && children.sheet == nil { let sheetViewGraphNode = ViewGraphNode( @@ -101,12 +109,12 @@ struct SheetModifier: TypeSafeView { .with(\.dismiss, dismissAction) .with(\.sheet, sheet) - let result = children.sheetContentNode!.update( + _ = children.sheetContentNode!.computeLayout( with: sheetContent(), - proposedSize: SIMD2(x: 10_000, y: 0), - environment: sheetEnvironment, - dryRun: false + proposedSize: .unspecified, + environment: sheetEnvironment ) + let result = children.sheetContentNode!.commit() let window = environment.window! as! Backend.Window let preferences = result.preferences @@ -117,7 +125,7 @@ struct SheetModifier: TypeSafeView { // sheetEnvironment here, because this is meant to be the sheet's // environment, not that of its content. environment: environment, - size: result.size.size, + size: result.size.vector, onDismiss: { handleDismiss(children: children) }, cornerRadius: preferences.presentationCornerRadius, detents: preferences.presentationDetents ?? [], @@ -147,15 +155,6 @@ struct SheetModifier: TypeSafeView { children.parentSheet = nil children.sheetContentNode = nil } - - // Reset presentation preferences so that they don't leak to enclosing sheets. - var modifiedResult = childResult - modifiedResult.preferences.interactiveDismissDisabled = nil - modifiedResult.preferences.presentationBackground = nil - modifiedResult.preferences.presentationCornerRadius = nil - modifiedResult.preferences.presentationDetents = nil - modifiedResult.preferences.presentationDragIndicatorVisibility = nil - return modifiedResult } func handleDismiss(children: Children) { diff --git a/Sources/SwiftCrossUI/Views/Modifiers/Style/CornerRadiusModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/Style/CornerRadiusModifier.swift index 1dd3b3ff0e4..98e350eaddd 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/Style/CornerRadiusModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/Style/CornerRadiusModifier.swift @@ -30,14 +30,29 @@ struct CornerRadiusModifier: View { body.layoutableChildren(backend: backend, children: children) } - func update( + func computeLayout( _ widget: Backend.Widget, children: any ViewGraphNodeChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { + backend: Backend + ) -> ViewLayoutResult { + body.computeLayout( + widget, + children: children, + proposedSize: proposedSize, + environment: environment, + backend: backend + ) + } + + func commit( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { // We used to wrap the child content in a container and then set the corner // radius on that, since it was the simplest approach. But Gtk3Backend has // extremely poor corner radius support and only applies the corner radius @@ -46,17 +61,13 @@ struct CornerRadiusModifier: View { // implement the modifier this way then you can at the very least set the // cornerRadius of a coloured rectangle, which is quite a common thing to // want to do. - let contentResult = body.update( + body.commit( widget, children: children, - proposedSize: proposedSize, + layout: layout, environment: environment, - backend: backend, - dryRun: dryRun + backend: backend ) - if !dryRun { - backend.setCornerRadius(of: widget, to: cornerRadius) - } - return contentResult + backend.setCornerRadius(of: widget, to: cornerRadius) } } diff --git a/Sources/SwiftCrossUI/Views/Modifiers/TextSelectionModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/TextSelectionModifier.swift index 07b0ec1df60..6d044b163a3 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/TextSelectionModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/TextSelectionModifier.swift @@ -1,10 +1,11 @@ extension View { - /// Set selectability of contained text. Ignored on tvOS. + /// Sets selectability of contained text. Ignored on tvOS. public func textSelectionEnabled(_ isEnabled: Bool = true) -> some View { EnvironmentModifier( self, modification: { environment in environment.with(\.isTextSelectionEnabled, isEnabled) - }) + } + ) } } diff --git a/Sources/SwiftCrossUI/Views/OptionalView.swift b/Sources/SwiftCrossUI/Views/OptionalView.swift index 97394a0be18..c39dfcbd6b6 100644 --- a/Sources/SwiftCrossUI/Views/OptionalView.swift +++ b/Sources/SwiftCrossUI/Views/OptionalView.swift @@ -39,23 +39,21 @@ extension OptionalView: TypeSafeView { return backend.createContainer() } - func update( + func computeLayout( _ widget: Backend.Widget, children: OptionalViewChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { + backend: Backend + ) -> ViewLayoutResult { let hasToggled: Bool - let result: ViewUpdateResult + let result: ViewLayoutResult if let view = view { if let node = children.node { - result = node.update( + result = node.computeLayout( with: view, proposedSize: proposedSize, - environment: environment, - dryRun: dryRun + environment: environment ) hasToggled = false } else { @@ -65,35 +63,42 @@ extension OptionalView: TypeSafeView { environment: environment ) children.node = node - result = node.update( + result = node.computeLayout( with: view, proposedSize: proposedSize, - environment: environment, - dryRun: dryRun + environment: environment ) hasToggled = true } } else { hasToggled = children.node != nil children.node = nil - result = ViewUpdateResult.leafView(size: .hidden) + result = ViewLayoutResult.leafView(size: .zero) } children.hasToggled = children.hasToggled || hasToggled - if !dryRun { - if children.hasToggled { - backend.removeAllChildren(of: widget) - if let node = children.node { - backend.addChild(node.widget.into(), to: widget) - backend.setPosition(ofChildAt: 0, in: widget, to: .zero) - } - children.hasToggled = false - } + return result + } - backend.setSize(of: widget, to: result.size.size) + func commit( + _ widget: Backend.Widget, + children: OptionalViewChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + if children.hasToggled { + backend.removeAllChildren(of: widget) + if let node = children.node { + backend.addChild(node.widget.into(), to: widget) + backend.setPosition(ofChildAt: 0, in: widget, to: .zero) + } + children.hasToggled = false } - return result + _ = children.node?.commit() + + backend.setSize(of: widget, to: layout.size.vector) } } diff --git a/Sources/SwiftCrossUI/Views/Picker.swift b/Sources/SwiftCrossUI/Views/Picker.swift index 89efa8424d0..d7157fccb35 100644 --- a/Sources/SwiftCrossUI/Views/Picker.swift +++ b/Sources/SwiftCrossUI/Views/Picker.swift @@ -18,17 +18,18 @@ public struct Picker: ElementaryView, View { self.value = value } - public func asWidget(backend: Backend) -> Backend.Widget { + func asWidget(backend: Backend) -> Backend.Widget { return backend.createPicker() } - public func update( + func computeLayout( _ widget: Backend.Widget, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { + backend: Backend + ) -> ViewLayoutResult { + // TODO: Implement picker sizing within SwiftCrossUI so that we can + // properly separate committing logic out into `commit`. backend.updatePicker( widget, options: options.map { "\($0)" }, @@ -46,27 +47,22 @@ public struct Picker: ElementaryView, View { // Special handling for UIKitBackend: // When backed by a UITableView, its natural size is -1 x -1, // but it can and should be as large as reasonable - let size = backend.naturalSize(of: widget) - if size == SIMD2(-1, -1) { - if !dryRun { - backend.setSize(of: widget, to: proposedSize) - } - - return ViewUpdateResult.leafView( - size: ViewSize( - size: proposedSize, - idealSize: SIMD2(10, 10), - minimumWidth: 0, - minimumHeight: 0, - maximumWidth: nil, - maximumHeight: nil - ) - ) + let naturalSize = backend.naturalSize(of: widget) + let size: ViewSize + if naturalSize == SIMD2(-1, -1) { + size = proposedSize.replacingUnspecifiedDimensions(by: ViewSize(10, 10)) } else { - // TODO: Implement picker sizing within SwiftCrossUI so that we can properly implement `dryRun`. - return ViewUpdateResult.leafView( - size: ViewSize(fixedSize: size) - ) + size = ViewSize(naturalSize) } + return ViewLayoutResult.leafView(size: size) + } + + func commit( + _ widget: Backend.Widget, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + backend.setSize(of: widget, to: layout.size.vector) } } diff --git a/Sources/SwiftCrossUI/Views/ProgressView.swift b/Sources/SwiftCrossUI/Views/ProgressView.swift index 1653410ba60..abd34ca147c 100644 --- a/Sources/SwiftCrossUI/Views/ProgressView.swift +++ b/Sources/SwiftCrossUI/Views/ProgressView.swift @@ -107,20 +107,28 @@ struct ProgressSpinnerView: ElementaryView { backend.createProgressSpinner() } - func update( + func computeLayout( _ widget: Backend.Widget, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - ViewUpdateResult.leafView( - size: ViewSize(fixedSize: backend.naturalSize(of: widget)) - ) + backend: Backend + ) -> ViewLayoutResult { + let size = ViewSize(backend.naturalSize(of: widget)) + return ViewLayoutResult.leafView(size: size) } + + func commit( + _ widget: Backend.Widget, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) {} } struct ProgressBarView: ElementaryView { + /// The ideal width of a ProgressBarView. + static let idealWidth: Double = 100 + var value: Double? init(value: Double?) { @@ -131,33 +139,28 @@ struct ProgressBarView: ElementaryView { backend.createProgressBar() } - func update( + func computeLayout( _ widget: Backend.Widget, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { + backend: Backend + ) -> ViewLayoutResult { let height = backend.naturalSize(of: widget).y - let size = SIMD2( - proposedSize.x, - height + let size = ViewSize( + proposedSize.width ?? Self.idealWidth, + Double(height) ) - if !dryRun { - backend.updateProgressBar(widget, progressFraction: value, environment: environment) - backend.setSize(of: widget, to: size) - } + return ViewLayoutResult.leafView(size: size) + } - return ViewUpdateResult.leafView( - size: ViewSize( - size: size, - idealSize: SIMD2(100, height), - minimumWidth: 0, - minimumHeight: height, - maximumWidth: nil, - maximumHeight: Double(height) - ) - ) + func commit( + _ widget: Backend.Widget, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + backend.updateProgressBar(widget, progressFraction: value, environment: environment) + backend.setSize(of: widget, to: layout.size.vector) } } diff --git a/Sources/SwiftCrossUI/Views/ScrollView.swift b/Sources/SwiftCrossUI/Views/ScrollView.swift index b3d7f1bc51f..caa14b14e91 100644 --- a/Sources/SwiftCrossUI/Views/ScrollView.swift +++ b/Sources/SwiftCrossUI/Views/ScrollView.swift @@ -1,3 +1,5 @@ +import Foundation + /// A view that is scrollable when it would otherwise overflow available space. Use the /// ``View/frame`` modifier to constrain height if necessary. public struct ScrollView: TypeSafeView, View { @@ -41,131 +43,133 @@ public struct ScrollView: TypeSafeView, View { return backend.createScrollContainer(for: children.innerContainer.into()) } - func update( + func computeLayout( _ widget: Backend.Widget, children: ScrollViewChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { + backend: Backend + ) -> ViewLayoutResult { + // If all scroll axes are unspecified, then our size is exactly that of + // the child view. This includes when we have no scroll axes. + let willEarlyExit = Axis.allCases.allSatisfy({ axis in + !axes.contains(axis) || proposedSize[component: axis] == nil + }) + // Probe how big the child would like to be - let childResult = children.child.update( + var childProposal = proposedSize + for axis in Axis.allCases where axes.contains(axis) { + childProposal[component: axis] = nil + } + + let childResult = children.child.computeLayout( with: body, - proposedSize: proposedSize, - environment: environment, - dryRun: true + proposedSize: childProposal, + environment: environment.with( + \.allowLayoutCaching, + !willEarlyExit || environment.allowLayoutCaching + ) ) + + if willEarlyExit { + print("early exit: childResult.size=\(childResult.size)") + return childResult + } + let contentSize = childResult.size - let scrollBarWidth = backend.scrollBarWidth + // An axis is present when it's a scroll axis AND the corresponding + // child content size is bigger then the proposed size. If the proposed + // size along the axis is nil then we don't have a scroll bar. + let hasHorizontalScrollBar: Bool + if axes.contains(.horizontal), let proposedWidth = proposedSize.width { + hasHorizontalScrollBar = contentSize.width > proposedWidth + } else { + hasHorizontalScrollBar = false + } + children.hasHorizontalScrollBar = hasHorizontalScrollBar - let hasHorizontalScrollBar = - axes.contains(.horizontal) && contentSize.idealWidthForProposedHeight > proposedSize.x - let hasVerticalScrollBar = - axes.contains(.vertical) && contentSize.idealHeightForProposedWidth > proposedSize.y + let hasVerticalScrollBar: Bool + if axes.contains(.vertical), let proposedHeight = proposedSize.height { + hasVerticalScrollBar = contentSize.height > proposedHeight + } else { + hasVerticalScrollBar = false + } + children.hasVerticalScrollBar = hasVerticalScrollBar + let scrollBarWidth = Double(backend.scrollBarWidth) let verticalScrollBarWidth = hasVerticalScrollBar ? scrollBarWidth : 0 let horizontalScrollBarHeight = hasHorizontalScrollBar ? scrollBarWidth : 0 - let scrollViewWidth: Int - let scrollViewHeight: Int - let minimumWidth: Int - let minimumHeight: Int - if axes.contains(.horizontal) { - scrollViewWidth = max(proposedSize.x, verticalScrollBarWidth) - minimumWidth = verticalScrollBarWidth - } else { - scrollViewWidth = min( - contentSize.size.x + verticalScrollBarWidth, - max(proposedSize.x, contentSize.minimumWidth + verticalScrollBarWidth) - ) - minimumWidth = contentSize.minimumWidth + verticalScrollBarWidth + // Compute the final size to propose to the child view. Subtract off + // scroll bar sizes from non-scrolling axes. + var finalContentSizeProposal = childProposal + if !axes.contains(.horizontal), let proposedWidth = childProposal.width { + finalContentSizeProposal.width = proposedWidth - verticalScrollBarWidth } - if axes.contains(.vertical) { - scrollViewHeight = max(proposedSize.y, horizontalScrollBarHeight) - minimumHeight = horizontalScrollBarHeight - } else { - scrollViewHeight = min( - contentSize.size.y + horizontalScrollBarHeight, - max(proposedSize.y, contentSize.minimumHeight + horizontalScrollBarHeight) - ) - minimumHeight = contentSize.minimumHeight + horizontalScrollBarHeight + + if !axes.contains(.vertical), let proposedHeight = childProposal.height { + finalContentSizeProposal.height = proposedHeight - horizontalScrollBarHeight } - let scrollViewSize = SIMD2( - scrollViewWidth, - scrollViewHeight + // Propose a final size to the child view. + let finalChildResult = children.child.computeLayout( + with: nil, + proposedSize: finalContentSizeProposal, + environment: environment ) - let finalResult: ViewUpdateResult - if !dryRun { - // TODO: scroll bar presence shouldn't affect whether we use current - // or ideal size. Only the presence of the given axis in the user's - // list of scroll axes should affect that. - let proposedContentSize = SIMD2( - hasHorizontalScrollBar - ? (hasVerticalScrollBar - ? contentSize.idealSize.x : contentSize.idealWidthForProposedHeight) - : min(contentSize.size.x, proposedSize.x - verticalScrollBarWidth), - hasVerticalScrollBar - ? (hasHorizontalScrollBar - ? contentSize.idealSize.y : contentSize.idealHeightForProposedWidth) - : min(contentSize.size.y, proposedSize.y - horizontalScrollBarHeight) - ) + // Compute the outer size. + var outerSize = finalChildResult.size + if axes.contains(.horizontal) { + outerSize.width = proposedSize.width + ?? (finalChildResult.size.width + verticalScrollBarWidth) + } else { + outerSize.width += verticalScrollBarWidth + } - finalResult = children.child.update( - with: body, - proposedSize: proposedContentSize, - environment: environment, - dryRun: false - ) - let finalContentSize = finalResult.size - - let clipViewWidth = scrollViewSize.x - verticalScrollBarWidth - let clipViewHeight = scrollViewSize.y - horizontalScrollBarHeight - var childPosition: SIMD2 = .zero - var innerContainerSize: SIMD2 = finalContentSize.size - if axes.contains(.vertical) && finalContentSize.size.x < clipViewWidth { - childPosition.x = (clipViewWidth - finalContentSize.size.x) / 2 - innerContainerSize.x = clipViewWidth - } - if axes.contains(.horizontal) && finalContentSize.size.y < clipViewHeight { - childPosition.y = (clipViewHeight - finalContentSize.size.y) / 2 - innerContainerSize.y = clipViewHeight - } - - backend.setSize(of: widget, to: scrollViewSize) - backend.setSize(of: children.innerContainer.into(), to: innerContainerSize) - backend.setPosition(ofChildAt: 0, in: children.innerContainer.into(), to: childPosition) - backend.setScrollBarPresence( - ofScrollContainer: widget, - hasVerticalScrollBar: hasVerticalScrollBar, - hasHorizontalScrollBar: hasHorizontalScrollBar - ) - backend.updateScrollContainer(widget, environment: environment) + if axes.contains(.vertical) { + outerSize.height = proposedSize.height + ?? (finalChildResult.size.height + horizontalScrollBarHeight) } else { - finalResult = childResult + outerSize.height += horizontalScrollBarHeight } - return ViewUpdateResult( - size: ViewSize( - size: scrollViewSize, - idealSize: contentSize.idealSize, - minimumWidth: minimumWidth, - minimumHeight: minimumHeight, - maximumWidth: nil, - maximumHeight: nil - ), - childResults: [finalResult] + return ViewLayoutResult( + size: outerSize, + childResults: [finalChildResult] ) } + + func commit( + _ widget: Backend.Widget, + children: ScrollViewChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + let scrollViewSize = layout.size + let finalContentSize = children.child.commit().size + + backend.setSize(of: widget, to: scrollViewSize.vector) + backend.setSize(of: children.innerContainer.into(), to: finalContentSize.vector) + backend.setPosition(ofChildAt: 0, in: children.innerContainer.into(), to: .zero) + backend.setScrollBarPresence( + ofScrollContainer: widget, + hasVerticalScrollBar: children.hasVerticalScrollBar, + hasHorizontalScrollBar: children.hasHorizontalScrollBar + ) + backend.updateScrollContainer(widget, environment: environment) + } } class ScrollViewChildren: ViewGraphNodeChildren { var children: TupleView1>.Children var innerContainer: AnyWidget + var hasVerticalScrollBar = false + var hasHorizontalScrollBar = false + var child: AnyViewGraphNode> { children.child0 } diff --git a/Sources/SwiftCrossUI/Views/Shapes/Circle.swift b/Sources/SwiftCrossUI/Views/Shapes/Circle.swift index 4eb1cfba9f4..cfa8035afdb 100644 --- a/Sources/SwiftCrossUI/Views/Shapes/Circle.swift +++ b/Sources/SwiftCrossUI/Views/Shapes/Circle.swift @@ -1,4 +1,7 @@ public struct Circle: Shape { + /// The ideal diameter of a Circle. + static let idealDiameter = 10.0 + public nonisolated init() {} public nonisolated func path(in bounds: Path.Rect) -> Path { @@ -6,18 +9,14 @@ public struct Circle: Shape { .addCircle(center: bounds.center, radius: min(bounds.width, bounds.height) / 2.0) } - public nonisolated func size(fitting proposal: SIMD2) -> ViewSize { - let diameter = min(proposal.x, proposal.y) + public nonisolated func size(fitting proposal: ProposedViewSize) -> ViewSize { + let diameter: Double + if let proposal = proposal.concrete { + diameter = min(proposal.width, proposal.height) + } else { + diameter = proposal.width ?? proposal.height ?? 10 + } - return ViewSize( - size: SIMD2(x: diameter, y: diameter), - idealSize: SIMD2(x: 10, y: 10), - idealWidthForProposedHeight: proposal.y, - idealHeightForProposedWidth: proposal.x, - minimumWidth: 0, - minimumHeight: 0, - maximumWidth: nil, - maximumHeight: nil - ) + return ViewSize(diameter, diameter) } } diff --git a/Sources/SwiftCrossUI/Views/Shapes/Shape.swift b/Sources/SwiftCrossUI/Views/Shapes/Shape.swift index e266588cbeb..c19a56030f7 100644 --- a/Sources/SwiftCrossUI/Views/Shapes/Shape.swift +++ b/Sources/SwiftCrossUI/Views/Shapes/Shape.swift @@ -36,29 +36,17 @@ public protocol Shape: View, Sendable where Content == EmptyView { func path(in bounds: Path.Rect) -> Path /// Determine the ideal size of this shape given the proposed bounds. /// - /// The default implementation accepts the proposal and imposes no practical limit on - /// the shape's size. - /// - Returns: Information about the shape's size. The ``ViewSize/size`` property is what - /// frame the shape will actually be rendered with if the current layout pass is not - /// a dry run, while the other properties are used to inform the layout engine how big - /// or small the shape can be. The ``ViewSize/idealSize`` property should not vary with - /// the `proposal`, and should only depend on the shape's contents. Pass `nil` for the - /// maximum width/height if the shape has no maximum size. - func size(fitting proposal: SIMD2) -> ViewSize + /// The default implementation accepts the proposal, replacing unspecified + /// dimensions with `10`. + /// - Returns: The shape's size for the given proposal. + func size(fitting proposal: ProposedViewSize) -> ViewSize } extension Shape { public var body: EmptyView { return EmptyView() } - public func size(fitting proposal: SIMD2) -> ViewSize { - return ViewSize( - size: proposal, - idealSize: SIMD2(x: 10, y: 10), - minimumWidth: 0, - minimumHeight: 0, - maximumWidth: nil, - maximumHeight: nil - ) + public func size(fitting proposal: ProposedViewSize) -> ViewSize { + proposal.replacingUnspecifiedDimensions(by: ViewSize(10, 10)) } @MainActor @@ -82,51 +70,54 @@ extension Shape { } @MainActor - public func update( + public func computeLayout( _ widget: Backend.Widget, children: any ViewGraphNodeChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - let storage = children as! ShapeStorage + backend: Backend + ) -> ViewLayoutResult { let size = size(fitting: proposedSize) + return ViewLayoutResult.leafView(size: size) + } + @MainActor + public func commit( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { let bounds = Path.Rect( x: 0.0, y: 0.0, - width: Double(size.size.x), - height: Double(size.size.y) + width: layout.size.width, + height: layout.size.height ) let path = path(in: bounds) - storage.pointsChanged = - storage.pointsChanged || storage.oldPath?.actions != path.actions + let storage = children as! ShapeStorage + let pointsChanged = storage.oldPath?.actions != path.actions storage.oldPath = path let backendPath = storage.backendPath as! Backend.Path - if !dryRun { - backend.updatePath( - backendPath, - path, - bounds: bounds, - pointsChanged: storage.pointsChanged, - environment: environment - ) - storage.pointsChanged = false - - backend.setSize(of: widget, to: size.size) - backend.renderPath( - backendPath, - container: widget, - strokeColor: .clear, - fillColor: environment.suggestedForegroundColor, - overrideStrokeStyle: nil - ) - } + backend.updatePath( + backendPath, + path, + bounds: bounds, + pointsChanged: pointsChanged, + environment: environment + ) - return ViewUpdateResult.leafView(size: size) + backend.setSize(of: widget, to: layout.size.vector) + backend.renderPath( + backendPath, + container: widget, + strokeColor: .clear, + fillColor: environment.suggestedForegroundColor, + overrideStrokeStyle: nil + ) } } @@ -135,5 +126,4 @@ final class ShapeStorage: ViewGraphNodeChildren { let erasedNodes: [ErasedViewGraphNode] = [] var backendPath: Any! var oldPath: Path? - var pointsChanged = false } diff --git a/Sources/SwiftCrossUI/Views/Shapes/StyledShape.swift b/Sources/SwiftCrossUI/Views/Shapes/StyledShape.swift index 1a09c59fc0c..7b04c9692ee 100644 --- a/Sources/SwiftCrossUI/Views/Shapes/StyledShape.swift +++ b/Sources/SwiftCrossUI/Views/Shapes/StyledShape.swift @@ -36,7 +36,7 @@ extension StyledShapeImpl: StyledShape { return base.path(in: bounds) } - func size(fitting proposal: SIMD2) -> ViewSize { + func size(fitting proposal: ProposedViewSize) -> ViewSize { return base.size(fitting: proposal) } } @@ -53,51 +53,53 @@ extension Shape { extension StyledShape { @MainActor - public func update( + public func computeLayout( _ widget: Backend.Widget, children: any ViewGraphNodeChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - // TODO: Don't duplicate this between Shape and StyledShape - let storage = children as! ShapeStorage + backend: Backend + ) -> ViewLayoutResult { let size = size(fitting: proposedSize) + return ViewLayoutResult.leafView(size: size) + } + @MainActor + public func commit( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { let bounds = Path.Rect( x: 0.0, y: 0.0, - width: Double(size.size.x), - height: Double(size.size.y) + width: layout.size.width, + height: layout.size.height ) let path = path(in: bounds) - storage.pointsChanged = - storage.pointsChanged || storage.oldPath?.actions != path.actions + let storage = children as! ShapeStorage + let pointsChanged = storage.oldPath?.actions != path.actions storage.oldPath = path let backendPath = storage.backendPath as! Backend.Path - if !dryRun { - backend.updatePath( - backendPath, - path, - bounds: bounds, - pointsChanged: storage.pointsChanged, - environment: environment - ) - storage.pointsChanged = false - - backend.setSize(of: widget, to: size.size) - backend.renderPath( - backendPath, - container: widget, - strokeColor: strokeColor ?? .clear, - fillColor: fillColor ?? .clear, - overrideStrokeStyle: strokeStyle - ) - } + backend.updatePath( + backendPath, + path, + bounds: bounds, + pointsChanged: pointsChanged, + environment: environment + ) - return ViewUpdateResult.leafView(size: size) + backend.setSize(of: widget, to: layout.size.vector) + backend.renderPath( + backendPath, + container: widget, + strokeColor: strokeColor ?? .clear, + fillColor: fillColor ?? .clear, + overrideStrokeStyle: strokeStyle + ) } } diff --git a/Sources/SwiftCrossUI/Views/Slider.swift b/Sources/SwiftCrossUI/Views/Slider.swift index 1745400aa5d..a7bc029c87d 100644 --- a/Sources/SwiftCrossUI/Views/Slider.swift +++ b/Sources/SwiftCrossUI/Views/Slider.swift @@ -43,6 +43,9 @@ struct IntegerValue: DoubleConvertible { /// A control for selecting a value from a bounded range of numerical values. public struct Slider: ElementaryView, View { + /// The ideal width of a Slider. + private static let idealWidth: Double = 100 + /// A binding to the current value. private var value: Binding? /// The slider's minimum value. @@ -86,54 +89,51 @@ public struct Slider: ElementaryView, View { decimalPlaces = 2 } - public func asWidget(backend: Backend) -> Backend.Widget { + func asWidget(backend: Backend) -> Backend.Widget { return backend.createSlider() } - public func update( + func computeLayout( _ widget: Backend.Widget, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - if !dryRun { - backend.updateSlider( - widget, - minimum: minimum, - maximum: maximum, - decimalPlaces: decimalPlaces, - environment: environment - ) { newValue in - if let value { - value.wrappedValue = newValue - } - } + backend: Backend + ) -> ViewLayoutResult { + // TODO: Don't rely on naturalSize for minimum size so that we can get + // Slider sizes without relying on the widget. + let naturalSize = backend.naturalSize(of: widget) + + let size = ViewSize( + max(Double(naturalSize.x), proposedSize.width ?? Self.idealWidth), + Double(naturalSize.y) + ) + + // TODO: Allow backends to specify their own ideal slider widths. + return ViewLayoutResult.leafView(size: size) + } - if let value = value?.wrappedValue { - backend.setValue(ofSlider: widget, to: value) + func commit( + _ widget: Backend.Widget, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + backend.updateSlider( + widget, + minimum: minimum, + maximum: maximum, + decimalPlaces: decimalPlaces, + environment: environment + ) { newValue in + if let value { + value.wrappedValue = newValue } } - // TODO: Don't rely on naturalSize for minimum size so that we can get Slider sizes without - // relying on the widget. - let naturalSize = backend.naturalSize(of: widget) - let size = SIMD2(proposedSize.x, naturalSize.y) - - if !dryRun { - backend.setSize(of: widget, to: size) + if let value = value?.wrappedValue { + backend.setValue(ofSlider: widget, to: value) } - // TODO: Allow backends to specify their own ideal slider widths. - return ViewUpdateResult.leafView( - size: ViewSize( - size: size, - idealSize: SIMD2(100, naturalSize.y), - minimumWidth: naturalSize.x, - minimumHeight: naturalSize.y, - maximumWidth: nil, - maximumHeight: Double(naturalSize.y) - ) - ) + backend.setSize(of: widget, to: layout.size.vector) } } diff --git a/Sources/SwiftCrossUI/Views/Spacer.swift b/Sources/SwiftCrossUI/Views/Spacer.swift index 913b3b6fa4c..f0a22ce01a1 100644 --- a/Sources/SwiftCrossUI/Views/Spacer.swift +++ b/Sources/SwiftCrossUI/Views/Spacer.swift @@ -1,6 +1,9 @@ /// A flexible space that expands along the major axis of its containing /// stack layout, or on both axes if not contained in a stack. public struct Spacer: ElementaryView, View { + /// The ideal length of a spacer. + static let idealLength: Double = 8 + /// The minimum length this spacer can be shrunk to, along the axis of /// expansion. package var minLength: Int? @@ -11,53 +14,32 @@ public struct Spacer: ElementaryView, View { self.minLength = minLength } - public func asWidget( - backend: Backend - ) -> Backend.Widget { + func asWidget(backend: Backend) -> Backend.Widget { return backend.createContainer() } - public func update( + func computeLayout( _ widget: Backend.Widget, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - let minLength = minLength ?? 0 + backend: Backend + ) -> ViewLayoutResult { + var size = ViewSize.zero + let proposedLength = proposedSize[component: environment.layoutOrientation] + size[component: environment.layoutOrientation] = max( + Double(minLength ?? 0), + proposedLength ?? Self.idealLength + ) - let size: SIMD2 - let minimumWidth: Int - let minimumHeight: Int - let maximumWidth: Double? - let maximumHeight: Double? - switch environment.layoutOrientation { - case .horizontal: - size = SIMD2(max(minLength, proposedSize.x), 0) - minimumWidth = minLength - minimumHeight = 0 - maximumWidth = nil - maximumHeight = 0 - case .vertical: - size = SIMD2(0, max(minLength, proposedSize.y)) - minimumWidth = 0 - minimumHeight = minLength - maximumWidth = 0 - maximumHeight = nil - } + return ViewLayoutResult.leafView(size: size) + } - if !dryRun { - backend.setSize(of: widget, to: size) - } - return ViewUpdateResult.leafView( - size: ViewSize( - size: size, - idealSize: SIMD2(minimumWidth, minimumHeight), - minimumWidth: minimumWidth, - minimumHeight: minimumHeight, - maximumWidth: maximumWidth, - maximumHeight: maximumHeight - ) - ) + func commit( + _ widget: Backend.Widget, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + // Spacers are invisible so we don't have to update anything. } } diff --git a/Sources/SwiftCrossUI/Views/SplitView.swift b/Sources/SwiftCrossUI/Views/SplitView.swift index 86a0e28844a..86b8231ba89 100644 --- a/Sources/SwiftCrossUI/Views/SplitView.swift +++ b/Sources/SwiftCrossUI/Views/SplitView.swift @@ -38,97 +38,130 @@ struct SplitView: TypeSafeView, View { ) } - func update( + func computeLayout( _ widget: Backend.Widget, children: Children, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - let leadingWidth = backend.sidebarWidth(ofSplitView: widget) - if !dryRun { - backend.setResizeHandler(ofSplitView: widget) { - environment.onResize(.empty) - } - } + backend: Backend + ) -> ViewLayoutResult { + let leadingWidth = Double(backend.sidebarWidth(ofSplitView: widget)) + + // TODO: If computeLayout ever becomes a pure requirement of View, then we + // can delay this until commit. + children.minimumLeadingWidth = + children.leadingChild.computeLayout( + with: body.view0, + proposedSize: ProposedViewSize( + 0, + proposedSize.height + ), + environment: environment + ).size.width + + children.minimumTrailingWidth = + children.trailingChild.computeLayout( + with: body.view1, + proposedSize: ProposedViewSize( + 0, + proposedSize.height + ), + environment: environment + ).size.width + // TODO: Figure out proper fixedSize behaviour (when width is unspecified) // Update pane children - let leadingResult = children.leadingChild.update( + let leadingResult = children.leadingChild.computeLayout( with: body.view0, - proposedSize: SIMD2( - leadingWidth, - proposedSize.y + proposedSize: ProposedViewSize( + proposedSize.width == nil ? nil : leadingWidth, + proposedSize.height ), - environment: environment, - dryRun: dryRun + environment: environment ) - let trailingResult = children.trailingChild.update( + let trailingResult = children.trailingChild.computeLayout( with: body.view1, - proposedSize: SIMD2( - proposedSize.x - max(leadingWidth, leadingResult.size.minimumWidth), - proposedSize.y + proposedSize: ProposedViewSize( + proposedSize.width.map { $0 - max(leadingWidth, leadingResult.size.width) }, + proposedSize.height ), - environment: environment, - dryRun: dryRun + environment: environment ) // Update split view size and sidebar width bounds let leadingContentSize = leadingResult.size let trailingContentSize = trailingResult.size - let size = SIMD2( - max(proposedSize.x, leadingContentSize.size.x + trailingContentSize.size.x), - max(proposedSize.y, max(leadingContentSize.size.y, trailingContentSize.size.y)) + var size = ViewSize( + leadingContentSize.width + trailingContentSize.width, + max(leadingContentSize.height, trailingContentSize.height) ) - if !dryRun { - backend.setSize(of: widget, to: size) - backend.setSidebarWidthBounds( - ofSplitView: widget, - minimum: leadingContentSize.minimumWidth, - maximum: max( - leadingContentSize.minimumWidth, - proposedSize.x - trailingContentSize.minimumWidth - ) - ) - - // Center pane children - backend.setPosition( - ofChildAt: 0, - in: children.leadingPaneContainer.into(), - to: SIMD2( - leadingWidth - leadingContentSize.size.x, - proposedSize.y - leadingContentSize.size.y - ) / 2 - ) - backend.setPosition( - ofChildAt: 0, - in: children.trailingPaneContainer.into(), - to: SIMD2( - proposedSize.x - leadingWidth - trailingContentSize.size.x, - proposedSize.y - trailingContentSize.size.y - ) / 2 - ) + + if let proposedWidth = proposedSize.width { + size.width = max(size.width, proposedWidth) + } + if let proposedHeight = proposedSize.height { + size.height = max(size.height, proposedHeight) } - return ViewUpdateResult( - size: ViewSize( - size: size, - idealSize: leadingContentSize.idealSize &+ trailingContentSize.idealSize, - minimumWidth: leadingContentSize.minimumWidth + trailingContentSize.minimumWidth, - minimumHeight: max( - leadingContentSize.minimumHeight, trailingContentSize.minimumHeight), - maximumWidth: nil, - maximumHeight: nil - ), + return ViewLayoutResult( + size: size, childResults: [leadingResult, trailingResult] ) } + + func commit( + _ widget: Backend.Widget, + children: Children, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + backend.setResizeHandler(ofSplitView: widget) { + // The parameter to onResize is currently unused + environment.onResize(.zero) + } + + let leadingWidth = backend.sidebarWidth(ofSplitView: widget) + let leadingResult = children.leadingChild.commit() + let trailingResult = children.trailingChild.commit() + + backend.setSize(of: widget, to: layout.size.vector) + backend.setSidebarWidthBounds( + ofSplitView: widget, + minimum: LayoutSystem.roundSize(children.minimumLeadingWidth), + maximum: LayoutSystem.roundSize( + max( + children.minimumLeadingWidth, + layout.size.width - children.minimumTrailingWidth + )) + ) + + // Center pane children + backend.setPosition( + ofChildAt: 0, + in: children.leadingPaneContainer.into(), + to: SIMD2( + leadingWidth - leadingResult.size.vector.x, + layout.size.vector.y - leadingResult.size.vector.y + ) / 2 + ) + backend.setPosition( + ofChildAt: 0, + in: children.trailingPaneContainer.into(), + to: SIMD2( + layout.size.vector.x - leadingWidth - trailingResult.size.vector.x, + layout.size.vector.y - trailingResult.size.vector.y + ) / 2 + ) + } } class SplitViewChildren: ViewGraphNodeChildren { var paneChildren: TupleView2.Children var leadingPaneContainer: AnyWidget var trailingPaneContainer: AnyWidget + var minimumLeadingWidth: Double + var minimumTrailingWidth: Double init( wrapping children: TupleView2.Children, @@ -143,6 +176,8 @@ class SplitViewChildren: ViewGraphNodeChildren { self.leadingPaneContainer = AnyWidget(leadingPaneContainer) self.trailingPaneContainer = AnyWidget(trailingPaneContainer) + self.minimumLeadingWidth = 0 + self.minimumTrailingWidth = 0 } var erasedNodes: [ErasedViewGraphNode] { diff --git a/Sources/SwiftCrossUI/Views/Table.swift b/Sources/SwiftCrossUI/Views/Table.swift index 30669177eb6..82d2f8efec1 100644 --- a/Sources/SwiftCrossUI/Views/Table.swift +++ b/Sources/SwiftCrossUI/Views/Table.swift @@ -32,37 +32,26 @@ public struct Table>: TypeSafeVi return backend.createTable() } - func update( + func computeLayout( _ widget: Backend.Widget, children: Children, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { + backend: Backend + ) -> ViewLayoutResult { let size = proposedSize - var cellResults: [ViewUpdateResult] = [] - let rowContent = rows.map(columns.content(for:)).map(RowView.init(_:)) - - for (node, content) in zip(children.rowNodes, rowContent) { - // Updating a RowView simply updates the view stored within its node, so the proposedSize - // is irrelevant. We can just set it to `.zero`. - _ = node.update( - with: content, - proposedSize: .zero, - environment: environment, - dryRun: dryRun - ) - } - + var cellResults: [ViewLayoutResult] = [] + children.rowContent = rows.map(columns.content(for:)).map(RowView.init(_:)) let columnLabels = columns.labels let columnCount = columnLabels.count - let remainder = rowContent.count - children.rowNodes.count + + // Create and destroy row nodes + let remainder = children.rowContent.count - children.rowNodes.count if remainder < 0 { children.rowNodes.removeLast(-remainder) children.cellContainerWidgets.removeLast(-remainder * columnCount) } else if remainder > 0 { - for row in rowContent[children.rowNodes.count...] { + for row in children.rowContent[children.rowNodes.count...] { let rowNode = AnyViewGraphNode( for: row, backend: backend, @@ -77,78 +66,110 @@ public struct Table>: TypeSafeVi } } - if !dryRun { - backend.setRowCount(ofTable: widget, to: rows.count) - backend.setColumnLabels(ofTable: widget, to: columnLabels, environment: environment) + // Update row nodes + for (node, content) in zip(children.rowNodes, children.rowContent) { + // TODO: Figure out if this is required + // This doesn't update the row's cells. It just updates the view + // instance stored in the row's ViewGraphNode + _ = node.computeLayout( + with: content, + proposedSize: .zero, + environment: environment + ) } - let columnWidth = proposedSize.x / columnCount + // TODO: Compute a proper ideal size for tables. Look to SwiftUI to see what it does. + let columnWidth = (proposedSize.width ?? 0) / Double(columnCount) + + // Compute cell layouts. Really only done during this initial layout + // step to propagate cell preference values. Otherwise we'd do it + // during commit. var rowHeights: [Int] = [] - for (rowIndex, (rowNode, content)) in zip(children.rowNodes, rowContent).enumerated() { + let rows = zip(children.rowNodes, children.rowContent) + for (rowNode, content) in rows { let rowCells = content.layoutableChildren( backend: backend, children: rowNode.getChildren() ) - var cellHeights: [Int] = [] + var rowCellHeights: [Int] = [] for rowCell in rowCells { - let cellResult = rowCell.update( - proposedSize: SIMD2(columnWidth, backend.defaultTableRowContentHeight), - environment: environment, - dryRun: dryRun + let cellResult = rowCell.computeLayout( + proposedSize: ProposedViewSize( + columnWidth, + Double(backend.defaultTableRowContentHeight) + ), + environment: environment ) cellResults.append(cellResult) - cellHeights.append(cellResult.size.size.y) + rowCellHeights.append(cellResult.size.vector.y) } let rowHeight = - max(cellHeights.max() ?? 0, backend.defaultTableRowContentHeight) - + backend.defaultTableCellVerticalPadding * 2 + max( + rowCellHeights.max() ?? 0, + backend.defaultTableRowContentHeight + ) + backend.defaultTableCellVerticalPadding * 2 + rowHeights.append(rowHeight) + } + children.rowHeights = rowHeights + + return ViewLayoutResult( + size: size.replacingUnspecifiedDimensions(by: .zero), + childResults: cellResults + ) + } - for (columnIndex, cellHeight) in zip(0..( + _ widget: Backend.Widget, + children: TableViewChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + let columnLabels = columns.labels + backend.setRowCount(ofTable: widget, to: rows.count) + backend.setColumnLabels(ofTable: widget, to: columnLabels, environment: environment) + + // TODO: Avoid overhead of converting `cellContainerWidgets` to + // `[AnyWidget]` and back again all the time. + backend.setCells( + ofTable: widget, + to: children.cellContainerWidgets.map { $0.into() }, + withRowHeights: children.rowHeights + ) + + let columnCount = columnLabels.count + for (rowIndex, rowHeight) in children.rowHeights.enumerated() { + let rowCells = children.rowContent[rowIndex].layoutableChildren( + backend: backend, + children: children.rowNodes[rowIndex].getChildren() + ) + + for (columnIndex, cell) in rowCells.enumerated() { let index = rowIndex * columnCount + columnIndex + let cellSize = cell.commit() backend.setPosition( ofChildAt: 0, in: children.cellContainerWidgets[index].into(), to: SIMD2( 0, - (rowHeight - cellHeight) / 2 + (rowHeight - cellSize.size.vector.y) / 2 ) ) } } - if !dryRun { - // TODO: Avoid overhead of converting `cellContainerWidgets` to `[AnyWidget]` and back again - // all the time. - backend.setCells( - ofTable: widget, - to: children.cellContainerWidgets.map { $0.into() }, - withRowHeights: rowHeights - ) - } - - backend.setSize(of: widget, to: size) - - // TODO: Compute a proper ideal size for tables - return ViewUpdateResult( - size: ViewSize( - size: size, - idealSize: .zero, - minimumWidth: 0, - minimumHeight: 0, - maximumWidth: nil, - maximumHeight: nil - ), - childResults: cellResults - ) + backend.setSize(of: widget, to: layout.size.vector) } } class TableViewChildren: ViewGraphNodeChildren { var rowNodes: [AnyViewGraphNode>] = [] var cellContainerWidgets: [AnyWidget] = [] + var rowHeights: [Int] = [] + var rowContent: [RowView] = [] /// Not used, just a protocol requirement. var widgets: [AnyWidget] { @@ -195,13 +216,21 @@ struct RowView: View { return backend.createContainer() } - func update( + func computeLayout( _ widget: Backend.Widget, children: any ViewGraphNodeChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, backend: Backend - ) -> ViewUpdateResult { - return ViewUpdateResult.leafView(size: .empty) + ) -> ViewLayoutResult { + return ViewLayoutResult.leafView(size: .zero) } + + func commit( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) {} } diff --git a/Sources/SwiftCrossUI/Views/Text.swift b/Sources/SwiftCrossUI/Views/Text.swift index ade37371625..3c0f54b7908 100644 --- a/Sources/SwiftCrossUI/Views/Text.swift +++ b/Sources/SwiftCrossUI/Views/Text.swift @@ -1,4 +1,48 @@ -/// A text view. +/// A view the displays text. +/// +/// ``Text`` truncates its content to fit within its proposed size. To wrap +/// without truncation, put the ``Text`` (or its enclosing view hierarchy) into +/// an ideal height context such as a ``ScrollView``. Alternatively, use +/// ``View/fixedSize(horizontal:vertical:)`` with `horizontal` set to false and +/// `vertical` set to true, but be aware that this may lead to unintuitive +/// minimum sizing behaviour when used within a window. Often when developers +/// use ``fixedSize`` on text, what they really need is a ``ScrollView``. +/// +/// To avoid wrapping and truncation entirely, use ``View/fixedSize()``. +/// +/// ## Technical notes +/// +/// The reason that ``Text`` truncates its content to fit its proposed size is +/// that SwiftCrossUI's layout system behaves rather unintuitively with views that +/// trade off width for height. The layout system used to support this behaviour +/// well, but when overhauling the layout system with performance in mind, we +/// discovered that it's not possible to handle minimum view sizing in the +/// intuitive way that we were, without a large performance cost or layout system +/// complexity cost. +/// +/// With the current system, windows determine the minimum size of their content +/// by proposing a size of 0x0. A text view that doesn't truncate its content +/// would take on a width of 0 and then lay out each character on a new line (as +/// that's what most UI frameworks do when text is given a small width). This leads +/// to the window thinking that its minimum height is `characterCount * lineHeight`, +/// even though when given a width larger than zero, the text view would be shorter +/// than this 'minimum height'. The underlying cause is the assumption that +/// 'minimum size' is a sensible notion for every view. A text view without +/// truncation doesn't have a 'minimum size'; are we minimizing width? minimizing +/// height? minimizing width + height? minimizing area? +/// +/// SwiftCrossUI's old layout system separated the concept of minimum size into +/// 'minimum width for current height', and 'minimum height for current width'. +/// This led to much more intuitive window sizing behaviour. If you had +/// non-truncating text inside a window, and resized the width of the window +/// such that the height of the text became taller than the window, then the window +/// would become taller, and if you resized the height of the window then you'd reach +/// the window's minimum height before the text could overflow the window horizontally. +/// Unfortunately this required a lot of book-keeping, and was deemed to be unfeasible +/// to do without significantly hurting performance due to all the layout assumptions +/// that we'd have to drop from our stack layout algorithm. +/// +/// The new layout system behaviour is in-line with SwiftUI's layout behaviour. public struct Text: Sendable { /// The string to be shown in the text view. var string: String @@ -19,60 +63,62 @@ extension Text: ElementaryView { return backend.createTextView() } - public func update( + public func computeLayout( _ widget: Backend.Widget, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - // TODO: Avoid this + backend: Backend + ) -> ViewLayoutResult { + // TODO: Avoid this. Move it to commit once we figure out a solution for Gtk. // Even in dry runs we must update the underlying text view widget // because GtkBackend currently relies on querying the widget for text // properties and such (via Pango). backend.updateTextView(widget, content: string, environment: environment) - let size = backend.size( + // UI frameworks often handle the zero proposal specially. We want to + // have standard text sizing behaviour so it's better for us to never + // propose zero in either dimension and then fix up the resulting size + // to match our expectations. + // + // Our desired behaviour is for a zero width proposal to result in at least + // one line's worth of height (for a non-empty string). Furthermore, if + // proposed more than one line's worth of height, then a zero width + // proposal should result in height equivalent to however many lines are + // required to put each character of the text on a new line (excluding + // whitespace). + // + // A zero height proposal should result in the text using at least one + // line of height (if non-empty). + var size = backend.size( of: string, whenDisplayedIn: widget, - proposedFrame: proposedSize, + proposedWidth: proposedSize.width.flatMap { + // For text, an infinite proposal is the same as an unspecified + // proposal, and this works nicer with most backends than converting + // .infinity to a large integer (which is the alternative). + $0 == .infinity ? nil : $0 + }.map(LayoutSystem.roundSize).map { max(1, $0) }, + proposedHeight: proposedSize.height.flatMap { + $0 == .infinity ? nil : $0 + }.map(LayoutSystem.roundSize).map { max(1, $0) }, environment: environment ) - if !dryRun { - backend.setSize(of: widget, to: size) - } - let idealSize = backend.size( - of: string, - whenDisplayedIn: widget, - proposedFrame: nil, - environment: environment - ) + // If the proposed width was 0 and the resuling width was 1, then set the + // resulting width to 0. See above for more detail. + if proposedSize.width == 0 && size.x == 1 { + size.x = 0 + } - let minimumWidth = backend.size( - of: string, - whenDisplayedIn: widget, - proposedFrame: SIMD2(1, proposedSize.y), - environment: environment - ).x - let minimumHeight = backend.size( - of: string, - whenDisplayedIn: widget, - proposedFrame: SIMD2(proposedSize.x, 1), - environment: environment - ).y + return ViewLayoutResult.leafView(size: ViewSize(size)) + } - return ViewUpdateResult.leafView( - size: ViewSize( - size: size, - idealSize: idealSize, - idealWidthForProposedHeight: idealSize.x, - idealHeightForProposedWidth: size.y, - minimumWidth: minimumWidth == 1 ? 0 : minimumWidth, - minimumHeight: minimumHeight, - maximumWidth: Double(idealSize.x), - maximumHeight: Double(size.y) - ) - ) + public func commit( + _ widget: Backend.Widget, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + backend.setSize(of: widget, to: layout.size.vector) } } diff --git a/Sources/SwiftCrossUI/Views/TextEditor.swift b/Sources/SwiftCrossUI/Views/TextEditor.swift index 7070d6075df..3e64f7b2f6f 100644 --- a/Sources/SwiftCrossUI/Views/TextEditor.swift +++ b/Sources/SwiftCrossUI/Views/TextEditor.swift @@ -10,51 +10,58 @@ public struct TextEditor: ElementaryView { backend.createTextEditor() } - func update( + func computeLayout( _ widget: Backend.Widget, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { + backend: Backend + ) -> ViewLayoutResult { // Avoid evaluating the binding multiple times let content = text - if !dryRun { - backend.updateTextEditor(widget, environment: environment) { newValue in - self.text = newValue - } - if content != backend.getContent(ofTextEditor: widget) { - backend.setContent(ofTextEditor: widget, to: content) - } + let size: ViewSize + if proposedSize == .unspecified { + size = ViewSize(10, 10) + } else if let width = proposedSize.width, proposedSize.height == nil { + // See ``Text``'s computeLayout for a more details on why we clamp + // the width to be positive. + let idealSize = backend.size( + of: content, + whenDisplayedIn: widget, + // For text, an infinite proposal is the same as an unspecified + // proposal, and this works nicer with most backends than converting + // .infinity to a large integer (which is the alternative). + proposedWidth: width == .infinity ? nil : max(1, LayoutSystem.roundSize(width)), + proposedHeight: nil, + environment: environment + ) + size = ViewSize( + max(width, Double(idealSize.x)), + Double(idealSize.y) + ) + } else { + size = proposedSize.replacingUnspecifiedDimensions(by: ViewSize(10, 10)) } - let idealHeight = backend.size( - of: content, - whenDisplayedIn: widget, - proposedFrame: SIMD2(proposedSize.x, 1), - environment: environment - ).y - let size = SIMD2( - proposedSize.x, - max(proposedSize.y, idealHeight) - ) - - if !dryRun { - backend.setSize(of: widget, to: size) + return ViewLayoutResult.leafView(size: size) + } + + func commit( + _ widget: Backend.Widget, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + // Avoid evaluating the binding multiple times + let content = self.text + + backend.updateTextEditor(widget, environment: environment) { newValue in + self.text = newValue + } + if text != backend.getContent(ofTextEditor: widget) { + backend.setContent(ofTextEditor: widget, to: content) } - return ViewUpdateResult.leafView( - size: ViewSize( - size: size, - idealSize: SIMD2(10, 10), - idealWidthForProposedHeight: 10, - idealHeightForProposedWidth: idealHeight, - minimumWidth: 0, - minimumHeight: idealHeight, - maximumWidth: nil, - maximumHeight: nil - ) - ) + backend.setSize(of: widget, to: layout.size.vector) } } diff --git a/Sources/SwiftCrossUI/Views/TextField.swift b/Sources/SwiftCrossUI/Views/TextField.swift index 8086f18fc4a..01117d417ab 100644 --- a/Sources/SwiftCrossUI/Views/TextField.swift +++ b/Sources/SwiftCrossUI/Views/TextField.swift @@ -1,5 +1,8 @@ /// A control that displays an editable text interface. public struct TextField: ElementaryView, View { + /// The ideal width of a TextField. + private static let idealWidth: Double = 100 + /// The label to show when the field is empty. private var placeholder: String /// The field's content. @@ -23,51 +26,45 @@ public struct TextField: ElementaryView, View { self.value = value ?? Binding(get: { dummy }, set: { dummy = $0 }) } - public func asWidget(backend: Backend) -> Backend.Widget { + func asWidget(backend: Backend) -> Backend.Widget { return backend.createTextField() } - public func update( + func computeLayout( _ widget: Backend.Widget, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - if !dryRun { - backend.updateTextField( - widget, - placeholder: placeholder, - environment: environment, - onChange: { newValue in - self.value?.wrappedValue = newValue - }, - onSubmit: environment.onSubmit ?? {} - ) - if let value = value?.wrappedValue, value != backend.getContent(ofTextField: widget) { - backend.setContent(ofTextField: widget, to: value) - } - } - + backend: Backend + ) -> ViewLayoutResult { let naturalHeight = backend.naturalSize(of: widget).y - let size = SIMD2( - proposedSize.x, - naturalHeight + let size = ViewSize( + proposedSize.width ?? Self.idealWidth, + Double(naturalHeight) ) - if !dryRun { - backend.setSize(of: widget, to: size) - } // TODO: Allow backends to set their own ideal text field width - return ViewUpdateResult.leafView( - size: ViewSize( - size: size, - idealSize: SIMD2(100, naturalHeight), - minimumWidth: 0, - minimumHeight: naturalHeight, - maximumWidth: nil, - maximumHeight: Double(naturalHeight) - ) + return ViewLayoutResult.leafView(size: size) + } + + func commit( + _ widget: Backend.Widget, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + backend.updateTextField( + widget, + placeholder: placeholder, + environment: environment, + onChange: { newValue in + self.value?.wrappedValue = newValue + }, + onSubmit: environment.onSubmit ?? {} ) + if let value = value?.wrappedValue, value != backend.getContent(ofTextField: widget) { + backend.setContent(ofTextField: widget, to: value) + } + + backend.setSize(of: widget, to: layout.size.vector) } } diff --git a/Sources/SwiftCrossUI/Views/ToggleButton.swift b/Sources/SwiftCrossUI/Views/ToggleButton.swift index b5488c4da8c..c6a1e129591 100644 --- a/Sources/SwiftCrossUI/Views/ToggleButton.swift +++ b/Sources/SwiftCrossUI/Views/ToggleButton.swift @@ -11,24 +11,32 @@ struct ToggleButton: ElementaryView, View { self.active = active } - public func asWidget(backend: Backend) -> Backend.Widget { + func asWidget(backend: Backend) -> Backend.Widget { return backend.createToggle() } - public func update( + func computeLayout( _ widget: Backend.Widget, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - // TODO: Implement toggle button sizing within SwiftCrossUI so that we can properly implement `dryRun`. - backend.setState(ofToggle: widget, to: active.wrappedValue) + backend: Backend + ) -> ViewLayoutResult { + // TODO: Implement toggle button sizing within SwiftCrossUI so that we + // can delay updating the underlying widget until `commit`. backend.updateToggle(widget, label: label, environment: environment) { newActiveState in active.wrappedValue = newActiveState } - return ViewUpdateResult.leafView( - size: ViewSize(fixedSize: backend.naturalSize(of: widget)) + return ViewLayoutResult.leafView( + size: ViewSize(backend.naturalSize(of: widget)) ) } + + func commit( + _ widget: Backend.Widget, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + backend.setState(ofToggle: widget, to: active.wrappedValue) + } } diff --git a/Sources/SwiftCrossUI/Views/ToggleSwitch.swift b/Sources/SwiftCrossUI/Views/ToggleSwitch.swift index d59d58b9080..ccee23d89b9 100644 --- a/Sources/SwiftCrossUI/Views/ToggleSwitch.swift +++ b/Sources/SwiftCrossUI/Views/ToggleSwitch.swift @@ -8,25 +8,29 @@ struct ToggleSwitch: ElementaryView, View { self.active = active } - public func asWidget(backend: Backend) -> Backend.Widget { + func asWidget(backend: Backend) -> Backend.Widget { return backend.createSwitch() } - public func update( + func computeLayout( _ widget: Backend.Widget, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - if !dryRun { - backend.updateSwitch(widget, environment: environment) { newActiveState in - active.wrappedValue = newActiveState - } - backend.setState(ofSwitch: widget, to: active.wrappedValue) + backend: Backend + ) -> ViewLayoutResult { + let size = ViewSize(backend.naturalSize(of: widget)) + return ViewLayoutResult.leafView(size: size) + } + + func commit( + _ widget: Backend.Widget, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + backend.updateSwitch(widget, environment: environment) { newActiveState in + active.wrappedValue = newActiveState } - return ViewUpdateResult.leafView( - size: ViewSize(fixedSize: backend.naturalSize(of: widget)) - ) + backend.setState(ofSwitch: widget, to: active.wrappedValue) } } diff --git a/Sources/SwiftCrossUI/Views/TupleView.swift b/Sources/SwiftCrossUI/Views/TupleView.swift index edd1d644fd7..7818e012003 100644 --- a/Sources/SwiftCrossUI/Views/TupleView.swift +++ b/Sources/SwiftCrossUI/Views/TupleView.swift @@ -7,14 +7,16 @@ private func layoutableChild( view: V ) -> LayoutSystem.LayoutableChild { LayoutSystem.LayoutableChild( - update: { proposedSize, environment, dryRun in - node.update( + computeLayout: { proposedSize, environment in + node.computeLayout( with: view, proposedSize: proposedSize, - environment: environment, - dryRun: dryRun + environment: environment ) }, + commit: { + node.commit() + }, tag: "\(type(of: view))" ) } @@ -34,22 +36,38 @@ extension TupleView { } @MainActor - func update( + func computeLayout( _ widget: Backend.Widget, children: Children, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { + backend: Backend + ) -> ViewLayoutResult { let group = Group(content: self) - return group.update( + return group.computeLayout( widget, children: children, proposedSize: proposedSize, environment: environment, - backend: backend, - dryRun: dryRun + backend: backend + ) + } + + @MainActor + func commit( + _ widget: Backend.Widget, + children: Children, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + let group = Group(content: self) + group.commit( + widget, + children: children, + layout: layout, + environment: environment, + backend: backend ) } } diff --git a/Sources/SwiftCrossUI/Views/TupleView.swift.gyb b/Sources/SwiftCrossUI/Views/TupleView.swift.gyb index 38396af12d9..68bf66cec54 100644 --- a/Sources/SwiftCrossUI/Views/TupleView.swift.gyb +++ b/Sources/SwiftCrossUI/Views/TupleView.swift.gyb @@ -10,14 +10,16 @@ private func layoutableChild( view: V ) -> LayoutSystem.LayoutableChild { LayoutSystem.LayoutableChild( - update: { proposedSize, environment, dryRun in - node.update( + computeLayout: { proposedSize, environment in + node.computeLayout( with: view, proposedSize: proposedSize, - environment: environment, - dryRun: dryRun + environment: environment ) }, + commit: { + node.commit() + }, tag: "\(type(of: view))" ) } @@ -37,22 +39,38 @@ extension TupleView { } @MainActor - func update( + func computeLayout( _ widget: Backend.Widget, children: Children, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { + backend: Backend + ) -> ViewLayoutResult { let group = Group(content: self) - return group.update( + return group.computeLayout( widget, children: children, proposedSize: proposedSize, environment: environment, - backend: backend, - dryRun: dryRun + backend: backend + ) + } + + @MainActor + func commit( + _ widget: Backend.Widget, + children: Children, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + let group = Group(content: self) + group.commit( + widget, + children: children, + layout: layout, + environment: environment, + backend: backend ) } } diff --git a/Sources/SwiftCrossUI/Views/TupleViewChildren.swift b/Sources/SwiftCrossUI/Views/TupleViewChildren.swift index acf995547a6..1b7dc209c1c 100644 --- a/Sources/SwiftCrossUI/Views/TupleViewChildren.swift +++ b/Sources/SwiftCrossUI/Views/TupleViewChildren.swift @@ -1,6 +1,17 @@ // This file was generated using gyb. Do not edit it directly. Edit // TupleViewChildren.swift.gyb instead. +struct StackLayoutCache { + var lastFlexibilityOrdering: [Int]? + var lastHiddenChildren: [Bool] = [] + var redistributeSpaceOnCommit = false +} + +protocol TupleViewChildren: ViewGraphNodeChildren { + @MainActor + var stackLayoutCache: StackLayoutCache { get nonmutating set } +} + /// A helper function to shorten node initialisations to a single line. This /// helps compress the generated code a bit and minimise the number of additions /// and deletions caused by updating the generator. @@ -21,7 +32,7 @@ private func node( /// A fixed-length strongly-typed collection of 1 child nodes. A counterpart to /// ``TupleView1``. -public struct TupleViewChildren1: ViewGraphNodeChildren { +public class TupleViewChildren1: TupleViewChildren { public var widgets: [AnyWidget] { return [child0.widget] } @@ -32,6 +43,8 @@ public struct TupleViewChildren1: ViewGraphNodeChildren { ] } + var stackLayoutCache = StackLayoutCache() + /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. public var child0: AnyViewGraphNode @@ -52,7 +65,7 @@ public struct TupleViewChildren1: ViewGraphNodeChildren { /// A fixed-length strongly-typed collection of 2 child nodes. A counterpart to /// ``TupleView2``. -public struct TupleViewChildren2: ViewGraphNodeChildren { +public class TupleViewChildren2: TupleViewChildren { public var widgets: [AnyWidget] { return [child0.widget, child1.widget] } @@ -64,6 +77,8 @@ public struct TupleViewChildren2: ViewGraphNodeChild ] } + var stackLayoutCache = StackLayoutCache() + /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. public var child0: AnyViewGraphNode /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. @@ -87,7 +102,7 @@ public struct TupleViewChildren2: ViewGraphNodeChild /// A fixed-length strongly-typed collection of 3 child nodes. A counterpart to /// ``TupleView3``. -public struct TupleViewChildren3: ViewGraphNodeChildren { +public class TupleViewChildren3: TupleViewChildren { public var widgets: [AnyWidget] { return [child0.widget, child1.widget, child2.widget] } @@ -100,6 +115,8 @@ public struct TupleViewChildren3: View ] } + var stackLayoutCache = StackLayoutCache() + /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. public var child0: AnyViewGraphNode /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. @@ -127,8 +144,8 @@ public struct TupleViewChildren3: View /// A fixed-length strongly-typed collection of 4 child nodes. A counterpart to /// ``TupleView4``. -public struct TupleViewChildren4: - ViewGraphNodeChildren +public class TupleViewChildren4: + TupleViewChildren { public var widgets: [AnyWidget] { return [child0.widget, child1.widget, child2.widget, child3.widget] @@ -143,6 +160,8 @@ public struct TupleViewChildren4 /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. @@ -173,9 +192,9 @@ public struct TupleViewChildren4: ViewGraphNodeChildren { +>: TupleViewChildren { public var widgets: [AnyWidget] { return [child0.widget, child1.widget, child2.widget, child3.widget, child4.widget] } @@ -190,6 +209,8 @@ public struct TupleViewChildren5< ] } + var stackLayoutCache = StackLayoutCache() + /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. public var child0: AnyViewGraphNode /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. @@ -224,9 +245,9 @@ public struct TupleViewChildren5< /// A fixed-length strongly-typed collection of 6 child nodes. A counterpart to /// ``TupleView6``. -public struct TupleViewChildren6< +public class TupleViewChildren6< Child0: View, Child1: View, Child2: View, Child3: View, Child4: View, Child5: View ->: ViewGraphNodeChildren { +>: TupleViewChildren { public var widgets: [AnyWidget] { return [ child0.widget, child1.widget, child2.widget, child3.widget, child4.widget, @@ -245,6 +266,8 @@ public struct TupleViewChildren6< ] } + var stackLayoutCache = StackLayoutCache() + /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. public var child0: AnyViewGraphNode /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. @@ -283,9 +306,9 @@ public struct TupleViewChildren6< /// A fixed-length strongly-typed collection of 7 child nodes. A counterpart to /// ``TupleView7``. -public struct TupleViewChildren7< +public class TupleViewChildren7< Child0: View, Child1: View, Child2: View, Child3: View, Child4: View, Child5: View, Child6: View ->: ViewGraphNodeChildren { +>: TupleViewChildren { public var widgets: [AnyWidget] { return [ child0.widget, child1.widget, child2.widget, child3.widget, child4.widget, @@ -305,6 +328,8 @@ public struct TupleViewChildren7< ] } + var stackLayoutCache = StackLayoutCache() + /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. public var child0: AnyViewGraphNode /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. @@ -347,10 +372,10 @@ public struct TupleViewChildren7< /// A fixed-length strongly-typed collection of 8 child nodes. A counterpart to /// ``TupleView8``. -public struct TupleViewChildren8< +public class TupleViewChildren8< Child0: View, Child1: View, Child2: View, Child3: View, Child4: View, Child5: View, Child6: View, Child7: View ->: ViewGraphNodeChildren { +>: TupleViewChildren { public var widgets: [AnyWidget] { return [ child0.widget, child1.widget, child2.widget, child3.widget, child4.widget, @@ -371,6 +396,8 @@ public struct TupleViewChildren8< ] } + var stackLayoutCache = StackLayoutCache() + /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. public var child0: AnyViewGraphNode /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. @@ -416,10 +443,10 @@ public struct TupleViewChildren8< /// A fixed-length strongly-typed collection of 9 child nodes. A counterpart to /// ``TupleView9``. -public struct TupleViewChildren9< +public class TupleViewChildren9< Child0: View, Child1: View, Child2: View, Child3: View, Child4: View, Child5: View, Child6: View, Child7: View, Child8: View ->: ViewGraphNodeChildren { +>: TupleViewChildren { public var widgets: [AnyWidget] { return [ child0.widget, child1.widget, child2.widget, child3.widget, child4.widget, @@ -441,6 +468,8 @@ public struct TupleViewChildren9< ] } + var stackLayoutCache = StackLayoutCache() + /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. public var child0: AnyViewGraphNode /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. @@ -490,10 +519,10 @@ public struct TupleViewChildren9< /// A fixed-length strongly-typed collection of 10 child nodes. A counterpart to /// ``TupleView10``. -public struct TupleViewChildren10< +public class TupleViewChildren10< Child0: View, Child1: View, Child2: View, Child3: View, Child4: View, Child5: View, Child6: View, Child7: View, Child8: View, Child9: View ->: ViewGraphNodeChildren { +>: TupleViewChildren { public var widgets: [AnyWidget] { return [ child0.widget, child1.widget, child2.widget, child3.widget, child4.widget, @@ -516,6 +545,8 @@ public struct TupleViewChildren10< ] } + var stackLayoutCache = StackLayoutCache() + /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. public var child0: AnyViewGraphNode /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. @@ -568,10 +599,10 @@ public struct TupleViewChildren10< /// A fixed-length strongly-typed collection of 11 child nodes. A counterpart to /// ``TupleView11``. -public struct TupleViewChildren11< +public class TupleViewChildren11< Child0: View, Child1: View, Child2: View, Child3: View, Child4: View, Child5: View, Child6: View, Child7: View, Child8: View, Child9: View, Child10: View ->: ViewGraphNodeChildren { +>: TupleViewChildren { public var widgets: [AnyWidget] { return [ child0.widget, child1.widget, child2.widget, child3.widget, child4.widget, @@ -596,6 +627,8 @@ public struct TupleViewChildren11< ] } + var stackLayoutCache = StackLayoutCache() + /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. public var child0: AnyViewGraphNode /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. @@ -653,10 +686,10 @@ public struct TupleViewChildren11< /// A fixed-length strongly-typed collection of 12 child nodes. A counterpart to /// ``TupleView12``. -public struct TupleViewChildren12< +public class TupleViewChildren12< Child0: View, Child1: View, Child2: View, Child3: View, Child4: View, Child5: View, Child6: View, Child7: View, Child8: View, Child9: View, Child10: View, Child11: View ->: ViewGraphNodeChildren { +>: TupleViewChildren { public var widgets: [AnyWidget] { return [ child0.widget, child1.widget, child2.widget, child3.widget, child4.widget, @@ -682,6 +715,8 @@ public struct TupleViewChildren12< ] } + var stackLayoutCache = StackLayoutCache() + /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. public var child0: AnyViewGraphNode /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. @@ -743,11 +778,11 @@ public struct TupleViewChildren12< /// A fixed-length strongly-typed collection of 13 child nodes. A counterpart to /// ``TupleView13``. -public struct TupleViewChildren13< +public class TupleViewChildren13< Child0: View, Child1: View, Child2: View, Child3: View, Child4: View, Child5: View, Child6: View, Child7: View, Child8: View, Child9: View, Child10: View, Child11: View, Child12: View ->: ViewGraphNodeChildren { +>: TupleViewChildren { public var widgets: [AnyWidget] { return [ child0.widget, child1.widget, child2.widget, child3.widget, child4.widget, @@ -774,6 +809,8 @@ public struct TupleViewChildren13< ] } + var stackLayoutCache = StackLayoutCache() + /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. public var child0: AnyViewGraphNode /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. @@ -839,11 +876,11 @@ public struct TupleViewChildren13< /// A fixed-length strongly-typed collection of 14 child nodes. A counterpart to /// ``TupleView14``. -public struct TupleViewChildren14< +public class TupleViewChildren14< Child0: View, Child1: View, Child2: View, Child3: View, Child4: View, Child5: View, Child6: View, Child7: View, Child8: View, Child9: View, Child10: View, Child11: View, Child12: View, Child13: View ->: ViewGraphNodeChildren { +>: TupleViewChildren { public var widgets: [AnyWidget] { return [ child0.widget, child1.widget, child2.widget, child3.widget, child4.widget, @@ -871,6 +908,8 @@ public struct TupleViewChildren14< ] } + var stackLayoutCache = StackLayoutCache() + /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. public var child0: AnyViewGraphNode /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. @@ -940,11 +979,11 @@ public struct TupleViewChildren14< /// A fixed-length strongly-typed collection of 15 child nodes. A counterpart to /// ``TupleView15``. -public struct TupleViewChildren15< +public class TupleViewChildren15< Child0: View, Child1: View, Child2: View, Child3: View, Child4: View, Child5: View, Child6: View, Child7: View, Child8: View, Child9: View, Child10: View, Child11: View, Child12: View, Child13: View, Child14: View ->: ViewGraphNodeChildren { +>: TupleViewChildren { public var widgets: [AnyWidget] { return [ child0.widget, child1.widget, child2.widget, child3.widget, child4.widget, @@ -973,6 +1012,8 @@ public struct TupleViewChildren15< ] } + var stackLayoutCache = StackLayoutCache() + /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. public var child0: AnyViewGraphNode /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. @@ -1047,11 +1088,11 @@ public struct TupleViewChildren15< /// A fixed-length strongly-typed collection of 16 child nodes. A counterpart to /// ``TupleView16``. -public struct TupleViewChildren16< +public class TupleViewChildren16< Child0: View, Child1: View, Child2: View, Child3: View, Child4: View, Child5: View, Child6: View, Child7: View, Child8: View, Child9: View, Child10: View, Child11: View, Child12: View, Child13: View, Child14: View, Child15: View ->: ViewGraphNodeChildren { +>: TupleViewChildren { public var widgets: [AnyWidget] { return [ child0.widget, child1.widget, child2.widget, child3.widget, child4.widget, @@ -1082,6 +1123,8 @@ public struct TupleViewChildren16< ] } + var stackLayoutCache = StackLayoutCache() + /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. public var child0: AnyViewGraphNode /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. @@ -1160,11 +1203,11 @@ public struct TupleViewChildren16< /// A fixed-length strongly-typed collection of 17 child nodes. A counterpart to /// ``TupleView17``. -public struct TupleViewChildren17< +public class TupleViewChildren17< Child0: View, Child1: View, Child2: View, Child3: View, Child4: View, Child5: View, Child6: View, Child7: View, Child8: View, Child9: View, Child10: View, Child11: View, Child12: View, Child13: View, Child14: View, Child15: View, Child16: View ->: ViewGraphNodeChildren { +>: TupleViewChildren { public var widgets: [AnyWidget] { return [ child0.widget, child1.widget, child2.widget, child3.widget, child4.widget, @@ -1196,6 +1239,8 @@ public struct TupleViewChildren17< ] } + var stackLayoutCache = StackLayoutCache() + /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. public var child0: AnyViewGraphNode /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. @@ -1278,11 +1323,11 @@ public struct TupleViewChildren17< /// A fixed-length strongly-typed collection of 18 child nodes. A counterpart to /// ``TupleView18``. -public struct TupleViewChildren18< +public class TupleViewChildren18< Child0: View, Child1: View, Child2: View, Child3: View, Child4: View, Child5: View, Child6: View, Child7: View, Child8: View, Child9: View, Child10: View, Child11: View, Child12: View, Child13: View, Child14: View, Child15: View, Child16: View, Child17: View ->: ViewGraphNodeChildren { +>: TupleViewChildren { public var widgets: [AnyWidget] { return [ child0.widget, child1.widget, child2.widget, child3.widget, child4.widget, @@ -1315,6 +1360,8 @@ public struct TupleViewChildren18< ] } + var stackLayoutCache = StackLayoutCache() + /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. public var child0: AnyViewGraphNode /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. @@ -1401,12 +1448,12 @@ public struct TupleViewChildren18< /// A fixed-length strongly-typed collection of 19 child nodes. A counterpart to /// ``TupleView19``. -public struct TupleViewChildren19< +public class TupleViewChildren19< Child0: View, Child1: View, Child2: View, Child3: View, Child4: View, Child5: View, Child6: View, Child7: View, Child8: View, Child9: View, Child10: View, Child11: View, Child12: View, Child13: View, Child14: View, Child15: View, Child16: View, Child17: View, Child18: View ->: ViewGraphNodeChildren { +>: TupleViewChildren { public var widgets: [AnyWidget] { return [ child0.widget, child1.widget, child2.widget, child3.widget, child4.widget, @@ -1440,6 +1487,8 @@ public struct TupleViewChildren19< ] } + var stackLayoutCache = StackLayoutCache() + /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. public var child0: AnyViewGraphNode /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. @@ -1531,12 +1580,12 @@ public struct TupleViewChildren19< /// A fixed-length strongly-typed collection of 20 child nodes. A counterpart to /// ``TupleView20``. -public struct TupleViewChildren20< +public class TupleViewChildren20< Child0: View, Child1: View, Child2: View, Child3: View, Child4: View, Child5: View, Child6: View, Child7: View, Child8: View, Child9: View, Child10: View, Child11: View, Child12: View, Child13: View, Child14: View, Child15: View, Child16: View, Child17: View, Child18: View, Child19: View ->: ViewGraphNodeChildren { +>: TupleViewChildren { public var widgets: [AnyWidget] { return [ child0.widget, child1.widget, child2.widget, child3.widget, child4.widget, @@ -1571,6 +1620,8 @@ public struct TupleViewChildren20< ] } + var stackLayoutCache = StackLayoutCache() + /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. public var child0: AnyViewGraphNode /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. diff --git a/Sources/SwiftCrossUI/Views/TupleViewChildren.swift.gyb b/Sources/SwiftCrossUI/Views/TupleViewChildren.swift.gyb index db3e4309f0b..b494c5c2667 100644 --- a/Sources/SwiftCrossUI/Views/TupleViewChildren.swift.gyb +++ b/Sources/SwiftCrossUI/Views/TupleViewChildren.swift.gyb @@ -4,6 +4,17 @@ maximum_child_count = 20 }% +struct StackLayoutCache { + var lastFlexibilityOrdering: [Int]? + var lastHiddenChildren: [Bool] = [] + var redistributeSpaceOnCommit = false +} + +protocol TupleViewChildren: ViewGraphNodeChildren { + @MainActor + var stackLayoutCache: StackLayoutCache { get nonmutating set } +} + /// A helper function to shorten node initialisations to a single line. This /// helps compress the generated code a bit and minimise the number of additions /// and deletions caused by updating the generator. @@ -34,7 +45,7 @@ variadic_type_parameters = ", ".join(children) /// A fixed-length strongly-typed collection of ${i + 1} child nodes. A counterpart to /// ``TupleView${i + 1}``. -public struct TupleViewChildren${i + 1}<${struct_type_parameters}>: ViewGraphNodeChildren { +public class TupleViewChildren${i + 1}<${struct_type_parameters}>: TupleViewChildren { public var widgets: [AnyWidget] { return [${", ".join("%s.widget" % child.lower() for child in children)}] } @@ -47,6 +58,8 @@ public struct TupleViewChildren${i + 1}<${struct_type_parameters}>: ViewGraphNod ] } + var stackLayoutCache = StackLayoutCache() + % for child in children: /// ``AnyViewGraphNode`` is used instead of ``ViewGraphNode`` because otherwise the backend leaks into views. public var ${child.lower()}: AnyViewGraphNode<${child}> diff --git a/Sources/SwiftCrossUI/Views/TypeSafeView.swift b/Sources/SwiftCrossUI/Views/TypeSafeView.swift index ef99bd4b194..fb218a76c1c 100644 --- a/Sources/SwiftCrossUI/Views/TypeSafeView.swift +++ b/Sources/SwiftCrossUI/Views/TypeSafeView.swift @@ -22,14 +22,21 @@ protocol TypeSafeView: View { backend: Backend ) -> Backend.Widget - func update( + func computeLayout( _ widget: Backend.Widget, children: Children, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult + backend: Backend + ) -> ViewLayoutResult + + func commit( + _ widget: Backend.Widget, + children: Children, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) } extension TypeSafeView { @@ -69,21 +76,35 @@ extension TypeSafeView { return asWidget(children as! Children, backend: backend) } - public func update( + public func computeLayout( _ widget: Backend.Widget, children: any ViewGraphNodeChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - update( + backend: Backend + ) -> ViewLayoutResult { + computeLayout( widget, children: children as! Children, proposedSize: proposedSize, environment: environment, - backend: backend, - dryRun: dryRun + backend: backend + ) + } + + public func commit( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + commit( + widget, + children: children as! Children, + layout: layout, + environment: environment, + backend: backend ) } } diff --git a/Sources/SwiftCrossUI/Views/VStack.swift b/Sources/SwiftCrossUI/Views/VStack.swift index b3b9973fd64..f3917a2a4be 100644 --- a/Sources/SwiftCrossUI/Views/VStack.swift +++ b/Sources/SwiftCrossUI/Views/VStack.swift @@ -39,25 +39,56 @@ public struct VStack: View { return vStack } - public func update( + public func computeLayout( _ widget: Backend.Widget, children: any ViewGraphNodeChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - return LayoutSystem.updateStackLayout( + backend: Backend + ) -> ViewLayoutResult { + if !(children is TupleViewChildren) { + // TODO: Make layout caching a ViewGraphNode feature so that we can handle + // these edge cases without a second thought. Would also make introducing + // a port of SwiftUI's Layout protocol much easier. + print("warning: VStack will not function correctly non-TupleView Content") + } + var cache = (children as? TupleViewChildren)?.stackLayoutCache ?? StackLayoutCache() + let result = LayoutSystem.computeStackLayout( container: widget, children: layoutableChildren(backend: backend, children: children), + cache: &cache, proposedSize: proposedSize, environment: environment .with(\.layoutOrientation, .vertical) .with(\.layoutAlignment, alignment.asStackAlignment) .with(\.layoutSpacing, spacing), - backend: backend, - dryRun: dryRun + backend: backend + ) + (children as? TupleViewChildren)?.stackLayoutCache = cache + return result + } + + public func commit( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + var cache = (children as? TupleViewChildren)?.stackLayoutCache ?? StackLayoutCache() + LayoutSystem.commitStackLayout( + container: widget, + children: layoutableChildren(backend: backend, children: children), + cache: &cache, + layout: layout, + environment: + environment + .with(\.layoutOrientation, .vertical) + .with(\.layoutAlignment, alignment.asStackAlignment) + .with(\.layoutSpacing, spacing), + backend: backend ) + (children as? TupleViewChildren)?.stackLayoutCache = cache } } diff --git a/Sources/SwiftCrossUI/Views/View.swift b/Sources/SwiftCrossUI/Views/View.swift index abeca203fe7..562f025cfb4 100644 --- a/Sources/SwiftCrossUI/Views/View.swift +++ b/Sources/SwiftCrossUI/Views/View.swift @@ -42,24 +42,27 @@ public protocol View { backend: Backend ) -> Backend.Widget - /// Updates the view's widget after a state change occurs (although the - /// change isn't guaranteed to have affected this particular view). - /// `proposedSize` is the size suggested by the parent container, but child - /// views always get the final call on their own size. - /// - /// Always called once immediately after creating the view's widget with. - /// This helps reduce code duplication between `asWidget` and `update`. - /// - Parameter dryRun: If `true`, avoids updating the UI and only computes - /// sizing. - /// - Returns: The view's new size. - func update( + /// Computes this view's layout after a state change or a change in + /// available space. `proposedSize` is the size suggested by the parent + /// container, but child views always get the final call on their own size. + /// - Returns: The view's layout size. + func computeLayout( _ widget: Backend.Widget, children: any ViewGraphNodeChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult + backend: Backend + ) -> ViewLayoutResult + + /// Commits the last computed layout to the underlying widget hierarchy. + /// `layout` is guaranteed to be the last value returned by ``computeLayout``. + func commit( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) } extension View { @@ -119,42 +122,71 @@ extension View { return vStack.asWidget(children, backend: backend) } - public func update( + public func computeLayout( _ widget: Backend.Widget, children: any ViewGraphNodeChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - defaultUpdate( + backend: Backend + ) -> ViewLayoutResult { + defaultComputeLayout( widget, children: children, proposedSize: proposedSize, environment: environment, - backend: backend, - dryRun: dryRun + backend: backend ) } - /// The default `View.update` implementation. Haters may see this as a + /// The default `View.computeLayout` implementation. Haters may see this as a /// composition lover re-implementing inheritance; I see it as innovation. - public func defaultUpdate( + public func defaultComputeLayout( _ widget: Backend.Widget, children: any ViewGraphNodeChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { + backend: Backend + ) -> ViewLayoutResult { let vStack = VStack(content: body) - return vStack.update( + return vStack.computeLayout( widget, children: children, proposedSize: proposedSize, environment: environment, - backend: backend, - dryRun: dryRun + backend: backend + ) + } + + public func commit( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + defaultCommit( + widget, + children: children, + layout: layout, + environment: environment, + backend: backend + ) + } + + public func defaultCommit( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + let vStack = VStack(content: body) + return vStack.commit( + widget, + children: children, + layout: layout, + environment: environment, + backend: backend ) } } diff --git a/Sources/SwiftCrossUI/Views/WebView.swift b/Sources/SwiftCrossUI/Views/WebView.swift index 0b9e1dae4e6..fab062c431c 100644 --- a/Sources/SwiftCrossUI/Views/WebView.swift +++ b/Sources/SwiftCrossUI/Views/WebView.swift @@ -2,6 +2,9 @@ import Foundation @available(tvOS, unavailable) public struct WebView: ElementaryView { + /// The ideal size of a WebView. + private static let idealSize = ViewSize(10, 10) + @State var currentURL: URL? @Binding var url: URL @@ -13,35 +16,30 @@ public struct WebView: ElementaryView { backend.createWebView() } - func update( + func computeLayout( _ widget: Backend.Widget, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - if !dryRun { - if url != currentURL { - backend.navigateWebView(widget, to: url) - currentURL = url - } - backend.updateWebView(widget, environment: environment) { destination in - currentURL = destination - url = destination - } - backend.setSize(of: widget, to: proposedSize) - } + backend: Backend + ) -> ViewLayoutResult { + let size = proposedSize.replacingUnspecifiedDimensions(by: Self.idealSize) + return ViewLayoutResult.leafView(size: size) + } - return ViewUpdateResult( - size: ViewSize( - size: proposedSize, - idealSize: SIMD2(10, 10), - minimumWidth: 0, - minimumHeight: 0, - maximumWidth: nil, - maximumHeight: nil - ), - childResults: [] - ) + func commit( + _ widget: Backend.Widget, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + if url != currentURL { + backend.navigateWebView(widget, to: url) + currentURL = url + } + backend.updateWebView(widget, environment: environment) { destination in + currentURL = destination + url = destination + } + backend.setSize(of: widget, to: layout.size.vector) } } diff --git a/Sources/SwiftCrossUI/Views/ZStack.swift b/Sources/SwiftCrossUI/Views/ZStack.swift index 5cd5657c519..87884604bc9 100644 --- a/Sources/SwiftCrossUI/Views/ZStack.swift +++ b/Sources/SwiftCrossUI/Views/ZStack.swift @@ -28,51 +28,50 @@ public struct ZStack: View { return zStack } - public func update( + public func computeLayout( _ widget: Backend.Widget, children: any ViewGraphNodeChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend: Backend, - dryRun: Bool - ) -> ViewUpdateResult { - var childResults: [ViewUpdateResult] = [] - for child in layoutableChildren(backend: backend, children: children) { - let childResult = child.update( - proposedSize: proposedSize, - environment: environment, - dryRun: dryRun - ) - childResults.append(childResult) - } + backend: Backend + ) -> ViewLayoutResult { + let childResults = layoutableChildren(backend: backend, children: children) + .map { child in + child.computeLayout( + proposedSize: proposedSize, + environment: environment + ) + } - let childSizes = childResults.map(\.size) let size = ViewSize( - size: SIMD2( - childSizes.map(\.size.x).max() ?? 0, - childSizes.map(\.size.y).max() ?? 0 - ), - idealSize: SIMD2( - childSizes.map(\.idealSize.x).max() ?? 0, - childSizes.map(\.idealSize.y).max() ?? 0 - ), - minimumWidth: childSizes.map(\.minimumWidth).max() ?? 0, - minimumHeight: childSizes.map(\.minimumHeight).max() ?? 0, - maximumWidth: childSizes.map(\.maximumWidth).max() ?? 0, - maximumHeight: childSizes.map(\.maximumHeight).max() ?? 0 + childResults.map(\.size.width).max() ?? 0, + childResults.map(\.size.height).max() ?? 0 ) - if !dryRun { - for (i, childSize) in childSizes.enumerated() { - let position = alignment.position( - ofChild: childSize.size, - in: size.size - ) - backend.setPosition(ofChildAt: i, in: widget, to: position) + return ViewLayoutResult(size: size, childResults: childResults) + } + + public func commit( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + let size = layout.size + let children = layoutableChildren(backend: backend, children: children) + .map { child in + child.commit() } - backend.setSize(of: widget, to: size.size) + + for (i, child) in children.enumerated() { + let position = alignment.position( + ofChild: child.size.vector, + in: size.vector + ) + backend.setPosition(ofChildAt: i, in: widget, to: position) } - return ViewUpdateResult(size: size, childResults: childResults) + backend.setSize(of: widget, to: size.vector) } } diff --git a/Sources/SwiftCrossUI/_App.swift b/Sources/SwiftCrossUI/_App.swift index 4d0a0a36247..4bd5a9e3827 100644 --- a/Sources/SwiftCrossUI/_App.swift +++ b/Sources/SwiftCrossUI/_App.swift @@ -15,6 +15,8 @@ class _App { var cancellables: [Cancellable] /// The root level environment. var environment: EnvironmentValues + /// The dynamic property updater for ``app``. + var dynamicPropertyUpdater: DynamicPropertyUpdater /// Wraps a user's app implementation. init(_ app: AppRoot) { @@ -22,16 +24,14 @@ class _App { self.app = app self.environment = EnvironmentValues(backend: backend) self.cancellables = [] + + dynamicPropertyUpdater = DynamicPropertyUpdater(for: app) } func forceRefresh() { - updateDynamicProperties( - of: self.app, - previousValue: nil, - environment: self.environment - ) + dynamicPropertyUpdater.update(app, with: environment, previousValue: nil) - self.sceneGraphRoot?.update( + sceneGraphRoot?.update( self.app.body, backend: self.backend, environment: environment @@ -46,10 +46,10 @@ class _App { defaultEnvironment: baseEnvironment ) - updateDynamicProperties( - of: self.app, - previousValue: nil, - environment: self.environment + self.dynamicPropertyUpdater.update( + self.app, + with: self.environment, + previousValue: nil ) let mirror = Mirror(reflecting: self.app) @@ -74,10 +74,12 @@ class _App { [weak self] in guard let self = self else { return } - updateDynamicProperties( - of: self.app, - previousValue: nil, - environment: self.environment + // TODO: Do we have to do this on state changes? Can probably get + // away with only doing it when the root environment changes. + self.dynamicPropertyUpdater.update( + self.app, + with: self.environment, + previousValue: nil ) let body = self.app.body diff --git a/Sources/UIKitBackend/UIKitBackend+Passive.swift b/Sources/UIKitBackend/UIKitBackend+Passive.swift index 9a5512da7ba..67f9b2474d5 100644 --- a/Sources/UIKitBackend/UIKitBackend+Passive.swift +++ b/Sources/UIKitBackend/UIKitBackend+Passive.swift @@ -36,9 +36,7 @@ extension UIKitBackend { } public func createTextView() -> Widget { - let widget = WrapperWidget() - widget.child.numberOfLines = 0 - return widget + WrapperWidget() } public func updateTextView( @@ -46,7 +44,7 @@ extension UIKitBackend { content: String, environment: EnvironmentValues ) { - let wrapper = textView as! WrapperWidget + let wrapper = textView as! WrapperWidget wrapper.child.overrideUserInterfaceStyle = environment.colorScheme.userInterfaceStyle wrapper.child.attributedText = UIKitBackend.attributedString( text: content, @@ -58,19 +56,17 @@ extension UIKitBackend { public func size( of text: String, whenDisplayedIn widget: Widget, - proposedFrame: SIMD2?, + proposedWidth: Int?, + proposedHeight: Int?, environment: EnvironmentValues ) -> SIMD2 { let attributedString = UIKitBackend.attributedString(text: text, environment: environment) - let boundingSize = - if let proposedFrame { - CGSize(width: CGFloat(proposedFrame.x), height: .greatestFiniteMagnitude) - } else { - CGSize(width: .greatestFiniteMagnitude, height: environment.resolvedFont.lineHeight) - } let size = attributedString.boundingRect( - with: boundingSize, - options: proposedFrame == nil ? [] : [.usesLineFragmentOrigin], + with: CGSize( + width: proposedWidth.map(Double.init) ?? .greatestFiniteMagnitude, + height: proposedHeight.map(Double.init) ?? .greatestFiniteMagnitude + ), + options: [.usesLineFragmentOrigin, .truncatesLastVisibleLine], context: nil ) return SIMD2( @@ -106,26 +102,43 @@ extension UIKitBackend { } } -// Inspired by https://medium.com/kinandcartacreated/making-uilabel-accessible-5f3d5c342df4 -// Thank you to Sam Dods for the base idea -final class OptionallySelectableLabel: UILabel { +final class CustomTextView: UIView { var isSelectable: Bool = false - override init(frame: CGRect) { - super.init(frame: frame) - setupTextSelection() + var attributedText: NSAttributedString { + get { + textStorage + } + set { + textStorage.setAttributedString(newValue) + setNeedsDisplay() + } } - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - setupTextSelection() + var text: String { + attributedText.string } - override var canBecomeFirstResponder: Bool { - isSelectable - } + var layoutManager: NSLayoutManager + var textStorage: NSTextStorage + var textContainer: NSTextContainer + + override init(frame: CGRect) { + layoutManager = NSLayoutManager() + + textStorage = NSTextStorage(attributedString: NSAttributedString(string: "")) + textStorage.addLayoutManager(layoutManager) + + textContainer = NSTextContainer(size: frame.size) + textContainer.lineBreakMode = .byTruncatingTail + layoutManager.addTextContainer(textContainer) + + super.init(frame: frame) + + isOpaque = false - private func setupTextSelection() { + // Inspired by https://medium.com/kinandcartacreated/making-uilabel-accessible-5f3d5c342df4 + // Thank you to Sam Dods for the base idea #if !os(tvOS) let longPress = UILongPressGestureRecognizer( target: self, action: #selector(didLongPress)) @@ -134,12 +147,33 @@ final class OptionallySelectableLabel: UILabel { #endif } + required init?(coder aDecoder: NSCoder) { + fatalError("init?(coder:) not implemented") + } + + override var canBecomeFirstResponder: Bool { + isSelectable + } + + override func layoutSubviews() { + super.layoutSubviews() + if textContainer.size != bounds.size { + textContainer.size = bounds.size + setNeedsDisplay() + } + } + + override func draw(_ rect: CGRect) { + let range = layoutManager.glyphRange(for: textContainer) + layoutManager.drawBackground(forGlyphRange: range, at: bounds.origin) + layoutManager.drawGlyphs(forGlyphRange: range, at: bounds.origin) + } + @objc private func didLongPress(_ gesture: UILongPressGestureRecognizer) { #if !os(tvOS) guard isSelectable, gesture.state == .began, - let text = self.attributedText?.string, !text.isEmpty else { return @@ -149,7 +183,7 @@ final class OptionallySelectableLabel: UILabel { let menu = UIMenuController.shared if !menu.isMenuVisible { - menu.showMenu(from: self, rect: textRect()) + menu.showMenu(from: self, rect: bounds) } #endif } @@ -158,12 +192,6 @@ final class OptionallySelectableLabel: UILabel { return action == #selector(copy(_:)) } - private func textRect() -> CGRect { - let inset: CGFloat = -4 - return textRect(forBounds: bounds, limitedToNumberOfLines: numberOfLines) - .insetBy(dx: inset, dy: inset) - } - private func cancelSelection() { #if !os(tvOS) let menu = UIMenuController.shared diff --git a/Sources/UIKitBackend/UIKitBackend+Sheet.swift b/Sources/UIKitBackend/UIKitBackend+Sheet.swift index ef085c3f537..9370394b7a4 100644 --- a/Sources/UIKitBackend/UIKitBackend+Sheet.swift +++ b/Sources/UIKitBackend/UIKitBackend+Sheet.swift @@ -16,7 +16,7 @@ extension UIKitBackend { // Fetch the child controller before adding the child to the view // hierarchy. Otherwise, if the child doesn't have its own controller, we'd // get back a reference to the sheet controller and attempt to add it as a - // child of itself. + // child of itself. if let childController = content.controller { sheet.addChild(childController) } @@ -52,7 +52,8 @@ extension UIKitBackend { sheet.onDismiss = onDismiss setPresentationDetents(of: sheet, to: detents) setPresentationCornerRadius(of: sheet, to: cornerRadius) - setPresentationDragIndicatorVisibility(of: sheet, to: dragIndicatorVisibility, detents: detents) + setPresentationDragIndicatorVisibility( + of: sheet, to: dragIndicatorVisibility, detents: detents) let defaultColor: UIColor? #if targetEnvironment(macCatalyst) diff --git a/Sources/UIKitBackend/UIViewControllerRepresentable.swift b/Sources/UIKitBackend/UIViewControllerRepresentable.swift index cfd73e84373..ad436307275 100644 --- a/Sources/UIKitBackend/UIViewControllerRepresentable.swift +++ b/Sources/UIKitBackend/UIViewControllerRepresentable.swift @@ -47,7 +47,8 @@ where Content == Never { /// contents. Pass `nil` for the maximum width/height if the view has no maximum size /// (and therefore may occupy the entire screen). func determineViewSize( - for proposal: SIMD2, uiViewController: UIViewControllerType, + for proposal: ProposedViewSize, + uiViewController: UIViewControllerType, context: UIViewControllerRepresentableContext ) -> ViewSize @@ -69,7 +70,8 @@ extension UIViewControllerRepresentable { } public func determineViewSize( - for proposal: SIMD2, uiViewController: UIViewControllerType, + for proposal: ProposedViewSize, + uiViewController: UIViewControllerType, context: UIViewControllerRepresentableContext ) -> ViewSize { defaultViewSize(proposal: proposal, view: uiViewController.view) @@ -111,27 +113,26 @@ where Self: UIViewControllerRepresentable { public func update( _ widget: Backend.Widget, children _: any ViewGraphNodeChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, backend _: Backend, dryRun: Bool - ) -> ViewUpdateResult { + ) -> ViewLayoutResult { let representingWidget = widget as! ControllerRepresentingWidget representingWidget.update(with: environment) - let size = - representingWidget.representable.determineViewSize( - for: proposedSize, - uiViewController: representingWidget.subcontroller, - context: representingWidget.context! - ) + let size = representingWidget.representable.determineViewSize( + for: proposedSize, + uiViewController: representingWidget.subcontroller, + context: representingWidget.context! + ) if !dryRun { - representingWidget.width = size.size.x - representingWidget.height = size.size.y + representingWidget.width = LayoutSystem.roundSize(size.width) + representingWidget.height = LayoutSystem.roundSize(size.height) } - return ViewUpdateResult.leafView(size: size) + return ViewLayoutResult.leafView(size: size) } } diff --git a/Sources/UIKitBackend/UIViewRepresentable.swift b/Sources/UIKitBackend/UIViewRepresentable.swift index 5b20929cd92..efc996b5ee5 100644 --- a/Sources/UIKitBackend/UIViewRepresentable.swift +++ b/Sources/UIKitBackend/UIViewRepresentable.swift @@ -64,29 +64,16 @@ where Content == Never { } // Used both here and by UIViewControllerRepresentable -func defaultViewSize(proposal: SIMD2, view: UIView) -> ViewSize { - let intrinsicSize = view.intrinsicContentSize - - let sizeThatFits = view.systemLayoutSizeFitting( - CGSize(width: CGFloat(proposal.x), height: CGFloat(proposal.y))) +func defaultViewSize(proposal: ProposedViewSize, view: UIView) -> ViewSize { + let size = CGSize(width: proposal.width ?? 10, height: proposal.height ?? 10) + let sizeThatFits = view.systemLayoutSizeFitting(size) let minimumSize = view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) let maximumSize = view.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize) return ViewSize( - size: SIMD2( - Int(sizeThatFits.width.rounded(.up)), - Int(sizeThatFits.height.rounded(.up))), - // The 10 here is a somewhat arbitrary constant value so that it's always the same. - // See also `Color` and `Picker`, which use the same constant. - idealSize: SIMD2( - intrinsicSize.width < 0.0 ? 10 : Int(intrinsicSize.width.rounded(.awayFromZero)), - intrinsicSize.height < 0.0 ? 10 : Int(intrinsicSize.height.rounded(.awayFromZero)) - ), - minimumWidth: Int(minimumSize.width.rounded(.towardZero)), - minimumHeight: Int(minimumSize.height.rounded(.towardZero)), - maximumWidth: maximumSize.width, - maximumHeight: maximumSize.height + sizeThatFits.width, + sizeThatFits.height ) } @@ -96,7 +83,8 @@ extension UIViewRepresentable { } public func determineViewSize( - for proposal: SIMD2, uiView: UIViewType, + for proposal: ProposedViewSize, + uiView: UIViewType, context _: UIViewRepresentableContext ) -> ViewSize { defaultViewSize(proposal: proposal, view: uiView) @@ -135,14 +123,13 @@ where Self: UIViewRepresentable { } } - public func update( + public func computeLayout( _ widget: Backend.Widget, children _: any ViewGraphNodeChildren, - proposedSize: SIMD2, + proposedSize: ProposedViewSize, environment: EnvironmentValues, - backend _: Backend, - dryRun: Bool - ) -> ViewUpdateResult { + backend _: Backend + ) -> ViewLayoutResult { let representingWidget = widget as! ViewRepresentingWidget representingWidget.update(with: environment) @@ -153,12 +140,19 @@ where Self: UIViewRepresentable { context: representingWidget.context! ) - if !dryRun { - representingWidget.width = size.size.x - representingWidget.height = size.size.y - } + return ViewLayoutResult.leafView(size: size) + } - return ViewUpdateResult.leafView(size: size) + public func commit( + _ widget: Backend.Widget, + children: any ViewGraphNodeChildren, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + let representingWidget = widget as! ViewRepresentingWidget + representingWidget.width = layout.size.vector.x + representingWidget.height = layout.size.vector.y } } diff --git a/Sources/WinUIBackend/WinUIBackend.swift b/Sources/WinUIBackend/WinUIBackend.swift index 948012ee16b..506db77b924 100644 --- a/Sources/WinUIBackend/WinUIBackend.swift +++ b/Sources/WinUIBackend/WinUIBackend.swift @@ -67,6 +67,8 @@ public final class WinUIBackend: AppBackend { private var windows: [Window] = [] + private var measurementTextBlock: TextBlock! + public init() { internalState = InternalState() } @@ -115,6 +117,8 @@ public final class WinUIBackend: AppBackend { // let pv: __ABI_Windows_Foundation.IPropertyValue = try! iinspectable.QueryInterface() // let value = try! pv.GetDoubleImpl() + self.measurementTextBlock = self.createTextView() as! TextBlock + callback() } WinUIApplication.main() @@ -547,19 +551,44 @@ public final class WinUIBackend: AppBackend { public func size( of text: String, whenDisplayedIn widget: Widget, - proposedFrame: SIMD2?, + proposedWidth: Int?, + proposedHeight: Int?, environment: EnvironmentValues ) -> SIMD2 { - let block = createTextView() - updateTextView(block, content: text, environment: environment) + // Update the text view's environment and measure its desired line height + updateTextView(measurementTextBlock, content: "a", environment: environment) + let lineHeight = Self.measure( + measurementTextBlock, + proposedWidth: nil, + proposedHeight: nil + ).y + + // Measure the text's size + measurementTextBlock.text = text + var size = Self.measure( + measurementTextBlock, + proposedWidth: proposedWidth, + proposedHeight: proposedHeight + ) + // Make sure the text doesn't get shorter than a single line of text even if + // it's empty. + size.y = max(size.y, lineHeight) + return size + } + + private static func measure( + _ textBlock: TextBlock, + proposedWidth: Int?, + proposedHeight: Int? + ) -> SIMD2 { let allocation = WindowsFoundation.Size( - width: (proposedFrame?.x).map(Float.init(_:)) ?? .infinity, - height: .infinity + width: proposedWidth.map(Float.init) ?? .infinity, + height: proposedHeight.map(Float.init) ?? .infinity ) - try! block.measure(allocation) + try! textBlock.measure(allocation) - let computedSize = block.desiredSize + let computedSize = textBlock.desiredSize return SIMD2( Int(computedSize.width), Int(computedSize.height) @@ -569,6 +598,7 @@ public final class WinUIBackend: AppBackend { public func createTextView() -> Widget { let textBlock = TextBlock() textBlock.textWrapping = .wrap + textBlock.textTrimming = .characterEllipsis return textBlock } diff --git a/Tests/SwiftCrossUITests/SwiftCrossUITests.swift b/Tests/SwiftCrossUITests/SwiftCrossUITests.swift index e88a827aee3..7b02705f83d 100644 --- a/Tests/SwiftCrossUITests/SwiftCrossUITests.swift +++ b/Tests/SwiftCrossUITests/SwiftCrossUITests.swift @@ -1,6 +1,7 @@ import Testing import Foundation +import DummyBackend @testable import SwiftCrossUI #if canImport(AppKitBackend) @@ -70,6 +71,47 @@ struct SwiftCrossUITests { return original == decoded } + @Test("Ensure that ScrollView satisfies basic invariants") + @MainActor + func testBasicScrollView() async throws { + let backend = DummyBackend() + let window = backend.createWindow(withDefaultSize: nil) + let environment = EnvironmentValues(backend: backend) + .with(\.window, window) + + let blueRectangleHeight = Double(100) + let view = ScrollView { + Color.blue.frame(height: Int(blueRectangleHeight)) + } + + let viewGraph = ViewGraph( + for: view, + backend: backend, + environment: environment + ) + let proposedSize = ViewSize(80, 80) + let result = viewGraph.computeLayout( + proposedSize: ProposedViewSize(proposedSize), + environment: environment + ) + viewGraph.commit() + + #expect(result.size == ViewSize(80, 80)) + + let rootWidget: DummyBackend.Widget = viewGraph.rootNode.widget.into() + let scrollView = try #require(rootWidget.firstWidget(ofType: DummyBackend.ScrollContainer.self)) + + #expect(scrollView.hasVerticalScrollBar) + #expect(!scrollView.hasHorizontalScrollBar) + + #expect(scrollView.size == proposedSize.vector) + let expectedSize = ViewSize( + proposedSize.width - Double(backend.scrollBarWidth), + blueRectangleHeight + ) + #expect(scrollView.child.size == expectedSize.vector) + } + #if canImport(AppKitBackend) @Test("Ensure that a basic view has the expected dimensions under AppKitBackend") @MainActor @@ -91,17 +133,13 @@ struct SwiftCrossUITests { ) backend.setChild(ofWindow: window, to: viewGraph.rootNode.widget.into()) - let result = viewGraph.update( - proposedSize: SIMD2(200, 200), - environment: environment, - dryRun: false + let result = viewGraph.computeLayout( + proposedSize: ProposedViewSize(200, 200), + environment: environment ) - let view: AppKitBackend.Widget = viewGraph.rootNode.widget.into() - backend.setSize(of: view, to: result.size.size) - backend.setSize(ofWindow: window, to: result.size.size) #expect( - result.size == ViewSize(fixedSize: SIMD2(92, 96)), + result.size == ViewSize(92, 96), "View update result mismatch" )