From 52406b9fd8d69eefcfef131cc3b8690e2d2e181e Mon Sep 17 00:00:00 2001 From: Riley Baker Date: Fri, 31 Oct 2025 19:56:21 -0400 Subject: [PATCH 1/2] Initial DatePicker implementation in UIKit Add DatePicker for AppKitBackend Add DatePickerExample to macOS CI Fix DatePicker update logic for AppKitBackend Update argument name to match SwiftUI Add more availability annotations Shut up tvOS let me see if the iOS CI will pass please work. Fine, here's your view Initial WinUI implementation Reformat WinUI code Implement minYear/maxYear for DatePicker Improve WinUI sizing code Fix CalendarDatePicker size Minor cleanup Generate GTK classes and improve manual type conversion oops Fix casing of calendar name Saving partial work on GtkBackend More partial work Use Gtk.Calendar Add missing parts to GtkBackend.updateDatePicker Add DatePickerExample to Linux CI Fix one Mac availability error Add availability annotation on unused widget Add time zone listener for UIKitBackend Add listener for AppKitBackend --- .github/workflows/build-test-and-docs.yml | 10 +- Examples/Bundler.toml | 5 + Examples/Package.resolved | 12 +- Examples/Package.swift | 4 + .../DatePickerExample/DatePickerApp.swift | 53 ++ Package.resolved | 10 +- Package.swift | 4 +- Sources/AppKitBackend/AppKitBackend.swift | 98 +++ Sources/Gtk/Generated/Calendar.swift | 216 ++++++ Sources/Gtk/Generated/SpinButton.swift | 669 ++++++++++++++++++ Sources/Gtk/Utility/GDateTime.swift | 55 ++ Sources/Gtk/Utility/GTimeZone.swift | 19 + Sources/Gtk/Widgets/Box.swift | 4 + .../Widgets/Calendar+ManualAdditions.swift | 15 + Sources/Gtk/Widgets/Calendar.swift | 18 - .../Widgets/SpinButton+ManualAdditions.swift | 7 + Sources/Gtk3/Generated/Calendar.swift | 232 ++++++ Sources/Gtk3/Generated/SpinButton.swift | 363 ++++++++++ Sources/Gtk3/Widgets/Calendar.swift | 16 - Sources/GtkBackend/GtkBackend.swift | 197 ++++++ Sources/GtkCodeGen/GtkCodeGen.swift | 12 +- Sources/SwiftCrossUI/Backend/AppBackend.swift | 26 + .../Environment/EnvironmentValues.swift | 15 + Sources/SwiftCrossUI/Views/DatePicker.swift | 152 ++++ .../Modifiers/DatePickerStyleModifier.swift | 13 + .../UIKitBackend/UIKitBackend+Control.swift | 74 ++ Sources/UIKitBackend/UIKitBackend.swift | 14 + Sources/WinUIBackend/WinUIBackend.swift | 345 +++++++++ 28 files changed, 2607 insertions(+), 51 deletions(-) create mode 100644 Examples/Sources/DatePickerExample/DatePickerApp.swift create mode 100644 Sources/Gtk/Generated/Calendar.swift create mode 100644 Sources/Gtk/Generated/SpinButton.swift create mode 100644 Sources/Gtk/Utility/GDateTime.swift create mode 100644 Sources/Gtk/Utility/GTimeZone.swift create mode 100644 Sources/Gtk/Widgets/Calendar+ManualAdditions.swift delete mode 100644 Sources/Gtk/Widgets/Calendar.swift create mode 100644 Sources/Gtk/Widgets/SpinButton+ManualAdditions.swift create mode 100644 Sources/Gtk3/Generated/Calendar.swift create mode 100644 Sources/Gtk3/Generated/SpinButton.swift delete mode 100644 Sources/Gtk3/Widgets/Calendar.swift create mode 100644 Sources/SwiftCrossUI/Views/DatePicker.swift create mode 100644 Sources/SwiftCrossUI/Views/Modifiers/DatePickerStyleModifier.swift diff --git a/.github/workflows/build-test-and-docs.yml b/.github/workflows/build-test-and-docs.yml index 96ec4c1941..d4a5742719 100644 --- a/.github/workflows/build-test-and-docs.yml +++ b/.github/workflows/build-test-and-docs.yml @@ -52,7 +52,8 @@ jobs: swift build --target SpreadsheetExample && \ swift build --target NotesExample && \ swift build --target GtkExample && \ - swift build --target PathsExample + swift build --target PathsExample && \ + swift build --target DatePickerExample - name: Test run: swift test --test-product swift-cross-uiPackageTests @@ -106,9 +107,10 @@ jobs: buildtarget PathsExample if [ $device_type != TV ]; then - # Slider is not implemented for tvOS + # Slider and DatePicker are not implemented for tvOS buildtarget ControlsExample buildtarget RandomNumberGeneratorExample + buildtarget DatePickerExample fi if [ $device_type = iPad ]; then @@ -165,6 +167,7 @@ jobs: buildtarget PathsExample buildtarget ControlsExample buildtarget RandomNumberGeneratorExample + buildtarget DatePickerExample # TODO test whether this works on Catalyst # buildtarget SplitExample @@ -305,7 +308,8 @@ jobs: swift build --target StressTestExample && \ swift build --target SpreadsheetExample && \ swift build --target NotesExample && \ - swift build --target GtkExample + swift build --target GtkExample && \ + swift build --target DatePickerExample - name: Test run: swift test --test-product swift-cross-uiPackageTests diff --git a/Examples/Bundler.toml b/Examples/Bundler.toml index 3b96dc6128..79a006f096 100644 --- a/Examples/Bundler.toml +++ b/Examples/Bundler.toml @@ -64,3 +64,8 @@ version = '0.1.0' identifier = 'dev.swiftcrossui.HoverExample' product = 'HoverExample' version = '0.1.0' + +[apps.DatePickerExample] +identifier = 'dev.swiftcrossui.DatePickerExample' +product = 'DatePickerExample' +version = '0.1.0' diff --git a/Examples/Package.resolved b/Examples/Package.resolved index 3842945e2a..a0d340fafb 100644 --- a/Examples/Package.resolved +++ b/Examples/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6977ba851e440a7fbdfc7cb46441e32853dc2ba48ba34fe702e6784699d08682", + "originHash" : "e48163963f1b0c0a4a505d749632d1c23f3997e66caf3ede5961e2d8b49fd2bb", "pins" : [ { "identity" : "aexml", @@ -283,7 +283,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stackotter/swift-uwp", "state" : { - "revision" : "e3ff9c195775e16b404b82cf6886c5e81d73b6c1" + "revision" : "8128f6615b7c5b46ada289ab6d49d871ca1e13a5" } }, { @@ -291,7 +291,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stackotter/swift-webview2core", "state" : { - "revision" : "c9911ca23455b9fcdb2429e98baa6f4d003b381c" + "revision" : "4396f5d94d6dfd1f95ab25e79de98141b7f4f183" } }, { @@ -299,7 +299,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stackotter/swift-windowsappsdk", "state" : { - "revision" : "ba6f0ec377b70d8be835d253102ff665a0e47d99" + "revision" : "f1c50892f10c0f7f635d3c7a3d728fd634ad001a" } }, { @@ -315,7 +315,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stackotter/swift-winui", "state" : { - "revision" : "1695ee3ea2b7a249f6504c7f1759e7ec7a38eb86" + "revision" : "42c47f4e4129c8b5a5d9912f05e1168c924ac180" } }, { @@ -401,4 +401,4 @@ } ], "version" : 3 -} +} \ No newline at end of file diff --git a/Examples/Package.swift b/Examples/Package.swift index 1735fc7675..f6446740a5 100644 --- a/Examples/Package.swift +++ b/Examples/Package.swift @@ -76,6 +76,10 @@ let package = Package( .executableTarget( name: "HoverExample", dependencies: exampleDependencies + ), + .executableTarget( + name: "DatePickerExample", + dependencies: exampleDependencies ) ] ) diff --git a/Examples/Sources/DatePickerExample/DatePickerApp.swift b/Examples/Sources/DatePickerExample/DatePickerApp.swift new file mode 100644 index 0000000000..d27562ce15 --- /dev/null +++ b/Examples/Sources/DatePickerExample/DatePickerApp.swift @@ -0,0 +1,53 @@ +import DefaultBackend +import Foundation +import SwiftCrossUI + +#if canImport(SwiftBundlerRuntime) + import SwiftBundlerRuntime +#endif + +@main +@HotReloadable +struct DatePickerApp: App { + @State var date = Date() + @State var style: DatePickerStyle? = .automatic + + var allStyles: [DatePickerStyle] + + init() { + allStyles = [.automatic] + + if #available(iOS 14, macCatalyst 14, *) { + allStyles.append(.graphical) + } + + #if !canImport(GtkBackend) + if #available(iOS 13.4, macCatalyst 13.4, *) { + allStyles.append(.compact) + #if os(iOS) || os(visionOS) || canImport(WinUIBackend) + allStyles.append(.wheel) + #endif + } + #endif + } + + var body: some Scene { + WindowGroup("Date Picker") { + VStack { + Text("Selected date: \(date)") + + Picker(of: allStyles, selection: $style) + + DatePicker( + "Test Picker", + selection: $date + ) + .datePickerStyle(style ?? .automatic) + + Button("Reset date") { + date = Date() + } + } + } + } +} diff --git a/Package.resolved b/Package.resolved index b260ee897f..18238c8079 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "77caf3e84e88f2ff183c89c647d8007a7ba7b50bfb1fb7216b22f7dfda4a2dc0", + "originHash" : "804c496a5b5ae9c797e640d6b4942ee27ff8156f7cac6d4ceb05ea6159d6deb3", "pins" : [ { "identity" : "jpeg", @@ -104,7 +104,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stackotter/swift-uwp", "state" : { - "revision" : "e3ff9c195775e16b404b82cf6886c5e81d73b6c1" + "revision" : "8128f6615b7c5b46ada289ab6d49d871ca1e13a5" } }, { @@ -112,7 +112,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stackotter/swift-webview2core", "state" : { - "revision" : "c9911ca23455b9fcdb2429e98baa6f4d003b381c" + "revision" : "4396f5d94d6dfd1f95ab25e79de98141b7f4f183" } }, { @@ -120,7 +120,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stackotter/swift-windowsappsdk", "state" : { - "revision" : "ba6f0ec377b70d8be835d253102ff665a0e47d99" + "revision" : "f1c50892f10c0f7f635d3c7a3d728fd634ad001a" } }, { @@ -136,7 +136,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stackotter/swift-winui", "state" : { - "revision" : "1695ee3ea2b7a249f6504c7f1759e7ec7a38eb86" + "revision" : "42c47f4e4129c8b5a5d9912f05e1168c924ac180" } }, { diff --git a/Package.swift b/Package.swift index 6d49268f6f..f084107638 100644 --- a/Package.swift +++ b/Package.swift @@ -112,7 +112,7 @@ let package = Package( ), .package( url: "https://github.com/stackotter/swift-windowsappsdk", - revision: "ba6f0ec377b70d8be835d253102ff665a0e47d99" + revision: "f1c50892f10c0f7f635d3c7a3d728fd634ad001a" ), .package( url: "https://github.com/stackotter/swift-windowsfoundation", @@ -120,7 +120,7 @@ let package = Package( ), .package( url: "https://github.com/stackotter/swift-winui", - revision: "1695ee3ea2b7a249f6504c7f1759e7ec7a38eb86" + revision: "42c47f4e4129c8b5a5d9912f05e1168c924ac180" ), .package( url: "https://github.com/stackotter/swift-benchmark", diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index 32adf784eb..870bbbe69a 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -350,6 +350,14 @@ public final class AppKitBackend: AppBackend { // Self.scrollBarWidth has changed action() } + + NotificationCenter.default.addObserver( + forName: .NSSystemTimeZoneDidChange, + object: nil, + queue: .main + ) { _ in + action() + } } public func computeWindowEnvironment( @@ -1803,6 +1811,80 @@ public final class NSCustomSheet: NSCustomWindow, NSWindowDelegate { onDismiss?() } } + + public func createDatePicker() -> NSView { + let datePicker = CustomDatePicker() + datePicker.delegate = datePicker.strongDelegate + return datePicker + } + + // Depending on the calendar, era is either necessary or must be omitted. Making the wrong + // choice for the current calendar means the cursor position is reset after every keystroke. I + // know of no simple way to tell whether NSDatePicker requires or forbids eras for a given + // calendar, so in lieu of that I have hardcoded the calendar identifiers. + private let calendarsWithEras: Set = [ + .buddhist, .coptic, .ethiopicAmeteAlem, .ethiopicAmeteMihret, .indian, .islamic, + .islamicCivil, .islamicTabular, .islamicUmmAlQura, .japanese, .persian, .republicOfChina, + ] + + public func updateDatePicker( + _ datePicker: NSView, + environment: EnvironmentValues, + date: Date, + range: ClosedRange, + components: DatePickerComponents, + onChange: @escaping (Date) -> Void + ) { + let datePicker = datePicker as! CustomDatePicker + + datePicker.isEnabled = environment.isEnabled + datePicker.textColor = environment.suggestedForegroundColor.nsColor + + // If the time zone is set to autoupdatingCurrent, then the cursor position is reset after + // every keystroke. Thanks Apple + datePicker.timeZone = + environment.timeZone == .autoupdatingCurrent ? .current : environment.timeZone + + // A couple properties cause infinite update loops if we assign to them on every update, so + // check their values first. + if datePicker.calendar != environment.calendar { + datePicker.calendar = environment.calendar + } + + if datePicker.dateValue != date { + datePicker.dateValue = date + } + + var elementFlags: NSDatePicker.ElementFlags = [] + if components.contains(.date) { + elementFlags.insert(.yearMonthDay) + if calendarsWithEras.contains(environment.calendar.identifier) { + elementFlags.insert(.era) + } + } + if components.contains(.hourMinuteAndSecond) { + elementFlags.insert(.hourMinuteSecond) + } else { + elementFlags.insert(.hourMinute) + } + + if datePicker.datePickerElements != elementFlags { + datePicker.datePickerElements = elementFlags + } + + datePicker.strongDelegate.onChange = onChange + + datePicker.minDate = range.lowerBound + datePicker.maxDate = range.upperBound + + datePicker.datePickerStyle = + switch environment.datePickerStyle { + case .automatic, .compact: + .textFieldAndStepper + case .graphical: + .clockAndCalendar + } + } } final class NSCustomTapGestureTarget: NSView { @@ -2310,3 +2392,19 @@ final class CustomWKNavigationDelegate: NSObject, WKNavigationDelegate { onNavigate?(url) } } + +final class CustomDatePicker: NSDatePicker { + var strongDelegate = CustomDatePickerDelegate() +} + +final class CustomDatePickerDelegate: NSObject, NSDatePickerCellDelegate { + var onChange: ((Date) -> Void)? + + func datePickerCell( + _: NSDatePickerCell, + validateProposedDateValue proposedDateValue: AutoreleasingUnsafeMutablePointer, + timeInterval _: UnsafeMutablePointer? + ) { + onChange?(proposedDateValue.pointee as Date) + } +} diff --git a/Sources/Gtk/Generated/Calendar.swift b/Sources/Gtk/Generated/Calendar.swift new file mode 100644 index 0000000000..5e20955cf9 --- /dev/null +++ b/Sources/Gtk/Generated/Calendar.swift @@ -0,0 +1,216 @@ +import CGtk + +/// `GtkCalendar` is a widget that displays a Gregorian calendar, one month +/// at a time. +/// +/// ![An example GtkCalendar](calendar.png) +/// +/// A `GtkCalendar` can be created with [ctor@Gtk.Calendar.new]. +/// +/// The date that is currently displayed can be altered with +/// [method@Gtk.Calendar.select_day]. +/// +/// To place a visual marker on a particular day, use +/// [method@Gtk.Calendar.mark_day] and to remove the marker, +/// [method@Gtk.Calendar.unmark_day]. Alternative, all +/// marks can be cleared with [method@Gtk.Calendar.clear_marks]. +/// +/// The selected date can be retrieved from a `GtkCalendar` using +/// [method@Gtk.Calendar.get_date]. +/// +/// Users should be aware that, although the Gregorian calendar is the +/// legal calendar in most countries, it was adopted progressively +/// between 1582 and 1929. Display before these dates is likely to be +/// historically incorrect. +/// +/// # Shortcuts and Gestures +/// +/// `GtkCalendar` supports the following gestures: +/// +/// - Scrolling up or down will switch to the previous or next month. +/// - Date strings can be dropped for setting the current day. +/// +/// # CSS nodes +/// +/// ``` +/// calendar.view +/// ├── header +/// │ ├── button +/// │ ├── stack.month +/// │ ├── button +/// │ ├── button +/// │ ├── label.year +/// │ ╰── button +/// ╰── grid +/// ╰── label[.day-name][.week-number][.day-number][.other-month][.today] +/// ``` +/// +/// `GtkCalendar` has a main node with name calendar. It contains a subnode +/// called header containing the widgets for switching between years and months. +/// +/// The grid subnode contains all day labels, including week numbers on the left +/// (marked with the .week-number css class) and day names on top (marked with the +/// .day-name css class). +/// +/// Day labels that belong to the previous or next month get the .other-month +/// style class. The label of the current day get the .today style class. +/// +/// Marked day labels get the :selected state assigned. +open class Calendar: Widget { + /// Creates a new calendar, with the current date being selected. + public convenience init() { + self.init( + gtk_calendar_new() + ) + } + + override func didMoveToParent() { + super.didMoveToParent() + + addSignal(name: "day-selected") { [weak self] () in + guard let self = self else { return } + self.daySelected?(self) + } + + addSignal(name: "next-month") { [weak self] () in + guard let self = self else { return } + self.nextMonth?(self) + } + + addSignal(name: "next-year") { [weak self] () in + guard let self = self else { return } + self.nextYear?(self) + } + + addSignal(name: "prev-month") { [weak self] () in + guard let self = self else { return } + self.prevMonth?(self) + } + + addSignal(name: "prev-year") { [weak self] () in + guard let self = self else { return } + self.prevYear?(self) + } + + let handler5: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::day", handler: gCallback(handler5)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyDay?(self, param0) + } + + let handler6: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::month", handler: gCallback(handler6)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyMonth?(self, param0) + } + + let handler7: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::show-day-names", handler: gCallback(handler7)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyShowDayNames?(self, param0) + } + + let handler8: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::show-heading", handler: gCallback(handler8)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyShowHeading?(self, param0) + } + + let handler9: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::show-week-numbers", handler: gCallback(handler9)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyShowWeekNumbers?(self, param0) + } + + let handler10: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::year", handler: gCallback(handler10)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyYear?(self, param0) + } + } + + /// The selected day (as a number between 1 and 31). + @GObjectProperty(named: "day") public var day: Int + + /// The selected month (as a number between 0 and 11). + /// + /// This property gets initially set to the current month. + @GObjectProperty(named: "month") public var month: Int + + /// Determines whether day names are displayed. + @GObjectProperty(named: "show-day-names") public var showDayNames: Bool + + /// Determines whether a heading is displayed. + @GObjectProperty(named: "show-heading") public var showHeading: Bool + + /// Determines whether week numbers are displayed. + @GObjectProperty(named: "show-week-numbers") public var showWeekNumbers: Bool + + /// The selected year. + /// + /// This property gets initially set to the current year. + @GObjectProperty(named: "year") public var year: Int + + /// Emitted when the user selects a day. + public var daySelected: ((Calendar) -> Void)? + + /// Emitted when the user switched to the next month. + public var nextMonth: ((Calendar) -> Void)? + + /// Emitted when user switched to the next year. + public var nextYear: ((Calendar) -> Void)? + + /// Emitted when the user switched to the previous month. + public var prevMonth: ((Calendar) -> Void)? + + /// Emitted when user switched to the previous year. + public var prevYear: ((Calendar) -> Void)? + + public var notifyDay: ((Calendar, OpaquePointer) -> Void)? + + public var notifyMonth: ((Calendar, OpaquePointer) -> Void)? + + public var notifyShowDayNames: ((Calendar, OpaquePointer) -> Void)? + + public var notifyShowHeading: ((Calendar, OpaquePointer) -> Void)? + + public var notifyShowWeekNumbers: ((Calendar, OpaquePointer) -> Void)? + + public var notifyYear: ((Calendar, OpaquePointer) -> Void)? +} diff --git a/Sources/Gtk/Generated/SpinButton.swift b/Sources/Gtk/Generated/SpinButton.swift new file mode 100644 index 0000000000..b20ebe5a9a --- /dev/null +++ b/Sources/Gtk/Generated/SpinButton.swift @@ -0,0 +1,669 @@ +import CGtk + +/// A `GtkSpinButton` is an ideal way to allow the user to set the +/// value of some attribute. +/// +/// ![An example GtkSpinButton](spinbutton.png) +/// +/// Rather than having to directly type a number into a `GtkEntry`, +/// `GtkSpinButton` allows the user to click on one of two arrows +/// to increment or decrement the displayed value. A value can still be +/// typed in, with the bonus that it can be checked to ensure it is in a +/// given range. +/// +/// The main properties of a `GtkSpinButton` are through an adjustment. +/// See the [class@Gtk.Adjustment] documentation for more details about +/// an adjustment's properties. +/// +/// Note that `GtkSpinButton` will by default make its entry large enough +/// to accommodate the lower and upper bounds of the adjustment. If this +/// is not desired, the automatic sizing can be turned off by explicitly +/// setting [property@Gtk.Editable:width-chars] to a value != -1. +/// +/// ## Using a GtkSpinButton to get an integer +/// +/// ```c +/// // Provides a function to retrieve an integer value from a GtkSpinButton +/// // and creates a spin button to model percentage values. +/// +/// int +/// grab_int_value (GtkSpinButton *button, +/// gpointer user_data) +/// { +/// return gtk_spin_button_get_value_as_int (button); +/// } +/// +/// void +/// create_integer_spin_button (void) +/// { +/// +/// GtkWidget *window, *button; +/// GtkAdjustment *adjustment; +/// +/// adjustment = gtk_adjustment_new (50.0, 0.0, 100.0, 1.0, 5.0, 0.0); +/// +/// window = gtk_window_new (); +/// +/// // creates the spinbutton, with no decimal places +/// button = gtk_spin_button_new (adjustment, 1.0, 0); +/// gtk_window_set_child (GTK_WINDOW (window), button); +/// +/// gtk_window_present (GTK_WINDOW (window)); +/// } +/// ``` +/// +/// ## Using a GtkSpinButton to get a floating point value +/// +/// ```c +/// // Provides a function to retrieve a floating point value from a +/// // GtkSpinButton, and creates a high precision spin button. +/// +/// float +/// grab_float_value (GtkSpinButton *button, +/// gpointer user_data) +/// { +/// return gtk_spin_button_get_value (button); +/// } +/// +/// void +/// create_floating_spin_button (void) +/// { +/// GtkWidget *window, *button; +/// GtkAdjustment *adjustment; +/// +/// adjustment = gtk_adjustment_new (2.500, 0.0, 5.0, 0.001, 0.1, 0.0); +/// +/// window = gtk_window_new (); +/// +/// // creates the spinbutton, with three decimal places +/// button = gtk_spin_button_new (adjustment, 0.001, 3); +/// gtk_window_set_child (GTK_WINDOW (window), button); +/// +/// gtk_window_present (GTK_WINDOW (window)); +/// } +/// ``` +/// +/// # Shortcuts and Gestures +/// +/// The following signals have default keybindings: +/// +/// - [signal@Gtk.SpinButton::change-value] +/// +/// # CSS nodes +/// +/// ``` +/// spinbutton.horizontal +/// ├── text +/// │ ├── undershoot.left +/// │ ╰── undershoot.right +/// ├── button.down +/// ╰── button.up +/// ``` +/// +/// ``` +/// spinbutton.vertical +/// ├── button.up +/// ├── text +/// │ ├── undershoot.left +/// │ ╰── undershoot.right +/// ╰── button.down +/// ``` +/// +/// `GtkSpinButton`s main CSS node has the name spinbutton. It creates subnodes +/// for the entry and the two buttons, with these names. The button nodes have +/// the style classes .up and .down. The `GtkText` subnodes (if present) are put +/// below the text node. The orientation of the spin button is reflected in +/// the .vertical or .horizontal style class on the main node. +/// +/// # Accessibility +/// +/// `GtkSpinButton` uses the %GTK_ACCESSIBLE_ROLE_SPIN_BUTTON role. +open class SpinButton: Widget, CellEditable, Editable, Orientable { + /// Creates a new `GtkSpinButton`. + public convenience init( + adjustment: UnsafeMutablePointer!, climbRate: Double, digits: UInt + ) { + self.init( + gtk_spin_button_new(adjustment, climbRate, guint(digits)) + ) + } + + /// Creates a new `GtkSpinButton` with the given properties. + /// + /// This is a convenience constructor that allows creation + /// of a numeric `GtkSpinButton` without manually creating + /// an adjustment. The value is initially set to the minimum + /// value and a page increment of 10 * @step is the default. + /// The precision of the spin button is equivalent to the + /// precision of @step. + /// + /// Note that the way in which the precision is derived works + /// best if @step is a power of ten. If the resulting precision + /// is not suitable for your needs, use + /// [method@Gtk.SpinButton.set_digits] to correct it. + public convenience init(range min: Double, max: Double, step: Double) { + self.init( + gtk_spin_button_new_with_range(min, max, step) + ) + } + + override func didMoveToParent() { + super.didMoveToParent() + + addSignal(name: "activate") { [weak self] () in + guard let self = self else { return } + self.activate?(self) + } + + let handler1: + @convention(c) (UnsafeMutableRawPointer, GtkScrollType, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "change-value", handler: gCallback(handler1)) { + [weak self] (param0: GtkScrollType) in + guard let self = self else { return } + self.changeValue?(self, param0) + } + + let handler2: + @convention(c) (UnsafeMutableRawPointer, gpointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "input", handler: gCallback(handler2)) { [weak self] (param0: gpointer) in + guard let self = self else { return } + self.input?(self, param0) + } + + addSignal(name: "output") { [weak self] () in + guard let self = self else { return } + self.output?(self) + } + + addSignal(name: "value-changed") { [weak self] () in + guard let self = self else { return } + self.valueChanged?(self) + } + + addSignal(name: "wrapped") { [weak self] () in + guard let self = self else { return } + self.wrapped?(self) + } + + addSignal(name: "editing-done") { [weak self] () in + guard let self = self else { return } + self.editingDone?(self) + } + + addSignal(name: "remove-widget") { [weak self] () in + guard let self = self else { return } + self.removeWidget?(self) + } + + addSignal(name: "changed") { [weak self] () in + guard let self = self else { return } + self.changed?(self) + } + + let handler9: + @convention(c) (UnsafeMutableRawPointer, Int, Int, UnsafeMutableRawPointer) -> Void = + { _, value1, value2, data in + SignalBox2.run(data, value1, value2) + } + + addSignal(name: "delete-text", handler: gCallback(handler9)) { + [weak self] (param0: Int, param1: Int) in + guard let self = self else { return } + self.deleteText?(self, param0, param1) + } + + let handler10: + @convention(c) ( + UnsafeMutableRawPointer, UnsafePointer, Int, gpointer, + UnsafeMutableRawPointer + ) -> Void = + { _, value1, value2, value3, data in + SignalBox3, Int, gpointer>.run( + data, value1, value2, value3) + } + + addSignal(name: "insert-text", handler: gCallback(handler10)) { + [weak self] (param0: UnsafePointer, param1: Int, param2: gpointer) in + guard let self = self else { return } + self.insertText?(self, param0, param1, param2) + } + + let handler11: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::activates-default", handler: gCallback(handler11)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyActivatesDefault?(self, param0) + } + + let handler12: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::adjustment", handler: gCallback(handler12)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyAdjustment?(self, param0) + } + + let handler13: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::climb-rate", handler: gCallback(handler13)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyClimbRate?(self, param0) + } + + let handler14: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::digits", handler: gCallback(handler14)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyDigits?(self, param0) + } + + let handler15: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::numeric", handler: gCallback(handler15)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyNumeric?(self, param0) + } + + let handler16: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::snap-to-ticks", handler: gCallback(handler16)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifySnapToTicks?(self, param0) + } + + let handler17: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::update-policy", handler: gCallback(handler17)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyUpdatePolicy?(self, param0) + } + + let handler18: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::value", handler: gCallback(handler18)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyValue?(self, param0) + } + + let handler19: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::wrap", handler: gCallback(handler19)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyWrap?(self, param0) + } + + let handler20: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::editing-canceled", handler: gCallback(handler20)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyEditingCanceled?(self, param0) + } + + let handler21: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::cursor-position", handler: gCallback(handler21)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyCursorPosition?(self, param0) + } + + let handler22: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::editable", handler: gCallback(handler22)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyEditable?(self, param0) + } + + let handler23: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::enable-undo", handler: gCallback(handler23)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyEnableUndo?(self, param0) + } + + let handler24: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::max-width-chars", handler: gCallback(handler24)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyMaxWidthChars?(self, param0) + } + + let handler25: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::selection-bound", handler: gCallback(handler25)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifySelectionBound?(self, param0) + } + + let handler26: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::text", handler: gCallback(handler26)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyText?(self, param0) + } + + let handler27: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::width-chars", handler: gCallback(handler27)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyWidthChars?(self, param0) + } + + let handler28: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::xalign", handler: gCallback(handler28)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyXalign?(self, param0) + } + + let handler29: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::orientation", handler: gCallback(handler29)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyOrientation?(self, param0) + } + } + + /// The acceleration rate when you hold down a button or key. + @GObjectProperty(named: "climb-rate") public var climbRate: Double + + /// The number of decimal places to display. + @GObjectProperty(named: "digits") public var digits: UInt + + /// Whether non-numeric characters should be ignored. + @GObjectProperty(named: "numeric") public var numeric: Bool + + /// Whether erroneous values are automatically changed to the spin buttons + /// nearest step increment. + @GObjectProperty(named: "snap-to-ticks") public var snapToTicks: Bool + + /// Whether the spin button should update always, or only when the value + /// is acceptable. + @GObjectProperty(named: "update-policy") public var updatePolicy: SpinButtonUpdatePolicy + + /// The current value. + @GObjectProperty(named: "value") public var value: Double + + /// Whether a spin button should wrap upon reaching its limits. + @GObjectProperty(named: "wrap") public var wrap: 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 + + /// If undo/redo should be enabled for the editable. + @GObjectProperty(named: "enable-undo") public var enableUndo: Bool + + /// The desired maximum width of the entry, in characters. + @GObjectProperty(named: "max-width-chars") public var maxWidthChars: Int + + /// The contents of the entry. + @GObjectProperty(named: "text") public var text: String + + /// 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 + + /// The orientation of the orientable. + @GObjectProperty(named: "orientation") public var orientation: Orientation + + /// Emitted when the spin button is activated. + /// + /// The keybindings for this signal are all forms of the Enter key. + /// + /// If the Enter key results in the value being committed to the + /// spin button, then activation does not occur until Enter is + /// pressed again. + public var activate: ((SpinButton) -> Void)? + + /// Emitted when the user initiates a value change. + /// + /// This is a [keybinding signal](class.SignalAction.html). + /// + /// Applications should not connect to it, but may emit it with + /// g_signal_emit_by_name() if they need to control the cursor + /// programmatically. + /// + /// The default bindings for this signal are Up/Down and PageUp/PageDown. + public var changeValue: ((SpinButton, GtkScrollType) -> Void)? + + /// Emitted to convert the users input into a double value. + /// + /// The signal handler is expected to use [method@Gtk.Editable.get_text] + /// to retrieve the text of the spinbutton and set @new_value to the + /// new value. + /// + /// The default conversion uses g_strtod(). + public var input: ((SpinButton, gpointer) -> Void)? + + /// Emitted to tweak the formatting of the value for display. + /// + /// ```c + /// // show leading zeros + /// static gboolean + /// on_output (GtkSpinButton *spin, + /// gpointer data) + /// { + /// char *text; + /// int value; + /// + /// value = gtk_spin_button_get_value_as_int (spin); + /// text = g_strdup_printf ("%02d", value); + /// gtk_editable_set_text (GTK_EDITABLE (spin), text): + /// g_free (text); + /// + /// return TRUE; + /// } + /// ``` + public var output: ((SpinButton) -> Void)? + + /// Emitted when the value is changed. + /// + /// Also see the [signal@Gtk.SpinButton::output] signal. + public var valueChanged: ((SpinButton) -> Void)? + + /// Emitted right after the spinbutton wraps from its maximum + /// to its minimum value or vice-versa. + public var wrapped: ((SpinButton) -> Void)? + + /// This signal is a sign for the cell renderer to update its + /// value from the @cell_editable. + /// + /// Implementations of `GtkCellEditable` are responsible for + /// emitting this signal when they are done editing, e.g. + /// `GtkEntry` emits this signal when the user presses Enter. Typical things to + /// do in a handler for ::editing-done are to capture the edited value, + /// disconnect the @cell_editable from signals on the `GtkCellRenderer`, etc. + /// + /// gtk_cell_editable_editing_done() is a convenience method + /// for emitting `GtkCellEditable::editing-done`. + public var editingDone: ((SpinButton) -> Void)? + + /// This signal is meant to indicate that the cell is finished + /// editing, and the @cell_editable widget is being removed and may + /// subsequently be destroyed. + /// + /// Implementations of `GtkCellEditable` are responsible for + /// emitting this signal when they are done editing. It must + /// be emitted after the `GtkCellEditable::editing-done` signal, + /// to give the cell renderer a chance to update the cell's value + /// before the widget is removed. + /// + /// gtk_cell_editable_remove_widget() is a convenience method + /// for emitting `GtkCellEditable::remove-widget`. + public var removeWidget: ((SpinButton) -> Void)? + + /// Emitted at the end of a single user-visible operation on the + /// contents. + /// + /// E.g., a paste operation that replaces the contents of the + /// selection will cause only one signal emission (even though it + /// is implemented by first deleting the selection, then inserting + /// the new content, and may cause multiple ::notify::text signals + /// to be emitted). + public var changed: ((SpinButton) -> Void)? + + /// Emitted when text is deleted from the widget by the user. + /// + /// The default handler for this signal will normally be responsible for + /// deleting the text, so by connecting to this signal and then stopping + /// the signal with g_signal_stop_emission(), it is possible to modify the + /// range of deleted text, or prevent it from being deleted entirely. + /// + /// The @start_pos and @end_pos parameters are interpreted as for + /// [method@Gtk.Editable.delete_text]. + public var deleteText: ((SpinButton, Int, Int) -> Void)? + + /// Emitted when text is inserted into the widget by the user. + /// + /// The default handler for this signal will normally be responsible + /// for inserting the text, so by connecting to this signal and then + /// stopping the signal with g_signal_stop_emission(), it is possible + /// to modify the inserted text, or prevent it from being inserted entirely. + public var insertText: ((SpinButton, UnsafePointer, Int, gpointer) -> Void)? + + public var notifyActivatesDefault: ((SpinButton, OpaquePointer) -> Void)? + + public var notifyAdjustment: ((SpinButton, OpaquePointer) -> Void)? + + public var notifyClimbRate: ((SpinButton, OpaquePointer) -> Void)? + + public var notifyDigits: ((SpinButton, OpaquePointer) -> Void)? + + public var notifyNumeric: ((SpinButton, OpaquePointer) -> Void)? + + public var notifySnapToTicks: ((SpinButton, OpaquePointer) -> Void)? + + public var notifyUpdatePolicy: ((SpinButton, OpaquePointer) -> Void)? + + public var notifyValue: ((SpinButton, OpaquePointer) -> Void)? + + public var notifyWrap: ((SpinButton, OpaquePointer) -> Void)? + + public var notifyEditingCanceled: ((SpinButton, OpaquePointer) -> Void)? + + public var notifyCursorPosition: ((SpinButton, OpaquePointer) -> Void)? + + public var notifyEditable: ((SpinButton, OpaquePointer) -> Void)? + + public var notifyEnableUndo: ((SpinButton, OpaquePointer) -> Void)? + + public var notifyMaxWidthChars: ((SpinButton, OpaquePointer) -> Void)? + + public var notifySelectionBound: ((SpinButton, OpaquePointer) -> Void)? + + public var notifyText: ((SpinButton, OpaquePointer) -> Void)? + + public var notifyWidthChars: ((SpinButton, OpaquePointer) -> Void)? + + public var notifyXalign: ((SpinButton, OpaquePointer) -> Void)? + + public var notifyOrientation: ((SpinButton, OpaquePointer) -> Void)? +} diff --git a/Sources/Gtk/Utility/GDateTime.swift b/Sources/Gtk/Utility/GDateTime.swift new file mode 100644 index 0000000000..a4d61cb666 --- /dev/null +++ b/Sources/Gtk/Utility/GDateTime.swift @@ -0,0 +1,55 @@ +import CGtk +import Foundation + +public class GDateTime { + public let pointer: OpaquePointer + + public init(_ pointer: OpaquePointer) { + self.pointer = pointer + } + + public init?(_ pointer: OpaquePointer?) { + guard let pointer else { return nil } + self.pointer = pointer + } + + public convenience init?(unixEpoch: TimeInterval) { + // g_date_time_new_from_unix_utc_usec appears to be too new + self.init(g_date_time_new_from_unix_utc(gint64(unixEpoch))) + } + + public convenience init?( + timeZone: GTimeZone, + year: Int, + month: Int, + day: Int, + hour: Int, + minute: Int, + second: Double + ) { + self.init( + g_date_time_new( + timeZone.pointer, + gint(year), + gint(month), + gint(day), + gint(hour), + gint(minute), + second + ) + ) + } + + public convenience init!(_ date: Date) { + self.init(unixEpoch: date.timeIntervalSince1970) + } + + deinit { + g_date_time_unref(pointer) + } + + public func toDate() -> Date { + let offset = g_date_time_to_unix(pointer) + return Date(timeIntervalSince1970: Double(offset)) + } +} diff --git a/Sources/Gtk/Utility/GTimeZone.swift b/Sources/Gtk/Utility/GTimeZone.swift new file mode 100644 index 0000000000..7190315e07 --- /dev/null +++ b/Sources/Gtk/Utility/GTimeZone.swift @@ -0,0 +1,19 @@ +import CGtk +import Foundation + +public final class GTimeZone { + public let pointer: OpaquePointer + + public init?(identifier: String) { + guard let pointer = g_time_zone_new_identifier(identifier) else { return nil } + self.pointer = pointer + } + + public convenience init?(_ timeZone: TimeZone) { + self.init(identifier: timeZone.identifier) + } + + deinit { + g_time_zone_unref(pointer) + } +} diff --git a/Sources/Gtk/Widgets/Box.swift b/Sources/Gtk/Widgets/Box.swift index 2111d4f1a2..73f2aa2abe 100644 --- a/Sources/Gtk/Widgets/Box.swift +++ b/Sources/Gtk/Widgets/Box.swift @@ -39,6 +39,10 @@ open class Box: Widget, Orientable { children = [] } + public func insert(child: Widget, after sibling: Widget) { + gtk_box_insert_child_after(castedPointer(), child.widgetPointer, sibling.widgetPointer) + } + @GObjectProperty(named: "spacing") open var spacing: Int @GObjectProperty(named: "orientation") open var orientation: Orientation diff --git a/Sources/Gtk/Widgets/Calendar+ManualAdditions.swift b/Sources/Gtk/Widgets/Calendar+ManualAdditions.swift new file mode 100644 index 0000000000..bfc400c1dc --- /dev/null +++ b/Sources/Gtk/Widgets/Calendar+ManualAdditions.swift @@ -0,0 +1,15 @@ +import CGtk +import Foundation + +extension Calendar { + public var date: Date { + get { + GDateTime(gtk_calendar_get_date(opaquePointer)).toDate() + } + set { + withExtendedLifetime(GDateTime(newValue)) { gDateTime in + gtk_calendar_select_day(opaquePointer, gDateTime.pointer) + } + } + } +} diff --git a/Sources/Gtk/Widgets/Calendar.swift b/Sources/Gtk/Widgets/Calendar.swift deleted file mode 100644 index de8215a136..0000000000 --- a/Sources/Gtk/Widgets/Calendar.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Copyright © 2016 Tomas Linhart. All rights reserved. -// - -import CGtk - -public class Calendar: Widget { - public convenience init() { - self.init( - gtk_calendar_new() - ) - } - - /// The selected year. This property gets initially set to the current year. - @GObjectProperty(named: "year") public var year: Int - /// Determines whether a heading is displayed. - @GObjectProperty(named: "show-heading") public var showHeading: Bool -} diff --git a/Sources/Gtk/Widgets/SpinButton+ManualAdditions.swift b/Sources/Gtk/Widgets/SpinButton+ManualAdditions.swift new file mode 100644 index 0000000000..1980000d06 --- /dev/null +++ b/Sources/Gtk/Widgets/SpinButton+ManualAdditions.swift @@ -0,0 +1,7 @@ +import CGtk + +extension SpinButton { + public func setRange(min: Double, max: Double) { + gtk_spin_button_set_range(opaquePointer, min, max) + } +} diff --git a/Sources/Gtk3/Generated/Calendar.swift b/Sources/Gtk3/Generated/Calendar.swift new file mode 100644 index 0000000000..0d23f2d51c --- /dev/null +++ b/Sources/Gtk3/Generated/Calendar.swift @@ -0,0 +1,232 @@ +import CGtk3 + +/// #GtkCalendar is a widget that displays a Gregorian calendar, one month +/// at a time. It can be created with gtk_calendar_new(). +/// +/// The month and year currently displayed can be altered with +/// gtk_calendar_select_month(). The exact day can be selected from the +/// displayed month using gtk_calendar_select_day(). +/// +/// To place a visual marker on a particular day, use gtk_calendar_mark_day() +/// and to remove the marker, gtk_calendar_unmark_day(). Alternative, all +/// marks can be cleared with gtk_calendar_clear_marks(). +/// +/// The way in which the calendar itself is displayed can be altered using +/// gtk_calendar_set_display_options(). +/// +/// The selected date can be retrieved from a #GtkCalendar using +/// gtk_calendar_get_date(). +/// +/// Users should be aware that, although the Gregorian calendar is the +/// legal calendar in most countries, it was adopted progressively +/// between 1582 and 1929. Display before these dates is likely to be +/// historically incorrect. +open class Calendar: Widget { + /// Creates a new calendar, with the current date being selected. + public convenience init() { + self.init( + gtk_calendar_new() + ) + } + + override func didMoveToParent() { + super.didMoveToParent() + + addSignal(name: "day-selected") { [weak self] () in + guard let self = self else { return } + self.daySelected?(self) + } + + addSignal(name: "day-selected-double-click") { [weak self] () in + guard let self = self else { return } + self.daySelectedDoubleClick?(self) + } + + addSignal(name: "month-changed") { [weak self] () in + guard let self = self else { return } + self.monthChanged?(self) + } + + addSignal(name: "next-month") { [weak self] () in + guard let self = self else { return } + self.nextMonth?(self) + } + + addSignal(name: "next-year") { [weak self] () in + guard let self = self else { return } + self.nextYear?(self) + } + + addSignal(name: "prev-month") { [weak self] () in + guard let self = self else { return } + self.prevMonth?(self) + } + + addSignal(name: "prev-year") { [weak self] () in + guard let self = self else { return } + self.prevYear?(self) + } + + let handler7: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::day", handler: gCallback(handler7)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyDay?(self, param0) + } + + let handler8: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::detail-height-rows", handler: gCallback(handler8)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyDetailHeightRows?(self, param0) + } + + let handler9: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::detail-width-chars", handler: gCallback(handler9)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyDetailWidthChars?(self, param0) + } + + let handler10: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::month", handler: gCallback(handler10)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyMonth?(self, param0) + } + + let handler11: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::no-month-change", handler: gCallback(handler11)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyNoMonthChange?(self, param0) + } + + let handler12: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::show-day-names", handler: gCallback(handler12)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyShowDayNames?(self, param0) + } + + let handler13: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::show-details", handler: gCallback(handler13)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyShowDetails?(self, param0) + } + + let handler14: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::show-heading", handler: gCallback(handler14)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyShowHeading?(self, param0) + } + + let handler15: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::show-week-numbers", handler: gCallback(handler15)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyShowWeekNumbers?(self, param0) + } + + let handler16: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::year", handler: gCallback(handler16)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyYear?(self, param0) + } + } + + /// Emitted when the user selects a day. + public var daySelected: ((Calendar) -> Void)? + + /// Emitted when the user double-clicks a day. + public var daySelectedDoubleClick: ((Calendar) -> Void)? + + /// Emitted when the user clicks a button to change the selected month on a + /// calendar. + public var monthChanged: ((Calendar) -> Void)? + + /// Emitted when the user switched to the next month. + public var nextMonth: ((Calendar) -> Void)? + + /// Emitted when user switched to the next year. + public var nextYear: ((Calendar) -> Void)? + + /// Emitted when the user switched to the previous month. + public var prevMonth: ((Calendar) -> Void)? + + /// Emitted when user switched to the previous year. + public var prevYear: ((Calendar) -> Void)? + + public var notifyDay: ((Calendar, OpaquePointer) -> Void)? + + public var notifyDetailHeightRows: ((Calendar, OpaquePointer) -> Void)? + + public var notifyDetailWidthChars: ((Calendar, OpaquePointer) -> Void)? + + public var notifyMonth: ((Calendar, OpaquePointer) -> Void)? + + public var notifyNoMonthChange: ((Calendar, OpaquePointer) -> Void)? + + public var notifyShowDayNames: ((Calendar, OpaquePointer) -> Void)? + + public var notifyShowDetails: ((Calendar, OpaquePointer) -> Void)? + + public var notifyShowHeading: ((Calendar, OpaquePointer) -> Void)? + + public var notifyShowWeekNumbers: ((Calendar, OpaquePointer) -> Void)? + + public var notifyYear: ((Calendar, OpaquePointer) -> Void)? +} diff --git a/Sources/Gtk3/Generated/SpinButton.swift b/Sources/Gtk3/Generated/SpinButton.swift new file mode 100644 index 0000000000..0e07eafef1 --- /dev/null +++ b/Sources/Gtk3/Generated/SpinButton.swift @@ -0,0 +1,363 @@ +import CGtk3 + +/// A #GtkSpinButton is an ideal way to allow the user to set the value of +/// some attribute. Rather than having to directly type a number into a +/// #GtkEntry, GtkSpinButton allows the user to click on one of two arrows +/// to increment or decrement the displayed value. A value can still be +/// typed in, with the bonus that it can be checked to ensure it is in a +/// given range. +/// +/// The main properties of a GtkSpinButton are through an adjustment. +/// See the #GtkAdjustment section for more details about an adjustment's +/// properties. Note that GtkSpinButton will by default make its entry +/// large enough to accomodate the lower and upper bounds of the adjustment, +/// which can lead to surprising results. Best practice is to set both +/// the #GtkEntry:width-chars and #GtkEntry:max-width-chars poperties +/// to the desired number of characters to display in the entry. +/// +/// # CSS nodes +/// +/// |[ +/// spinbutton.horizontal +/// ├── undershoot.left +/// ├── undershoot.right +/// ├── entry +/// │ ╰── ... +/// ├── button.down +/// ╰── button.up +/// ]| +/// +/// |[ +/// spinbutton.vertical +/// ├── undershoot.left +/// ├── undershoot.right +/// ├── button.up +/// ├── entry +/// │ ╰── ... +/// ╰── button.down +/// ]| +/// +/// GtkSpinButtons main CSS node has the name spinbutton. It creates subnodes +/// for the entry and the two buttons, with these names. The button nodes have +/// the style classes .up and .down. The GtkEntry subnodes (if present) are put +/// below the entry node. The orientation of the spin button is reflected in +/// the .vertical or .horizontal style class on the main node. +/// +/// ## Using a GtkSpinButton to get an integer +/// +/// |[ +/// // Provides a function to retrieve an integer value from a GtkSpinButton +/// // and creates a spin button to model percentage values. +/// +/// gint +/// grab_int_value (GtkSpinButton *button, +/// gpointer user_data) +/// { +/// return gtk_spin_button_get_value_as_int (button); +/// } +/// +/// void +/// create_integer_spin_button (void) +/// { +/// +/// GtkWidget *window, *button; +/// GtkAdjustment *adjustment; +/// +/// adjustment = gtk_adjustment_new (50.0, 0.0, 100.0, 1.0, 5.0, 0.0); +/// +/// window = gtk_window_new (GTK_WINDOW_TOPLEVEL); +/// gtk_container_set_border_width (GTK_CONTAINER (window), 5); +/// +/// // creates the spinbutton, with no decimal places +/// button = gtk_spin_button_new (adjustment, 1.0, 0); +/// gtk_container_add (GTK_CONTAINER (window), button); +/// +/// gtk_widget_show_all (window); +/// } +/// ]| +/// +/// ## Using a GtkSpinButton to get a floating point value +/// +/// |[ +/// // Provides a function to retrieve a floating point value from a +/// // GtkSpinButton, and creates a high precision spin button. +/// +/// gfloat +/// grab_float_value (GtkSpinButton *button, +/// gpointer user_data) +/// { +/// return gtk_spin_button_get_value (button); +/// } +/// +/// void +/// create_floating_spin_button (void) +/// { +/// GtkWidget *window, *button; +/// GtkAdjustment *adjustment; +/// +/// adjustment = gtk_adjustment_new (2.500, 0.0, 5.0, 0.001, 0.1, 0.0); +/// +/// window = gtk_window_new (GTK_WINDOW_TOPLEVEL); +/// gtk_container_set_border_width (GTK_CONTAINER (window), 5); +/// +/// // creates the spinbutton, with three decimal places +/// button = gtk_spin_button_new (adjustment, 0.001, 3); +/// gtk_container_add (GTK_CONTAINER (window), button); +/// +/// gtk_widget_show_all (window); +/// } +/// ]| +open class SpinButton: Entry, Orientable { + /// Creates a new #GtkSpinButton. + public convenience init( + adjustment: UnsafeMutablePointer!, climbRate: Double, digits: UInt + ) { + self.init( + gtk_spin_button_new(adjustment, climbRate, guint(digits)) + ) + } + + /// This is a convenience constructor that allows creation of a numeric + /// #GtkSpinButton without manually creating an adjustment. The value is + /// initially set to the minimum value and a page increment of 10 * @step + /// is the default. The precision of the spin button is equivalent to the + /// precision of @step. + /// + /// Note that the way in which the precision is derived works best if @step + /// is a power of ten. If the resulting precision is not suitable for your + /// needs, use gtk_spin_button_set_digits() to correct it. + public convenience init(range min: Double, max: Double, step: Double) { + self.init( + gtk_spin_button_new_with_range(min, max, step) + ) + } + + override func didMoveToParent() { + super.didMoveToParent() + + let handler0: + @convention(c) (UnsafeMutableRawPointer, GtkScrollType, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "change-value", handler: gCallback(handler0)) { + [weak self] (param0: GtkScrollType) in + guard let self = self else { return } + self.changeValue?(self, param0) + } + + let handler1: + @convention(c) (UnsafeMutableRawPointer, gpointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "input", handler: gCallback(handler1)) { [weak self] (param0: gpointer) in + guard let self = self else { return } + self.input?(self, param0) + } + + addSignal(name: "output") { [weak self] () in + guard let self = self else { return } + self.output?(self) + } + + addSignal(name: "value-changed") { [weak self] () in + guard let self = self else { return } + self.valueChanged?(self) + } + + addSignal(name: "wrapped") { [weak self] () in + guard let self = self else { return } + self.wrapped?(self) + } + + let handler5: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::adjustment", handler: gCallback(handler5)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyAdjustment?(self, param0) + } + + let handler6: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::climb-rate", handler: gCallback(handler6)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyClimbRate?(self, param0) + } + + let handler7: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::digits", handler: gCallback(handler7)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyDigits?(self, param0) + } + + let handler8: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::numeric", handler: gCallback(handler8)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyNumeric?(self, param0) + } + + let handler9: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::snap-to-ticks", handler: gCallback(handler9)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifySnapToTicks?(self, param0) + } + + let handler10: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::update-policy", handler: gCallback(handler10)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyUpdatePolicy?(self, param0) + } + + let handler11: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::value", handler: gCallback(handler11)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyValue?(self, param0) + } + + let handler12: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::wrap", handler: gCallback(handler12)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyWrap?(self, param0) + } + + let handler13: + @convention(c) (UnsafeMutableRawPointer, OpaquePointer, UnsafeMutableRawPointer) -> Void = + { _, value1, data in + SignalBox1.run(data, value1) + } + + addSignal(name: "notify::orientation", handler: gCallback(handler13)) { + [weak self] (param0: OpaquePointer) in + guard let self = self else { return } + self.notifyOrientation?(self, param0) + } + } + + @GObjectProperty(named: "digits") public var digits: UInt + + @GObjectProperty(named: "numeric") public var numeric: Bool + + @GObjectProperty(named: "snap-to-ticks") public var snapToTicks: Bool + + @GObjectProperty(named: "update-policy") public var updatePolicy: SpinButtonUpdatePolicy + + @GObjectProperty(named: "value") public var value: Double + + @GObjectProperty(named: "wrap") public var wrap: Bool + + /// The ::change-value signal is a [keybinding signal][GtkBindingSignal] + /// which gets emitted when the user initiates a value change. + /// + /// Applications should not connect to it, but may emit it with + /// g_signal_emit_by_name() if they need to control the cursor + /// programmatically. + /// + /// The default bindings for this signal are Up/Down and PageUp and/PageDown. + public var changeValue: ((SpinButton, GtkScrollType) -> Void)? + + /// The ::input signal can be used to influence the conversion of + /// the users input into a double value. The signal handler is + /// expected to use gtk_entry_get_text() to retrieve the text of + /// the entry and set @new_value to the new value. + /// + /// The default conversion uses g_strtod(). + public var input: ((SpinButton, gpointer) -> Void)? + + /// The ::output signal can be used to change to formatting + /// of the value that is displayed in the spin buttons entry. + /// |[ + /// // show leading zeros + /// static gboolean + /// on_output (GtkSpinButton *spin, + /// gpointer data) + /// { + /// GtkAdjustment *adjustment; + /// gchar *text; + /// int value; + /// + /// adjustment = gtk_spin_button_get_adjustment (spin); + /// value = (int)gtk_adjustment_get_value (adjustment); + /// text = g_strdup_printf ("%02d", value); + /// gtk_entry_set_text (GTK_ENTRY (spin), text); + /// g_free (text); + /// + /// return TRUE; + /// } + /// ]| + public var output: ((SpinButton) -> Void)? + + /// The ::value-changed signal is emitted when the value represented by + /// @spinbutton changes. Also see the #GtkSpinButton::output signal. + public var valueChanged: ((SpinButton) -> Void)? + + /// The ::wrapped signal is emitted right after the spinbutton wraps + /// from its maximum to minimum value or vice-versa. + public var wrapped: ((SpinButton) -> Void)? + + public var notifyAdjustment: ((SpinButton, OpaquePointer) -> Void)? + + public var notifyClimbRate: ((SpinButton, OpaquePointer) -> Void)? + + public var notifyDigits: ((SpinButton, OpaquePointer) -> Void)? + + public var notifyNumeric: ((SpinButton, OpaquePointer) -> Void)? + + public var notifySnapToTicks: ((SpinButton, OpaquePointer) -> Void)? + + public var notifyUpdatePolicy: ((SpinButton, OpaquePointer) -> Void)? + + public var notifyValue: ((SpinButton, OpaquePointer) -> Void)? + + public var notifyWrap: ((SpinButton, OpaquePointer) -> Void)? + + public var notifyOrientation: ((SpinButton, OpaquePointer) -> Void)? +} diff --git a/Sources/Gtk3/Widgets/Calendar.swift b/Sources/Gtk3/Widgets/Calendar.swift deleted file mode 100644 index d1141f6b35..0000000000 --- a/Sources/Gtk3/Widgets/Calendar.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Copyright © 2016 Tomas Linhart. All rights reserved. -// - -import CGtk3 - -public class Calendar: Widget { - public convenience init() { - self.init(gtk_calendar_new()) - } - - /// The selected year. This property gets initially set to the current year. - @GObjectProperty(named: "year") public var year: Int - /// Determines whether a heading is displayed. - @GObjectProperty(named: "show-heading") public var showHeading: Bool -} diff --git a/Sources/GtkBackend/GtkBackend.swift b/Sources/GtkBackend/GtkBackend.swift index 49f5fc8d2b..9ad383a731 100644 --- a/Sources/GtkBackend/GtkBackend.swift +++ b/Sources/GtkBackend/GtkBackend.swift @@ -1507,6 +1507,37 @@ public final class GtkBackend: AppBackend { } } + public func createDatePicker() -> Widget { + let widget = Gtk.Calendar() + widget.date = Date() + return widget + } + + public func updateDatePicker( + _ datePicker: Widget, + environment: EnvironmentValues, + date: Date, + range: ClosedRange, + components: DatePickerComponents, + onChange: @escaping (Date) -> Void + ) { + if components.contains(.hourAndMinute) { + print("Warning: time picker is unimplemented on GtkBackend") + } + if environment.datePickerStyle != .automatic && environment.datePickerStyle != .graphical { + print("Warning: only DatePickerStyle.graphical is implemented in GtkBackend") + } + + let calendarWidget = datePicker as! Gtk.Calendar + calendarWidget.date = date + calendarWidget.daySelected = { calendarWidget in + onChange(calendarWidget.date) + } + calendarWidget.sensitive = environment.isEnabled + calendarWidget.css.clear() + calendarWidget.css.set(properties: Self.cssProperties(for: environment, isControl: true)) + } + // MARK: Helpers private func wrapInCustomRootContainer(_ widget: Widget) -> Widget { @@ -1692,3 +1723,169 @@ extension UnsafeMutablePointer { class CustomListBox: ListBox { var cachedSelection: Int? = nil } + +// This kinda sorta works. Beyond the fact that it never shows the AM/PM picker, the SpinButtons +// don't behave correctly on change, and calendar.date(bySetting:value:of:) doesn't do what we need +// it to do. +@available(macOS 13, *) +final class TimePicker: Box { + private var hourCycle: Locale.HourCycle + private let hourPicker: SpinButton + private let hourMinuteSeparator = Label(string: ":") + private let minutePicker = SpinButton(range: 0, max: 59, step: 1) + private var minuteSecondSeparator: Label? + private var secondPicker: SpinButton? + private var amPmPicker: DropDown? + + var onChange: ((Date) -> Void)? + + init() { + let hourCycle = Locale.current.hourCycle + + self.hourCycle = hourCycle + self.hourPicker = SpinButton( + range: TimePicker.minHour(for: hourCycle), + max: TimePicker.maxHour(for: hourCycle), + step: 1 + ) + + super.init(gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0)) + + self.hourPicker.wrap = true + self.hourPicker.orientation = .vertical + self.hourPicker.numeric = true + self.minutePicker.wrap = true + self.minutePicker.orientation = .vertical + self.minutePicker.numeric = true + + self.add(self.hourPicker) + self.add(self.hourMinuteSeparator) + self.add(self.minutePicker) + } + + func setEnabled(to isEnabled: Bool) { + hourPicker.sensitive = isEnabled + } + + private static func minHour(for hourCycle: Locale.HourCycle) -> Double { + switch hourCycle { + case .zeroToEleven, .zeroToTwentyThree: 0 + case .oneToTwelve, .oneToTwentyFour: 1 + #if os(macOS) + @unknown default: fatalError() + #endif + } + } + + private static func maxHour(for hourCycle: Locale.HourCycle) -> Double { + switch hourCycle { + case .zeroToEleven: 11 + case .oneToTwelve: 12 + case .zeroToTwentyThree: 23 + case .oneToTwentyFour: 24 + #if os(macOS) + @unknown default: fatalError() + #endif + } + } + + func update(calendar: Foundation.Calendar, date: Date, showSeconds: Bool) { + let components = calendar.dateComponents([.hour, .minute, .second], from: date) + + if showSeconds { + let secondsRange = calendar.range(of: .second, in: .minute, for: date) ?? 0..<60 + if let secondPicker { + secondPicker.setRange( + min: Double(secondsRange.lowerBound), + max: Double(secondsRange.upperBound - 1) + ) + } else { + minuteSecondSeparator = Label(string: ":") + secondPicker = SpinButton( + range: Double(secondsRange.lowerBound), + max: Double(secondsRange.upperBound - 1), + step: 1 + ) + secondPicker!.numeric = true + secondPicker!.wrap = true + secondPicker!.text = "\(components.second!)" + insert(child: minuteSecondSeparator!, after: minutePicker) + insert(child: secondPicker!, after: minuteSecondSeparator!) + } + } else { + if let minuteSecondSeparator { + remove(minuteSecondSeparator) + self.minuteSecondSeparator = nil + } + if let secondPicker { + remove(secondPicker) + self.secondPicker = nil + } + } + + let minutesRange = calendar.range(of: .minute, in: .hour, for: date) ?? 0..<60 + minutePicker.setRange( + min: Double(minutesRange.lowerBound), + max: Double(minutesRange.upperBound - 1) + ) + minutePicker.text = "\(components.minute!)" + minutePicker.valueChanged = { [unowned self] minutePicker in + guard let value = Int(exactly: minutePicker.value), + let newDate = calendar.date(bySetting: .minute, value: value, of: date) + else { + return + } + self.onChange?(newDate) + } + + let hoursRange = calendar.range(of: .hour, in: .day, for: date) + self.hourCycle = (calendar.locale ?? .current).hourCycle + let effectiveHours = hoursRange?.map { + TimePicker.transformToRange($0, hourCycle: self.hourCycle) + } + + hourPicker.setRange( + min: effectiveHours?.min().map(Double.init(_:)) + ?? TimePicker.minHour(for: self.hourCycle), + max: effectiveHours?.max().map(Double.init(_:)) + ?? TimePicker.maxHour(for: self.hourCycle) + ) + + if self.hourCycle == .oneToTwelve || self.hourCycle == .zeroToEleven { + if let amPmPicker { + // update strings if necessary + } else { + amPmPicker = DropDown(strings: [calendar.amSymbol, calendar.pmSymbol]) + add(amPmPicker!) + } + } else { + if let amPmPicker { + remove(amPmPicker) + self.amPmPicker = nil + } + } + + hourPicker.text = + "\(TimePicker.transformToRange(components.hour!, hourCycle: self.hourCycle))" + hourPicker.valueChanged = { [unowned self] hourPicker in + guard let value = Int(exactly: hourPicker.value), + let newDate = calendar.date(bySetting: .hour, value: value, of: date) + else { + return + } + self.onChange?(newDate) + } + } + + private static func transformToRange(_ value: Int, hourCycle: Locale.HourCycle) -> Int { + switch hourCycle { + case .zeroToEleven: value % 12 + case .oneToTwelve: (value + 11) % 12 + 1 + case .zeroToTwentyThree: value % 24 + case .oneToTwentyFour: (value + 23) % 24 + 1 + #if os(macOS) + @unknown default: fatalError() + #endif + } + } +} diff --git a/Sources/GtkCodeGen/GtkCodeGen.swift b/Sources/GtkCodeGen/GtkCodeGen.swift index a1f18b01c2..fd3468f8de 100644 --- a/Sources/GtkCodeGen/GtkCodeGen.swift +++ b/Sources/GtkCodeGen/GtkCodeGen.swift @@ -27,6 +27,12 @@ struct GtkCodeGen { "GtkSelectionModel*": "OpaquePointer?", "GtkListItemFactory*": "OpaquePointer?", "GtkTextTagTable*": "OpaquePointer?", + "int": "Int", + ] + + static let cTypesManuallyConverted: [String: String] = [ + "guint": "guint", + "int": "CInt", ] /// Problematic signals which are excluded from the generated Swift @@ -111,7 +117,7 @@ struct GtkCodeGen { "Button", "Entry", "Label", "Range", "Scale", "Image", "Switch", "Spinner", "ProgressBar", "FileChooserNative", "NativeDialog", "GestureClick", "GestureSingle", "Gesture", "EventController", "GestureLongPress", "GLArea", "DrawingArea", - "CheckButton", + "CheckButton", "Calendar", "SpinButton", ] let gtk3AllowListedClasses = ["MenuShell", "EventBox"] let gtk4AllowListedClasses = [ @@ -807,6 +813,10 @@ struct GtkCodeGen { .unsafeCopy() .baseAddress! """ + } else if let type = parameter.type?.cType, + let destinationType = cTypesManuallyConverted[type] + { + return "\(destinationType)(\(argument))" } return argument diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index 52dfbab1ad..ba563b69f0 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -539,6 +539,19 @@ public protocol AppBackend: Sendable { /// Sets the index of the selected option of a picker. func setSelectedOption(ofPicker picker: Widget, to selectedOption: Int?) + func createDatePicker() -> Widget + + #if !os(tvOS) + func updateDatePicker( + _ datePicker: Widget, + environment: EnvironmentValues, + date: Date, + range: ClosedRange, + components: DatePickerComponents, + onChange: @escaping (Date) -> Void + ) + #endif + /// Creates an indeterminate progress spinner. func createProgressSpinner() -> Widget @@ -1289,4 +1302,17 @@ extension AppBackend { ) { todo() } + + public func createDatePicker() -> Widget { todo() } + + #if !os(tvOS) + public func updateDatePicker( + _ datePicker: Widget, + environment: EnvironmentValues, + date: Date, + range: ClosedRange, + components: DatePickerComponents, + onChange: @escaping (Date) -> Void + ) { todo() } + #endif } diff --git a/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift b/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift index cafe0dea63..25e26d975a 100644 --- a/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift +++ b/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift @@ -195,6 +195,16 @@ public struct EnvironmentValues { ) } + /// The current calendar that views should use when handling dates. + public var calendar: Calendar + + /// The current time zone that views should use when handling dates. + public var timeZone: TimeZone + + #if !os(tvOS) + public var datePickerStyle: DatePickerStyle + #endif + /// Creates the default environment. package init(backend: Backend) { self.backend = backend @@ -217,6 +227,11 @@ public struct EnvironmentValues { isEnabled = true scrollDismissesKeyboardMode = .automatic isTextSelectionEnabled = false + calendar = .autoupdatingCurrent + timeZone = .autoupdatingCurrent + #if !os(tvOS) + datePickerStyle = .automatic + #endif } /// Returns a copy of the environment with the specified property set to the diff --git a/Sources/SwiftCrossUI/Views/DatePicker.swift b/Sources/SwiftCrossUI/Views/DatePicker.swift new file mode 100644 index 0000000000..7d4a7dd2d1 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/DatePicker.swift @@ -0,0 +1,152 @@ +import Foundation + +@available(tvOS, unavailable) +public struct DatePickerComponents: OptionSet, Sendable { + public let rawValue: UInt + + public init(rawValue: UInt) { + self.rawValue = rawValue + } + + public init() { + self.rawValue = 0 + } + + public static let date = DatePickerComponents(rawValue: 0x1C) + public static let hourAndMinute = DatePickerComponents(rawValue: 0x60) + + @available(iOS, unavailable) + @available(visionOS, unavailable) + @available(macCatalyst, unavailable) + public static let hourMinuteAndSecond = DatePickerComponents(rawValue: 0xE0) +} + +@available(tvOS, unavailable) +public enum DatePickerStyle: Sendable, Hashable { + /// A date input chosen by the backend. + case automatic + + /// A date input that shows a calendar grid. + @available(iOS 14, macCatalyst 14, *) + case graphical + + /// A smaller date input. This may be a text field, or a button that opens a calendar pop-up. + @available(iOS 13.4, macCatalyst 13.4, *) + case compact + + /// A set of scrollable inputs that can be used to select a date. + @available(iOS 13.4, macCatalyst 13.4, *) + @available(macOS, unavailable) + case wheel +} + +@available(tvOS, unavailable) +public struct DatePicker { + private var label: Label + private var selection: Binding + private var range: ClosedRange + private var components: DatePickerComponents + private var style: DatePickerStyle = .automatic + + /// Displays a date input. + /// - Parameters: + /// - selection: The currently-selected date. + /// - range: The range of dates to display. The backend takes this as a hint but it is not + /// necessarily enforced. As such this parameter should be treated as an aid to validation + /// rather than a replacement for it. + /// - displayedComponents: What parts of the date/time to display in the input. + /// - label: The view to be shown next to the date input. + public nonisolated init( + selection: Binding, + range: ClosedRange = Date.distantPast...Date.distantFuture, + displayedComponents: DatePickerComponents = [.hourAndMinute, .date], + @ViewBuilder label: () -> Label + ) { + self.label = label() + self.selection = selection + self.range = range + self.components = displayedComponents + } + + /// Displays a date input. + /// - Parameters: + /// - label: The text to be shown next to the date input. + /// - selection: The currently-selected date. + /// - range: The range of dates to display. The backend takes this as a hint but it is not + /// necessarily enforced. As such this parameter should be treated as an aid to validation + /// rather than a replacement for it. + /// - displayedComponents: What parts of the date/time to display in the input. + public nonisolated init( + _ label: String, + selection: Binding, + range: ClosedRange = Date.distantPast...Date.distantFuture, + displayedComponents: DatePickerComponents = [.hourAndMinute, .date] + ) where Label == Text { + self.label = Text(label) + self.selection = selection + self.range = range + self.components = displayedComponents + } + + public typealias Components = DatePickerComponents +} + +@available(tvOS, unavailable) +extension DatePicker: View { + public var body: some View { + HStack { + label + + DatePickerImplementation(selection: selection, range: range, components: components) + } + } +} + +@available(tvOS, unavailable) +internal struct DatePickerImplementation: ElementaryView { + @Binding private var selection: Date + private var range: ClosedRange + private var components: DatePickerComponents + + init(selection: Binding, range: ClosedRange, components: DatePickerComponents) { + self._selection = selection + self.range = range + self.components = components + } + + let body = EmptyView() + + func asWidget(backend: Backend) -> Backend.Widget { + backend.createDatePicker() + } + + func update( + _ widget: Backend.Widget, + proposedSize: SIMD2, + environment: EnvironmentValues, + backend: Backend, + dryRun: Bool + ) -> ViewUpdateResult { + #if os(tvOS) + preconditionFailure() + #else + if !dryRun { + backend.updateDatePicker( + widget, + environment: environment, + date: selection, + range: range, + components: components, + onChange: { selection = $0 } + ) + } + + // I reject your proposedSize and substitute my own + let naturalSize = backend.naturalSize(of: widget) + if !dryRun { + backend.setSize(of: widget, to: naturalSize) + } + return ViewUpdateResult.leafView(size: ViewSize(fixedSize: naturalSize)) + #endif + } +} diff --git a/Sources/SwiftCrossUI/Views/Modifiers/DatePickerStyleModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/DatePickerStyleModifier.swift new file mode 100644 index 0000000000..6b1407da13 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Modifiers/DatePickerStyleModifier.swift @@ -0,0 +1,13 @@ +extension View { + @available(tvOS, unavailable) + public func datePickerStyle(_ style: DatePickerStyle) -> some View { + #if os(tvOS) + assertionFailure() + return EmptyView() + #else + EnvironmentModifier(self) { environment in + environment.with(\.datePickerStyle, style) + } + #endif + } +} diff --git a/Sources/UIKitBackend/UIKitBackend+Control.swift b/Sources/UIKitBackend/UIKitBackend+Control.swift index 238a97bdc1..6c31e865e4 100644 --- a/Sources/UIKitBackend/UIKitBackend+Control.swift +++ b/Sources/UIKitBackend/UIKitBackend+Control.swift @@ -239,6 +239,26 @@ final class SliderWidget: WrapperWidget { } } +@available(tvOS, unavailable) +final class DatePickerWidget: WrapperWidget { + var onChange: ((Date) -> Void)? { + didSet { + if oldValue == nil { + child.addTarget(self, action: #selector(dateChanged), for: .valueChanged) + } + } + } + + @objc + func dateChanged(sender: UIDatePicker) { + onChange?(sender.date) + } + + override var intrinsicContentSize: CGSize { + return child.sizeThatFits(UIView.layoutFittingCompressedSize) + } +} + extension UIKitBackend { public func createButton() -> Widget { ButtonWidget() @@ -501,5 +521,59 @@ extension UIKitBackend { let sliderWidget = slider as! SliderWidget sliderWidget.child.setValue(Float(value), animated: true) } + + public func createDatePicker() -> Widget { + DatePickerWidget() + } + + public func updateDatePicker( + _ datePicker: Widget, + environment: EnvironmentValues, + date: Date, + range: ClosedRange, + components: DatePickerComponents, + onChange: @escaping (Date) -> Void + ) { + let datePickerWidget = datePicker as! DatePickerWidget + + datePickerWidget.child.date = date + datePickerWidget.onChange = onChange + + datePickerWidget.child.isEnabled = environment.isEnabled + datePickerWidget.child.calendar = environment.calendar + datePickerWidget.child.timeZone = environment.timeZone + datePickerWidget.child.minimumDate = range.lowerBound + datePickerWidget.child.maximumDate = range.upperBound + + datePickerWidget.child.datePickerMode = + switch components { + case [.date, .hourAndMinute]: + .dateAndTime + case .date: + .date + case .hourAndMinute: + .time + default: + // Crashing upon receiving [] is consistent with SwiftUI. + fatalError("Unexpected Components: \(components)") + } + + if #available(iOS 13.4, macCatalyst 13.4, *) { + switch environment.datePickerStyle { + case .automatic: + datePickerWidget.child.preferredDatePickerStyle = .automatic + case .compact: + datePickerWidget.child.preferredDatePickerStyle = .compact + case .graphical: + guard #available(iOS 14, macCatalyst 14, *) else { + preconditionFailure( + "DatePickerStyle.graphical is only available on iOS 14 or newer") + } + datePickerWidget.child.preferredDatePickerStyle = .inline + case .wheel: + datePickerWidget.child.preferredDatePickerStyle = .wheels + } + } + } #endif } diff --git a/Sources/UIKitBackend/UIKitBackend.swift b/Sources/UIKitBackend/UIKitBackend.swift index bb7010556b..c61e0cd8b4 100644 --- a/Sources/UIKitBackend/UIKitBackend.swift +++ b/Sources/UIKitBackend/UIKitBackend.swift @@ -10,6 +10,8 @@ public final class UIKitBackend: AppBackend { static var mainWindow: UIWindow? static var hasReturnedAWindow = false + private var timeZoneObserver: NSObjectProtocol? + public let scrollBarWidth = 0 public let defaultPaddingAmount = 15 public let requiresToggleSwitchSpacer = true @@ -115,6 +117,7 @@ public final class UIKitBackend: AppBackend { var environment = defaultEnvironment environment.toggleStyle = .switch + environment.timeZone = .current switch UITraitCollection.current.userInterfaceStyle { case .light: @@ -130,6 +133,17 @@ public final class UIKitBackend: AppBackend { public func setRootEnvironmentChangeHandler(to action: @escaping () -> Void) { onTraitCollectionChange = action + if timeZoneObserver == nil { + timeZoneObserver = NotificationCenter.default.addObserver( + forName: .NSSystemTimeZoneDidChange, + object: nil, + queue: .main + ) { [unowned self] _ in + MainActor.assumeIsolated { + self.onTraitCollectionChange?() + } + } + } } public func computeWindowEnvironment( diff --git a/Sources/WinUIBackend/WinUIBackend.swift b/Sources/WinUIBackend/WinUIBackend.swift index 948012ee16..c79dab22b8 100644 --- a/Sources/WinUIBackend/WinUIBackend.swift +++ b/Sources/WinUIBackend/WinUIBackend.swift @@ -464,6 +464,16 @@ public final class WinUIBackend: AppBackend { // the defaults set in the following code from the WinUI repository: // https://github.com/marcelwgn/microsoft-ui-xaml/blob/ff21f9b212cea2191b959649e45e52486c8465aa/src/controls/dev/ProgressRing/ProgressRing.xaml#L12 return SIMD2(32, 32) + } else if let datePicker = widget as? CustomDatePicker { + // CustomDatePicker is a StackPanel whose individual subviews need to be manually sized + // and then added together. Its naturalSize(in:) method dispatches back here once for + // each of its children. + return datePicker.naturalSize(in: self) + } else if widget is WinUI.DatePicker { + // Width is 296: + // https://github.com/marcelwgn/microsoft-ui-xaml/blob/ff21f9b212cea2191b959649e45e52486c8465aa/src/controls/dev/CommonStyles/DatePicker_themeresources.xaml#L261 + // Height is experimentally 29 which I don't see anywhere in that file. + return SIMD2(296, 29) } let oldWidth = widget.width @@ -527,6 +537,17 @@ public final class WinUIBackend: AppBackend { 64, 32 ) + } else if widget is CalendarView { + // I don't actually know why this is necessary, but without it the abbreviations for the + // weekdays wrap, making it taller than it says it is. Value was derived by trial and + // error. + adjustment = SIMD2(20, 0) + } else if computedSize.width == 0 && computedSize.width == 0 && widget is CalendarDatePicker + { + // I can't find any source on what the size of CalendarDatePicker is, but it reports 0x0 + // in at least some cases before initial render. In these cases, use a size derived + // experimentally. + adjustment = SIMD2(116, 32) } else { adjustment = .zero } @@ -1705,6 +1726,57 @@ public final class WinUIBackend: AppBackend { winUiPath.data = path.group } + public func createDatePicker() -> Widget { + return CustomDatePicker() + } + + public func updateDatePicker( + _ datePicker: Widget, + environment: EnvironmentValues, + date: Date, + range: ClosedRange, + components: DatePickerComponents, + onChange: @escaping (Date) -> Void + ) { + let customDatePicker = datePicker as! CustomDatePicker + + if components.contains(.hourMinuteAndSecond) { + print( + "DatePickerComponents.hourMinuteAndSecond is not supported in WinUIBackend. Falling back to .hourAndMinute." + ) + } + + customDatePicker.toggleTimeView(shown: components.contains(.hourAndMinute)) + + if environment.timeZone != .autoupdatingCurrent { + print("environment.timeZone is has no effect in WinUIBackend.") + } + + let dateViewType: CustomDatePicker.DateViewType.Discriminator? = + if components.contains(.date) { + switch environment.datePickerStyle { + case .automatic, .wheel: + .datePicker + case .compact: + .calendarDatePicker + case .graphical: + .calendarView + } + } else { + nil + } + + customDatePicker.onChange = onChange + customDatePicker.changeDateView(to: dateViewType) + customDatePicker.updateIfNeeded(date: date, calendar: environment.calendar) + customDatePicker.setDateRange(to: range) + customDatePicker.setEnabled(to: environment.isEnabled) + + // TODO(parity): foreground color ignored + // Setting foreground like for other views works for TimePicker and DatePicker but not for + // CalendarView or CalendarDatePicker. + } + // public func createTable(rows: Int, columns: Int) -> Widget { // let grid = Grid() // grid.columnSpacing = 10 @@ -1941,3 +2013,276 @@ public final class GeometryGroupHolder { var group = GeometryGroup() var strokeStyle: StrokeStyle? } + +@MainActor +final class CustomDatePicker: StackPanel { + override init() { + super.init() + self.spacing = 10 + } + + deinit { + timeChangedEvent?.dispose() + dateChangedEvent?.dispose() + } + + enum DateViewType { + case calendarView(CalendarView) + case calendarDatePicker(CalendarDatePicker) + case datePicker(WinUI.DatePicker) + + var asControl: Control { + switch self { + case .calendarView(let calendarView): calendarView + case .calendarDatePicker(let calendarDatePicker): calendarDatePicker + case .datePicker(let datePicker): datePicker + } + } + + enum Discriminator { + case calendarView, calendarDatePicker, datePicker + } + + var discriminator: Discriminator { + switch self { + case .calendarView(_): .calendarView + case .calendarDatePicker(_): .calendarDatePicker + case .datePicker(_): .datePicker + } + } + } + + private var dateView: DateViewType? + private var timeView: TimePicker? + private var date = Date() + private var calendar = Calendar.current + private var needsUpdate = false + var onChange: ((Date) -> Void)? + private var timeChangedEvent: EventCleanup? + private var dateChangedEvent: EventCleanup? + + func toggleTimeView(shown: Bool) { + guard shown != (self.timeView != nil) else { return } + + if shown { + let timeView = TimePicker() + children.append(timeView) + self.timeView = timeView + timeChangedEvent = timeView.timeChanged.addHandler { [unowned self] _, change in + guard let change else { return } + self.date = + calendar.startOfDay(for: date) + + Double(change.newTime.duration) / ticksPerSecond + self.onChange?(self.date) + } + needsUpdate = true + } else { + timeChangedEvent?.dispose() + timeChangedEvent = nil + children.removeAtEnd() + self.timeView = nil + } + } + + func setEnabled(to isEnabled: Bool) { + dateView?.asControl.isEnabled = isEnabled + timeView?.isEnabled = isEnabled + } + + func changeDateView(to newDiscriminator: DateViewType.Discriminator?) { + guard newDiscriminator != dateView?.discriminator else { return } + + dateChangedEvent?.dispose() + if dateView != nil { + children.removeAt(0) + } + + switch newDiscriminator { + case .calendarView: + let calendarView = CalendarView() + dateView = .calendarView(calendarView) + children.insertAt(0, calendarView) + orientation = .vertical + dateChangedEvent = calendarView.selectedDatesChanged.addHandler { + [unowned self] _, _ in + + guard calendarView.selectedDates.size > 0 else { return } + + self.date = componentsToFoundationDate( + dateTime: calendarView.selectedDates.getAt(0), + timeSpan: timeView?.selectedTime + ) + + if calendarView.selectedDates.size > 1 { + self.needsUpdate = true + } + + self.onChange?(self.date) + } + needsUpdate = true + case .calendarDatePicker: + let calendarDatePicker = CalendarDatePicker() + dateView = .calendarDatePicker(calendarDatePicker) + children.insertAt(0, calendarDatePicker) + orientation = .horizontal + dateChangedEvent = calendarDatePicker.dateChanged.addHandler { + [unowned self] _, change in + + guard let newDate = change?.newDate else { return } + self.date = componentsToFoundationDate( + dateTime: newDate, timeSpan: timeView?.selectedTime) + self.onChange?(self.date) + } + needsUpdate = true + case .datePicker: + let datePicker = WinUI.DatePicker() + dateView = .datePicker(datePicker) + children.insertAt(0, datePicker) + orientation = .horizontal + dateChangedEvent = datePicker.selectedDateChanged.addHandler { + [unowned self] _, _ in + + guard let selectedDate = datePicker.selectedDate else { return } + self.date = componentsToFoundationDate( + dateTime: selectedDate, timeSpan: timeView?.selectedTime) + self.onChange?(self.date) + } + needsUpdate = true + case nil: + break + } + } + + func setDateRange(to range: ClosedRange) { + guard let dateView else { return } + + let (startDate, _) = foundationDateToComponents(range.lowerBound) + let (endDate, _) = foundationDateToComponents(range.upperBound) + + switch dateView { + case .calendarView(let calendarView): + calendarView.minDate = startDate + calendarView.maxDate = endDate + case .calendarDatePicker(let calendarDatePicker): + calendarDatePicker.minDate = startDate + calendarDatePicker.maxDate = endDate + case .datePicker(let datePicker): + datePicker.minYear = startDate + datePicker.maxYear = endDate + } + } + + func updateIfNeeded(date: Date, calendar: Calendar) { + if !needsUpdate && date == self.date && calendar == self.calendar { return } + defer { needsUpdate = false } + + self.date = date + self.calendar = calendar + + let (dateTime, timeSpan) = foundationDateToComponents(date) + + switch dateView { + case .calendarView(let calendarView): + calendarView.calendarIdentifier = identifier(for: calendar) + switch calendarView.selectedDates.size { + case 0: + calendarView.selectedDates.append(dateTime) + case 1: + calendarView.selectedDates.setAt(0, dateTime) + default: + calendarView.selectedDates.clear() + calendarView.selectedDates.setAt(0, dateTime) + } + case .calendarDatePicker(let calendarDatePicker): + calendarDatePicker.calendarIdentifier = identifier(for: calendar) + calendarDatePicker.date = dateTime + case .datePicker(let datePicker): + datePicker.selectedDate = dateTime + case nil: + break + } + + if let timeView { + timeView.selectedTime = timeSpan + } + } + + private func identifier(for calendar: Calendar) -> String { + switch calendar.identifier { + case .chinese: "ChineseLunarCalendar" + case .gregorian, .iso8601: "GregorianCalendar" + case .hebrew: "HebrewCalendar" + case .islamicTabular: "HijriCalendar" + case .islamicUmmAlQura: "UmAlQuraCalendar" + case .japanese: "JapaneseCalendar" + case .persian: "PersianCalendar" + case .republicOfChina: "TaiwanCalendar" + #if compiler(>=6.2) + case .vietnamese: "VietnameseLunarCalendar" + #endif + case let id: fatalError("Unsupported calendar identifier \(id)") + } + } + + // Magic numbers taken from https://stackoverflow.com/a/5471380/6253337 + private let ticksPerSecond: Double = 10_000_000 + private let unixEpochInUniversalTime: Int64 = 116_444_736_000_000_000 + + private func foundationDateToComponents(_ date: Date) -> (DateTime, TimeSpan) { + let timeInterval = date.timeIntervalSince(calendar.startOfDay(for: date)) + + return ( + DateTime( + universalTime: Int64( + date.timeIntervalSince1970 * ticksPerSecond + Double(unixEpochInUniversalTime) + ) + ), + TimeSpan(duration: Int64(timeInterval * ticksPerSecond)) + ) + } + + private func componentsToFoundationDate(dateTime: DateTime, timeSpan: TimeSpan?) -> Date { + let baseDate = Date( + timeIntervalSince1970: Double(dateTime.universalTime - unixEpochInUniversalTime) + / ticksPerSecond + ) + + if let timeSpan { + let time = Double(timeSpan.duration) / ticksPerSecond + return calendar.startOfDay(for: baseDate) + time + } else { + return baseDate + } + } + + func naturalSize(in backend: WinUIBackend) -> SIMD2 { + let timeViewSize = + if timeView != nil { + // Width is 242, as shown in the WinUI repository: + // https://github.com/marcelwgn/microsoft-ui-xaml/blob/ff21f9b212cea2191b959649e45e52486c8465aa/src/controls/dev/CommonStyles/TimePicker_themeresources.xaml#L116 + // Height is experimentally 29 which I don't see anywhere in that file. + SIMD2(242, 29) + } else { + SIMD2.zero + } + + let dateViewSize = + if let dateControl = dateView?.asControl { + backend.naturalSize(of: dateControl) + } else { + SIMD2.zero + } + + if orientation == .horizontal { + return SIMD2( + x: timeViewSize.x + dateViewSize.x + Int(self.spacing), + y: max(timeViewSize.y, dateViewSize.y) + ) + } else { + return SIMD2( + x: max(timeViewSize.x, dateViewSize.x), + y: timeViewSize.y + dateViewSize.y + Int(self.spacing) + ) + } + } +} From 81f68e3118f825145d19e9706d0f97786decb857 Mon Sep 17 00:00:00 2001 From: William Baker Date: Mon, 1 Dec 2025 23:43:57 -0500 Subject: [PATCH 2/2] Address some PR comments Update some doc comments Support date-only pickers in AppKitBackend Change default timezone and calendar from autoupdatingCurrent to current --- .../DatePickerExample/DatePickerApp.swift | 41 ++++++------------- Sources/AppKitBackend/AppKitBackend.swift | 39 +++++++++--------- Sources/Gtk/Utility/GDateTime.swift | 4 +- Sources/Gtk3Backend/Gtk3Backend.swift | 1 + Sources/GtkBackend/GtkBackend.swift | 10 ++--- Sources/SwiftCrossUI/Backend/AppBackend.swift | 4 ++ .../Environment/EnvironmentValues.swift | 17 ++++---- Sources/SwiftCrossUI/Views/DatePicker.swift | 23 ++++++++--- .../Modifiers/DatePickerStyleModifier.swift | 15 ++++--- Sources/UIKitBackend/UIKitBackend.swift | 14 +++++++ Sources/WinUIBackend/WinUIBackend.swift | 27 +++++++----- 11 files changed, 107 insertions(+), 88 deletions(-) diff --git a/Examples/Sources/DatePickerExample/DatePickerApp.swift b/Examples/Sources/DatePickerExample/DatePickerApp.swift index d27562ce15..b908d7d8fd 100644 --- a/Examples/Sources/DatePickerExample/DatePickerApp.swift +++ b/Examples/Sources/DatePickerExample/DatePickerApp.swift @@ -12,40 +12,25 @@ struct DatePickerApp: App { @State var date = Date() @State var style: DatePickerStyle? = .automatic - var allStyles: [DatePickerStyle] - - init() { - allStyles = [.automatic] - - if #available(iOS 14, macCatalyst 14, *) { - allStyles.append(.graphical) - } - - #if !canImport(GtkBackend) - if #available(iOS 13.4, macCatalyst 13.4, *) { - allStyles.append(.compact) - #if os(iOS) || os(visionOS) || canImport(WinUIBackend) - allStyles.append(.wheel) - #endif - } - #endif - } + @Environment(\.supportedDatePickerStyles) var allStyles: [DatePickerStyle] var body: some Scene { WindowGroup("Date Picker") { - VStack { - Text("Selected date: \(date)") + #hotReloadable { + VStack { + Text("Selected date: \(date)") - Picker(of: allStyles, selection: $style) + Picker(of: allStyles, selection: $style) - DatePicker( - "Test Picker", - selection: $date - ) - .datePickerStyle(style ?? .automatic) + DatePicker( + "Test Picker", + selection: $date + ) + .datePickerStyle(style ?? .automatic) - Button("Reset date") { - date = Date() + Button("Reset date to now") { + date = Date() + } } } } diff --git a/Sources/AppKitBackend/AppKitBackend.swift b/Sources/AppKitBackend/AppKitBackend.swift index 870bbbe69a..089e317d87 100644 --- a/Sources/AppKitBackend/AppKitBackend.swift +++ b/Sources/AppKitBackend/AppKitBackend.swift @@ -26,6 +26,7 @@ public final class AppKitBackend: AppBackend { public let menuImplementationStyle = MenuImplementationStyle.dynamicPopover public let canRevealFiles = true public let deviceClass = DeviceClass.desktop + public let supportedDatePickerStyles: [DatePickerStyle] = [.automatic, .graphical, .compact] public var scrollBarWidth: Int { // We assume that all scrollers have their controlSize set to `.regular` by default. @@ -1796,22 +1797,7 @@ public final class AppKitBackend: AppBackend { parent.endSheet(sheet) parent.nestedSheet = nil } -} - -public final class NSCustomSheet: NSCustomWindow, NSWindowDelegate { - public var onDismiss: (() -> Void)? - - public var interactiveDismissDisabled: Bool = false - - public var backgroundView: NSView? - - @objc override public func cancelOperation(_ sender: Any?) { - if !interactiveDismissDisabled { - sheetParent?.endSheet(self) - onDismiss?() - } - } - + public func createDatePicker() -> NSView { let datePicker = CustomDatePicker() datePicker.delegate = datePicker.strongDelegate @@ -1822,7 +1808,7 @@ public final class NSCustomSheet: NSCustomWindow, NSWindowDelegate { // choice for the current calendar means the cursor position is reset after every keystroke. I // know of no simple way to tell whether NSDatePicker requires or forbids eras for a given // calendar, so in lieu of that I have hardcoded the calendar identifiers. - private let calendarsWithEras: Set = [ + private let calendarsRequiringEra: Set = [ .buddhist, .coptic, .ethiopicAmeteAlem, .ethiopicAmeteMihret, .indian, .islamic, .islamicCivil, .islamicTabular, .islamicUmmAlQura, .japanese, .persian, .republicOfChina, ] @@ -1858,13 +1844,13 @@ public final class NSCustomSheet: NSCustomWindow, NSWindowDelegate { var elementFlags: NSDatePicker.ElementFlags = [] if components.contains(.date) { elementFlags.insert(.yearMonthDay) - if calendarsWithEras.contains(environment.calendar.identifier) { + if calendarsRequiringEra.contains(environment.calendar.identifier) { elementFlags.insert(.era) } } if components.contains(.hourMinuteAndSecond) { elementFlags.insert(.hourMinuteSecond) - } else { + } else if components.contains(.hourAndMinute) { elementFlags.insert(.hourMinute) } @@ -1887,6 +1873,21 @@ public final class NSCustomSheet: NSCustomWindow, NSWindowDelegate { } } +public final class NSCustomSheet: NSCustomWindow, NSWindowDelegate { + public var onDismiss: (() -> Void)? + + public var interactiveDismissDisabled: Bool = false + + public var backgroundView: NSView? + + @objc override public func cancelOperation(_ sender: Any?) { + if !interactiveDismissDisabled { + sheetParent?.endSheet(self) + onDismiss?() + } + } +} + final class NSCustomTapGestureTarget: NSView { var leftClickHandler: (() -> Void)? { didSet { diff --git a/Sources/Gtk/Utility/GDateTime.swift b/Sources/Gtk/Utility/GDateTime.swift index a4d61cb666..0bf61dbeae 100644 --- a/Sources/Gtk/Utility/GDateTime.swift +++ b/Sources/Gtk/Utility/GDateTime.swift @@ -13,7 +13,7 @@ public class GDateTime { self.pointer = pointer } - public convenience init?(unixEpoch: TimeInterval) { + public convenience init?(unixEpoch: Int) { // g_date_time_new_from_unix_utc_usec appears to be too new self.init(g_date_time_new_from_unix_utc(gint64(unixEpoch))) } @@ -41,7 +41,7 @@ public class GDateTime { } public convenience init!(_ date: Date) { - self.init(unixEpoch: date.timeIntervalSince1970) + self.init(unixEpoch: Int(date.timeIntervalSince1970)) } deinit { diff --git a/Sources/Gtk3Backend/Gtk3Backend.swift b/Sources/Gtk3Backend/Gtk3Backend.swift index a8e412896b..1bc34e98e1 100644 --- a/Sources/Gtk3Backend/Gtk3Backend.swift +++ b/Sources/Gtk3Backend/Gtk3Backend.swift @@ -38,6 +38,7 @@ public final class Gtk3Backend: AppBackend { public let menuImplementationStyle = MenuImplementationStyle.dynamicPopover public let canRevealFiles = true public let deviceClass = DeviceClass.desktop + public let supportedDatePickerStyles: [DatePickerStyle] = [] var gtkApp: Application diff --git a/Sources/GtkBackend/GtkBackend.swift b/Sources/GtkBackend/GtkBackend.swift index 9ad383a731..5333b00064 100644 --- a/Sources/GtkBackend/GtkBackend.swift +++ b/Sources/GtkBackend/GtkBackend.swift @@ -43,6 +43,7 @@ public final class GtkBackend: AppBackend { public let canRevealFiles = true public let deviceClass = DeviceClass.desktop public let defaultSheetCornerRadius = 10 + public let supportedDatePickerStyles: [DatePickerStyle] = [.automatic, .graphical] var gtkApp: Application @@ -1524,9 +1525,6 @@ public final class GtkBackend: AppBackend { if components.contains(.hourAndMinute) { print("Warning: time picker is unimplemented on GtkBackend") } - if environment.datePickerStyle != .automatic && environment.datePickerStyle != .graphical { - print("Warning: only DatePickerStyle.graphical is implemented in GtkBackend") - } let calendarWidget = datePicker as! Gtk.Calendar calendarWidget.date = date @@ -1772,7 +1770,7 @@ final class TimePicker: Box { case .zeroToEleven, .zeroToTwentyThree: 0 case .oneToTwelve, .oneToTwentyFour: 1 #if os(macOS) - @unknown default: fatalError() + @unknown default: fatalError("Unrecognized hourCycle \(hourCycle)") #endif } } @@ -1784,7 +1782,7 @@ final class TimePicker: Box { case .zeroToTwentyThree: 23 case .oneToTwentyFour: 24 #if os(macOS) - @unknown default: fatalError() + @unknown default: fatalError("Unrecognized hourCycle \(hourCycle)") #endif } } @@ -1884,7 +1882,7 @@ final class TimePicker: Box { case .zeroToTwentyThree: value % 24 case .oneToTwentyFour: (value + 23) % 24 + 1 #if os(macOS) - @unknown default: fatalError() + @unknown default: fatalError("Unrecognized hourCycle \(hourCycle)") #endif } } diff --git a/Sources/SwiftCrossUI/Backend/AppBackend.swift b/Sources/SwiftCrossUI/Backend/AppBackend.swift index ba563b69f0..eccbad006f 100644 --- a/Sources/SwiftCrossUI/Backend/AppBackend.swift +++ b/Sources/SwiftCrossUI/Backend/AppBackend.swift @@ -98,6 +98,10 @@ public protocol AppBackend: Sendable { /// Mobile backends generally can't. var canRevealFiles: Bool { get } + /// The supported date picker styles. Must include ``DatePickerStyle/automatic`` if date pickers + /// are supported at all. + nonisolated var supportedDatePickerStyles: [DatePickerStyle] { get } + /// Often in UI frameworks (such as Gtk), code is run in a callback /// after starting the app, and hence this generic root window creation /// API must reflect that. This is always the first method to be called diff --git a/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift b/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift index 25e26d975a..6db4be33d1 100644 --- a/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift +++ b/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift @@ -201,9 +201,11 @@ public struct EnvironmentValues { /// The current time zone that views should use when handling dates. public var timeZone: TimeZone - #if !os(tvOS) - public var datePickerStyle: DatePickerStyle - #endif + /// The display style used by ``DatePicker``. + public var datePickerStyle: DatePickerStyle + + /// The display styles supported by ``DatePicker``. ``datePickerStyle`` must be one of these. + public let supportedDatePickerStyles: [DatePickerStyle] /// Creates the default environment. package init(backend: Backend) { @@ -227,11 +229,10 @@ public struct EnvironmentValues { isEnabled = true scrollDismissesKeyboardMode = .automatic isTextSelectionEnabled = false - calendar = .autoupdatingCurrent - timeZone = .autoupdatingCurrent - #if !os(tvOS) - datePickerStyle = .automatic - #endif + calendar = .current + timeZone = .current + datePickerStyle = .automatic + supportedDatePickerStyles = backend.supportedDatePickerStyles } /// Returns a copy of the environment with the specified property set to the diff --git a/Sources/SwiftCrossUI/Views/DatePicker.swift b/Sources/SwiftCrossUI/Views/DatePicker.swift index 7d4a7dd2d1..a040d57a92 100644 --- a/Sources/SwiftCrossUI/Views/DatePicker.swift +++ b/Sources/SwiftCrossUI/Views/DatePicker.swift @@ -12,6 +12,18 @@ public struct DatePickerComponents: OptionSet, Sendable { self.rawValue = 0 } + /* + * These magic numbers are the same as SwiftUI. It's actually a bitfield: + * + * smhdMy-- + * date 00011100 + * hourAndMinute 01100000 + * hourMinuteAndSecond 11100000 + * + * Like SwiftUI, not all combinations are valid (SwiftUI fatalErrors if you try to get creative + * with your choice of flags), and hourMinuteAndSecond intentionally includes hourAndMinute. + */ + public static let date = DatePickerComponents(rawValue: 0x1C) public static let hourAndMinute = DatePickerComponents(rawValue: 0x60) @@ -21,9 +33,8 @@ public struct DatePickerComponents: OptionSet, Sendable { public static let hourMinuteAndSecond = DatePickerComponents(rawValue: 0xE0) } -@available(tvOS, unavailable) public enum DatePickerStyle: Sendable, Hashable { - /// A date input chosen by the backend. + /// A date input that adapts to the current platform and context. case automatic /// A date input that shows a calendar grid. @@ -54,11 +65,11 @@ public struct DatePicker { /// - range: The range of dates to display. The backend takes this as a hint but it is not /// necessarily enforced. As such this parameter should be treated as an aid to validation /// rather than a replacement for it. - /// - displayedComponents: What parts of the date/time to display in the input. + /// - displayedComponents: Which parts of the date/time to display in the input. /// - label: The view to be shown next to the date input. public nonisolated init( selection: Binding, - range: ClosedRange = Date.distantPast...Date.distantFuture, + in range: ClosedRange = Date.distantPast...Date.distantFuture, displayedComponents: DatePickerComponents = [.hourAndMinute, .date], @ViewBuilder label: () -> Label ) { @@ -75,11 +86,11 @@ public struct DatePicker { /// - range: The range of dates to display. The backend takes this as a hint but it is not /// necessarily enforced. As such this parameter should be treated as an aid to validation /// rather than a replacement for it. - /// - displayedComponents: What parts of the date/time to display in the input. + /// - displayedComponents: Which parts of the date/time to display in the input. public nonisolated init( _ label: String, selection: Binding, - range: ClosedRange = Date.distantPast...Date.distantFuture, + in range: ClosedRange = Date.distantPast...Date.distantFuture, displayedComponents: DatePickerComponents = [.hourAndMinute, .date] ) where Label == Text { self.label = Text(label) diff --git a/Sources/SwiftCrossUI/Views/Modifiers/DatePickerStyleModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/DatePickerStyleModifier.swift index 6b1407da13..aaaa645288 100644 --- a/Sources/SwiftCrossUI/Views/Modifiers/DatePickerStyleModifier.swift +++ b/Sources/SwiftCrossUI/Views/Modifiers/DatePickerStyleModifier.swift @@ -1,13 +1,12 @@ extension View { - @available(tvOS, unavailable) public func datePickerStyle(_ style: DatePickerStyle) -> some View { - #if os(tvOS) - assertionFailure() - return EmptyView() - #else - EnvironmentModifier(self) { environment in - environment.with(\.datePickerStyle, style) + EnvironmentModifier(self) { environment in + var style = style + if !environment.supportedDatePickerStyles.contains(style) { + assertionFailure("Unsupported date picker style: \(style)") + style = .automatic } - #endif + return environment.with(\.datePickerStyle, style) + } } } diff --git a/Sources/UIKitBackend/UIKitBackend.swift b/Sources/UIKitBackend/UIKitBackend.swift index c61e0cd8b4..f213b4bb50 100644 --- a/Sources/UIKitBackend/UIKitBackend.swift +++ b/Sources/UIKitBackend/UIKitBackend.swift @@ -44,6 +44,20 @@ public final class UIKitBackend: AppBackend { } } + public nonisolated var supportedDatePickerStyles: [DatePickerStyle] { + #if os(tvOS) + [] + #else + if #available(iOS 14, macCatalyst 14, *) { + [.automatic, .graphical, .compact, .wheel] + } else if #available(iOS 13.4, macCatalyst 13.4, *) { + [.automatic, .compact, .wheel] + } else { + [.automatic] + } + #endif + } + var onTraitCollectionChange: (() -> Void)? private let appDelegateClass: ApplicationDelegate.Type diff --git a/Sources/WinUIBackend/WinUIBackend.swift b/Sources/WinUIBackend/WinUIBackend.swift index c79dab22b8..0d7b5bcba2 100644 --- a/Sources/WinUIBackend/WinUIBackend.swift +++ b/Sources/WinUIBackend/WinUIBackend.swift @@ -44,6 +44,9 @@ public final class WinUIBackend: AppBackend { public let menuImplementationStyle = MenuImplementationStyle.dynamicPopover public let canRevealFiles = false public let deviceClass = DeviceClass.desktop + public let supportedDatePickerStyles: [DatePickerStyle] = [ + .automatic, .graphical, .compact, .wheel, + ] public var scrollBarWidth: Int { 12 @@ -1748,7 +1751,7 @@ public final class WinUIBackend: AppBackend { customDatePicker.toggleTimeView(shown: components.contains(.hourAndMinute)) - if environment.timeZone != .autoupdatingCurrent { + if environment.timeZone != .current { print("environment.timeZone is has no effect in WinUIBackend.") } @@ -2209,18 +2212,20 @@ final class CustomDatePicker: StackPanel { private func identifier(for calendar: Calendar) -> String { switch calendar.identifier { - case .chinese: "ChineseLunarCalendar" - case .gregorian, .iso8601: "GregorianCalendar" - case .hebrew: "HebrewCalendar" - case .islamicTabular: "HijriCalendar" - case .islamicUmmAlQura: "UmAlQuraCalendar" - case .japanese: "JapaneseCalendar" - case .persian: "PersianCalendar" - case .republicOfChina: "TaiwanCalendar" + case .chinese: return "ChineseLunarCalendar" + case .gregorian, .iso8601: return "GregorianCalendar" + case .hebrew: return "HebrewCalendar" + case .islamicTabular: return "HijriCalendar" + case .islamicUmmAlQura: return "UmAlQuraCalendar" + case .japanese: return "JapaneseCalendar" + case .persian: return "PersianCalendar" + case .republicOfChina: return "TaiwanCalendar" #if compiler(>=6.2) - case .vietnamese: "VietnameseLunarCalendar" + case .vietnamese: return "VietnameseLunarCalendar" #endif - case let id: fatalError("Unsupported calendar identifier \(id)") + case let id: + print("Unsupported calendar identifier '\(id)'. Falling back to Gregorian.") + return "GregorianCalendar" } }