From 607450419893878e4e16cb728170527681cff737 Mon Sep 17 00:00:00 2001 From: Miguel de Icaza Date: Wed, 6 Aug 2025 16:42:26 -0400 Subject: [PATCH] Bring back the terminal backend to life. It renames 'CursesBackend' to TermKit, as now TermKit has various console drivers, and curses is just one of them. The challenge is that TermKit deals with screen sizes like 80x24, not things like 600x400, so both the default sizes and spacing in the codebase will need to compensate for that. The AppBackend introduces a defaultStackSpacingAmount which is the spacing that VStack and HStack would use, it is currently kept as a default to the existing value, but for the TermKit backend, this value is set to zero. Now HStack and VStack rather than storing a resolved value like 'spacing: Int', they store the request 'spacing: Int?', which is resolved before it is actually needed by querying the backend for this data. There is also a new 'limitScreenBounds' that is a no-op for most, but on the curses cases, ensures that a misuse of the defaultSize does not trickle up and down the stack with unrealistic view sizes. --- Package.swift | 18 +- Sources/CursesBackend/CursesBackend.swift | 147 ------ Sources/SwiftCrossUI/Backend/AppBackend.swift | 10 + .../SwiftCrossUI/Scenes/WindowGroupNode.swift | 4 +- Sources/SwiftCrossUI/Views/HStack.swift | 6 +- Sources/SwiftCrossUI/Views/VStack.swift | 8 +- Sources/TermKitBackend/TermKitBackend.swift | 449 ++++++++++++++++++ 7 files changed, 476 insertions(+), 166 deletions(-) delete mode 100644 Sources/CursesBackend/CursesBackend.swift create mode 100644 Sources/TermKitBackend/TermKitBackend.swift diff --git a/Package.swift b/Package.swift index f24ca7956c..314a88007e 100644 --- a/Package.swift +++ b/Package.swift @@ -73,7 +73,7 @@ let package = Package( .library(name: "Gtk", type: libraryType, targets: ["Gtk"]), .library(name: "Gtk3", type: libraryType, targets: ["Gtk3"]), .executable(name: "GtkExample", targets: ["GtkExample"]), - // .library(name: "CursesBackend", type: libraryType, targets: ["CursesBackend"]), + .library(name: "TermKitBackend", type: libraryType, targets: ["TermKitBackend"]), // .library(name: "QtBackend", type: libraryType, targets: ["QtBackend"]), // .library(name: "LVGLBackend", type: libraryType, targets: ["LVGLBackend"]), ], @@ -110,10 +110,10 @@ let package = Package( url: "https://github.com/stackotter/swift-winui", branch: "927e2c46430cfb1b6c195590b9e65a30a8fd98a2" ), - // .package( - // url: "https://github.com/stackotter/TermKit", - // revision: "163afa64f1257a0c026cc83ed8bc47a5f8fc9704" - // ), + .package( + url: "https://github.com/migueldeicaza/TermKit", + revision: "6b82436223a739af53b19045784b4bbc3f92505f" + ), // .package( // url: "https://github.com/PADL/LVGLSwift", // revision: "19c19a942153b50d61486faf1d0d45daf79e7be5" @@ -252,10 +252,10 @@ let package = Package( name: "WinUIInterop", dependencies: [] ), - // .target( - // name: "CursesBackend", - // dependencies: ["SwiftCrossUI", "TermKit"] - // ), + .target( + name: "TermKitBackend", + dependencies: ["SwiftCrossUI", .product(name: "TermKit", package: "TermKit")] + ), // .target( // name: "QtBackend", // dependencies: ["SwiftCrossUI", .product(name: "Qlift", package: "qlift")] diff --git a/Sources/CursesBackend/CursesBackend.swift b/Sources/CursesBackend/CursesBackend.swift deleted file mode 100644 index 4075678593..0000000000 --- a/Sources/CursesBackend/CursesBackend.swift +++ /dev/null @@ -1,147 +0,0 @@ -import Foundation -import SwiftCrossUI -import TermKit - -extension App { - public typealias Backend = CursesBackend -} - -public final class CursesBackend: AppBackend { - public typealias Window = RootView - public typealias Widget = TermKit.View - - var root: RootView - var hasCreatedWindow = false - - public init() { - Application.prepare() - root = RootView() - Application.top.addSubview(root) - } - - public func runMainLoop(_ callback: @escaping () -> Void) { - callback() - Application.run() - } - - public func createWindow(withDefaultSize defaultSize: SwiftCrossUI.Size?) -> Window { - guard !hasCreatedWindow else { - fatalError("CursesBackend doesn't support multi-windowing") - } - hasCreatedWindow = true - return root - } - - public func setTitle(ofWindow window: Window, to title: String) {} - - public func setResizability(ofWindow window: Window, to resizable: Bool) {} - - public func setChild(ofWindow window: Window, to child: Widget) { - window.addSubview(child) - } - - public func show(window: Window) {} - - public func runInMainThread(action: @escaping () -> Void) { - #if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) - DispatchQueue.main.async { - action() - } - #else - action() - #endif - } - - public func show(widget: Widget) { - widget.setNeedsDisplay() - } - - public func createVStack() -> Widget { - return View() - } - - public func setChildren(_ children: [Widget], ofVStack container: Widget) { - // TODO: Properly calculate layout - for child in children { - child.y = Pos.at(container.subviews.count) - container.addSubview(child) - } - } - - public func setSpacing(ofVStack container: Widget, to spacing: Int) {} - - public func createHStack() -> Widget { - return View() - } - - public func setChildren(_ children: [Widget], ofHStack container: Widget) { - // TODO: Properly calculate layout - for child in children { - child.y = Pos.at(container.subviews.count) - container.addSubview(child) - } - } - - public func setSpacing(ofHStack container: Widget, to spacing: Int) {} - - public func updateScrollContainer(_ scrollView: Widget, environment: EnvironmentValues) {} - - public func createTextView() -> Widget { - let label = Label("") - label.width = Dim.fill() - return label - } - - public func updateTextView(_ textView: Widget, content: String, shouldWrap: Bool) { - // TODO: Implement text wrap handling - let label = textView as! Label - label.text = content - } - - public func createButton() -> Widget { - let button = TermKit.Button("") - button.height = Dim.sized(1) - return button - } - - public func updateButton(_ button: Widget, label: String, action: @escaping () -> Void) { - (button as! TermKit.Button).text = label - (button as! TermKit.Button).clicked = { _ in - action() - } - } - - // TODO: Properly implement padding container. Perhaps use a conversion factor to - // convert the pixel values to 'characters' of padding - public func createPaddingContainer(for child: Widget) -> Widget { - return child - } - - public func getChild(ofPaddingContainer container: Widget) -> Widget { - return container - } - - public func setPadding( - ofPaddingContainer container: Widget, - top: Int, - bottom: Int, - leading: Int, - trailing: Int - ) {} -} - -public class RootView: TermKit.View { - public override func processKey(event: KeyEvent) -> Bool { - if super.processKey(event: event) { - return true - } - - switch event.key { - case .controlC, .esc: - Application.requestStop() - return true - default: - return false - } - } -} diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index f1e0d2e640..9d27957b35 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -63,6 +63,9 @@ public protocol AppBackend: Sendable { /// The default amount of padding used when a user uses the ``View/padding(_:_:)`` /// modifier. var defaultPaddingAmount: Int { get } + /// The default amount of spacing used when a user uses the ``HStack`` or ``VStack`` + /// classes + var defaultStackSpacingAmount: Int { get } /// Gets the layout width of a backend's scroll bars. Assumes that the width /// is the same for both vertical and horizontal scroll bars (where the width /// of a horizontal scroll bar is what pedants may call its height). If the @@ -693,6 +696,8 @@ public protocol AppBackend: Sendable { ) /// Navigates a web view to a given URL. func navigateWebView(_ webView: Widget, to url: URL) + + func limitScreenBounds(_ bounds: SIMD2) -> SIMD2 } extension AppBackend { @@ -709,6 +714,7 @@ extension AppBackend { } extension AppBackend { + public var defaultStackSpacingAmount: Int { 10 } /// Used by placeholder implementations of backend methods. private func todo(_ function: String = #function) -> Never { print("\(type(of: self)): \(function) not implemented") @@ -1139,4 +1145,8 @@ extension AppBackend { ) { todo() } + + public func limitScreenBounds(_ bounds: SIMD2) -> SIMD2 { + return bounds + } } diff --git a/Sources/SwiftCrossUI/Scenes/WindowGroupNode.swift b/Sources/SwiftCrossUI/Scenes/WindowGroupNode.swift index 717906b484..c7ee337280 100644 --- a/Sources/SwiftCrossUI/Scenes/WindowGroupNode.swift +++ b/Sources/SwiftCrossUI/Scenes/WindowGroupNode.swift @@ -88,8 +88,8 @@ public final class WindowGroupNode: SceneGraphNode { _ = update( newScene, proposedWindowSize: isFirstUpdate && isProgramaticallyResizable - ? (newScene ?? scene).defaultSize - : backend.size(ofWindow: window), + ? backend.limitScreenBounds((newScene ?? scene).defaultSize) + : backend.limitScreenBounds(backend.size(ofWindow: window)), backend: backend, environment: environment, windowSizeIsFinal: !isProgramaticallyResizable diff --git a/Sources/SwiftCrossUI/Views/HStack.swift b/Sources/SwiftCrossUI/Views/HStack.swift index 097df08c0c..17ee141402 100644 --- a/Sources/SwiftCrossUI/Views/HStack.swift +++ b/Sources/SwiftCrossUI/Views/HStack.swift @@ -3,7 +3,7 @@ public struct HStack: View { public var body: Content /// The amount of spacing to apply between children. - private var spacing: Int + private var spacing: Int? /// The alignment of the stack's children in the vertical direction. private var alignment: VerticalAlignment @@ -14,7 +14,7 @@ public struct HStack: View { @ViewBuilder _ content: () -> Content ) { body = content() - self.spacing = spacing ?? VStack.defaultSpacing + self.spacing = spacing self.alignment = alignment } @@ -45,7 +45,7 @@ public struct HStack: View { environment .with(\.layoutOrientation, .horizontal) .with(\.layoutAlignment, alignment.asStackAlignment) - .with(\.layoutSpacing, spacing), + .with(\.layoutSpacing, spacing ?? backend.defaultStackSpacingAmount), backend: backend, dryRun: dryRun ) diff --git a/Sources/SwiftCrossUI/Views/VStack.swift b/Sources/SwiftCrossUI/Views/VStack.swift index b3b9973fd6..c8d8987321 100644 --- a/Sources/SwiftCrossUI/Views/VStack.swift +++ b/Sources/SwiftCrossUI/Views/VStack.swift @@ -1,11 +1,9 @@ /// A view that arranges its subviews vertically. public struct VStack: View { - static var defaultSpacing: Int { 10 } - public var body: Content /// The amount of spacing to apply between children. - private var spacing: Int + private var spacing: Int? /// The alignment of the stack's children in the horizontal direction. private var alignment: HorizontalAlignment @@ -24,7 +22,7 @@ public struct VStack: View { content: Content ) { body = content - self.spacing = spacing ?? Self.defaultSpacing + self.spacing = spacing self.alignment = alignment } @@ -55,7 +53,7 @@ public struct VStack: View { environment .with(\.layoutOrientation, .vertical) .with(\.layoutAlignment, alignment.asStackAlignment) - .with(\.layoutSpacing, spacing), + .with(\.layoutSpacing, spacing ?? backend.defaultStackSpacingAmount), backend: backend, dryRun: dryRun ) diff --git a/Sources/TermKitBackend/TermKitBackend.swift b/Sources/TermKitBackend/TermKitBackend.swift new file mode 100644 index 0000000000..518609b724 --- /dev/null +++ b/Sources/TermKitBackend/TermKitBackend.swift @@ -0,0 +1,449 @@ +import Foundation +import SwiftCrossUI +import TermKit + +extension App { + public typealias Backend = CursesBackend +} + +public class TKMenu { + +} + +public class TKAlert { + +} + +public class TKPath { + +} + +public final class CursesBackend: AppBackend { + public typealias Menu = TKMenu + + public typealias Alert = TKAlert + + public typealias Path = TKPath + + public var defaultTableRowContentHeight: Int = 1 + + public var defaultTableCellVerticalPadding: Int = 0 + + public var defaultPaddingAmount: Int = 1 + + public var defaultStackSpacingAmount: Int = 0 + + public var scrollBarWidth: Int = 2 + + public var requiresToggleSwitchSpacer: Bool = false + + public var requiresImageUpdateOnScaleFactorChange: Bool = false + + public var menuImplementationStyle: SwiftCrossUI.MenuImplementationStyle = .dynamicPopover + + public var deviceClass: SwiftCrossUI.DeviceClass = .desktop + + public var canRevealFiles: Bool = false + + public func isWindowProgrammaticallyResizable(_ window: RootView) -> Bool { + return true + } + + public func setSize(ofWindow window: RootView, to newSize: SIMD2) { + window.width = Dim.sized(min(newSize.x, Application.terminalSize.width)) + window.height = Dim.sized(min(newSize.y, Application.terminalSize.height)) + } + + public func setMinimumSize(ofWindow window: RootView, to minimumSize: SIMD2) { + // TODO + } + + public func activate(window: RootView) { + fatalError() + } + + public func runInMainThread(action: @escaping @MainActor () -> Void) { +#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) + DispatchQueue.main.async { + action() + } +#else + action() +#endif + } + + public func computeRootEnvironment(defaultEnvironment: SwiftCrossUI.EnvironmentValues) -> SwiftCrossUI.EnvironmentValues { + return defaultEnvironment + } + + public func setRootEnvironmentChangeHandler(to action: @escaping () -> Void) { + // TODO + } + + public func setApplicationMenu(_ submenus: [ResolvedMenu.Submenu]) { + // TODO + } + + public func computeWindowEnvironment(window: RootView, rootEnvironment: SwiftCrossUI.EnvironmentValues) -> SwiftCrossUI.EnvironmentValues { + return rootEnvironment + } + + public func setWindowEnvironmentChangeHandler(of window: RootView, to action: @escaping () -> Void) { + // TODO + } + + public func setIncomingURLHandler(to action: @escaping (URL) -> Void) { + // TODO + } + + public func createContainer() -> TermKit.View { + let c = TermKit.View() + c.canFocus = true + return c + } + + public func removeAllChildren(of container: TermKit.View) { + container.removeAllSubviews() + } + + public func addChild(_ child: TermKit.View, to container: TermKit.View) { + container.addSubview(child) + } + + public func setPosition(ofChildAt index: Int, in container: TermKit.View, to position: SIMD2) { + let view = container.subviews[index] + view.x = Pos.at(position.x) + view.y = Pos.at(position.y) + } + + public func removeChild(_ child: TermKit.View, from container: TermKit.View) { + container.removeSubview(child) + } + + public func naturalSize(of widget: TermKit.View) -> SIMD2 { + // TODO + return SIMD2(10, 1) + } + + public func setSize(of widget: TermKit.View, to size: SIMD2) { + if size.x > 127 || size.y > 32 { + //fatalError() + } + widget.width = Dim.sized(size.x) + widget.height = Dim.sized(size.y) + } + + public typealias Window = RootView + public typealias Widget = TermKit.View + + var root: RootView + var hasCreatedWindow = false + + public init() { + Application.prepare() + root = RootView() + Application.top.addSubview(root) + } + + public func runMainLoop(_ callback: @escaping @MainActor () -> Void) { + callback() + Application.run() + } + + public func setResizeHandler(ofWindow window: RootView, to action: @escaping (SIMD2) -> Void) { + // TODO: implement this + } + + public func size(ofWindow window: RootView) -> SIMD2 { + return SIMD2(window.frame.width, window.frame.height) + } + + public func size( + of text: String, + whenDisplayedIn widget: Widget, + proposedFrame: SIMD2?, + environment: EnvironmentValues + ) -> SIMD2 { + // TODO + return SIMD2(text.count, 1) + } + + public func createWindow(withDefaultSize defaultSize: SIMD2?) -> RootView { + guard !hasCreatedWindow else { + fatalError("CursesBackend doesn't support multi-windowing") + } + hasCreatedWindow = true + return root + } + + + public func setTitle(ofWindow window: Window, to title: String) {} + + public func setResizability(ofWindow window: Window, to resizable: Bool) {} + + public func setChild(ofWindow window: Window, to child: Widget) { + window.addSubview(child) + } + + public func show(window: Window) {} + + public func show(widget: Widget) { + widget.setNeedsDisplay() + } + + public func createVStack() -> Widget { + return View() + } + + public func setChildren(_ children: [Widget], ofVStack container: Widget) { + // TODO: Properly calculate layout + for child in children { + child.y = Pos.at(container.subviews.count) + container.addSubview(child) + } + } + + public func setSpacing(ofVStack container: Widget, to spacing: Int) {} + + public func createHStack() -> Widget { + return View() + } + + public func setChildren(_ children: [Widget], ofHStack container: Widget) { + // TODO: Properly calculate layout + for child in children { + child.y = Pos.at(container.subviews.count) + container.addSubview(child) + } + } + + public func setSpacing(ofHStack container: Widget, to spacing: Int) {} + + public func updateScrollContainer(_ scrollView: Widget, environment: EnvironmentValues) {} + + public func createTextView() -> Widget { + let label = Label("TEXT") + label.width = Dim.fill() + return label + } + + public func updateTextView( + _ textView: Widget, + content: String, + environment: EnvironmentValues + ) { + // TODO: Implement text wrap handling + let label = textView as! Label + label.text = content + } + + public func updateTextField( + _ textField: Widget, + placeholder: String, + environment: EnvironmentValues, + onChange: @escaping (String) -> Void, + onSubmit: @escaping () -> Void + ) { + guard let textField = textField as? TermKit.TextField else { return } + textField.enabled = environment.isEnabled + // TODO placeholder + // TODO appearance + textField.textChanged = { textField, _ in + onChange(textField.text) + } + textField.onSubmit = { _ in onSubmit() } + // TODO: environment.textContentType can be used to configure the input + } + + public func getContent(ofTextField textField: Widget) -> String { + guard let textField = textField as? TermKit.TextField else { return "" } + return textField.text + } + + public func setContent(ofTextField textField: Widget, to content: String) { + guard let textField = textField as? TermKit.TextField else { return } + textField.text = content + } + + + public func createTextField() -> Widget { + return TermKit.TextField() + } + + public func createButton() -> Widget { + let button = TermKit.Button("BUTTON") + button.height = Dim.sized(1) + return button + } + + public func updateToggle( + _ toggle: Widget, + label: String, + environment: EnvironmentValues, + onChange: @escaping (Bool) -> Void + ) { + guard let checkbox = toggle as? TermKit.Checkbox else { + return + } + checkbox.text = label + checkbox.enabled = environment.isEnabled + checkbox.toggled = { toggle in + onChange(toggle.checked) + } + } + + public func setState(ofToggle toggle: Widget, to state: Bool) { + guard let checkbox = toggle as? TermKit.Checkbox else { + return + } + checkbox.setState(to: state) + } + + + public func createToggle() -> Widget { + return TermKit.Checkbox("TOGGLE") + } + + public func setState(ofSwitch toggleSwitch: Widget, to state: Bool) { + guard let checkbox = toggleSwitch as? TermKit.Checkbox else { + return + } + checkbox.setState(to: state) + } + + public func updateSwitch( + _ toggleSwitch: Widget, + environment: EnvironmentValues, + onChange: @escaping (Bool) -> Void + ) { + guard let checkbox = toggleSwitch as? TermKit.Checkbox else { + return + } + checkbox.text = "SWITCH" + checkbox.enabled = environment.isEnabled + checkbox.toggled = { toggle in + onChange(toggle.checked) + } + } + + public func createSwitch() -> Widget { + return TermKit.Checkbox("SWITCH") + } + + public func updateCheckbox( + _ checkbox: Widget, + environment: EnvironmentValues, + onChange: @escaping (Bool) -> Void + ) { + guard let checkbox = checkbox as? TermKit.Checkbox else { + return + } + checkbox.text = "CHECK" + checkbox.enabled = environment.isEnabled + checkbox.toggled = { toggle in + onChange(toggle.checked) + } + } + + public func setState(ofCheckbox checkbox: Widget, to state: Bool) { + guard let checkbox = checkbox as? TermKit.Checkbox else { + return + } + checkbox.setState(to: state) + } + + public func createCheckbox() -> Widget { + return TermKit.Checkbox("CHECK") + } + + public func updateSlider( + _ slider: Widget, + minimum: Double, + maximum: Double, + decimalPlaces: Int, + environment: EnvironmentValues, + onChange: @escaping (Double) -> Void + ) { + // TODO + } + + public func setValue(ofSlider slider: Widget, to value: Double) { + guard let slider = slider as? TermKit.View else { return } + // TODO: set the value + } + + public func createSlider() -> Widget { + return Label("TODO:SLIDER") + } + + public func updatePicker( + _ picker: Widget, + options: [String], + environment: EnvironmentValues, + onChange: @escaping (Int?) -> Void + ) { + } + + public func setSelectedOption(ofPicker picker: Widget, to selectedOption: Int?) { + } + + public func createPicker() -> Widget { + return Label("TODO:PICKER") + } + + public func updateButton( + _ button: Widget, + label: String, + environment: EnvironmentValues, + action: @escaping () -> Void + ) { + (button as! TermKit.Button).text = label + (button as! TermKit.Button).clicked = { _ in + action() + } + } + + // TODO: Properly implement padding container. Perhaps use a conversion factor to + // convert the pixel values to 'characters' of padding + public func createPaddingContainer(for child: Widget) -> Widget { + return child + } + + public func getChild(ofPaddingContainer container: Widget) -> Widget { + return container + } + + public func setPadding( + ofPaddingContainer container: Widget, + top: Int, + bottom: Int, + leading: Int, + trailing: Int + ) {} + + public func limitScreenBounds(_ bounds: SIMD2) -> SIMD2 { + return SIMD2(min(bounds.x, Application.terminalSize.width), + min(bounds.y, Application.terminalSize.height)) + } +} + +public class RootView: TermKit.View { + override init() { + super.init() + canFocus = true + } + + public override func processKey(event: KeyEvent) -> Bool { + if super.processKey(event: event) { + return true + } + + switch event.key { + case .controlC, .esc: + Application.requestStop() + return true + default: + return false + } + } +}