From 1f0092a99df82fe16769b29a8be011136361360a Mon Sep 17 00:00:00 2001 From: stackotter Date: Thu, 9 Jan 2025 15:31:43 +1000 Subject: [PATCH] Introduce @State property wrapper based state management (breaking) Replaces the old protocol requirement based state handling. I've tried my best to make migration as painless as possible with descriptive warnings and fix-its when SwiftCrossUI detects outdated state properties. --- .../Sources/ControlsExample/ControlsApp.swift | 25 +++--- .../Sources/CounterExample/CounterApp.swift | 14 ++-- .../GreetingGeneratorApp.swift | 22 +++-- .../NavigationExample/NavigationApp.swift | 22 +++-- .../Sources/NotesExample/ContentView.swift | 47 +++++------ .../RandomNumberGeneratorApp.swift | 32 ++++---- Examples/Sources/SplitExample/SplitApp.swift | 38 ++++----- .../SpreadsheetExample/SpreadsheetApp.swift | 22 ++--- .../StressTestExample/StressTestApp.swift | 30 +++---- .../WindowingExample/WindowingApp.swift | 18 ++--- Examples/notes.json | 1 - README.md | 12 +-- .../Gtk/Generated/AccessibleRelation.swift | 4 +- Sources/Gtk/Generated/Actionable.swift | 2 +- Sources/Gtk/Generated/Align.swift | 2 +- Sources/Gtk/Generated/Button.swift | 7 ++ Sources/Gtk/Generated/ButtonsType.swift | 2 +- Sources/Gtk/Generated/Editable.swift | 8 ++ Sources/Gtk/Generated/Entry.swift | 8 ++ Sources/Gtk/Generated/Label.swift | 38 ++++++++- Sources/Gtk/Generated/Popover.swift | 13 +++ Sources/Gtk/Generated/Range.swift | 6 ++ Sources/Gtk/Generated/Scale.swift | 9 +++ Sources/Gtk/Generated/Switch.swift | 5 ++ Sources/Gtk/Generated/TextView.swift | 45 ++++++++++- Sources/SwiftCrossUI/App.swift | 11 --- .../Modifiers/AlertModifier.swift | 1 - .../Modifiers/OnChangeModifier.swift | 17 ++-- .../SwiftCrossUI/Modifiers/TaskModifier.swift | 9 ++- Sources/SwiftCrossUI/State/AppState.swift | 2 - .../SwiftCrossUI/State/EmptyAppState.swift | 2 - Sources/SwiftCrossUI/State/EmptyState.swift | 5 -- .../SwiftCrossUI/State/EmptyViewState.swift | 2 - Sources/SwiftCrossUI/State/Observable.swift | 3 - Sources/SwiftCrossUI/State/Publisher.swift | 5 -- Sources/SwiftCrossUI/State/State.swift | 81 +++++++++++++++++++ Sources/SwiftCrossUI/State/ViewState.swift | 2 - .../SwiftCrossUI.docc/SwiftCrossUI.md | 8 +- .../ViewGraph/ErasedViewGraphNode.swift | 7 -- .../SwiftCrossUI/ViewGraph/ViewGraph.swift | 36 --------- .../ViewGraph/ViewGraphNode.swift | 78 +++++++++++------- .../ViewGraph/ViewGraphSnapshotter.swift | 75 ++++++++--------- .../SwiftCrossUI/Views/ElementaryView.swift | 5 -- Sources/SwiftCrossUI/Views/Table.swift | 1 - Sources/SwiftCrossUI/Views/TupleView.swift | 10 --- .../SwiftCrossUI/Views/TupleView.swift.gyb | 1 - Sources/SwiftCrossUI/Views/View.swift | 19 ----- Sources/SwiftCrossUI/_App.swift | 29 ++++++- 48 files changed, 462 insertions(+), 379 deletions(-) delete mode 100755 Examples/notes.json delete mode 100644 Sources/SwiftCrossUI/State/AppState.swift delete mode 100644 Sources/SwiftCrossUI/State/EmptyAppState.swift delete mode 100644 Sources/SwiftCrossUI/State/EmptyState.swift delete mode 100644 Sources/SwiftCrossUI/State/EmptyViewState.swift create mode 100644 Sources/SwiftCrossUI/State/State.swift delete mode 100644 Sources/SwiftCrossUI/State/ViewState.swift diff --git a/Examples/Sources/ControlsExample/ControlsApp.swift b/Examples/Sources/ControlsExample/ControlsApp.swift index 8720f9de..e0938d94 100644 --- a/Examples/Sources/ControlsExample/ControlsApp.swift +++ b/Examples/Sources/ControlsExample/ControlsApp.swift @@ -6,16 +6,15 @@ import SwiftCrossUI #endif class ControlsState: Observable { - @Observed var count = 0 - @Observed var exampleButtonState = false - @Observed var exampleSwitchState = false - @Observed var sliderValue = 5.0 } @main @HotReloadable struct ControlsApp: App { - let state = ControlsState() + @State var count = 0 + @State var exampleButtonState = false + @State var exampleSwitchState = false + @State var sliderValue = 5.0 var body: some Scene { WindowGroup("ControlsApp") { @@ -24,32 +23,32 @@ struct ControlsApp: App { VStack { Text("Button") Button("Click me!") { - state.count += 1 + count += 1 } - Text("Count: \(state.count)") + Text("Count: \(count)") } .padding(.bottom, 20) VStack { Text("Toggle button") - Toggle("Toggle me!", active: state.$exampleButtonState) + Toggle("Toggle me!", active: $exampleButtonState) .toggleStyle(.button) - Text("Currently enabled: \(state.exampleButtonState)") + Text("Currently enabled: \(exampleButtonState)") } .padding(.bottom, 20) VStack { Text("Toggle switch") - Toggle("Toggle me:", active: state.$exampleSwitchState) + Toggle("Toggle me:", active: $exampleSwitchState) .toggleStyle(.switch) - Text("Currently enabled: \(state.exampleSwitchState)") + Text("Currently enabled: \(exampleSwitchState)") } VStack { Text("Slider") - Slider(state.$sliderValue, minimum: 0, maximum: 10) + Slider($sliderValue, minimum: 0, maximum: 10) .frame(maxWidth: 200) - Text("Value: \(String(format: "%.02f", state.sliderValue))") + Text("Value: \(String(format: "%.02f", sliderValue))") } } } diff --git a/Examples/Sources/CounterExample/CounterApp.swift b/Examples/Sources/CounterExample/CounterApp.swift index 2682fc18..264ff9e0 100644 --- a/Examples/Sources/CounterExample/CounterApp.swift +++ b/Examples/Sources/CounterExample/CounterApp.swift @@ -5,25 +5,21 @@ import SwiftCrossUI import SwiftBundlerRuntime #endif -class CounterState: Observable { - @Observed var count = 0 -} - @main @HotReloadable struct CounterApp: App { - let state = CounterState() + @State var count = 0 var body: some Scene { - WindowGroup("CounterExample: \(state.count)") { + WindowGroup("CounterExample: \(count)") { #hotReloadable { HStack(spacing: 20) { Button("-") { - state.count -= 1 + count -= 1 } - Text("Count: \(state.count)") + Text("Count: \(count)") Button("+") { - state.count += 1 + count += 1 } } .padding() diff --git a/Examples/Sources/GreetingGeneratorExample/GreetingGeneratorApp.swift b/Examples/Sources/GreetingGeneratorExample/GreetingGeneratorApp.swift index f7d7e469..463ce4d3 100644 --- a/Examples/Sources/GreetingGeneratorExample/GreetingGeneratorApp.swift +++ b/Examples/Sources/GreetingGeneratorExample/GreetingGeneratorApp.swift @@ -5,41 +5,37 @@ import SwiftCrossUI import SwiftBundlerRuntime #endif -class GreetingGeneratorState: Observable { - @Observed var name = "" - @Observed var greetings: [String] = [] -} - @main @HotReloadable struct GreetingGeneratorApp: App { - let state = GreetingGeneratorState() + @State var name = "" + @State var greetings: [String] = [] var body: some Scene { WindowGroup("Greeting Generator") { #hotReloadable { VStack { - TextField("Name", state.$name) + TextField("Name", $name) HStack { Button("Generate") { - state.greetings.append("Hello, \(state.name)!") + greetings.append("Hello, \(name)!") } Button("Reset") { - state.greetings = [] - state.name = "" + greetings = [] + name = "" } } - if let latest = state.greetings.last { + if let latest = greetings.last { Text(latest) .padding(.top, 5) - if state.greetings.count > 1 { + if greetings.count > 1 { Text("History:") .padding(.top, 20) ScrollView { - ForEach(state.greetings.reversed()[1...]) { greeting in + ForEach(greetings.reversed()[1...]) { greeting in Text(greeting) } } diff --git a/Examples/Sources/NavigationExample/NavigationApp.swift b/Examples/Sources/NavigationExample/NavigationApp.swift index f9b96aa5..d0a65273 100644 --- a/Examples/Sources/NavigationExample/NavigationApp.swift +++ b/Examples/Sources/NavigationExample/NavigationApp.swift @@ -21,24 +21,20 @@ enum HumanitiesSubject: Codable { case history } -class NavigationAppState: Observable { - @Observed var path = NavigationPath() -} - @main @HotReloadable struct NavigationApp: App { - let state = NavigationAppState() + @State var path = NavigationPath() var body: some Scene { WindowGroup("Navigation") { #hotReloadable { - NavigationStack(path: state.$path) { + NavigationStack(path: $path) { Text("Learn about subject areas") .padding(.bottom, 10) - NavigationLink("Science", value: SubjectArea.science, path: state.$path) - NavigationLink("Humanities", value: SubjectArea.humanities, path: state.$path) + NavigationLink("Science", value: SubjectArea.science, path: $path) + NavigationLink("Humanities", value: SubjectArea.humanities, path: $path) } .navigationDestination(for: SubjectArea.self) { area in switch area { @@ -47,17 +43,17 @@ struct NavigationApp: App { .padding(.bottom, 10) NavigationLink( - "Physics", value: ScienceSubject.physics, path: state.$path) + "Physics", value: ScienceSubject.physics, path: $path) NavigationLink( - "Chemistry", value: ScienceSubject.chemistry, path: state.$path) + "Chemistry", value: ScienceSubject.chemistry, path: $path) case .humanities: Text("Choose a humanities subject") .padding(.bottom, 10) NavigationLink( - "English", value: HumanitiesSubject.english, path: state.$path) + "English", value: HumanitiesSubject.english, path: $path) NavigationLink( - "History", value: HumanitiesSubject.history, path: state.$path) + "History", value: HumanitiesSubject.history, path: $path) } backButton @@ -91,7 +87,7 @@ struct NavigationApp: App { @ViewBuilder var backButton: some View { Button("Back") { - state.path.removeLast() + path.removeLast() } .padding(.top, 10) } diff --git a/Examples/Sources/NotesExample/ContentView.swift b/Examples/Sources/NotesExample/ContentView.swift index 2e81fddf..779b107b 100644 --- a/Examples/Sources/NotesExample/ContentView.swift +++ b/Examples/Sources/NotesExample/ContentView.swift @@ -7,9 +7,10 @@ struct Note: Codable, Equatable { var content: String } -class NotesState: Observable, Codable { - @Observed - var notes: [Note] = [ +struct ContentView: View { + let notesFile = URL(fileURLWithPath: "notes.json") + + @State var notes: [Note] = [ Note(title: "Hello, world!", content: "Welcome SwiftCrossNotes!"), Note( title: "Shopping list", @@ -21,25 +22,17 @@ class NotesState: Observable, Codable { ), ] - @Observed - var selectedNoteId: UUID? - - @Observed - var error: String? -} - -struct ContentView: View { - let notesFile = URL(fileURLWithPath: "notes.json") + @State var selectedNoteId: UUID? - var state = NotesState() + @State var error: String? var selectedNote: Binding? { - guard let id = state.selectedNoteId else { + guard let id = selectedNoteId else { return nil } guard - let index = state.notes.firstIndex(where: { note in + let index = notes.firstIndex(where: { note in note.id == id }) else { @@ -49,10 +42,10 @@ struct ContentView: View { // TODO: This is unsafe, index could change/not exist anymore return Binding( get: { - state.notes[index] + notes[index] }, set: { newValue in - state.notes[index] = newValue + notes[index] = newValue } ) } @@ -60,29 +53,29 @@ struct ContentView: View { var body: some View { NavigationSplitView { VStack { - ForEach(state.notes) { note in + ForEach(notes) { note in Button(note.title) { - state.selectedNoteId = note.id + selectedNoteId = note.id } } Spacer() - if let error = state.error { + if let error = error { Text(error) .foregroundColor(.red) } Button("New note") { let note = Note(title: "Untitled", content: "") - state.notes.append(note) - state.selectedNoteId = note.id + notes.append(note) + selectedNoteId = note.id } } - .onChange(of: state.notes) { + .onChange(of: notes) { do { - let data = try JSONEncoder().encode(state.notes) + let data = try JSONEncoder().encode(notes) try data.write(to: notesFile) } catch { print("Error: \(error)") - state.error = "Failed to save notes" + self.error = "Failed to save notes" } } .onAppear { @@ -92,10 +85,10 @@ struct ContentView: View { do { let data = try Data(contentsOf: notesFile) - state.notes = try JSONDecoder().decode([Note].self, from: data) + notes = try JSONDecoder().decode([Note].self, from: data) } catch { print("Error: \(error)") - state.error = "Failed to load notes" + self.error = "Failed to load notes" } } .padding(10) diff --git a/Examples/Sources/RandomNumberGeneratorExample/RandomNumberGeneratorApp.swift b/Examples/Sources/RandomNumberGeneratorExample/RandomNumberGeneratorApp.swift index 5651c8f0..2eb7458a 100644 --- a/Examples/Sources/RandomNumberGeneratorExample/RandomNumberGeneratorApp.swift +++ b/Examples/Sources/RandomNumberGeneratorExample/RandomNumberGeneratorApp.swift @@ -5,13 +5,6 @@ import SwiftCrossUI import SwiftBundlerRuntime #endif -class RandomNumberGeneratorState: Observable { - @Observed var minNum = 0 - @Observed var maxNum = 100 - @Observed var randomNumber = 0 - @Observed var colorOption: ColorOption? = ColorOption.red -} - enum ColorOption: String, CaseIterable { case red case green @@ -32,22 +25,25 @@ enum ColorOption: String, CaseIterable { @main @HotReloadable struct RandomNumberGeneratorApp: App { - let state = RandomNumberGeneratorState() + @State var minNum = 0 + @State var maxNum = 100 + @State var randomNumber = 0 + @State var colorOption: ColorOption? = ColorOption.red var body: some Scene { WindowGroup("Random Number Generator") { #hotReloadable { VStack { - Text("Random Number: \(state.randomNumber)") + Text("Random Number: \(randomNumber)") Button("Generate") { - state.randomNumber = Int.random(in: Int(state.minNum)...Int(state.maxNum)) + randomNumber = Int.random(in: Int(minNum)...Int(maxNum)) } Text("Minimum:") Slider( - state.$minNum.onChange { newValue in - if newValue > state.maxNum { - state.minNum = state.maxNum + $minNum.onChange { newValue in + if newValue > maxNum { + minNum = maxNum } }, minimum: 0, @@ -56,9 +52,9 @@ struct RandomNumberGeneratorApp: App { Text("Maximum:") Slider( - state.$maxNum.onChange { newValue in - if newValue < state.minNum { - state.maxNum = state.minNum + $maxNum.onChange { newValue in + if newValue < minNum { + maxNum = minNum } }, minimum: 0, @@ -67,11 +63,11 @@ struct RandomNumberGeneratorApp: App { HStack { Text("Choose a color:") - Picker(of: ColorOption.allCases, selection: state.$colorOption) + Picker(of: ColorOption.allCases, selection: $colorOption) } } .padding(10) - .foregroundColor(state.colorOption?.color ?? .red) + .foregroundColor(colorOption?.color ?? .red) } } .defaultSize(width: 500, height: 0) diff --git a/Examples/Sources/SplitExample/SplitApp.swift b/Examples/Sources/SplitExample/SplitApp.swift index 9f25b8e5..e41a22d2 100644 --- a/Examples/Sources/SplitExample/SplitApp.swift +++ b/Examples/Sources/SplitExample/SplitApp.swift @@ -26,17 +26,13 @@ enum Columns { case three } -class SplitAppState: Observable { - @Observed var selectedArea: SubjectArea? - @Observed var selectedDetail: Any? - @Observed var columns: Columns = .two -} - struct ContentView: View { - var state = SplitAppState() + @State var selectedArea: SubjectArea? + @State var selectedDetail: Any? + @State var columns: Columns = .two var body: some View { - switch state.columns { + switch columns { case .two: doubleColumn case .three: @@ -48,14 +44,14 @@ struct ContentView: View { var doubleColumn: some View { NavigationSplitView { VStack { - Button("Science") { state.selectedArea = .science } - Button("Humanities") { state.selectedArea = .humanities } + Button("Science") { selectedArea = .science } + Button("Humanities") { selectedArea = .humanities } Spacer() - Button("Switch to 3 column example") { state.columns = .three } + Button("Switch to 3 column example") { columns = .three } }.padding(10) } detail: { VStack { - switch state.selectedArea { + switch selectedArea { case .science: Text("Science") case .humanities: @@ -71,24 +67,24 @@ struct ContentView: View { var tripleColumn: some View { NavigationSplitView { VStack { - Button("Science") { state.selectedArea = .science } - Button("Humanities") { state.selectedArea = .humanities } + Button("Science") { selectedArea = .science } + Button("Humanities") { selectedArea = .humanities } Spacer() - Button("Switch to 2 column example") { state.columns = .two } + Button("Switch to 2 column example") { columns = .two } }.padding(10) } content: { VStack { - switch state.selectedArea { + switch selectedArea { case .science: Text("Choose a science subject") .padding(.bottom, 10) - Button("Physics") { state.selectedDetail = ScienceSubject.physics } - Button("Chemistry") { state.selectedDetail = ScienceSubject.chemistry } + Button("Physics") { selectedDetail = ScienceSubject.physics } + Button("Chemistry") { selectedDetail = ScienceSubject.chemistry } case .humanities: Text("Choose a humanities subject") .padding(.bottom, 10) - Button("English") { state.selectedDetail = HumanitiesSubject.english } - Button("History") { state.selectedDetail = HumanitiesSubject.history } + Button("English") { selectedDetail = HumanitiesSubject.english } + Button("History") { selectedDetail = HumanitiesSubject.history } case nil: Text("Select an area") } @@ -97,7 +93,7 @@ struct ContentView: View { .frame(minWidth: 190) } detail: { VStack { - switch state.selectedDetail { + switch selectedDetail { case let subject as ScienceSubject: switch subject { case .physics: diff --git a/Examples/Sources/SpreadsheetExample/SpreadsheetApp.swift b/Examples/Sources/SpreadsheetExample/SpreadsheetApp.swift index e4e50d4c..83e11348 100644 --- a/Examples/Sources/SpreadsheetExample/SpreadsheetApp.swift +++ b/Examples/Sources/SpreadsheetExample/SpreadsheetApp.swift @@ -14,9 +14,10 @@ struct Person { var occupation: String } -class SpreadsheetState: Observable { - @Observed - var data = [ +@main +@HotReloadable +struct SpreadsheetApp: App { + @State var data = [ Person( name: "Alice", age: 99, phone: "(+61)1234123412", email: "alice@example.com", occupation: "developer" @@ -27,28 +28,21 @@ class SpreadsheetState: Observable { ), ] - @Observed - var greeting: String? -} - -@main -@HotReloadable -struct SpreadsheetApp: App { - let state = SpreadsheetState() + @State var greeting: String? var body: some Scene { WindowGroup("Spreadsheet") { #hotReloadable { VStack(spacing: 0) { VStack { - if let greeting = state.greeting { + if let greeting = greeting { Text(greeting) } else { Text("Pending greeting...") } } .padding(10) - Table(state.data) { + Table(data) { TableColumn("Name", value: \Person.name) TableColumn("Age", value: \Person.age.description) TableColumn("Phone", value: \Person.phone) @@ -56,7 +50,7 @@ struct SpreadsheetApp: App { TableColumn("Occupation", value: \Person.occupation) TableColumn("Action") { (person: Person) in Button("Greet") { - state.greeting = "Hello, \(person.name)!" + greeting = "Hello, \(person.name)!" } } } diff --git a/Examples/Sources/StressTestExample/StressTestApp.swift b/Examples/Sources/StressTestExample/StressTestApp.swift index d37db682..a26657da 100644 --- a/Examples/Sources/StressTestExample/StressTestApp.swift +++ b/Examples/Sources/StressTestExample/StressTestApp.swift @@ -5,14 +5,10 @@ import SwiftCrossUI import SwiftBundlerRuntime #endif -class StressTestState: Observable { - @Observed - var tab = 0 - - @Observed - var values: [Int: [String]] = [:] - - let options: [String] = [ +@main +@HotReloadable +struct StressTestApp: App { + static let options: [String] = [ "red", "green", "blue", @@ -26,32 +22,30 @@ class StressTestState: Observable { "bar", "baz", ] -} -@main -@HotReloadable -struct StressTestApp: App { - let state = StressTestState() + @State var tab = 0 + + @State var values: [Int: [String]] = [:] var body: some Scene { WindowGroup("Stress Tester") { #hotReloadable { NavigationSplitView { VStack { - Button("List 1") { state.tab = 0 } - Button("List 2") { state.tab = 1 } + Button("List 1") { tab = 0 } + Button("List 2") { tab = 1 } }.padding(10) } detail: { VStack { Button("Generate") { var values: [String] = [] for _ in 0..<1000 { - values.append(state.options.randomElement()!) + values.append(Self.options.randomElement()!) } - state.values[state.tab] = values + self.values[tab] = values } - if let values = state.values[state.tab] { + if let values = values[tab] { ScrollView { ForEach(values) { value in Text(value) diff --git a/Examples/Sources/WindowingExample/WindowingApp.swift b/Examples/Sources/WindowingExample/WindowingApp.swift index bf1ceb3a..f0c9fd0e 100644 --- a/Examples/Sources/WindowingExample/WindowingApp.swift +++ b/Examples/Sources/WindowingExample/WindowingApp.swift @@ -6,26 +6,22 @@ import SwiftCrossUI import SwiftBundlerRuntime #endif -class WindowingAppState: Observable { - @Observed var title = "My window" - @Observed var resizable = false -} - @main @HotReloadable struct WindowingApp: App { - let state = WindowingAppState() + @State var title = "My window" + @State var resizable = false var body: some Scene { - WindowGroup(state.title) { + WindowGroup(title) { #hotReloadable { VStack { HStack { Text("Window title:") - TextField("My window", state.$title) + TextField("My window", $title) } - Button(state.resizable ? "Disable resizing" : "Enable resizing") { - state.resizable = !state.resizable + Button(resizable ? "Disable resizing" : "Enable resizing") { + resizable = !resizable } Image(Bundle.module.bundleURL.appendingPathComponent("Banner.png")) } @@ -33,7 +29,7 @@ struct WindowingApp: App { } } .defaultSize(width: 500, height: 500) - .windowResizability(state.resizable ? .contentMinSize : .contentSize) + .windowResizability(resizable ? .contentMinSize : .contentSize) WindowGroup("Secondary window") { #hotReloadable { diff --git a/Examples/notes.json b/Examples/notes.json deleted file mode 100755 index 80fbb076..00000000 --- a/Examples/notes.json +++ /dev/null @@ -1 +0,0 @@ -[{"content":"Welcome SwiftCrossNotes!","id":"D70F96BB-2153-4FC6-A79F-FD231B4F3523","title":"Hello, world!"},{"content":"- Carrots","id":"04864C37-38E3-4FC1-8BAF-C2BA41AAF8D9","title":"Shopping list"},{"title":"My note","id":"EAA92A96-E443-4374-AB35-41CD4BD99EBD","content":"I hope for world piss"}] \ No newline at end of file diff --git a/README.md b/README.md index 64031757..742ab256 100644 --- a/README.md +++ b/README.md @@ -46,27 +46,23 @@ import SwiftCrossUI // Import whichever backend you need import DefaultBackend -class CounterState: Observable { - @Observed var count = 0 -} - @main struct CounterApp: App { // Optionally, you can explicitly select which imported backend to use (if you only // import one backend then this is done automatically). // typealias Backend = DefaultBackend - var state = CounterState() + @State var count = 0 var body: some Scene { WindowGroup("CounterApp") { HStack { Button("-") { - state.count -= 1 + count -= 1 } - Text("Count: \(state.count)") + Text("Count: \(count)") Button("+") { - state.count += 1 + count += 1 } } .padding(10) diff --git a/Sources/Gtk/Generated/AccessibleRelation.swift b/Sources/Gtk/Generated/AccessibleRelation.swift index 9e5551ff..8e6cba4e 100644 --- a/Sources/Gtk/Generated/AccessibleRelation.swift +++ b/Sources/Gtk/Generated/AccessibleRelation.swift @@ -33,8 +33,8 @@ public enum AccessibleRelation: GValueRepresentableEnum { /// Identifies the element (or elements) that /// provide additional information related to the object. Value type: reference case details - /// Identifies the element that provides - /// an error message for an object. Value type: reference + /// Identifies the element (or elements) that + /// provide an error message for an object. Value type: reference case errorMessage /// Identifies the next element (or elements) /// in an alternate reading order of content which, at the user's discretion, diff --git a/Sources/Gtk/Generated/Actionable.swift b/Sources/Gtk/Generated/Actionable.swift index 2581308d..dce348c2 100644 --- a/Sources/Gtk/Generated/Actionable.swift +++ b/Sources/Gtk/Generated/Actionable.swift @@ -14,7 +14,7 @@ import CGtk /// are added with [method@Gtk.Widget.insert_action_group] will be consulted /// as well. public protocol Actionable: GObjectRepresentable { - + /// The name of the action with which this widget should be associated. var actionName: String? { get set } } diff --git a/Sources/Gtk/Generated/Align.swift b/Sources/Gtk/Generated/Align.swift index 358812df..1502f941 100644 --- a/Sources/Gtk/Generated/Align.swift +++ b/Sources/Gtk/Generated/Align.swift @@ -13,7 +13,7 @@ import CGtk /// are interpreted relative to text direction. /// /// Baseline support is optional for containers and widgets, and is only available -/// for vertical alignment. `GTK_ALIGN_BASELINE_CENTER and `GTK_ALIGN_BASELINE_FILL` +/// for vertical alignment. `GTK_ALIGN_BASELINE_CENTER` and `GTK_ALIGN_BASELINE_FILL` /// are treated similar to `GTK_ALIGN_CENTER` and `GTK_ALIGN_FILL`, except that it /// positions the widget to line up the baselines, where that is supported. public enum Align: GValueRepresentableEnum { diff --git a/Sources/Gtk/Generated/Button.swift b/Sources/Gtk/Generated/Button.swift index 6f4f7ea3..22458e8d 100644 --- a/Sources/Gtk/Generated/Button.swift +++ b/Sources/Gtk/Generated/Button.swift @@ -9,6 +9,12 @@ import CGtk /// almost any other standard `GtkWidget`. The most commonly used child is the /// `GtkLabel`. /// +/// # Shortcuts and Gestures +/// +/// The following signals have default keybindings: +/// +/// - [signal@Gtk.Button::activate] +/// /// # CSS nodes /// /// `GtkButton` has a single CSS node with name button. The node will get the @@ -194,6 +200,7 @@ public class Button: Widget, Actionable { /// to be used as mnemonic. @GObjectProperty(named: "use-underline") public var useUnderline: Bool + /// The name of the action with which this widget should be associated. @GObjectProperty(named: "action-name") public var actionName: String? /// Emitted to animate press then release. diff --git a/Sources/Gtk/Generated/ButtonsType.swift b/Sources/Gtk/Generated/ButtonsType.swift index a286c43a..ae70565d 100644 --- a/Sources/Gtk/Generated/ButtonsType.swift +++ b/Sources/Gtk/Generated/ButtonsType.swift @@ -7,7 +7,7 @@ import CGtk /// /// > Please note that %GTK_BUTTONS_OK, %GTK_BUTTONS_YES_NO /// > and %GTK_BUTTONS_OK_CANCEL are discouraged by the -/// > [GNOME Human Interface Guidelines](http://library.gnome.org/devel/hig-book/stable/). +/// > [GNOME Human Interface Guidelines](https://developer.gnome.org/hig/). public enum ButtonsType: GValueRepresentableEnum { public typealias GtkEnum = GtkButtonsType diff --git a/Sources/Gtk/Generated/Editable.swift b/Sources/Gtk/Generated/Editable.swift index 1b22165a..a88be9c7 100644 --- a/Sources/Gtk/Generated/Editable.swift +++ b/Sources/Gtk/Generated/Editable.swift @@ -129,6 +129,9 @@ import CGtk /// and [signal@Gtk.Editable::delete-text] signals, you will need to connect /// to them on the delegate obtained via [method@Gtk.Editable.get_delegate]. public protocol Editable: GObjectRepresentable { + /// The current position of the insertion cursor in chars. + var cursorPosition: Int { get set } + /// Whether the entry contents can be edited. var editable: Bool { get set } @@ -144,6 +147,11 @@ public protocol Editable: GObjectRepresentable { /// Number of characters to leave space for in the entry. var widthChars: Int { get set } + /// The horizontal alignment, from 0 (left) to 1 (right). + /// + /// Reversed for RTL layouts. + var xalign: Float { get set } + /// Emitted at the end of a single user-visible operation on the /// contents. /// diff --git a/Sources/Gtk/Generated/Entry.swift b/Sources/Gtk/Generated/Entry.swift index a0330677..56293a87 100644 --- a/Sources/Gtk/Generated/Entry.swift +++ b/Sources/Gtk/Generated/Entry.swift @@ -796,6 +796,9 @@ public class Entry: Widget, CellEditable, Editable { /// actual text (“password mode”). @GObjectProperty(named: "visibility") public var visibility: Bool + /// The current position of the insertion cursor in chars. + @GObjectProperty(named: "cursor-position") public var cursorPosition: Int + /// Whether the entry contents can be edited. @GObjectProperty(named: "editable") public var editable: Bool @@ -811,6 +814,11 @@ public class Entry: Widget, CellEditable, Editable { /// Number of characters to leave space for in the entry. @GObjectProperty(named: "width-chars") public var widthChars: Int + /// The horizontal alignment, from 0 (left) to 1 (right). + /// + /// Reversed for RTL layouts. + @GObjectProperty(named: "xalign") public var xalign: Float + /// Emitted when the entry is activated. /// /// The keybindings for this signal are all forms of the Enter key. diff --git a/Sources/Gtk/Generated/Label.swift b/Sources/Gtk/Generated/Label.swift index 456e67b5..3c005c0a 100644 --- a/Sources/Gtk/Generated/Label.swift +++ b/Sources/Gtk/Generated/Label.swift @@ -7,6 +7,40 @@ import CGtk /// /// ![An example GtkLabel](label.png) /// +/// ## Shortcuts and Gestures +/// +/// `GtkLabel` supports the following keyboard shortcuts, when the cursor is +/// visible: +/// +/// - Shift+F10 or Menu opens the context menu. +/// - Ctrl+A or Ctrl+/ +/// selects all. +/// - Ctrl+Shift+A or +/// Ctrl+\ unselects all. +/// +/// Additionally, the following signals have default keybindings: +/// +/// - [signal@Gtk.Label::activate-current-link] +/// - [signal@Gtk.Label::copy-clipboard] +/// - [signal@Gtk.Label::move-cursor] +/// +/// ## Actions +/// +/// `GtkLabel` defines a set of built-in actions: +/// +/// - `clipboard.copy` copies the text to the clipboard. +/// - `clipboard.cut` doesn't do anything, since text in labels can't be deleted. +/// - `clipboard.paste` doesn't do anything, since text in labels can't be +/// edited. +/// - `link.open` opens the link, when activated on a link inside the label. +/// - `link.copy` copies the link to the clipboard, when activated on a link +/// inside the label. +/// - `menu.popup` opens the context menu. +/// - `selection.delete` doesn't do anything, since text in labels can't be +/// deleted. +/// - `selection.select-all` selects all of the text, if the label allows +/// selection. +/// /// ## CSS nodes /// /// ``` @@ -592,8 +626,8 @@ public class Label: Widget { /// programmatically. /// /// The default bindings for this signal come in two variants, - /// the variant with the Shift modifier extends the selection, - /// the variant without the Shift modifier does not. + /// the variant with the Shift modifier extends the selection, + /// the variant without the Shift modifier does not. /// There are too many key combinations to list them all here. /// /// - , , , diff --git a/Sources/Gtk/Generated/Popover.swift b/Sources/Gtk/Generated/Popover.swift index 97cb89be..0276c91a 100644 --- a/Sources/Gtk/Generated/Popover.swift +++ b/Sources/Gtk/Generated/Popover.swift @@ -29,6 +29,17 @@ import CGtk ///
horizontal-buttonsCutapp.cutedit-cut-symbolicCopyapp.copyedit-copy-symbolicPasteapp.pasteedit-paste-symbolic
/// ``` /// +/// # Shortcuts and Gestures +/// +/// `GtkPopover` supports the following keyboard shortcuts: +/// +/// - Escape closes the popover. +/// - Alt makes the mnemonics visible. +/// +/// The following signals have default keybindings: +/// +/// - [signal@Gtk.Popover::activate-default] +/// /// # CSS nodes /// /// ``` @@ -198,6 +209,8 @@ public class Popover: Widget, Native, ShortcutManager { /// Emitted whend the user activates the default widget. /// /// This is a [keybinding signal](class.SignalAction.html). + /// + /// The default binding for this signal is Enter. public var activateDefault: ((Popover) -> Void)? /// Emitted when the popover is closed. diff --git a/Sources/Gtk/Generated/Range.swift b/Sources/Gtk/Generated/Range.swift index d6e2c063..f8aef0b1 100644 --- a/Sources/Gtk/Generated/Range.swift +++ b/Sources/Gtk/Generated/Range.swift @@ -9,6 +9,12 @@ import CGtk /// Apart from signals for monitoring the parameters of the adjustment, /// `GtkRange` provides properties and methods for setting a /// “fill level” on range widgets. See [method@Gtk.Range.set_fill_level]. +/// +/// # Shortcuts and Gestures +/// +/// The `GtkRange` slider is draggable. Holding the Shift key while +/// dragging, or initiating the drag with a long-press will enable the +/// fine-tuning mode. public class Range: Widget, Orientable { override func didMoveToParent() { diff --git a/Sources/Gtk/Generated/Scale.swift b/Sources/Gtk/Generated/Scale.swift index 8f919425..65ab7abf 100644 --- a/Sources/Gtk/Generated/Scale.swift +++ b/Sources/Gtk/Generated/Scale.swift @@ -24,6 +24,15 @@ import CGtk /// the mark. It can be translated with the usual ”translatable” and /// “context” attributes. /// +/// # Shortcuts and Gestures +/// +/// `GtkPopoverMenu` supports the following keyboard shortcuts: +/// +/// - Arrow keys, + and - will increment or decrement +/// by step, or by page when combined with Ctrl. +/// - PgUp and PgDn will increment or decrement by page. +/// - Home and End will set the minimum or maximum value. +/// /// # CSS nodes /// /// ``` diff --git a/Sources/Gtk/Generated/Switch.swift b/Sources/Gtk/Generated/Switch.swift index f9b46ce0..d6d469d9 100644 --- a/Sources/Gtk/Generated/Switch.swift +++ b/Sources/Gtk/Generated/Switch.swift @@ -17,6 +17,10 @@ import CGtk /// /// See [signal@Gtk.Switch::state-set] for details. /// +/// # Shortcuts and Gestures +/// +/// `GtkSwitch` supports pan and drag gestures to move the slider. +/// /// # CSS nodes /// /// ``` @@ -121,6 +125,7 @@ public class Switch: Widget, Actionable { /// See [signal@Gtk.Switch::state-set] for details. @GObjectProperty(named: "state") public var state: Bool + /// The name of the action with which this widget should be associated. @GObjectProperty(named: "action-name") public var actionName: String? /// Emitted to animate the switch. diff --git a/Sources/Gtk/Generated/TextView.swift b/Sources/Gtk/Generated/TextView.swift index f791e3a8..09ab92bb 100644 --- a/Sources/Gtk/Generated/TextView.swift +++ b/Sources/Gtk/Generated/TextView.swift @@ -2,12 +2,49 @@ import CGtk /// A widget that displays the contents of a [class@Gtk.TextBuffer]. /// -/// ![An example GtkTextview](multiline-text.png) +/// ![An example GtkTextView](multiline-text.png) /// /// You may wish to begin by reading the [conceptual overview](section-text-widget.html), /// which gives an overview of all the objects and data types related to the /// text widget and how they work together. /// +/// ## Shortcuts and Gestures +/// +/// `GtkTextView` supports the following keyboard shortcuts: +/// +/// - Shift+F10 or Menu opens the context menu. +/// - Ctrl+Z undoes the last modification. +/// - Ctrl+Y or Ctrl+Shift+Z +/// redoes the last undone modification. +/// +/// Additionally, the following signals have default keybindings: +/// +/// - [signal@Gtk.TextView::backspace] +/// - [signal@Gtk.TextView::copy-clipboard] +/// - [signal@Gtk.TextView::cut-clipboard] +/// - [signal@Gtk.TextView::delete-from-cursor] +/// - [signal@Gtk.TextView::insert-emoji] +/// - [signal@Gtk.TextView::move-cursor] +/// - [signal@Gtk.TextView::paste-clipboard] +/// - [signal@Gtk.TextView::select-all] +/// - [signal@Gtk.TextView::toggle-cursor-visible] +/// - [signal@Gtk.TextView::toggle-overwrite] +/// +/// ## Actions +/// +/// `GtkTextView` defines a set of built-in actions: +/// +/// - `clipboard.copy` copies the contents to the clipboard. +/// - `clipboard.cut` copies the contents to the clipboard and deletes it from +/// the widget. +/// - `clipboard.paste` inserts the contents of the clipboard into the widget. +/// - `menu.popup` opens the context menu. +/// - `misc.insert-emoji` opens the Emoji chooser. +/// - `selection.delete` deletes the current selection. +/// - `selection.select-all` selects all of the widgets content. +/// - `text.redo` redoes the last change to the contents. +/// - `text.undo` undoes the last change to the contents. +/// /// ## CSS nodes /// /// ``` @@ -515,6 +552,7 @@ public class TextView: Widget, Scrollable { /// If the insertion cursor is shown. @GObjectProperty(named: "cursor-visible") public var cursorVisible: Bool + /// Whether the text can be modified by the user. @GObjectProperty(named: "editable") public var editable: Bool /// Amount to indent the paragraph, in pixels. @@ -530,6 +568,7 @@ public class TextView: Widget, Scrollable { /// methods to adjust their behaviour. @GObjectProperty(named: "input-purpose") public var inputPurpose: InputPurpose + /// Left, right, or center justification. @GObjectProperty(named: "justification") public var justification: Justification /// The default left margin for text in the text view. @@ -550,10 +589,13 @@ public class TextView: Widget, Scrollable { /// Whether entered text overwrites existing contents. @GObjectProperty(named: "overwrite") public var overwrite: Bool + /// Pixels of blank space above paragraphs. @GObjectProperty(named: "pixels-above-lines") public var pixelsAboveLines: Int + /// Pixels of blank space below paragraphs. @GObjectProperty(named: "pixels-below-lines") public var pixelsBelowLines: Int + /// Pixels of blank space between wrapped lines in a paragraph. @GObjectProperty(named: "pixels-inside-wrap") public var pixelsInsideWrap: Int /// The default right margin for text in the text view. @@ -574,6 +616,7 @@ public class TextView: Widget, Scrollable { /// Don't confuse this property with [property@Gtk.Widget:margin-top]. @GObjectProperty(named: "top-margin") public var topMargin: Int + /// Whether to wrap lines never, at word boundaries, or at character boundaries. @GObjectProperty(named: "wrap-mode") public var wrapMode: WrapMode /// Determines when horizontal scrolling should start. diff --git a/Sources/SwiftCrossUI/App.swift b/Sources/SwiftCrossUI/App.swift index 3d4ee17a..c02e9e9d 100644 --- a/Sources/SwiftCrossUI/App.swift +++ b/Sources/SwiftCrossUI/App.swift @@ -6,8 +6,6 @@ public protocol App { associatedtype Backend: AppBackend /// The type of scene representing the content of the app. associatedtype Body: Scene - /// The app's observed state. - associatedtype State: Observable /// Metadata loaded at app start up. By default SwiftCrossUI attempts /// to load metadata inserted by Swift Bundler if present. Used by backends' @@ -17,9 +15,6 @@ public protocol App { /// The application's backend. var backend: Backend { get } - /// The application's state. - var state: State { get } - /// The content of the app. @SceneBuilder var body: Body { get } @@ -134,9 +129,3 @@ extension App { } } } - -extension App where State == EmptyState { - public var state: State { - EmptyState() - } -} diff --git a/Sources/SwiftCrossUI/Modifiers/AlertModifier.swift b/Sources/SwiftCrossUI/Modifiers/AlertModifier.swift index f5f1a7e1..8e79ee79 100644 --- a/Sources/SwiftCrossUI/Modifiers/AlertModifier.swift +++ b/Sources/SwiftCrossUI/Modifiers/AlertModifier.swift @@ -16,7 +16,6 @@ extension View { struct AlertModifierView: TypeSafeView { typealias Children = AlertModifierViewChildren - var state = EmptyState() var body = EmptyView() var child: Child diff --git a/Sources/SwiftCrossUI/Modifiers/OnChangeModifier.swift b/Sources/SwiftCrossUI/Modifiers/OnChangeModifier.swift index 34da38d0..9c226532 100644 --- a/Sources/SwiftCrossUI/Modifiers/OnChangeModifier.swift +++ b/Sources/SwiftCrossUI/Modifiers/OnChangeModifier.swift @@ -13,12 +13,10 @@ extension View { } } -class OnChangeModifierState: Observable { - var previousValue: Value? -} - struct OnChangeModifier: View { - var state = OnChangeModifierState() + // TODO: This probably doesn't have to trigger view updates. We're only + // really using @State here to persist the data. + @State var previousValue: Value? var body: TupleView1 @@ -34,12 +32,15 @@ struct OnChangeModifier: View { backend: Backend, dryRun: Bool ) -> ViewUpdateResult { - if let previousValue = state.previousValue, value != previousValue { + if let previousValue = previousValue, value != previousValue { action() - } else if initial && state.previousValue == nil { + } else if initial && previousValue == nil { action() } - state.previousValue = value + + if previousValue != value { + previousValue = value + } return defaultUpdate( widget, diff --git a/Sources/SwiftCrossUI/Modifiers/TaskModifier.swift b/Sources/SwiftCrossUI/Modifiers/TaskModifier.swift index e2fc3731..14533912 100644 --- a/Sources/SwiftCrossUI/Modifiers/TaskModifier.swift +++ b/Sources/SwiftCrossUI/Modifiers/TaskModifier.swift @@ -39,7 +39,8 @@ class TaskModifierState: Observable { } struct TaskModifier: View { - var state = TaskModifierState() + @State + var task: Task<(), any Error>? = nil var id: Id var content: Content @@ -52,13 +53,13 @@ struct TaskModifier: View { return content .onChange(of: id, initial: true) { - state.task?.cancel() - state.task = Task(priority: priority) { + task?.cancel() + task = Task(priority: priority) { await action() } } .onDisappear { - state.task?.cancel() + task?.cancel() } } } diff --git a/Sources/SwiftCrossUI/State/AppState.swift b/Sources/SwiftCrossUI/State/AppState.swift deleted file mode 100644 index 9f932e3b..00000000 --- a/Sources/SwiftCrossUI/State/AppState.swift +++ /dev/null @@ -1,2 +0,0 @@ -@available(*, deprecated, message: "Replace with Observable") -public typealias AppState = Observable diff --git a/Sources/SwiftCrossUI/State/EmptyAppState.swift b/Sources/SwiftCrossUI/State/EmptyAppState.swift deleted file mode 100644 index 3352af2f..00000000 --- a/Sources/SwiftCrossUI/State/EmptyAppState.swift +++ /dev/null @@ -1,2 +0,0 @@ -@available(*, deprecated, message: "Replace with EmptyState") -public typealias EmptyAppState = EmptyState diff --git a/Sources/SwiftCrossUI/State/EmptyState.swift b/Sources/SwiftCrossUI/State/EmptyState.swift deleted file mode 100644 index 5f16941b..00000000 --- a/Sources/SwiftCrossUI/State/EmptyState.swift +++ /dev/null @@ -1,5 +0,0 @@ -/// The state to use for stateless apps and views. -public class EmptyState: Observable { - /// Creates an empty state. - public init() {} -} diff --git a/Sources/SwiftCrossUI/State/EmptyViewState.swift b/Sources/SwiftCrossUI/State/EmptyViewState.swift deleted file mode 100644 index 1974189a..00000000 --- a/Sources/SwiftCrossUI/State/EmptyViewState.swift +++ /dev/null @@ -1,2 +0,0 @@ -@available(*, deprecated, message: "Replace with EmptyState") -public typealias EmptyViewState = EmptyState diff --git a/Sources/SwiftCrossUI/State/Observable.swift b/Sources/SwiftCrossUI/State/Observable.swift index 4216d269..58d2249e 100644 --- a/Sources/SwiftCrossUI/State/Observable.swift +++ b/Sources/SwiftCrossUI/State/Observable.swift @@ -39,9 +39,6 @@ extension Observable { public var didChange: Publisher { let publisher = Publisher() .tag(with: String(describing: type(of: self))) - guard type(of: self) != EmptyState.self else { - return publisher - } var mirror: Mirror? = Mirror(reflecting: self) while let aClass = mirror { diff --git a/Sources/SwiftCrossUI/State/Publisher.swift b/Sources/SwiftCrossUI/State/Publisher.swift index c0610b02..18c682ea 100644 --- a/Sources/SwiftCrossUI/State/Publisher.swift +++ b/Sources/SwiftCrossUI/State/Publisher.swift @@ -37,11 +37,6 @@ public class Publisher { return Cancellable { [weak self] in guard let self = self else { return } self.observations[id] = nil - if self.observations.isEmpty { - for cancellable in self.cancellables { - cancellable.cancel() - } - } } .tag(with: tag) } diff --git a/Sources/SwiftCrossUI/State/State.swift b/Sources/SwiftCrossUI/State/State.swift new file mode 100644 index 00000000..66932075 --- /dev/null +++ b/Sources/SwiftCrossUI/State/State.swift @@ -0,0 +1,81 @@ +import Foundation + +@propertyWrapper +public struct State: DynamicProperty, StateProperty { + class Storage { + var value: Value + var didChange = Publisher() + + init(_ value: Value) { + self.value = value + } + } + + var storage: Storage + + var didChange: Publisher { + storage.didChange + } + + public var wrappedValue: Value { + get { + storage.value + } + nonmutating set { + storage.value = newValue + didChange.send() + } + } + + public var projectedValue: Binding { + Binding( + get: { + storage.value + }, + set: { newValue in + storage.value = newValue + didChange.send() + } + ) + } + + public init(wrappedValue initialValue: Value) { + storage = Storage(initialValue) + + if let initialValue = initialValue as? Observable { + _ = didChange.link(toUpstream: initialValue.didChange) + } + } + + public func update(with environment: EnvironmentValues, previousValue: State?) { + if let previousValue { + storage.value = previousValue.storage.value + storage.didChange = previousValue.didChange + } + } + + func tryRestoreFromSnapshot(_ snapshot: Data) { + guard + let decodable = Value.self as? Codable.Type, + let state = try? JSONDecoder().decode(decodable, from: snapshot) + else { + return + } + + storage.value = state as! Value + } + + func snapshot() throws -> Data? { + if let value = storage.value as? Codable { + return try JSONEncoder().encode(value) + } else { + return nil + } + } +} + +protocol StateProperty { + var didChange: Publisher { get } + func tryRestoreFromSnapshot(_ snapshot: Data) + func snapshot() throws -> Data? +} diff --git a/Sources/SwiftCrossUI/State/ViewState.swift b/Sources/SwiftCrossUI/State/ViewState.swift deleted file mode 100644 index 28b224c5..00000000 --- a/Sources/SwiftCrossUI/State/ViewState.swift +++ /dev/null @@ -1,2 +0,0 @@ -@available(*, deprecated, message: "Replace with Observable") -public typealias ViewState = Observable diff --git a/Sources/SwiftCrossUI/SwiftCrossUI.docc/SwiftCrossUI.md b/Sources/SwiftCrossUI/SwiftCrossUI.docc/SwiftCrossUI.md index ac1f98d0..1ea64bbe 100644 --- a/Sources/SwiftCrossUI/SwiftCrossUI.docc/SwiftCrossUI.md +++ b/Sources/SwiftCrossUI/SwiftCrossUI.docc/SwiftCrossUI.md @@ -52,16 +52,12 @@ The wide variety of views available that you can combine to create complex UIs. Objects that are read from and/or written to as part of your app. -- ``AppState`` +- ``State`` - ``Binding`` -- ``Cancellable`` -- ``EmptyAppState`` -- ``EmptyState`` -- ``EmptyViewState`` - ``Observable`` - ``Observed`` - ``Publisher`` -- ``ViewState`` +- ``Cancellable`` ### Implementation Details diff --git a/Sources/SwiftCrossUI/ViewGraph/ErasedViewGraphNode.swift b/Sources/SwiftCrossUI/ViewGraph/ErasedViewGraphNode.swift index 58edf984..79e882ff 100644 --- a/Sources/SwiftCrossUI/ViewGraph/ErasedViewGraphNode.swift +++ b/Sources/SwiftCrossUI/ViewGraph/ErasedViewGraphNode.swift @@ -16,7 +16,6 @@ public struct ErasedViewGraphNode { ) -> (viewTypeMatched: Bool, size: ViewUpdateResult) public var getWidget: () -> AnyWidget - public var getState: () -> Data? public var viewType: any View.Type public var backendType: any AppBackend.Type @@ -67,12 +66,6 @@ public struct ErasedViewGraphNode { getWidget = { return AnyWidget(node.widget) } - getState = { - guard let encodable = node.view.state as? any Codable else { - return nil - } - return try? JSONEncoder().encode(encodable) - } } public init(wrapping node: AnyViewGraphNode) { diff --git a/Sources/SwiftCrossUI/ViewGraph/ViewGraph.swift b/Sources/SwiftCrossUI/ViewGraph/ViewGraph.swift index 1d5b7c51..97ff4e8e 100644 --- a/Sources/SwiftCrossUI/ViewGraph/ViewGraph.swift +++ b/Sources/SwiftCrossUI/ViewGraph/ViewGraph.swift @@ -40,42 +40,6 @@ public class ViewGraph { backend.setIncomingURLHandler { url in self.currentRootViewResult.preferences.onOpenURL?(url) } - - cancellable = view.state.didChange.observe { [weak self] in - guard let self else { return } - backend.runInMainThread { - // We first compute the root view's new size so that we don't end up - // updating the root view twice if we need to propagate the update to - // the parent scene. - let currentResult = self.currentRootViewResult - let dryRunResult = self.update( - proposedSize: self.windowSize, - environment: self.parentEnvironment, - dryRun: true - ) - if dryRunResult.size != currentResult.size { - self.currentRootViewResult = dryRunResult - environment.onResize(dryRunResult.size) - } else { - let finalResult = self.update( - proposedSize: self.windowSize, - environment: self.parentEnvironment, - dryRun: false - ) - if finalResult.size != dryRunResult.size { - print( - """ - warning: State-triggered view update had mismatch \ - between dry-run size and final size. - -> dryRunSize: \(dryRunResult.size) - -> finalSize: \(finalResult.size) - """ - ) - } - self.currentRootViewResult = finalResult - } - } - } } /// Recomputes the entire UI (e.g. due to the root view's state updating). diff --git a/Sources/SwiftCrossUI/ViewGraph/ViewGraphNode.swift b/Sources/SwiftCrossUI/ViewGraph/ViewGraphNode.swift index 5b8b71ad..0a8e6984 100644 --- a/Sources/SwiftCrossUI/ViewGraph/ViewGraphNode.swift +++ b/Sources/SwiftCrossUI/ViewGraph/ViewGraphNode.swift @@ -44,8 +44,8 @@ public class ViewGraphNode { /// view as a result of a state change rather than the parent view updating. private var lastProposedSize: SIMD2 - /// A cancellable handle to the view's state observation. - private var cancellable: Cancellable? + /// A cancellable handle to the view's state property observations. + private var cancellables: [Cancellable] /// The environment most recently provided by this node's parent. private var parentEnvironment: EnvironmentValues @@ -61,24 +61,8 @@ public class ViewGraphNode { self.backend = backend // Restore node snapshot if present. - self.view = snapshot?.restore(to: nodeView) ?? nodeView - - #if DEBUG - var mirror: Mirror? = Mirror(reflecting: self.view.state) - while let aClass = mirror { - for (label, property) in aClass.children { - guard - property is ObservedMarkerProtocol, - let property = property as? Observable - else { - continue - } - - property.didChange.tag(with: "(\(label ?? "_"): Observed<_>)") - } - mirror = aClass.superclassMirror - } - #endif + self.view = nodeView + snapshot?.restore(to: view) // First create the view's child nodes and widgets let childSnapshots = @@ -89,6 +73,7 @@ public class ViewGraphNode { resultCache = [:] lastProposedSize = .zero parentEnvironment = environment + cancellables = [] let viewEnvironment = updateEnvironment(environment) @@ -116,16 +101,39 @@ public class ViewGraphNode { backend.tag(widget: widget, as: tag) // Update the view and its children when state changes (children are always updated first). - cancellable = view.state.didChange - .observeAsUIUpdater(backend: backend) { [weak self] in - guard let self = self else { return } - self.bottomUpUpdate() + let mirror = Mirror(reflecting: view) + for property in mirror.children { + if property.label == "state" && property.value is Observable { + print( + """ + + warning: The View.state protocol requirement has been removed in favour of + SwiftUI-style @State annotations. Decorate \(NodeView.self).state + with the @State property wrapper to restore previous behaviour. + + """ + ) + } + + guard let value = property.value as? StateProperty else { + continue } + + cancellables.append( + value.didChange + .observeAsUIUpdater(backend: backend) { [weak self] in + guard let self = self else { return } + self.bottomUpUpdate() + } + ) + } } /// Stops observing the view's state. deinit { - cancellable?.cancel() + for cancellable in cancellables { + cancellable.cancel() + } } /// Triggers the view to be updated as part of a bottom-up chain of updates (where either the @@ -146,11 +154,22 @@ public class ViewGraphNode { resultCache[lastProposedSize] = newResult parentEnvironment.onResize(newResult.size) } else { - self.currentResult = self.update( + 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 } } @@ -205,11 +224,12 @@ public class ViewGraphNode { parentEnvironment = environment lastProposedSize = proposedSize - let previousView = newView != nil ? view : nil + let previousView: NodeView? if let newView { - var newView = newView - newView.state = view.state + previousView = view view = newView + } else { + previousView = nil } let viewEnvironment = updateEnvironment(environment) diff --git a/Sources/SwiftCrossUI/ViewGraph/ViewGraphSnapshotter.swift b/Sources/SwiftCrossUI/ViewGraph/ViewGraphSnapshotter.swift index 9a58e038..7121c527 100644 --- a/Sources/SwiftCrossUI/ViewGraph/ViewGraphSnapshotter.swift +++ b/Sources/SwiftCrossUI/ViewGraph/ViewGraphSnapshotter.swift @@ -3,13 +3,19 @@ import Foundation public struct ViewGraphSnapshotter: ErasedViewGraphNodeTransformer { public struct NodeSnapshot: CustomDebugStringConvertible, Equatable { var viewTypeName: String - var state: StateSnapshot? + /// Property names mapped to encoded JSON objects + var state: [String: Data] var children: [NodeSnapshot] public var debugDescription: String { var description = "\(viewTypeName)" - if let state = state { - description += "\n| state: \(state.debugDescription)" + if !state.isEmpty { + description += "\n| state: {" + for (propertyName, data) in state { + let encodedState = String(data: data, encoding: .utf8) ?? "" + description += "\n| \(propertyName): \(encodedState)," + } + description += "\n| }" } if !children.isEmpty { @@ -37,42 +43,25 @@ public struct ViewGraphSnapshotter: ErasedViewGraphNodeTransformer { name(of: V.self) == viewTypeName } - public func restore(to view: V) -> V { + public func restore(to view: V) { guard isValid(for: V.self) else { - return view + return } - switch state { - case let .encoded(data): - return Self.setState(of: view, to: data) - case .encodingFailure, .none: - return view - } + Self.updateState(of: view, withSnapshot: state) } - private static func setState(of view: V, to data: Data) -> V { - guard - let decodable = V.State.self as? Codable.Type, - let state = try? JSONDecoder().decode(decodable, from: data) - else { - return view - } - var view = view - view.state = state as! V.State - return view - } - } - - public enum StateSnapshot: CustomDebugStringConvertible, Equatable { - case encodingFailure - case encoded(Data) - - public var debugDescription: String { - switch self { - case .encodingFailure: - return "failedToEncode" - case let .encoded(data): - return String(data: data, encoding: .utf8) ?? "invalidUTF8" + private static func updateState(of view: V, withSnapshot state: [String: Data]) { + let mirror = Mirror(reflecting: view) + for property in mirror.children { + guard + let stateProperty = property as? StateProperty, + let propertyName = property.label, + let encodedState = state[propertyName] + else { + continue + } + stateProperty.tryRestoreFromSnapshot(encodedState) } } } @@ -86,15 +75,17 @@ public struct ViewGraphSnapshotter: ErasedViewGraphNodeTransformer { } public static func snapshot(of node: AnyViewGraphNode) -> NodeSnapshot { - let stateSnapshot: StateSnapshot? - if let state = node.getView().state as? Codable { - if let encodedState = try? JSONEncoder().encode(state) { - stateSnapshot = .encoded(encodedState) - } else { - stateSnapshot = .encodingFailure + var stateSnapshot: [String: Data] = [:] + let mirror = Mirror(reflecting: node.getView()) + for property in mirror.children { + guard + let propertyName = property.label, + let property = property as? StateProperty, + let encodedState = try? property.snapshot() + else { + continue } - } else { - stateSnapshot = nil + stateSnapshot[propertyName] = encodedState } let nodeChildren = node.getChildren().erasedNodes diff --git a/Sources/SwiftCrossUI/Views/ElementaryView.swift b/Sources/SwiftCrossUI/Views/ElementaryView.swift index 9d2c5e5d..87a61bc0 100644 --- a/Sources/SwiftCrossUI/Views/ElementaryView.swift +++ b/Sources/SwiftCrossUI/Views/ElementaryView.swift @@ -16,11 +16,6 @@ protocol ElementaryView: View where Content == EmptyView { } extension ElementaryView { - /// This default prevents ``EmptyView/body`` from getting called. - public var flexibility: Int { - 0 - } - public var body: EmptyView { return EmptyView() } diff --git a/Sources/SwiftCrossUI/Views/Table.swift b/Sources/SwiftCrossUI/Views/Table.swift index aea79330..30669177 100644 --- a/Sources/SwiftCrossUI/Views/Table.swift +++ b/Sources/SwiftCrossUI/Views/Table.swift @@ -2,7 +2,6 @@ public struct Table>: TypeSafeView, View { typealias Children = TableViewChildren - public var state = EmptyState() public var body = EmptyView() /// The row data to display. diff --git a/Sources/SwiftCrossUI/Views/TupleView.swift b/Sources/SwiftCrossUI/Views/TupleView.swift index a292f9cb..19ace3d9 100644 --- a/Sources/SwiftCrossUI/Views/TupleView.swift +++ b/Sources/SwiftCrossUI/Views/TupleView.swift @@ -4,7 +4,6 @@ /// Has the same behaviour as ``Group`` when rendered directly. public struct TupleView1: TypeSafeView, View { public typealias Content = EmptyView - public typealias State = EmptyState typealias Children = TupleViewChildren1 @@ -85,7 +84,6 @@ public struct TupleView1: TypeSafeView, View { /// Has the same behaviour as ``Group`` when rendered directly. public struct TupleView2: TypeSafeView, View { public typealias Content = EmptyView - public typealias State = EmptyState typealias Children = TupleViewChildren2 @@ -182,7 +180,6 @@ public struct TupleView2: TypeSafeView, View { /// Has the same behaviour as ``Group`` when rendered directly. public struct TupleView3: TypeSafeView, View { public typealias Content = EmptyView - public typealias State = EmptyState typealias Children = TupleViewChildren3 @@ -295,7 +292,6 @@ public struct TupleView3: TypeSafeView, V /// Has the same behaviour as ``Group`` when rendered directly. public struct TupleView4: TypeSafeView, View { public typealias Content = EmptyView - public typealias State = EmptyState typealias Children = TupleViewChildren4 @@ -426,7 +422,6 @@ public struct TupleView5 @@ -573,7 +568,6 @@ public struct TupleView6< View0: View, View1: View, View2: View, View3: View, View4: View, View5: View >: TypeSafeView, View { public typealias Content = EmptyView - public typealias State = EmptyState typealias Children = TupleViewChildren6 @@ -739,7 +733,6 @@ public struct TupleView7< View0: View, View1: View, View2: View, View3: View, View4: View, View5: View, View6: View >: TypeSafeView, View { public typealias Content = EmptyView - public typealias State = EmptyState typealias Children = TupleViewChildren7 @@ -922,7 +915,6 @@ public struct TupleView8< View7: View >: TypeSafeView, View { public typealias Content = EmptyView - public typealias State = EmptyState typealias Children = TupleViewChildren8 @@ -1121,7 +1113,6 @@ public struct TupleView9< View7: View, View8: View >: TypeSafeView, View { public typealias Content = EmptyView - public typealias State = EmptyState typealias Children = TupleViewChildren9< View0, View1, View2, View3, View4, View5, View6, View7, View8 @@ -1338,7 +1329,6 @@ public struct TupleView10< View7: View, View8: View, View9: View >: TypeSafeView, View { public typealias Content = EmptyView - public typealias State = EmptyState typealias Children = TupleViewChildren10< View0, View1, View2, View3, View4, View5, View6, View7, View8, View9 diff --git a/Sources/SwiftCrossUI/Views/TupleView.swift.gyb b/Sources/SwiftCrossUI/Views/TupleView.swift.gyb index bfc96a78..b662427c 100644 --- a/Sources/SwiftCrossUI/Views/TupleView.swift.gyb +++ b/Sources/SwiftCrossUI/Views/TupleView.swift.gyb @@ -18,7 +18,6 @@ children_type_parameters = ", ".join(["View%d" % j for j in range(i + 1)]) /// Has the same behaviour as ``Group`` when rendered directly. public struct ${view}<${struct_parameters}>: TypeSafeView, View { public typealias Content = EmptyView - public typealias State = EmptyState typealias Children = TupleViewChildren${i + 1}<${children_type_parameters}> diff --git a/Sources/SwiftCrossUI/Views/View.swift b/Sources/SwiftCrossUI/Views/View.swift index 91dceeab..e6f77962 100644 --- a/Sources/SwiftCrossUI/Views/View.swift +++ b/Sources/SwiftCrossUI/Views/View.swift @@ -2,12 +2,6 @@ public protocol View { /// The view's content (composed of other views). associatedtype Content: View - /// The view's observed state. - associatedtype State: Observable - - /// The views observed state. Any observed changes cause ``View/body`` to be recomputed, - /// and the view itself to be updated. - var state: State { get set } /// The view's contents. @ViewBuilder var body: Content { get } @@ -159,16 +153,3 @@ extension View { ) } } - -extension View where State == EmptyState { - // swiftlint:disable unused_setter_value - public var state: State { - get { - EmptyState() - } - set { - return - } - } - // swiftlint:enable unused_setter_value -} diff --git a/Sources/SwiftCrossUI/_App.swift b/Sources/SwiftCrossUI/_App.swift index cc8bdcdc..7756b27f 100644 --- a/Sources/SwiftCrossUI/_App.swift +++ b/Sources/SwiftCrossUI/_App.swift @@ -10,8 +10,8 @@ class _App { let backend: AppRoot.Backend /// The root of the app's scene graph. var sceneGraphRoot: AppRoot.Body.Node? - /// A cancellable handle to observation of the app's state . - var cancellable: Cancellable? + /// Cancellables for observations of the app's state properties. + var cancellables: [Cancellable] /// The root level environment. var environment: EnvironmentValues @@ -20,6 +20,7 @@ class _App { backend = app.backend self.app = app self.environment = EnvironmentValues(backend: backend) + self.cancellables = [] } func forceRefresh() { @@ -76,8 +77,26 @@ class _App { ) self.sceneGraphRoot = rootNode - self.cancellable = self.app.state.didChange - .observeAsUIUpdater(backend: self.backend) { [weak self] in + let mirror = Mirror(reflecting: self.app) + for property in mirror.children { + if property.label == "state" && property.value is Observable { + print( + """ + + warning: The App.state protocol requirement has been removed in favour of + SwiftUI-style @State annotations. Decorate \(AppRoot.self).state + with the @State property wrapper to restore previous behaviour. + + """ + ) + } + + guard let value = property.value as? StateProperty else { + continue + } + + let cancellable = value.didChange.observeAsUIUpdater(backend: self.backend) { + [weak self] in guard let self = self else { return } updateDynamicProperties( @@ -95,6 +114,8 @@ class _App { self.backend.setApplicationMenu(body.commands.resolve()) } + self.cancellables.append(cancellable) + } } } }