diff --git a/Examples/Package.resolved b/Examples/Package.resolved index cd3bc8c9046..308a16b3799 100644 --- a/Examples/Package.resolved +++ b/Examples/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f29a33ba90b5b5615d0de581d82e49b8fa747057114f7c3fd44c8916099b361c", + "originHash" : "59a78895e517f75f5661e5d3f5fa875db78bb91f2aa42b34b0ee04c4321eb969", "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" } }, { diff --git a/Examples/Package.swift b/Examples/Package.swift index 9d75315a954..dbcd42f75e7 100644 --- a/Examples/Package.swift +++ b/Examples/Package.swift @@ -76,11 +76,11 @@ let package = Package( .executableTarget( name: "HoverExample", dependencies: exampleDependencies - ), - .executableTarget( + ), + .executableTarget( name: "ForEachExample", dependencies: exampleDependencies - ), + ), .executableTarget( name: "AdvancedCustomizationExample", dependencies: exampleDependencies, diff --git a/Examples/Sources/ControlsExample/ControlsApp.swift b/Examples/Sources/ControlsExample/ControlsApp.swift index 89b7ec486b1..f739cf6efe9 100644 --- a/Examples/Sources/ControlsExample/ControlsApp.swift +++ b/Examples/Sources/ControlsExample/ControlsApp.swift @@ -1,4 +1,5 @@ import DefaultBackend +import Foundation import SwiftCrossUI #if canImport(SwiftBundlerRuntime) @@ -16,10 +17,14 @@ struct ControlsApp: App { @State var text = "" @State var flavor: String? = nil @State var enabled = true + @State var date = Date() + @State var datePickerStyle: DatePickerStyle? = .automatic @State var menuToggleState = false @State var progressViewSize: Int = 10 @State var isProgressViewResizable = true + @Environment(\.supportedDatePickerStyles) var supportedDatePickerStyles: [DatePickerStyle] + var body: some Scene { WindowGroup("ControlsApp") { #hotReloadable { @@ -94,15 +99,40 @@ struct ControlsApp: App { .frame(width: progressViewSize, height: progressViewSize) } - VStack { - Text("Drop down") - HStack { - Text("Flavor: ") - Picker( - of: ["Vanilla", "Chocolate", "Strawberry"], selection: $flavor) + #if !canImport(Gtk3Backend) + VStack { + Text("Drop down") + HStack { + Text("Flavor: ") + Picker( + of: ["Vanilla", "Chocolate", "Strawberry"], + selection: $flavor + ) + } + Text("You chose: \(flavor ?? "Nothing yet!")") } - Text("You chose: \(flavor ?? "Nothing yet!")") - } + + #if !os(tvOS) + VStack { + Text("Selected date: \(date)") + + HStack { + Text("Date picker style: ") + Picker( + of: supportedDatePickerStyles, + selection: $datePickerStyle + ) + } + + DatePicker(selection: $date) {} + .datePickerStyle(datePickerStyle ?? .automatic) + + Button("Reset date to now") { + date = Date() + } + } + #endif + #endif }.padding().disabled(!enabled) Toggle(enabled ? "Disable all" : "Enable all", isOn: $enabled) diff --git a/Package.resolved b/Package.resolved index 39a57c805aa..ef1339fea37 100644 --- a/Package.resolved +++ b/Package.resolved @@ -31,7 +31,7 @@ { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", + "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { "revision" : "cdd0ef3755280949551dc26dee5de9ddeda89f54", "version" : "1.6.2" @@ -113,7 +113,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stackotter/swift-uwp", "state" : { - "revision" : "e3ff9c195775e16b404b82cf6886c5e81d73b6c1" + "revision" : "8128f6615b7c5b46ada289ab6d49d871ca1e13a5" } }, { @@ -121,7 +121,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stackotter/swift-webview2core", "state" : { - "revision" : "c9911ca23455b9fcdb2429e98baa6f4d003b381c" + "revision" : "4396f5d94d6dfd1f95ab25e79de98141b7f4f183" } }, { @@ -129,7 +129,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stackotter/swift-windowsappsdk", "state" : { - "revision" : "ba6f0ec377b70d8be835d253102ff665a0e47d99" + "revision" : "f1c50892f10c0f7f635d3c7a3d728fd634ad001a" } }, { @@ -145,7 +145,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stackotter/swift-winui", "state" : { - "revision" : "1695ee3ea2b7a249f6504c7f1759e7ec7a38eb86" + "revision" : "42c47f4e4129c8b5a5d9912f05e1168c924ac180" } }, { diff --git a/Package.swift b/Package.swift index c8a573d79b9..42c272a7792 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 25da783e8c0..dbae6ce17d0 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. @@ -370,6 +371,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( @@ -1829,6 +1838,80 @@ public final class AppKitBackend: AppBackend { parent.endSheet(sheet) parent.nestedSheet = nil } + + 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 calendarsRequiringEra: 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 calendarsRequiringEra.contains(environment.calendar.identifier) { + elementFlags.insert(.era) + } + } + if components.contains(.hourMinuteAndSecond) { + elementFlags.insert(.hourMinuteSecond) + } else if components.contains(.hourAndMinute) { + 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 + } + } } public final class NSCustomSheet: NSCustomWindow, NSWindowDelegate { @@ -2361,3 +2444,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/DummyBackend/DummyBackend.swift b/Sources/DummyBackend/DummyBackend.swift index 3aebfe22919..d0c7d1ab7e3 100644 --- a/Sources/DummyBackend/DummyBackend.swift +++ b/Sources/DummyBackend/DummyBackend.swift @@ -247,6 +247,7 @@ public final class DummyBackend: AppBackend { public var menuImplementationStyle = MenuImplementationStyle.dynamicPopover public var deviceClass = DeviceClass.desktop public var canRevealFiles = false + public var supportedDatePickerStyles: [DatePickerStyle] = [] public var incomingURLHandler: ((URL) -> Void)? diff --git a/Sources/Gtk/Generated/Calendar.swift b/Sources/Gtk/Generated/Calendar.swift new file mode 100644 index 00000000000..f826d689e0a --- /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() + ) + } + + open override func didMoveToParent() { + super.didMoveToParent() + + addSignal(name: "day-selected") { [weak self] () in + guard let self else { return } + self.daySelected?(self) + } + + addSignal(name: "next-month") { [weak self] () in + guard let self else { return } + self.nextMonth?(self) + } + + addSignal(name: "next-year") { [weak self] () in + guard let self else { return } + self.nextYear?(self) + } + + addSignal(name: "prev-month") { [weak self] () in + guard let self else { return } + self.prevMonth?(self) + } + + addSignal(name: "prev-year") { [weak self] () in + guard let 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 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 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 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 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 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 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 00000000000..591983e9c48 --- /dev/null +++ b/Sources/Gtk/Generated/SpinButton.swift @@ -0,0 +1,663 @@ +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)); +/// } +/// ``` +/// +/// # 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) + ) + } + + open override func didMoveToParent() { + super.didMoveToParent() + + addSignal(name: "activate") { [weak self] () in + guard let 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 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 else { return } + self.input?(self, param0) + } + + addSignal(name: "output") { [weak self] () in + guard let self else { return } + self.output?(self) + } + + addSignal(name: "value-changed") { [weak self] () in + guard let self else { return } + self.valueChanged?(self) + } + + addSignal(name: "wrapped") { [weak self] () in + guard let self else { return } + self.wrapped?(self) + } + + addSignal(name: "editing-done") { [weak self] () in + guard let self else { return } + self.editingDone?(self) + } + + addSignal(name: "remove-widget") { [weak self] () in + guard let self else { return } + self.removeWidget?(self) + } + + addSignal(name: "changed") { [weak self] () in + guard let 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 00000000000..66bdcaa3e57 --- /dev/null +++ b/Sources/Gtk/Utility/GDateTime.swift @@ -0,0 +1,57 @@ +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: Int) { + // g_date_time_new_from_unix_local_usec appears to be too new + self.init(g_date_time_new_from_unix_local(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 + ) + ) + } + + /// Create a GDateTime in the user's current timezone from a Foundation Date, discarding + /// fractional seconds. + public convenience init!(_ date: Date) { + self.init(unixEpoch: Int(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 00000000000..7190315e074 --- /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 75e0b8d1ca8..09c97ebec6f 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 00000000000..bfc400c1dce --- /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 de8215a1360..00000000000 --- 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 00000000000..1980000d067 --- /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 00000000000..8f9fe20c450 --- /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() + ) + } + + open override func didMoveToParent() { + super.didMoveToParent() + + addSignal(name: "day-selected") { [weak self] () in + guard let self else { return } + self.daySelected?(self) + } + + addSignal(name: "day-selected-double-click") { [weak self] () in + guard let self else { return } + self.daySelectedDoubleClick?(self) + } + + addSignal(name: "month-changed") { [weak self] () in + guard let self else { return } + self.monthChanged?(self) + } + + addSignal(name: "next-month") { [weak self] () in + guard let self else { return } + self.nextMonth?(self) + } + + addSignal(name: "next-year") { [weak self] () in + guard let self else { return } + self.nextYear?(self) + } + + addSignal(name: "prev-month") { [weak self] () in + guard let self else { return } + self.prevMonth?(self) + } + + addSignal(name: "prev-year") { [weak self] () in + guard let 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 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 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 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 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 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 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 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 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 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 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 00000000000..e2e87b791bc --- /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) + ) + } + + open 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 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 else { return } + self.input?(self, param0) + } + + addSignal(name: "output") { [weak self] () in + guard let self else { return } + self.output?(self) + } + + addSignal(name: "value-changed") { [weak self] () in + guard let self else { return } + self.valueChanged?(self) + } + + addSignal(name: "wrapped") { [weak self] () in + guard let 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 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 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 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 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 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 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 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 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 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 d1141f6b355..00000000000 --- 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/Gtk3Backend/Gtk3Backend.swift b/Sources/Gtk3Backend/Gtk3Backend.swift index e04188bd134..635b0e0d067 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 1a37592123e..b69d084a161 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 @@ -55,6 +56,28 @@ public final class GtkBackend: AppBackend { /// precreated window until it gets 'created' via `createWindow`. var windows: [Window] = [] + private struct LogLocation: Hashable, Equatable { + let file: String + let line: Int + let column: Int + } + + private var logsPerformed: Set = [] + + func debugLogOnce( + _ message: String, + file: String = #file, + line: Int = #line, + column: Int = #column + ) { + #if DEBUG + let location = LogLocation(file: file, line: line, column: column) + if logsPerformed.insert(location).inserted { + logger.notice("\(message)") + } + #endif + } + // A separate initializer to satisfy ``AppBackend``'s requirements. public convenience init() { self.init(appIdentifier: nil) @@ -1551,6 +1574,36 @@ 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) { + debugLogOnce("Warning: time picker is unimplemented on GtkBackend") + } + + let calendarWidget = datePicker as! Gtk.Calendar + calendarWidget.date = date + calendarWidget.daySelected = { calendarWidget in + let date = max(range.lowerBound, min(calendarWidget.date, range.upperBound)) + calendarWidget.date = date + onChange(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 { @@ -1764,3 +1817,172 @@ class CustomLabel: Label { ) } } + +// This class is incomplete and unused. It was meant to implement time components for DatePicker, +// but I couldn't get the spin buttons to work. TODOs include: +// - Fix the spin buttons +// - Update the strings in the AM/PM picker when the locale changes +// - Replace the calls to calendar.date(bySetting:value:of:) with something that actually does what we need +// - Implement range when possible +@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("Unrecognized hourCycle \(hourCycle)") + #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("Unrecognized hourCycle \(hourCycle)") + #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("Unrecognized hourCycle \(hourCycle)") + #endif + } + } +} diff --git a/Sources/GtkCodeGen/GtkCodeGen.swift b/Sources/GtkCodeGen/GtkCodeGen.swift index dd87e435272..c79d0d53db6 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 @@ -112,7 +118,7 @@ struct GtkCodeGen { "Button", "Entry", "Label", "Range", "Scale", "Image", "Switch", "Spinner", "ProgressBar", "FileChooserNative", "NativeDialog", "GestureClick", "GestureSingle", "Gesture", "EventController", "GestureLongPress", "GLArea", - "DrawingArea", "CheckButton", + "DrawingArea", "CheckButton", "Calendar", "SpinButton", ] let gtk3AllowListedClasses = ["MenuShell", "EventBox"] let gtk4AllowListedClasses = [ @@ -810,6 +816,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 3599db29ab9..136ca5d7d4d 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 @@ -550,6 +554,17 @@ 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 + + func updateDatePicker( + _ datePicker: Widget, + environment: EnvironmentValues, + date: Date, + range: ClosedRange, + components: DatePickerComponents, + onChange: @escaping (Date) -> Void + ) + /// Creates an indeterminate progress spinner. func createProgressSpinner() -> Widget @@ -1318,4 +1333,15 @@ extension AppBackend { ) { todo() } + + public func createDatePicker() -> Widget { todo() } + + public func updateDatePicker( + _ datePicker: Widget, + environment: EnvironmentValues, + date: Date, + range: ClosedRange, + components: DatePickerComponents, + onChange: @escaping (Date) -> Void + ) { todo() } } diff --git a/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift b/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift index 5925f325b32..6f8593abf6c 100644 --- a/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift +++ b/Sources/SwiftCrossUI/Environment/EnvironmentValues.swift @@ -207,6 +207,18 @@ 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 + + /// 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) { self.backend = backend @@ -231,6 +243,16 @@ public struct EnvironmentValues { isTextSelectionEnabled = false menuOrder = .automatic allowLayoutCaching = false + calendar = .current + timeZone = .current + datePickerStyle = .automatic + + let supportedDatePickerStyles = backend.supportedDatePickerStyles + if supportedDatePickerStyles.isEmpty { + self.supportedDatePickerStyles = [.automatic] + } else { + self.supportedDatePickerStyles = 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 new file mode 100644 index 00000000000..fe4982d3cc3 --- /dev/null +++ b/Sources/SwiftCrossUI/Views/DatePicker.swift @@ -0,0 +1,161 @@ +import Foundation + +public struct DatePickerComponents: OptionSet, Sendable { + public let rawValue: UInt + + public init(rawValue: UInt) { + self.rawValue = rawValue + } + + public init() { + 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) + + @available(iOS, unavailable) + @available(visionOS, unavailable) + @available(macCatalyst, unavailable) + public static let hourMinuteAndSecond = DatePickerComponents(rawValue: 0xE0) +} + +public enum DatePickerStyle: Sendable, Hashable { + /// A date input that adapts to the current platform and context. + 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: 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, + in 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: Which parts of the date/time to display in the input. + public nonisolated init( + _ label: String, + selection: Binding, + in 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 computeLayout( + _ widget: Backend.Widget, + proposedSize: ProposedViewSize, + environment: EnvironmentValues, + backend: Backend + ) -> ViewLayoutResult { + 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) + return ViewLayoutResult.leafView(size: ViewSize(naturalSize)) + } + + func commit( + _ widget: Backend.Widget, + layout: ViewLayoutResult, + environment: EnvironmentValues, + backend: Backend + ) { + backend.setSize(of: widget, to: layout.size.vector) + } +} diff --git a/Sources/SwiftCrossUI/Views/Modifiers/DatePickerStyleModifier.swift b/Sources/SwiftCrossUI/Views/Modifiers/DatePickerStyleModifier.swift new file mode 100644 index 00000000000..aaaa645288e --- /dev/null +++ b/Sources/SwiftCrossUI/Views/Modifiers/DatePickerStyleModifier.swift @@ -0,0 +1,12 @@ +extension View { + public func datePickerStyle(_ style: DatePickerStyle) -> some View { + EnvironmentModifier(self) { environment in + var style = style + if !environment.supportedDatePickerStyles.contains(style) { + assertionFailure("Unsupported date picker style: \(style)") + style = .automatic + } + return environment.with(\.datePickerStyle, style) + } + } +} diff --git a/Sources/UIKitBackend/UIKitBackend+Control.swift b/Sources/UIKitBackend/UIKitBackend+Control.swift index 238a97bdc18..6c31e865e46 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 f840aa98cc5..8b89c173715 100644 --- a/Sources/UIKitBackend/UIKitBackend.swift +++ b/Sources/UIKitBackend/UIKitBackend.swift @@ -11,6 +11,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 @@ -43,6 +45,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 @@ -116,6 +132,7 @@ public final class UIKitBackend: AppBackend { var environment = defaultEnvironment environment.toggleStyle = .switch + environment.timeZone = .current switch UITraitCollection.current.userInterfaceStyle { case .light: @@ -131,6 +148,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 572aeceeeda..dd8b1a1cdc3 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 @@ -434,7 +437,8 @@ public final class WinUIBackend: AppBackend { /// A static version of `naturalSize(of:)` for convenience. Used by /// WinUIElementRepresentable. - public nonisolated static func naturalSize(of widget: Widget) -> SIMD2 { + @MainActor + public static func naturalSize(of widget: Widget) -> SIMD2 { let allocation = WindowsFoundation.Size( width: .infinity, height: .infinity @@ -494,6 +498,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() + } 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 @@ -525,9 +539,11 @@ public final class WinUIBackend: AppBackend { /// We can detect such elements because their padding property will be set /// to zero until first render (and atm WinUIBackend doesn't set this padding /// property itself so this is a safe detection method). - public nonisolated static func sizeCorrection(for widget: Widget) -> SIMD2 { + @MainActor + public static func sizeCorrection(for widget: Widget) -> SIMD2 { let adjustment: SIMD2 let noPadding = Thickness(left: 0, top: 0, right: 0, bottom: 0) + let computedSize = widget.desiredSize if let button = widget as? WinUI.Button, button.padding == noPadding { // WinUI buttons have padding, but the `padding` property returns // zero until the button has been rendered at least once. And even @@ -568,6 +584,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 } @@ -1752,6 +1779,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 != .current { + 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 @@ -1988,3 +2066,282 @@ 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 { + let (dateTime, _) = foundationDateToComponents(self.date) + calendarView.selectedDates.append(dateTime) + 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: 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: return "VietnameseLunarCalendar" + #endif + case let id: + print("Unsupported calendar identifier '\(id)'. Falling back to Gregorian.") + return "GregorianCalendar" + } + } + + // 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() -> 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 { + WinUIBackend.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) + ) + } + } +} diff --git a/Sources/WinUIBackend/WinUIElementRepresentable.swift b/Sources/WinUIBackend/WinUIElementRepresentable.swift index efffcca46c2..9e0c4337dcc 100644 --- a/Sources/WinUIBackend/WinUIElementRepresentable.swift +++ b/Sources/WinUIBackend/WinUIElementRepresentable.swift @@ -79,13 +79,12 @@ extension WinUIElementRepresentable { // no-op } + @MainActor public func sizeThatFits( _ proposal: ProposedViewSize, winUIElement: WinUIElementType, context _: Context ) -> ViewSize { - let adjustment: SIMD2 = WinUIBackend.sizeCorrection(for: winUIElement) - let allocation = WindowsFoundation.Size( width: proposal.width.map(Float.init) ?? .infinity, height: proposal.height.map(Float.init) ?? .infinity @@ -93,6 +92,8 @@ extension WinUIElementRepresentable { try! winUIElement.measure(allocation) let sizeThatFits = winUIElement.desiredSize + let adjustment: SIMD2 = WinUIBackend.sizeCorrection(for: winUIElement) + let idealWidth = Double(sizeThatFits.width) + Double(adjustment.x) let idealHeight = Double(sizeThatFits.height) + Double(adjustment.y)