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 + } + } +}