From e0e0e68737315e8e74ffbf39c3d615c831a894f4 Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 00:07:57 -0700 Subject: [PATCH 1/4] elements: number-stepper kind + sheet detents/sizing props Adds a general-purpose numeric stepper element (kind number-stepper, props value/min/max/step/text/enabled, emits ValueChanged) so apps can express bounded numeric input with a step increment, and two presentation props on sheet: detents (comma-separated medium|large|fraction) and sizing (form|fitted|page). Apple backend: LUINumberStepperView renders a SwiftUI Stepper over a clamped range; sheets apply .presentationDetents and .presentationSizing (iOS 18+/macOS 15+ gated) via LUIModalPresentationPolicy on the shared modal surface, so navigation-form sheets compose. Integral wire floats (e.g. value:60.0) decode as Int through Foundation's JSONDecoder, so float-typed props now normalize int -> double like grow/sourceX already did. Web/melange renders an ; qt, flutter, and winui gain schema support and minimal renderers; winui coerces integral JSON numbers for float props the same way. --- .../LUIAppleBackend/LUIAppleBackend.swift | 22 ++- .../LUIAppleBackend/LUISwiftUIRoot.swift | 91 +++++++++- .../LUIAppleBackend/LUIWireProtocol.swift | 48 ++++- .../LUIAppleBackend/LUIWireSchema.swift | 6 + .../LUIAppleBackendTests.swift | 116 +++++++++++- platform/flutter/lib/lui_flutter_backend.dart | 80 ++++++++- platform/flutter/lib/lui_wire_schema.g.dart | 2 + platform/qt/lib/lui_qml_backend.cpp | 16 +- platform/qt/lib/lui_schema.cpp | 55 +++++- platform/qt/lib/lui_wire_schema.h | 19 ++ platform/web/melange/core/lui_web_store.ml | 3 + platform/web/melange/events/lui_web_events.ml | 2 +- platform/web/melange/nodes/lui_web_nodes.ml | 9 + platform/web/melange/render/lui_web_props.ml | 29 ++- platform/winui/LUI.Core/LUIBackend.cs | 34 +++- platform/winui/LUI.Core/LUISchema.cs | 68 ++++++- platform/winui/LUI.Core/LUIWireSchema.g.cs | 18 ++ platform/winui/LUI.WinUI/LUIElement.Sync.cs | 33 ++++ platform/winui/LUI.WinUI/LUIElementFactory.cs | 2 + schema/components.json | 36 ++++ src/lui_elements.ml | 27 ++- src/lui_elements.mli | 44 +++++ src/lui_json_view.ml | 5 +- src/lui_protocol.ml | 44 ++++- src/lui_protocol.mli | 6 + src/lui_wire_schema.ml | 11 +- test/test_lui.ml | 170 ++++++++++++++++++ 27 files changed, 955 insertions(+), 41 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift b/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift index 22fe3d89..340e1531 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift @@ -171,6 +171,16 @@ final class LUINodeModel: Identifiable { properties[.containerRelativeFrameInset]?.intValue ?? 0 } var sliderValue: Double { properties[.progressValue]?.doubleValue ?? 0.0 } + var stepperValue: Double { properties[.progressValue]?.doubleValue ?? 0.0 } + var stepperRange: ClosedRange { + let lower = properties[.minValue]?.doubleValue ?? 0.0 + let upper = properties[.maxValue]?.doubleValue ?? .greatestFiniteMagnitude + return lower...max(lower, upper) + } + var stepperStep: Double { + let step = properties[.stepValue]?.doubleValue ?? 1.0 + return step > 0 ? step : 1.0 + } var splitFraction: Double { properties[.progressValue]?.doubleValue ?? 0.0 } var splitGap: Int { properties[.gap]?.intValue ?? 9 } var splitResizeDuration: Int { properties[.resizeDuration]?.intValue ?? 0 } @@ -749,12 +759,20 @@ public final class LUIAppleBackend { func performValueChange(node: Int, value: Double) throws { guard let model = models[node], - model.kind == .slider || model.kind == .split, + model.kind == .slider || model.kind == .split || + model.kind == .numberStepper, model.isEnabled, value.isFinite else { throw invalid("node \(node) is not an enabled value control") } - emit(.valueChanged(node: node, value: min(max(value, 0.0), 1.0))) + if model.kind == .numberStepper { + emit(.valueChanged( + node: node, + value: min(max(value, model.stepperRange.lowerBound), model.stepperRange.upperBound) + )) + } else { + emit(.valueChanged(node: node, value: min(max(value, 0.0), 1.0))) + } } func performDismiss(node: Int) throws { diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index abe710c8..b0ff8a7e 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -765,6 +765,8 @@ private struct LUINodeView: View { .disabled(!model.isEnabled) .frame(minHeight: minimumTouchHeight) ) + case .numberStepper: + return AnyView(LUINumberStepperView(model: model, backend: backend)) case .progress: return AnyView( ProgressView(value: model.progressFraction) @@ -2208,22 +2210,76 @@ private struct LUITooltipHost: View { } private struct LUIModalPresentationStyle: ViewModifier { - let kind: LUINodeKind + let model: LUINodeModel @ViewBuilder func body(content: Content) -> some View { - if LUIModalPresentationPolicy.showsDragIndicator(kind: kind) { - content.presentationDragIndicator(.visible) + let withDetents = content.presentationDetents( + LUIModalPresentationPolicy.detents(for: model) + ) + if #available(iOS 18.0, macOS 15.0, *) { + // `sizing` is a best-effort prop: absent or unrecognized values + // keep the platform default. + switch LUIModalPresentationPolicy.sizing(for: model) { + case .form: + withDetents.presentationSizing(.form) + case .fitted: + withDetents.presentationSizing(.fitted) + case .page: + withDetents.presentationSizing(.page) + case nil: + withDetents + } } else { - content + withDetents } } } +/// `sizing` prop values for `sheet`, mirroring `PresentationSizing` cases. +enum LUISheetSizing: Equatable { + case form + case fitted + case page +} + +@MainActor enum LUIModalPresentationPolicy { static func showsDragIndicator(kind: LUINodeKind) -> Bool { false } + + /// `detents` is a comma-separated list of `medium`, `large`, or a + /// fractional height in (0, 1]. Unknown tokens are ignored; an absent or + /// fully-unrecognized value falls back to the default `.large` detent. + static func detents(for model: LUINodeModel) -> Set { + guard let raw = model.property(.detents)?.stringValue else { + return [.large] + } + let parsed = raw.split(separator: ",").compactMap(detent) + return parsed.isEmpty ? [.large] : Set(parsed) + } + + private static func detent(_ token: Substring) -> PresentationDetent? { + let trimmed = token.trimmingCharacters(in: .whitespaces) + switch trimmed { + case "medium": return .medium + case "large": return .large + default: + guard let fraction = Double(trimmed), + fraction > 0, fraction <= 1 else { return nil } + return .fraction(CGFloat(fraction)) + } + } + + static func sizing(for model: LUINodeModel) -> LUISheetSizing? { + switch model.property(.sizing)?.stringValue { + case "form": return .form + case "fitted": return .fitted + case "page": return .page + default: return nil + } + } } private struct LUIModalSurfaceContent: View { @@ -2233,7 +2289,9 @@ private struct LUIModalSurfaceContent: View { @Environment(\.colorScheme) private var colorScheme var body: some View { - surfaceContent.sheet(item: Binding( + surfaceContent + .modifier(LUIModalPresentationStyle(model: model)) + .sheet(item: Binding( get: { backend.modalPresentation.nestedSheets[model.id] }, set: { value in if value == nil, @@ -2949,6 +3007,29 @@ private struct LUIMediaSurfaceView: View { } } +private struct LUINumberStepperView: View { + let model: LUINodeModel + let backend: LUIAppleBackend + + var body: some View { + Stepper( + model.text, + value: Binding( + get: { + min( + max(model.stepperValue, model.stepperRange.lowerBound), + model.stepperRange.upperBound + ) + }, + set: { try? backend.performValueChange(node: model.id, value: $0) } + ), + in: model.stepperRange, + step: model.stepperStep + ) + .disabled(!model.isEnabled) + } +} + private struct LUIStepperView: View { let model: LUINodeModel let backend: LUIAppleBackend diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift index f2571376..67c723e2 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift @@ -148,6 +148,17 @@ enum LUIWireValue: Decodable, Equatable { .anchorOffset: guard let value = doubleValue else { return false } return value.isFinite + case .minValue, .maxValue: + guard let value = doubleValue else { return false } + return value.isFinite + case .stepValue: + guard let value = doubleValue else { return false } + return value.isFinite && value > 0 + case .detents: + return stringValue != nil + case .sizing: + guard let value = stringValue else { return false } + return ["form", "fitted", "page"].contains(value) case .orientation: guard let value = stringValue else { return false } return value == "horizontal" || value == "vertical" @@ -720,7 +731,8 @@ struct LUIRetainedTree { kind == .text || kind == .heading || kind == .paragraph || kind == .label || kind == .button || kind == .toggleButton || isTextEntry(kind) || kind == .checkbox || kind == .toggle || - kind == .radio || kind == .slider || kind == .spinner || kind == .icon + kind == .radio || kind == .slider || kind == .numberStepper || + kind == .spinner || kind == .icon || kind == .select || kind == .combobox || kind == .dropdownMenu || kind == .menuItem || kind == .listItem || kind == .tableCell || kind == .resizable || kind == .split || @@ -733,11 +745,12 @@ struct LUIRetainedTree { kind == .tooltip || kind == .tableCell || kind == .alert || kind == .bubble || kind == .statusBar || - isModalSurface(kind) || kind == .drawer + isModalSurface(kind) || kind == .drawer || kind == .numberStepper case .enabled: kind == .button || kind == .toggleButton || isTextEntry(kind) || kind == .checkbox || kind == .switchControl || kind == .toggle || - kind == .radio || kind == .slider || kind == .select || + kind == .radio || kind == .slider || kind == .numberStepper || + kind == .select || kind == .combobox || kind == .menuItem || kind == .listItem || kind == .drawer case .gap: @@ -761,12 +774,14 @@ struct LUIRetainedTree { kind == .mediaSurface || kind == .tree || kind == .resizable || kind == .split || kind == .drawer || kind == .alert || kind == .bubble || - kind == .listItem || + kind == .listItem || kind == .numberStepper || isTreeRow(kind) case .headingLevel: kind == .heading case .checked: kind == .checkbox || kind == .switchControl || kind == .toggle || kind == .radio - case .progressValue: kind == .progress || kind == .slider || kind == .split + case .progressValue: + kind == .progress || kind == .slider || kind == .numberStepper || + kind == .split case .resizeDuration, .resizeEasing, .resizeOrigin: kind == .split case .orientation: kind == .divider || kind == .tabs || kind == .scroll case .placement: kind == .toolbar @@ -816,6 +831,8 @@ struct LUIRetainedTree { kind == .tableCell || kind == .bubble || kind == .statusBar case .role: isTreeRow(kind) || kind == .listItem case .treeLevel, .expanded: isTreeRow(kind) + case .minValue, .maxValue, .stepValue: kind == .numberStepper + case .detents, .sizing: kind == .sheet case .active, .title, .description, .meta, .indicator, .connector: false } } @@ -917,6 +934,23 @@ struct LUIRetainedTree { throw invalid("value control requires an accessibility label") } } + if node.kind == .numberStepper { + let text = node.properties[.text]?.stringValue ?? "" + let label = node.properties[.accessibilityLabel]?.stringValue ?? "" + guard !text.isEmpty || !label.isEmpty else { + throw invalid("number-stepper requires a label") + } + guard case let .double(value)? = node.properties[.progressValue], + value.isFinite else { + throw invalid("number-stepper requires a finite value") + } + if case let .double(minimum)? = node.properties[.minValue], + case let .double(maximum)? = node.properties[.maxValue] { + guard minimum <= maximum else { + throw invalid("number-stepper min must not exceed max") + } + } + } if node.kind == .slider || node.kind == .progress { guard case let .double(value)? = node.properties[.progressValue], value.isFinite else { throw invalid("value control requires a finite fractional value") @@ -1218,7 +1252,9 @@ extension LUIWireValue { func normalized(for property: LUIProperty) -> LUIWireValue { if property == .grow || property == .anchorOffset || property == .sourceX || property == .sourceY || - property == .sourceWidth || property == .sourceHeight, + property == .sourceWidth || property == .sourceHeight || + property == .progressValue || property == .minValue || + property == .maxValue || property == .stepValue, case let .int(value) = self { return .double(Double(value)) } diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift index 8e2e5505..973f73cb 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift @@ -22,6 +22,7 @@ enum LUINodeKind: String, Decodable, Equatable { case radioGroup = "radio-group" case radio = "radio" case slider = "slider" + case numberStepper = "number-stepper" case textField = "text-field" case secureField = "secure-field" case input = "input" @@ -150,6 +151,11 @@ enum LUIProperty: String, Decodable, Hashable { case resizeOrigin = "resize-origin" case theme = "theme" case themeMode = "theme-mode" + case minValue = "min" + case maxValue = "max" + case stepValue = "step" + case detents = "detents" + case sizing = "sizing" } enum LUISchemaMatrix { diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift index 6f99514d..5281994c 100644 --- a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift @@ -2293,7 +2293,7 @@ struct LUISwiftUIBackendTests { #expect(throws: LUIBackendError.self) { try backend.apply(json: """ {"generation":3,"ops":[ - {"op":"set-prop","id":1,"property":"value","value":1} + {"op":"set-prop","id":1,"property":"value","value":"x"} ]} """) } @@ -3414,6 +3414,120 @@ struct LUISwiftUIBackendTests { } } + @Test("maps a number-stepper to a clamped value control") + func mapsNumberStepper() throws { + let backend = LUIAppleBackend() + var events: [LUIEvent] = [] + backend.onEvent = { events.append($0) } + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"column"}, + {"op":"create-node","id":2,"kind":"number-stepper"}, + {"op":"set-prop","id":2,"property":"text","value":"Days"}, + {"op":"set-prop","id":2,"property":"value","value":5.0}, + {"op":"set-prop","id":2,"property":"min","value":0.0}, + {"op":"set-prop","id":2,"property":"max","value":10.0}, + {"op":"set-prop","id":2,"property":"step","value":1.0}, + {"op":"insert-child","parent":1,"child":2,"index":0} + ]} + """) + + let stepper = try #require(backend.model(id: 2)) + #expect(stepper.kind == .numberStepper) + #expect(stepper.stepperRange == 0.0...10.0) + #expect(stepper.stepperStep == 1.0) + _ = LUISwiftUIRoot(backend: backend, rootID: 1) + + try backend.performValueChange(node: 2, value: 99.0) + #expect(events == [.valueChanged(node: 2, value: 10.0)]) + + #expect(throws: LUIBackendError.self) { + try backend.apply(json: """ + {"generation":2,"ops":[ + {"op":"set-prop","id":2,"property":"step","value":0.0} + ]} + """) + } + #expect(backend.generation == 1) + + #expect(throws: LUIBackendError.self) { + try backend.apply(json: """ + {"generation":2,"ops":[ + {"op":"create-node","id":3,"kind":"number-stepper"}, + {"op":"set-prop","id":3,"property":"value","value":2.0} + ]} + """) + } + #expect(throws: LUIBackendError.self) { + try backend.apply(json: """ + {"generation":2,"ops":[ + {"op":"create-node","id":4,"kind":"number-stepper"}, + {"op":"set-prop","id":4,"property":"text","value":"Days"}, + {"op":"set-prop","id":4,"property":"value","value":2.0}, + {"op":"set-prop","id":4,"property":"min","value":10.0}, + {"op":"set-prop","id":4,"property":"max","value":0.0} + ]} + """) + } + #expect(backend.generation == 1) + } + + @Test("parses sheet detents and sizing props") + func sheetPresentationProps() throws { + let backend = LUIAppleBackend() + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"column"}, + {"op":"create-node","id":2,"kind":"sheet"}, + {"op":"create-node","id":3,"kind":"input"}, + {"op":"set-prop","id":2,"property":"text","value":"Settings"}, + {"op":"set-prop","id":2,"property":"detents","value":"medium,0.4,large"}, + {"op":"set-prop","id":2,"property":"sizing","value":"form"}, + {"op":"insert-child","parent":1,"child":2,"index":0}, + {"op":"insert-child","parent":2,"child":3,"index":0} + ]} + """) + + let sheet = try #require(backend.model(id: 2)) + #expect(LUIModalPresentationPolicy.detents(for: sheet) == [ + .medium, .fraction(0.4), .large, + ]) + #expect(LUIModalPresentationPolicy.sizing(for: sheet) == .form) + _ = LUISwiftUIRoot(backend: backend, rootID: 1) + + try backend.apply(json: """ + {"generation":2,"ops":[ + {"op":"set-prop","id":2,"property":"detents","value":"bogus,0.25"} + ]} + """) + #expect(LUIModalPresentationPolicy.detents(for: sheet) == [ + .fraction(0.25), + ]) + + try backend.apply(json: """ + {"generation":3,"ops":[ + {"op":"set-prop","id":2,"property":"detents","value":"bogus"} + ]} + """) + #expect(LUIModalPresentationPolicy.detents(for: sheet) == [.large]) + + #expect(throws: LUIBackendError.self) { + try backend.apply(json: """ + {"generation":4,"ops":[ + {"op":"set-prop","id":2,"property":"sizing","value":"huge"} + ]} + """) + } + #expect(throws: LUIBackendError.self) { + try backend.apply(json: """ + {"generation":4,"ops":[ + {"op":"set-prop","id":1,"property":"detents","value":"large"} + ]} + """) + } + #expect(backend.generation == 3) + } + @Test("maps ContextMenu metadata to native SwiftUI actions") func mapsContextMenu() throws { let backend = LUIAppleBackend() diff --git a/platform/flutter/lib/lui_flutter_backend.dart b/platform/flutter/lib/lui_flutter_backend.dart index 06a72c3a..0989ab20 100644 --- a/platform/flutter/lib/lui_flutter_backend.dart +++ b/platform/flutter/lib/lui_flutter_backend.dart @@ -1461,12 +1461,22 @@ final class LUIFlutterBackend { void performValueChange(int node, double value) { final state = _requireState(_states, node); - if ((state.kind != _NodeKind.slider && state.kind != _NodeKind.split) || + if ((state.kind != _NodeKind.slider && + state.kind != _NodeKind.split && + state.kind != _NodeKind.numberStepper) || state.properties['enabled'] == false || !value.isFinite) { throw LUIBackendException('node $node is not an enabled value control'); } - onEvent?.call(LUIEvent.valueChanged(node: node, value: value.clamp(0, 1))); + var emitted = value.clamp(0.0, 1.0); + if (state.kind == _NodeKind.numberStepper) { + final minimum = + (state.properties['min'] as num?)?.toDouble() ?? 0.0; + final maximum = + (state.properties['max'] as num?)?.toDouble() ?? double.maxFinite; + emitted = value.clamp(minimum, maximum < minimum ? minimum : maximum); + } + onEvent?.call(LUIEvent.valueChanged(node: node, value: emitted.toDouble())); } void performDismiss(int node) { @@ -1580,6 +1590,31 @@ final class LUIFlutterBackend { state.kind == _NodeKind.button && state.parent != null && _states[state.parent]?.kind == _NodeKind.tabs; + Widget numberStepperView(_NodeState stepperState, bool stepperEnabled, int id) { + final step = + (stepperState.properties['step'] as num?)?.toDouble() ?? 1.0; + final current = + (stepperState.properties['value'] as num?)?.toDouble() ?? 0.0; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: Text(stepperState.properties['text'] as String? ?? '')), + IconButton( + icon: const Icon(Icons.remove), + onPressed: stepperEnabled + ? () => performValueChange(id, current - step) + : null, + ), + IconButton( + icon: const Icon(Icons.add), + onPressed: stepperEnabled + ? () => performValueChange(id, current + step) + : null, + ), + ], + ); + } + Widget textControl({required _NodeKind kind}) { final multiline = kind == _NodeKind.textarea; final combobox = kind == _NodeKind.combobox; @@ -2838,6 +2873,7 @@ final class LUIFlutterBackend { ), onChanged: enabled ? (value) => performValueChange(id, value) : null, ), + _NodeKind.numberStepper => numberStepperView(state, enabled, id), _NodeKind.textField || _NodeKind.secureField || _NodeKind.input || @@ -3625,6 +3661,7 @@ final class LUIFlutterBackend { kind == _NodeKind.switchControl || kind == _NodeKind.toggle || kind == _NodeKind.radio || + kind == _NodeKind.numberStepper || kind == _NodeKind.select || kind == _NodeKind.menuItem || kind == _NodeKind.menuTrigger || @@ -3646,6 +3683,7 @@ final class LUIFlutterBackend { kind == _NodeKind.toggle || kind == _NodeKind.radio || kind == _NodeKind.slider || + kind == _NodeKind.numberStepper || kind == _NodeKind.select || kind == _NodeKind.menuItem || kind == _NodeKind.listItem || @@ -3655,7 +3693,20 @@ final class LUIFlutterBackend { value.isFinite && (kind == _NodeKind.progress || kind == _NodeKind.slider || + kind == _NodeKind.numberStepper || kind == _NodeKind.split), + 'min' || 'max' => + value is num && value.isFinite && kind == _NodeKind.numberStepper, + 'step' => + value is num && + value.isFinite && + value > 0 && + kind == _NodeKind.numberStepper, + 'detents' => value is String && kind == _NodeKind.sheet, + 'sizing' => + value is String && + const {'form', 'fitted', 'page'}.contains(value) && + kind == _NodeKind.sheet, 'resize-duration' => value is int && value >= 0 && kind == _NodeKind.split, 'resize-easing' => @@ -3809,6 +3860,7 @@ final class LUIFlutterBackend { kind == _NodeKind.toggle || kind == _NodeKind.radio || kind == _NodeKind.slider || + kind == _NodeKind.numberStepper || kind == _NodeKind.spinner || kind == _NodeKind.icon || kind == _NodeKind.select || @@ -3880,6 +3932,7 @@ final class LUIFlutterBackend { kind == _NodeKind.pagination || kind == _NodeKind.radio || kind == _NodeKind.slider || + kind == _NodeKind.numberStepper || kind == _NodeKind.avatar || kind == _NodeKind.image || kind == _NodeKind.mediaSurface || @@ -3954,6 +4007,27 @@ final class LUIFlutterBackend { ); } } + if (state.kind == _NodeKind.numberStepper) { + final value = state.properties['value']; + if (value is! double || !value.isFinite) { + throw const LUIBackendException( + 'number-stepper requires a finite value', + ); + } + final text = state.properties['text'] as String? ?? ''; + final label = + state.properties['accessibility-label'] as String? ?? ''; + if (text.isEmpty && label.isEmpty) { + throw const LUIBackendException('number-stepper requires a label'); + } + final minimum = state.properties['min']; + final maximum = state.properties['max']; + if (minimum is num && maximum is num && minimum > maximum) { + throw const LUIBackendException( + 'number-stepper min must not exceed max', + ); + } + } if (state.kind == _NodeKind.split) { if (state.children.length != 2) { throw const LUIBackendException( @@ -4396,6 +4470,7 @@ final class LUIFlutterBackend { _NodeKind.toggle, _NodeKind.radio, _NodeKind.slider, + _NodeKind.numberStepper, _NodeKind.textField, _NodeKind.secureField, _NodeKind.input, @@ -4419,6 +4494,7 @@ final class LUIFlutterBackend { _NodeKind.toggle, _NodeKind.radio, _NodeKind.slider, + _NodeKind.numberStepper, _NodeKind.textField, _NodeKind.secureField, _NodeKind.input, diff --git a/platform/flutter/lib/lui_wire_schema.g.dart b/platform/flutter/lib/lui_wire_schema.g.dart index 2dd74cff..e16c5c7d 100644 --- a/platform/flutter/lib/lui_wire_schema.g.dart +++ b/platform/flutter/lib/lui_wire_schema.g.dart @@ -22,6 +22,7 @@ enum _NodeKind { radioGroup, radio, slider, + numberStepper, textField, secureField, input, @@ -101,6 +102,7 @@ _NodeKind _decodeNodeKind(Object? value) { 'radio-group' => _NodeKind.radioGroup, 'radio' => _NodeKind.radio, 'slider' => _NodeKind.slider, + 'number-stepper' => _NodeKind.numberStepper, 'text-field' => _NodeKind.textField, 'secure-field' => _NodeKind.secureField, 'input' => _NodeKind.input, diff --git a/platform/qt/lib/lui_qml_backend.cpp b/platform/qt/lib/lui_qml_backend.cpp index 8e92d688..6835f9f8 100644 --- a/platform/qt/lib/lui_qml_backend.cpp +++ b/platform/qt/lib/lui_qml_backend.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include namespace LUI { @@ -1298,14 +1299,25 @@ bool LuiQmlBackend::performChange(qint64 node) { bool LuiQmlBackend::performValueChanged(qint64 node, double value) { const NodeState *state = standardState(m_states, node); if (state == nullptr) return staleNode(node); - if ((state->kind != NodeKind::Slider && state->kind != NodeKind::Split) || + if ((state->kind != NodeKind::Slider && state->kind != NodeKind::Split && + state->kind != NodeKind::NumberStepper) || isFalse(state->properties.value(QStringLiteral("enabled"))) || !std::isfinite(value)) { return fail(QStringLiteral("node %1 is not an enabled value control") .arg(node)); } + double emitted = std::clamp(value, 0.0, 1.0); + if (state->kind == NodeKind::NumberStepper) { + const double minimum = + state->properties.value(QStringLiteral("min")).toDouble(); + const double maximum = + state->properties.contains(QStringLiteral("max")) + ? state->properties.value(QStringLiteral("max")).toDouble() + : std::numeric_limits::max(); + emitted = std::clamp(value, minimum, std::max(minimum, maximum)); + } emitEvent(node, QStringLiteral("value-changed"), - {{QStringLiteral("value"), std::clamp(value, 0.0, 1.0)}}); + {{QStringLiteral("value"), emitted}}); return true; } diff --git a/platform/qt/lib/lui_schema.cpp b/platform/qt/lib/lui_schema.cpp index 38ee30c0..a0b1bca7 100644 --- a/platform/qt/lib/lui_schema.cpp +++ b/platform/qt/lib/lui_schema.cpp @@ -116,7 +116,8 @@ bool treeRowKind(NodeKind kind) { bool contextMenuHostKind(NodeKind kind) { return oneOf(kind, {NodeKind::Button, NodeKind::ToggleButton, NodeKind::Toggle, - NodeKind::Radio, NodeKind::Slider, NodeKind::TextField, + NodeKind::Radio, NodeKind::Slider, NodeKind::NumberStepper, + NodeKind::TextField, NodeKind::SecureField, NodeKind::Input, NodeKind::SearchField, NodeKind::Textarea, NodeKind::Checkbox, NodeKind::SwitchControl, NodeKind::Select, NodeKind::Combobox, NodeKind::MenuItem, @@ -127,7 +128,8 @@ bool contextMenuHostKind(NodeKind kind) { bool contextMenuLeafHostKind(NodeKind kind) { return oneOf(kind, {NodeKind::Button, NodeKind::ToggleButton, NodeKind::Toggle, - NodeKind::Radio, NodeKind::Slider, NodeKind::TextField, + NodeKind::Radio, NodeKind::Slider, NodeKind::NumberStepper, + NodeKind::TextField, NodeKind::SecureField, NodeKind::Input, NodeKind::SearchField, NodeKind::Textarea, NodeKind::Checkbox, NodeKind::SwitchControl, NodeKind::Select, NodeKind::Combobox, NodeKind::MenuItem, @@ -269,7 +271,8 @@ bool eventSupported(NodeKind kind, Event event) { case Event::Change: return kind == NodeKind::Radio; case Event::ValueChanged: - return kind == NodeKind::Slider || kind == NodeKind::Split; + return kind == NodeKind::Slider || kind == NodeKind::NumberStepper || + kind == NodeKind::Split; case Event::Dismiss: return oneOf(kind, {NodeKind::Select, NodeKind::Combobox, NodeKind::DropdownMenu, NodeKind::Toast, @@ -418,7 +421,8 @@ bool commonPropertySupported(NodeKind kind, Property property) { NodeKind::TextField, NodeKind::SecureField, NodeKind::Input, NodeKind::SearchField, NodeKind::Textarea, NodeKind::Checkbox, NodeKind::Toggle, NodeKind::Radio, - NodeKind::Slider, NodeKind::Spinner, NodeKind::Icon, + NodeKind::Slider, NodeKind::NumberStepper, + NodeKind::Spinner, NodeKind::Icon, NodeKind::Select, NodeKind::Combobox, NodeKind::DropdownMenu, NodeKind::MenuItem, NodeKind::ListItem, NodeKind::TableCell, @@ -444,7 +448,8 @@ bool commonPropertySupported(NodeKind kind, Property property) { NodeKind::SearchField, NodeKind::Textarea, NodeKind::Checkbox, NodeKind::SwitchControl, NodeKind::Toggle, NodeKind::RadioGroup, - NodeKind::Radio, NodeKind::Slider, NodeKind::Avatar, + NodeKind::Radio, NodeKind::Slider, + NodeKind::NumberStepper, NodeKind::Avatar, NodeKind::Image, NodeKind::MediaSurface, NodeKind::Tree, NodeKind::Resizable, NodeKind::Split, NodeKind::Drawer, NodeKind::Alert, NodeKind::Bubble, @@ -463,8 +468,8 @@ bool commonPropertySupported(NodeKind kind, Property property) { return oneOf(kind, {NodeKind::Checkbox, NodeKind::SwitchControl, NodeKind::Toggle, NodeKind::Radio}); case Property::ProgressValue: - return oneOf(kind, - {NodeKind::Progress, NodeKind::Slider, NodeKind::Split}); + return oneOf(kind, {NodeKind::Progress, NodeKind::Slider, + NodeKind::NumberStepper, NodeKind::Split}); case Property::OrientationValue: return oneOf(kind, {NodeKind::Divider, NodeKind::Tabs, NodeKind::Scroll}); @@ -562,7 +567,8 @@ bool commonPropertySupported(NodeKind kind, Property property) { NodeKind::Combobox, NodeKind::MenuItem, NodeKind::ListItem, NodeKind::Avatar, NodeKind::Dialog, NodeKind::Drawer, NodeKind::Sheet, NodeKind::Tooltip, NodeKind::TableCell, - NodeKind::Alert, NodeKind::Bubble, NodeKind::StatusBar}); + NodeKind::Alert, NodeKind::Bubble, NodeKind::StatusBar, + NodeKind::NumberStepper}); case Property::Enabled: return oneOf(kind, {NodeKind::Button, NodeKind::ToggleButton, @@ -570,7 +576,8 @@ bool commonPropertySupported(NodeKind kind, Property property) { NodeKind::SearchField, NodeKind::Textarea, NodeKind::Checkbox, NodeKind::SwitchControl, NodeKind::Toggle, NodeKind::Radio, NodeKind::Slider, - NodeKind::Select, NodeKind::Combobox, NodeKind::MenuItem, + NodeKind::NumberStepper, NodeKind::Select, + NodeKind::Combobox, NodeKind::MenuItem, NodeKind::ListItem, NodeKind::Drawer, NodeKind::BottomTab}); case Property::ActiveIndex: case Property::DescriptionValue: @@ -580,6 +587,13 @@ bool commonPropertySupported(NodeKind kind, Property property) { return false; case Property::TitleValue: return kind == NodeKind::BottomTab; + case Property::MinValue: + case Property::MaxValue: + case Property::StepValue: + return kind == NodeKind::NumberStepper; + case Property::Detents: + case Property::Sizing: + return kind == NodeKind::Sheet; case Property::Gap: return oneOf(kind, {NodeKind::Row, NodeKind::Column, NodeKind::Grid, NodeKind::ListContainer, NodeKind::VirtualList, @@ -803,6 +817,16 @@ bool propertyValueSupported(Property property, const QVariant &value) { case Property::ThemeMode: return isString(value) && inSet(value.toString(), {"system", "light", "dark"}); + case Property::MinValue: + case Property::MaxValue: + return isNumeric(value); + case Property::StepValue: + return isNumeric(value) && value.toDouble() > 0.0; + case Property::Detents: + return isString(value); + case Property::Sizing: + return isString(value) && + inSet(value.toString(), {"form", "fitted", "page"}); } return false; } @@ -922,6 +946,19 @@ bool nodePropertiesSupported(NodeKind kind, const QVariantMap &properties) { const QVariant value = properties.value(QStringLiteral("value")); if (!isNumeric(value)) return false; } + if (kind == NodeKind::NumberStepper) { + if (!isNumeric(properties.value(QStringLiteral("value")))) return false; + if (!stringPropertyNonempty(properties, "text") && + !stringPropertyNonempty(properties, "accessibility-label")) { + return false; + } + const QVariant minimum = properties.value(QStringLiteral("min")); + const QVariant maximum = properties.value(QStringLiteral("max")); + if (isNumeric(minimum) && isNumeric(maximum) && + minimum.toDouble() > maximum.toDouble()) { + return false; + } + } if (kind == NodeKind::Tree || kind == NodeKind::Toolbar) { if (!stringPropertyNonempty(properties, "accessibility-label")) { return false; diff --git a/platform/qt/lib/lui_wire_schema.h b/platform/qt/lib/lui_wire_schema.h index fd11ca9b..45ce22f0 100644 --- a/platform/qt/lib/lui_wire_schema.h +++ b/platform/qt/lib/lui_wire_schema.h @@ -26,6 +26,7 @@ enum class NodeKind { RadioGroup, Radio, Slider, + NumberStepper, TextField, SecureField, Input, @@ -154,6 +155,11 @@ enum class Property { ResizeOrigin, ThemeValue, ThemeMode, + MinValue, + MaxValue, + StepValue, + Detents, + Sizing, }; inline const char *nodeKindWireName(NodeKind kind) { @@ -178,6 +184,7 @@ inline const char *nodeKindWireName(NodeKind kind) { case NodeKind::RadioGroup: return "radio-group"; case NodeKind::Radio: return "radio"; case NodeKind::Slider: return "slider"; + case NodeKind::NumberStepper: return "number-stepper"; case NodeKind::TextField: return "text-field"; case NodeKind::SecureField: return "secure-field"; case NodeKind::Input: return "input"; @@ -255,6 +262,7 @@ inline bool decodeNodeKind(const char *name, NodeKind *kind) { if (std::strcmp(name, "radio-group") == 0) { *kind = NodeKind::RadioGroup; return true; } if (std::strcmp(name, "radio") == 0) { *kind = NodeKind::Radio; return true; } if (std::strcmp(name, "slider") == 0) { *kind = NodeKind::Slider; return true; } + if (std::strcmp(name, "number-stepper") == 0) { *kind = NodeKind::NumberStepper; return true; } if (std::strcmp(name, "text-field") == 0) { *kind = NodeKind::TextField; return true; } if (std::strcmp(name, "secure-field") == 0) { *kind = NodeKind::SecureField; return true; } if (std::strcmp(name, "input") == 0) { *kind = NodeKind::Input; return true; } @@ -387,6 +395,7 @@ inline const char *nodeKindComponentName(NodeKind kind) { case NodeKind::RadioGroup: return "LuiRadioGroup.qml"; case NodeKind::Radio: return "LuiRadio.qml"; case NodeKind::Slider: return "LuiSlider.qml"; + case NodeKind::NumberStepper: return "LuiNumberStepper.qml"; case NodeKind::TextField: return "LuiTextField.qml"; case NodeKind::SecureField: return "LuiSecureField.qml"; case NodeKind::Input: return "LuiInput.qml"; @@ -518,6 +527,11 @@ inline const char *propertyWireName(Property property) { case Property::ResizeOrigin: return "resize-origin"; case Property::ThemeValue: return "theme"; case Property::ThemeMode: return "theme-mode"; + case Property::MinValue: return "min"; + case Property::MaxValue: return "max"; + case Property::StepValue: return "step"; + case Property::Detents: return "detents"; + case Property::Sizing: return "sizing"; } return "unknown"; } @@ -596,6 +610,11 @@ inline bool decodePropertyWireName(const char *name, Property *property) { if (std::strcmp(name, "resize-origin") == 0) { *property = Property::ResizeOrigin; return true; } if (std::strcmp(name, "theme") == 0) { *property = Property::ThemeValue; return true; } if (std::strcmp(name, "theme-mode") == 0) { *property = Property::ThemeMode; return true; } + if (std::strcmp(name, "min") == 0) { *property = Property::MinValue; return true; } + if (std::strcmp(name, "max") == 0) { *property = Property::MaxValue; return true; } + if (std::strcmp(name, "step") == 0) { *property = Property::StepValue; return true; } + if (std::strcmp(name, "detents") == 0) { *property = Property::Detents; return true; } + if (std::strcmp(name, "sizing") == 0) { *property = Property::Sizing; return true; } return false; } diff --git a/platform/web/melange/core/lui_web_store.ml b/platform/web/melange/core/lui_web_store.ml index ebaeb70c..2b89e4fe 100644 --- a/platform/web/melange/core/lui_web_store.ml +++ b/platform/web/melange/core/lui_web_store.ml @@ -450,6 +450,9 @@ let validate_progress_structure current = | Some TimelineItem -> if not (Property_map.mem TitleValue current.retained_properties) then invalid_arg "timeline-item requires title" + | Some NumberStepper -> + if not (Property_map.mem ProgressValue current.retained_properties) then + invalid_arg "number-stepper requires value" | _ -> () let child_kind_of nodes child_id = diff --git a/platform/web/melange/events/lui_web_events.ml b/platform/web/melange/events/lui_web_events.ml index eb1b2bac..491bd782 100644 --- a/platform/web/melange/events/lui_web_events.ml +++ b/platform/web/melange/events/lui_web_events.ml @@ -458,7 +458,7 @@ let attach_events renderer node kind dom_node = | Checkbox | SwitchControl -> attach_toggle_event renderer node kind dom_node | Radio -> attach_radio_event renderer node dom_node - | Slider -> attach_slider_event renderer node dom_node + | Slider | NumberStepper -> attach_slider_event renderer node dom_node | Split -> ignore (Lui_web_split.attach_split_events renderer node dom_node) | Tabs | ButtonGroup | ToggleGroup | Breadcrumb | Pagination -> ignore (Lui_web_focus.attach_horizontal_focus renderer node kind dom_node) diff --git a/platform/web/melange/nodes/lui_web_nodes.ml b/platform/web/melange/nodes/lui_web_nodes.ml index a071c9b0..8abad288 100644 --- a/platform/web/melange/nodes/lui_web_nodes.ml +++ b/platform/web/melange/nodes/lui_web_nodes.ml @@ -31,6 +31,7 @@ let base_class_name kind = | RadioGroup -> "lui-radio-group" | Radio -> "lui-radio" | Slider -> "lui-slider" + | NumberStepper -> "lui-number-stepper" | TextField -> "lui-text-field" | SecureField -> "lui-text-field" | Input -> "lui-input" @@ -147,6 +148,13 @@ let create_combobox_node renderer = [ ("type", "button"); ("aria-label", "Open menu") ] [] ] +let create_number_stepper_node renderer = + let document = renderer.web_document in + Util.element document "label" (base_class_name NumberStepper) [] + [ Util.element document "input" "lui-number-stepper-control" + [ ("type", "number") ] []; + Util.element document "span" "lui-number-stepper-label" [] [] ] + let create_select_node renderer = let document = renderer.web_document in Util.element document "button" "lui-select" @@ -368,6 +376,7 @@ let platform_node renderer kind = match kind with | Button | ToggleButton | Toggle -> create_button_node renderer kind | Checkbox | SwitchControl | Radio -> create_direct_toggle_node renderer kind + | NumberStepper -> create_number_stepper_node renderer | Select -> create_select_node renderer | Combobox -> create_combobox_node renderer | DropdownMenu -> create_dropdown_node renderer diff --git a/platform/web/melange/render/lui_web_props.ml b/platform/web/melange/render/lui_web_props.ml index 19c5c818..115b5b16 100644 --- a/platform/web/melange/render/lui_web_props.ml +++ b/platform/web/melange/render/lui_web_props.ml @@ -204,6 +204,8 @@ let apply_text_value renderer node kind dom_node text = | Dialog | Sheet -> W.Element.setTextContent (Util.child_element (Util.child_element dom_node 0) 0) text + | NumberStepper -> + W.Element.setTextContent (Util.toggle_label_node dom_node) text | Avatar -> W.Element.setTextContent (Util.child_element dom_node 1) text; Widgets.update_avatar renderer node dom_node @@ -239,7 +241,8 @@ let apply_enabled renderer node kind dom_node enabled = end else begin let control_node = - if Store.direct_toggle kind then Util.child_element dom_node 0 + if Store.direct_toggle kind || kind = NumberStepper then + Util.child_element dom_node 0 else if kind = Combobox then Util.child_element dom_node 0 else dom_node in @@ -298,7 +301,8 @@ let apply_accessibility_label kind dom_node label = (Util.child_element dom_node 1) else W.Element.setAttribute "aria-label" label - (if Store.direct_toggle kind then Util.child_element dom_node 0 + (if Store.direct_toggle kind || kind = NumberStepper then + Util.child_element dom_node 0 else dom_node) let apply_checked kind dom_node checked = @@ -539,6 +543,19 @@ and apply_secondary_property renderer node kind dom_node property value = | Checked, BoolValue checked -> apply_checked kind dom_node checked | ProgressValue, FloatValue value -> apply_progress_value renderer node kind dom_node value + | MinValue, FloatValue value -> + W.Element.setAttribute "min" (Js.Float.toString value) + (Util.child_element dom_node 0) + | MaxValue, FloatValue value -> + W.Element.setAttribute "max" (Js.Float.toString value) + (Util.child_element dom_node 0) + | StepValue, FloatValue value -> + W.Element.setAttribute "step" (Js.Float.toString value) + (Util.child_element dom_node 0) + | Detents, StringValue value -> + W.Element.setAttribute "data-detents" value dom_node + | Sizing, StringValue value -> + W.Element.setAttribute "data-sizing" value dom_node | ResizeDuration, IntValue _duration -> Lui_web_split.update_split renderer node | ResizeEasing, StringValue _easing -> @@ -690,6 +707,14 @@ let remove_property renderer node kind dom_node property = W.Element.removeAttribute "aria-label" (if Store.direct_toggle kind then Util.child_element dom_node 0 else dom_node) + | MinValue -> + W.Element.removeAttribute "min" (Util.child_element dom_node 0) + | MaxValue -> + W.Element.removeAttribute "max" (Util.child_element dom_node 0) + | StepValue -> + W.Element.removeAttribute "step" (Util.child_element dom_node 0) + | Detents -> W.Element.removeAttribute "data-detents" dom_node + | Sizing -> W.Element.removeAttribute "data-sizing" dom_node | AccessibilityIdentifier -> W.Element.removeAttribute "id" dom_node | OrientationValue -> if kind = Tabs then begin diff --git a/platform/winui/LUI.Core/LUIBackend.cs b/platform/winui/LUI.Core/LUIBackend.cs index f061a4b2..3097f83a 100644 --- a/platform/winui/LUI.Core/LUIBackend.cs +++ b/platform/winui/LUI.Core/LUIBackend.cs @@ -353,6 +353,20 @@ void ApplyState( } LUIWireValue value = WireValue( operation.GetProperty("value"), "value"); + if (value is LUIWireValue.Int intValue && + (property == LUIProperty.Grow || + property == LUIProperty.AnchorOffset || + property == LUIProperty.SourceX || + property == LUIProperty.SourceY || + property == LUIProperty.SourceWidth || + property == LUIProperty.SourceHeight || + property == LUIProperty.ProgressValue || + property == LUIProperty.MinValue || + property == LUIProperty.MaxValue || + property == LUIProperty.StepValue)) + { + value = new LUIWireValue.Float(intValue.Value); + } if (!LUISchema.PropertySupported(node.Kind, property) || !LUISchema.PropertyValueSupportedForKind( node.Kind, property, value)) @@ -1462,16 +1476,28 @@ public void PerformValueChange(long node, double value) { LUINodeState state = RequireState(node); if ((state.Kind != LUINodeKind.Slider && - state.Kind != LUINodeKind.Split) || + state.Kind != LUINodeKind.Split && + state.Kind != LUINodeKind.NumberStepper) || IsDisabled(state) || double.IsNaN(value) || double.IsInfinity(value)) { throw new LUIBackendException( $"node {node} is not an enabled value control"); } - OnEvent?.Invoke( - new LUIEvent.ValueChanged( - node, System.Math.Clamp(value, 0.0, 1.0))); + double emitted = System.Math.Clamp(value, 0.0, 1.0); + if (state.Kind == LUINodeKind.NumberStepper) + { + double minimum = FloatProperty( + state, LUIProperty.MinValue, 0.0); + double maximum = state.Properties.TryGetValue( + LUIProperty.MaxValue, out LUIWireValue? maxValue) && + maxValue is LUIWireValue.Float maxFloat + ? maxFloat.Value + : double.MaxValue; + emitted = System.Math.Clamp( + value, minimum, System.Math.Max(minimum, maximum)); + } + OnEvent?.Invoke(new LUIEvent.ValueChanged(node, emitted)); } public void PerformDismiss(long node) diff --git a/platform/winui/LUI.Core/LUISchema.cs b/platform/winui/LUI.Core/LUISchema.cs index 69449b55..f3a067ed 100644 --- a/platform/winui/LUI.Core/LUISchema.cs +++ b/platform/winui/LUI.Core/LUISchema.cs @@ -45,6 +45,7 @@ public static bool ContextMenuHostKind(LUINodeKind kind) case LUINodeKind.Toggle: case LUINodeKind.Radio: case LUINodeKind.Slider: + case LUINodeKind.NumberStepper: case LUINodeKind.TextField: case LUINodeKind.SecureField: case LUINodeKind.Input: @@ -74,6 +75,7 @@ public static bool ContextMenuLeafHostKind(LUINodeKind kind) case LUINodeKind.Toggle: case LUINodeKind.Radio: case LUINodeKind.Slider: + case LUINodeKind.NumberStepper: case LUINodeKind.TextField: case LUINodeKind.SecureField: case LUINodeKind.Input: @@ -203,7 +205,9 @@ public static bool EventSupported(LUINodeKind kind, LUIEventKind eventKind) case LUIEventKind.Change: return kind == LUINodeKind.Radio; case LUIEventKind.ValueChanged: - return kind == LUINodeKind.Slider || kind == LUINodeKind.Split; + return kind == LUINodeKind.Slider || + kind == LUINodeKind.NumberStepper || + kind == LUINodeKind.Split; case LUIEventKind.Dismiss: switch (kind) { @@ -466,6 +470,7 @@ public static bool CommonPropertySupported( case LUINodeKind.Toggle: case LUINodeKind.Radio: case LUINodeKind.Slider: + case LUINodeKind.NumberStepper: case LUINodeKind.Spinner: case LUINodeKind.Icon: case LUINodeKind.Select: @@ -511,6 +516,7 @@ public static bool CommonPropertySupported( kind == LUINodeKind.RadioGroup || kind == LUINodeKind.Radio || kind == LUINodeKind.Slider || + kind == LUINodeKind.NumberStepper || HorizontalContainer(kind) || kind == LUINodeKind.Avatar || kind == LUINodeKind.Image || @@ -543,6 +549,7 @@ public static bool CommonPropertySupported( case LUIProperty.ProgressValue: return kind == LUINodeKind.Progress || kind == LUINodeKind.Slider || + kind == LUINodeKind.NumberStepper || kind == LUINodeKind.Split; case LUIProperty.OrientationValue: return kind == LUINodeKind.Divider || @@ -656,6 +663,13 @@ public static bool CommonPropertySupported( case LUIProperty.ResizeEasing: case LUIProperty.ResizeOrigin: return kind == LUINodeKind.Split; + case LUIProperty.MinValue: + case LUIProperty.MaxValue: + case LUIProperty.StepValue: + return kind == LUINodeKind.NumberStepper; + case LUIProperty.Detents: + case LUIProperty.Sizing: + return kind == LUINodeKind.Sheet; case LUIProperty.ThemeValue: case LUIProperty.ThemeMode: return CanContainChildren(kind); @@ -686,6 +700,7 @@ public static bool CommonPropertySupported( case LUINodeKind.Drawer: case LUINodeKind.Sheet: case LUINodeKind.Tooltip: + case LUINodeKind.NumberStepper: case LUINodeKind.TableCell: case LUINodeKind.Alert: case LUINodeKind.Bubble: @@ -709,6 +724,7 @@ public static bool CommonPropertySupported( case LUINodeKind.Toggle: case LUINodeKind.Radio: case LUINodeKind.Slider: + case LUINodeKind.NumberStepper: case LUINodeKind.Select: case LUINodeKind.Combobox: case LUINodeKind.MenuItem: @@ -1089,6 +1105,27 @@ public static bool PropertyValueSupported( (text.Value == "system" || text.Value == "light" || text.Value == "dark"); } + case LUIProperty.MinValue: + case LUIProperty.MaxValue: + { + return value is LUIWireValue.Float number && + IsFinite(number.Value); + } + case LUIProperty.StepValue: + { + return value is LUIWireValue.Float stepValue && + IsFinite(stepValue.Value) && stepValue.Value > 0.0; + } + case LUIProperty.Detents: + { + return value is LUIWireValue.String; + } + case LUIProperty.Sizing: + { + return value is LUIWireValue.String sizing && + (sizing.Value == "form" || sizing.Value == "fitted" || + sizing.Value == "page"); + } default: { return false; @@ -1327,6 +1364,35 @@ public static bool NodePropertiesSupported( return false; } } + if (kind == LUINodeKind.NumberStepper) + { + if (!properties.TryGetValue( + LUIProperty.ProgressValue, + out LUIWireValue? stepperValue) || + stepperValue is not LUIWireValue.Float) + { + return false; + } + if (!StringPropertyNonempty( + properties, LUIProperty.TextValue) && + !StringPropertyNonempty( + properties, LUIProperty.AccessibilityLabel)) + { + return false; + } + if (properties.TryGetValue( + LUIProperty.MinValue, + out LUIWireValue? minimum) && + properties.TryGetValue( + LUIProperty.MaxValue, + out LUIWireValue? maximum) && + minimum is LUIWireValue.Float minFloat && + maximum is LUIWireValue.Float maxFloat && + minFloat.Value > maxFloat.Value) + { + return false; + } + } if (kind == LUINodeKind.Tree || kind == LUINodeKind.Toolbar) { if (!StringPropertyNonempty( diff --git a/platform/winui/LUI.Core/LUIWireSchema.g.cs b/platform/winui/LUI.Core/LUIWireSchema.g.cs index 513775f4..22c9902e 100644 --- a/platform/winui/LUI.Core/LUIWireSchema.g.cs +++ b/platform/winui/LUI.Core/LUIWireSchema.g.cs @@ -28,6 +28,7 @@ public enum LUINodeKind RadioGroup, Radio, Slider, + NumberStepper, TextField, SecureField, Input, @@ -157,6 +158,11 @@ public enum LUIProperty ResizeOrigin, ThemeValue, ThemeMode, + MinValue, + MaxValue, + StepValue, + Detents, + Sizing, } public static class LUIWireSchema @@ -184,6 +190,7 @@ public static class LUIWireSchema { "radio-group", LUINodeKind.RadioGroup }, { "radio", LUINodeKind.Radio }, { "slider", LUINodeKind.Slider }, + { "number-stepper", LUINodeKind.NumberStepper }, { "text-field", LUINodeKind.TextField }, { "secure-field", LUINodeKind.SecureField }, { "input", LUINodeKind.Input }, @@ -314,6 +321,11 @@ public static class LUIWireSchema { "resize-origin", LUIProperty.ResizeOrigin }, { "theme", LUIProperty.ThemeValue }, { "theme-mode", LUIProperty.ThemeMode }, + { "min", LUIProperty.MinValue }, + { "max", LUIProperty.MaxValue }, + { "step", LUIProperty.StepValue }, + { "detents", LUIProperty.Detents }, + { "sizing", LUIProperty.Sizing }, }; public static string WireName(this LUINodeKind kind) => kind switch @@ -338,6 +350,7 @@ public static class LUIWireSchema LUINodeKind.RadioGroup => "radio-group", LUINodeKind.Radio => "radio", LUINodeKind.Slider => "slider", + LUINodeKind.NumberStepper => "number-stepper", LUINodeKind.TextField => "text-field", LUINodeKind.SecureField => "secure-field", LUINodeKind.Input => "input", @@ -520,6 +533,11 @@ LUINodeKind.Toast or LUIProperty.ResizeOrigin => "resize-origin", LUIProperty.ThemeValue => "theme", LUIProperty.ThemeMode => "theme-mode", + LUIProperty.MinValue => "min", + LUIProperty.MaxValue => "max", + LUIProperty.StepValue => "step", + LUIProperty.Detents => "detents", + LUIProperty.Sizing => "sizing", _ => "unknown", }; diff --git a/platform/winui/LUI.WinUI/LUIElement.Sync.cs b/platform/winui/LUI.WinUI/LUIElement.Sync.cs index 28d01ada..a71568cb 100644 --- a/platform/winui/LUI.WinUI/LUIElement.Sync.cs +++ b/platform/winui/LUI.WinUI/LUIElement.Sync.cs @@ -86,6 +86,9 @@ void SyncKind(LUINodeState state, LUISyncContext context) case LUINodeKind.Slider: SyncSlider(state, context); break; + case LUINodeKind.NumberStepper: + SyncNumberStepper(state, context); + break; case LUINodeKind.TextField: case LUINodeKind.Input: case LUINodeKind.Textarea: @@ -382,6 +385,36 @@ void OnSliderChanged( } } + void SyncNumberStepper(LUINodeState state, LUISyncContext context) + { + if (Control is not NumberBox box) return; + box.Minimum = LUIPropertyApplier.Prop( + state, LUIProperty.MinValue)?.AsFloat ?? 0.0; + box.Maximum = LUIPropertyApplier.Prop( + state, LUIProperty.MaxValue)?.AsFloat ?? double.MaxValue; + box.SmallChange = LUIPropertyApplier.Prop( + state, LUIProperty.StepValue)?.AsFloat ?? 1.0; + box.ValueChanged -= OnNumberStepperChanged; + double? value = LUIPropertyApplier.Prop( + state, LUIProperty.ProgressValue)?.AsFloat; + if (value != null && box.Value != value.Value) + { + box.Value = value.Value; + } + box.ValueChanged += OnNumberStepperChanged; + } + + void OnNumberStepperChanged(NumberBox sender, + NumberBoxValueChangedEventArgs args) + { + LUISyncContext? context = Context; + if (context != null && !double.IsNaN(args.NewValue)) + { + Gate(() => + context.Backend.PerformValueChange(Id, args.NewValue)); + } + } + // ---- inputs ------------------------------------------------------- void SyncTextBox(LUINodeState state, LUISyncContext context) diff --git a/platform/winui/LUI.WinUI/LUIElementFactory.cs b/platform/winui/LUI.WinUI/LUIElementFactory.cs index 5e2e3d50..b6e79426 100644 --- a/platform/winui/LUI.WinUI/LUIElementFactory.cs +++ b/platform/winui/LUI.WinUI/LUIElementFactory.cs @@ -65,6 +65,8 @@ public static FrameworkElement Create(LUINodeKind kind) return new RadioButton(); case LUINodeKind.Slider: return new Slider { Minimum = 0, Maximum = 1 }; + case LUINodeKind.NumberStepper: + return new NumberBox { SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Inline }; case LUINodeKind.TextField: case LUINodeKind.Input: return new TextBox(); diff --git a/schema/components.json b/schema/components.json index 5890108b..77f5025a 100644 --- a/schema/components.json +++ b/schema/components.json @@ -225,6 +225,10 @@ "name": "slider", "status": "supported" }, + { + "name": "number-stepper", + "status": "supported" + }, { "name": "progress", "status": "supported" @@ -435,6 +439,13 @@ "swift": "slider", "container": false }, + { + "lg": "NumberStepper", + "wire": "number-stepper", + "dart": "numberStepper", + "swift": "numberStepper", + "container": false + }, { "lg": "TextField", "wire": "text-field", @@ -1165,6 +1176,31 @@ "lg": "ThemeMode", "wire": "theme-mode", "swift": "themeMode" + }, + { + "lg": "MinValue", + "wire": "min", + "swift": "minValue" + }, + { + "lg": "MaxValue", + "wire": "max", + "swift": "maxValue" + }, + { + "lg": "StepValue", + "wire": "step", + "swift": "stepValue" + }, + { + "lg": "Detents", + "wire": "detents", + "swift": "detents" + }, + { + "lg": "Sizing", + "wire": "sizing", + "swift": "sizing" } ], "events": [ diff --git a/src/lui_elements.ml b/src/lui_elements.ml index b75ee827..ef74c3a8 100644 --- a/src/lui_elements.ml +++ b/src/lui_elements.ml @@ -913,6 +913,27 @@ let slider ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?p mount_children context node children; node +let number_stepper ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?padding_vertical ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?max_width ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?on_appear ?value ?value_signal ?min ?max ?step ?text ?text_signal ?label ?enabled ?enabled_signal ?on_value_changed (children : t list) : t = + fun context parent -> + let node = Lui_ui.create context NumberStepper in + apply_universal context node ~key ~gap ~main ~cross ~grow ~columns ~padding ~padding_horizontal ~padding_vertical ~background ~foreground ~border_color ~border_width ~corner_radius ~width ~height ~min_width ~max_width ~min_height ~max_height ~container_relative_frame ~container_relative_frame_inset ~accessibility_identifier ~accessibility_identifier_signal ~foreground_signal ~background_signal ~style_class ~on_appear; + Option.iter (Lui_ui.float_property context node ProgressValue) value; + Option.iter (Lui_ui.float_property_signal context node ProgressValue) value_signal; + Option.iter (Lui_ui.float_property context node MinValue) min; + Option.iter (Lui_ui.float_property context node MaxValue) max; + Option.iter (Lui_ui.float_property context node StepValue) step; + Option.iter (Lui_ui.string_property context node TextValue) text; + Option.iter (Lui_ui.string_property_signal context node TextValue) text_signal; + Option.iter (Lui_ui.string_property context node AccessibilityLabel) label; + Option.iter (Lui_ui.bool_property context node Enabled) enabled; + Option.iter (Lui_ui.bool_property_signal context node Enabled) enabled_signal; + (match on_value_changed with + | Some handler -> register_resize context node handler + | None -> ()); + attach context parent node; + mount_children context node children; + node + let progress ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?padding_vertical ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?max_width ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?on_appear ?value ?value_signal (_children : nothing list) : t = fun context parent -> let node = Lui_ui.create context Progress in @@ -1206,12 +1227,16 @@ let dialog ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?p mount_children context node children; node -let sheet ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?padding_vertical ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?max_width ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?on_appear ?text ?text_signal ?on_dismiss (children : t list) : t = +let sheet ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizontal ?padding_vertical ?background ?foreground ?border_color ?border_width ?corner_radius ?width ?height ?min_width ?max_width ?min_height ?max_height ?container_relative_frame ?container_relative_frame_inset ?accessibility_identifier ?accessibility_identifier_signal ?foreground_signal ?background_signal ?style_class ?on_appear ?text ?text_signal ?detents ?detents_signal ?sizing ?sizing_signal ?on_dismiss (children : t list) : t = fun context parent -> let node = Lui_ui.sheet context in apply_universal context node ~key ~gap ~main ~cross ~grow ~columns ~padding ~padding_horizontal ~padding_vertical ~background ~foreground ~border_color ~border_width ~corner_radius ~width ~height ~min_width ~max_width ~min_height ~max_height ~container_relative_frame ~container_relative_frame_inset ~accessibility_identifier ~accessibility_identifier_signal ~foreground_signal ~background_signal ~style_class ~on_appear; Option.iter (Lui_ui.string_property context node TextValue) text; Option.iter (Lui_ui.string_property_signal context node TextValue) text_signal; + Option.iter (Lui_ui.string_property context node Detents) detents; + Option.iter (Lui_ui.string_property_signal context node Detents) detents_signal; + Option.iter (Lui_ui.string_property context node Sizing) sizing; + Option.iter (Lui_ui.string_property_signal context node Sizing) sizing_signal; (match on_dismiss with | Some handler -> register_dismiss context node handler | None -> ()); diff --git a/src/lui_elements.mli b/src/lui_elements.mli index 7f63da1c..a5cb890d 100644 --- a/src/lui_elements.mli +++ b/src/lui_elements.mli @@ -1563,6 +1563,46 @@ val slider : ?disabled:bool -> ?disabled_signal:bool Signal.signal -> ?on_change:(Lui_protocol.event -> unit) -> t list -> t +val number_stepper : + ?key:string -> + ?gap:int -> + ?main:main_alignment -> + ?cross:cross_alignment -> + ?grow:float -> + ?columns:int -> + ?padding:int -> + ?padding_horizontal:int -> + ?padding_vertical:int -> + ?background:string -> + ?foreground:string -> + ?border_color:string -> + ?border_width:int -> + ?corner_radius:int -> + ?width:int -> + ?height:int -> + ?min_width:int -> + ?max_width:int -> + ?min_height:int -> + ?max_height:int -> + ?container_relative_frame:frame_axes -> + ?container_relative_frame_inset:int -> + ?accessibility_identifier:string -> + ?accessibility_identifier_signal:string Signal.signal -> + ?foreground_signal:string Signal.signal -> + ?background_signal:string Signal.signal -> + ?style_class:string -> + ?on_appear:(Lui_protocol.event -> unit) -> + ?value:float -> + ?value_signal:float Signal.signal -> + ?min:float -> + ?max:float -> + ?step:float -> + ?text:string -> + ?text_signal:string Signal.signal -> + ?label:string -> + ?enabled:bool -> + ?enabled_signal:bool Signal.signal -> + ?on_value_changed:(Lui_protocol.event -> unit) -> t list -> t val progress : ?key:string -> ?gap:int -> @@ -2149,6 +2189,10 @@ val sheet : ?on_appear:(Lui_protocol.event -> unit) -> ?text:string -> ?text_signal:string Signal.signal -> + ?detents:string -> + ?detents_signal:string Signal.signal -> + ?sizing:string -> + ?sizing_signal:string Signal.signal -> ?on_dismiss:(Lui_protocol.event -> unit) -> t list -> t val tooltip : ?key:string -> diff --git a/src/lui_json_view.ml b/src/lui_json_view.ml index a0b40d31..0242794b 100644 --- a/src/lui_json_view.ml +++ b/src/lui_json_view.ml @@ -397,6 +397,9 @@ let rec element ~emit (node : value) : E.t = | "slider" -> E.slider ?key:s.s_key ?gap:s.s_gap ?main:s.s_main ?cross:s.s_cross ?grow:s.s_grow ?columns:s.s_columns ?padding:s.s_pad ?padding_horizontal:s.s_pad_h ?padding_vertical:s.s_pad_v ?background:s.s_bg ?foreground:s.s_fg ?border_color:s.s_border_color ?border_width:s.s_border_width ?corner_radius:s.s_radius ?width:s.s_width ?height:s.s_height ?min_width:s.s_min_width ?max_width:s.s_max_width ?min_height:s.s_min_height ?max_height:s.s_max_height ?container_relative_frame:(frame_axes_opt (v_str props "container-relative-frame")) ?container_relative_frame_inset:(v_int props "container-relative-frame-inset") ?accessibility_identifier:(v_str props "accessibility-identifier") ?style_class:s.s_class ?on_appear:(ev "appear" emit props) ?value:(v_float props "value") ?label:(v_str props "label") ?disabled:(v_bool props "disabled") ?on_change:(ev "change" emit props) (List.map (element ~emit) child_nodes) + | "number-stepper" -> + E.number_stepper + ?key:s.s_key ?gap:s.s_gap ?main:s.s_main ?cross:s.s_cross ?grow:s.s_grow ?columns:s.s_columns ?padding:s.s_pad ?padding_horizontal:s.s_pad_h ?padding_vertical:s.s_pad_v ?background:s.s_bg ?foreground:s.s_fg ?border_color:s.s_border_color ?border_width:s.s_border_width ?corner_radius:s.s_radius ?width:s.s_width ?height:s.s_height ?min_width:s.s_min_width ?max_width:s.s_max_width ?min_height:s.s_min_height ?max_height:s.s_max_height ?container_relative_frame:(frame_axes_opt (v_str props "container-relative-frame")) ?container_relative_frame_inset:(v_int props "container-relative-frame-inset") ?accessibility_identifier:(v_str props "accessibility-identifier") ?style_class:s.s_class ?on_appear:(ev "appear" emit props) ?value:(v_float props "value") ?min:(v_float props "min") ?max:(v_float props "max") ?step:(v_float props "step") ?text:(v_str props "text") ?label:(v_str props "label") ?enabled:(v_bool props "enabled") ?on_value_changed:(ev "change" emit props) (List.map (element ~emit) child_nodes) | "progress" -> E.progress ?key:s.s_key ?gap:s.s_gap ?main:s.s_main ?cross:s.s_cross ?grow:s.s_grow ?columns:s.s_columns ?padding:s.s_pad ?padding_horizontal:s.s_pad_h ?padding_vertical:s.s_pad_v ?background:s.s_bg ?foreground:s.s_fg ?border_color:s.s_border_color ?border_width:s.s_border_width ?corner_radius:s.s_radius ?width:s.s_width ?height:s.s_height ?min_width:s.s_min_width ?max_width:s.s_max_width ?min_height:s.s_min_height ?max_height:s.s_max_height ?container_relative_frame:(frame_axes_opt (v_str props "container-relative-frame")) ?container_relative_frame_inset:(v_int props "container-relative-frame-inset") ?accessibility_identifier:(v_str props "accessibility-identifier") ?style_class:s.s_class ?on_appear:(ev "appear" emit props) ?value:(v_float props "value") [] @@ -447,7 +450,7 @@ let rec element ~emit (node : value) : E.t = ?key:s.s_key ?gap:s.s_gap ?main:s.s_main ?cross:s.s_cross ?grow:s.s_grow ?columns:s.s_columns ?padding:s.s_pad ?padding_horizontal:s.s_pad_h ?padding_vertical:s.s_pad_v ?background:s.s_bg ?foreground:s.s_fg ?border_color:s.s_border_color ?border_width:s.s_border_width ?corner_radius:s.s_radius ?width:s.s_width ?height:s.s_height ?min_width:s.s_min_width ?max_width:s.s_max_width ?min_height:s.s_min_height ?max_height:s.s_max_height ?container_relative_frame:(frame_axes_opt (v_str props "container-relative-frame")) ?container_relative_frame_inset:(v_int props "container-relative-frame-inset") ?accessibility_identifier:(v_str props "accessibility-identifier") ?style_class:s.s_class ?on_appear:(ev "appear" emit props) ?text:(v_str props "text") ?description:(v_str props "description") ?on_dismiss:(ev "dismiss" emit props) (List.map (element ~emit) child_nodes) | "sheet" -> E.sheet - ?key:s.s_key ?gap:s.s_gap ?main:s.s_main ?cross:s.s_cross ?grow:s.s_grow ?columns:s.s_columns ?padding:s.s_pad ?padding_horizontal:s.s_pad_h ?padding_vertical:s.s_pad_v ?background:s.s_bg ?foreground:s.s_fg ?border_color:s.s_border_color ?border_width:s.s_border_width ?corner_radius:s.s_radius ?width:s.s_width ?height:s.s_height ?min_width:s.s_min_width ?max_width:s.s_max_width ?min_height:s.s_min_height ?max_height:s.s_max_height ?container_relative_frame:(frame_axes_opt (v_str props "container-relative-frame")) ?container_relative_frame_inset:(v_int props "container-relative-frame-inset") ?accessibility_identifier:(v_str props "accessibility-identifier") ?style_class:s.s_class ?on_appear:(ev "appear" emit props) ?text:(v_str props "text") ?on_dismiss:(ev "dismiss" emit props) (List.map (element ~emit) child_nodes) + ?key:s.s_key ?gap:s.s_gap ?main:s.s_main ?cross:s.s_cross ?grow:s.s_grow ?columns:s.s_columns ?padding:s.s_pad ?padding_horizontal:s.s_pad_h ?padding_vertical:s.s_pad_v ?background:s.s_bg ?foreground:s.s_fg ?border_color:s.s_border_color ?border_width:s.s_border_width ?corner_radius:s.s_radius ?width:s.s_width ?height:s.s_height ?min_width:s.s_min_width ?max_width:s.s_max_width ?min_height:s.s_min_height ?max_height:s.s_max_height ?container_relative_frame:(frame_axes_opt (v_str props "container-relative-frame")) ?container_relative_frame_inset:(v_int props "container-relative-frame-inset") ?accessibility_identifier:(v_str props "accessibility-identifier") ?style_class:s.s_class ?on_appear:(ev "appear" emit props) ?text:(v_str props "text") ?detents:(v_str props "detents") ?sizing:(v_str props "sizing") ?on_dismiss:(ev "dismiss" emit props) (List.map (element ~emit) child_nodes) | "tooltip" -> E.tooltip ?key:s.s_key ?gap:s.s_gap ?main:s.s_main ?cross:s.s_cross ?grow:s.s_grow ?columns:s.s_columns ?padding:s.s_pad ?padding_horizontal:s.s_pad_h ?padding_vertical:s.s_pad_v ?background:s.s_bg ?foreground:s.s_fg ?border_color:s.s_border_color ?border_width:s.s_border_width ?corner_radius:s.s_radius ?width:s.s_width ?height:s.s_height ?min_width:s.s_min_width ?max_width:s.s_max_width ?min_height:s.s_min_height ?max_height:s.s_max_height ?container_relative_frame:(frame_axes_opt (v_str props "container-relative-frame")) ?container_relative_frame_inset:(v_int props "container-relative-frame-inset") ?accessibility_identifier:(v_str props "accessibility-identifier") ?style_class:s.s_class ?on_appear:(ev "appear" emit props) ?text:(v_str props "text") ?anchor:(anchor_opt (v_str props "anchor")) ?anchor_alignment:(anchor_align_opt (v_str props "anchor-alignment")) ?anchor_offset:(v_float props "anchor-offset") ?tooltip_delay:(v_int props "tooltip-delay") [] diff --git a/src/lui_protocol.ml b/src/lui_protocol.ml index ebdfeea7..1323bcbf 100644 --- a/src/lui_protocol.ml +++ b/src/lui_protocol.ml @@ -24,6 +24,7 @@ type node_kind = | RadioGroup | Radio | Slider + | NumberStepper | TextField | SecureField | Input @@ -173,6 +174,11 @@ type property = | ResizeOrigin | ThemeValue | ThemeMode + | MinValue + | MaxValue + | StepValue + | Detents + | Sizing module Property_map = Map.Make @@ -257,6 +263,7 @@ let context_menu_host_kind kind = || kind = Toggle || kind = Radio || kind = Slider + || kind = NumberStepper || kind = TextField || kind = SecureField || kind = Input @@ -278,6 +285,7 @@ let context_menu_leaf_host_kind kind = || kind = Toggle || kind = Radio || kind = Slider + || kind = NumberStepper || kind = TextField || kind = SecureField || kind = Input @@ -346,6 +354,7 @@ let event_supported kind event = | ValueChanged _ -> (match kind with | Slider + | NumberStepper | Split -> true | _ -> false) | Dismiss _ -> @@ -516,6 +525,9 @@ let icon_name_supported value = let theme_mode_supported value = value = "system" || value = "light" || value = "dark" +let sizing_supported value = + value = "form" || value = "fitted" || value = "page" + let main_alignment_supported value = value = "start" || value = "center" || value = "end" || value = "space_between" @@ -595,7 +607,7 @@ let common_property_supported kind property = | Row | Column | Grid | Box | Panel | Card | Stack | Scroll | Avatar | Text | Heading | Paragraph | Label | Button | ToggleButton | TextField | SecureField | Input | SearchField | Textarea | Checkbox | Toggle | Radio - | Slider | Spinner | Icon | Select | Combobox | DropdownMenu | MenuItem + | Slider | NumberStepper | Spinner | Icon | Select | Combobox | DropdownMenu | MenuItem | ListItem | TableCell | Resizable | Split | Alert | Bubble | StatusBar -> true | _ -> false) | WidthValue | HeightValue -> kind <> Tooltip @@ -619,6 +631,7 @@ let common_property_supported kind property = || kind = RadioGroup || kind = Radio || kind = Slider + || kind = NumberStepper || horizontal_container kind || kind = Avatar || kind = Image @@ -643,7 +656,8 @@ let common_property_supported kind property = | HeadingLevel -> kind = Heading | Checked -> kind = Checkbox || kind = SwitchControl || kind = Toggle || kind = Radio - | ProgressValue -> kind = Progress || kind = Slider || kind = Split + | ProgressValue -> + kind = Progress || kind = Slider || kind = NumberStepper || kind = Split | OrientationValue -> kind = Divider || kind = Tabs || kind = Scroll | PlacementValue -> kind = Toolbar | SizeValue -> @@ -713,6 +727,8 @@ let common_property_supported kind property = | RoleValue -> tree_row_kind kind || kind = ListItem | TreeLevel | Expanded -> tree_row_kind kind | ResizeDuration | ResizeEasing | ResizeOrigin -> kind = Split + | MinValue | MaxValue | StepValue -> kind = NumberStepper + | Detents | Sizing -> kind = Sheet | ThemeValue | ThemeMode -> can_contain_children kind | TextValue -> (match kind with @@ -743,6 +759,7 @@ let common_property_supported kind property = | TableCell | Alert | Bubble + | NumberStepper | StatusBar -> true | _ -> false) | Enabled -> @@ -759,6 +776,7 @@ let common_property_supported kind property = | Toggle | Radio | Slider + | NumberStepper | Select | Combobox | MenuItem @@ -917,6 +935,11 @@ let property_value_supported property value = | ResizeOrigin, FloatValue value -> is_finite value | ThemeValue, StringValue _ -> true | ThemeMode, StringValue value -> theme_mode_supported value + | MinValue, FloatValue value | MaxValue, FloatValue value -> + is_finite value + | StepValue, FloatValue value -> is_finite value && value > 0.0 + | Detents, StringValue _ -> true + | Sizing, StringValue value -> sizing_supported value | _ -> false let property_value_supported_for_kind kind property value = @@ -1059,6 +1082,23 @@ let node_properties_supported kind properties = | Some (FloatValue _) -> true | _ -> false else true) + && (if kind = NumberStepper then + (let text = string_property_or properties TextValue "" in + let label = string_property_or properties AccessibilityLabel "" in + text <> "" || label <> "") + && + (match Property_map.find_opt ProgressValue properties with + | Some (FloatValue value) -> is_finite value + | _ -> false) + && + match + ( Property_map.find_opt MinValue properties, + Property_map.find_opt MaxValue properties ) + with + | Some (FloatValue minimum), Some (FloatValue maximum) -> + minimum <= maximum + | _ -> true + else true) && (if kind = Tree || kind = Toolbar then string_property_nonempty properties AccessibilityLabel else true) diff --git a/src/lui_protocol.mli b/src/lui_protocol.mli index 7cb03118..f642e19c 100644 --- a/src/lui_protocol.mli +++ b/src/lui_protocol.mli @@ -22,6 +22,7 @@ type node_kind = | RadioGroup | Radio | Slider + | NumberStepper | TextField | SecureField | Input @@ -171,6 +172,11 @@ type property = | ResizeOrigin | ThemeValue | ThemeMode + | MinValue + | MaxValue + | StepValue + | Detents + | Sizing module Property_map : Map.S with type key = property diff --git a/src/lui_wire_schema.ml b/src/lui_wire_schema.ml index 8185329b..ff6d7d68 100644 --- a/src/lui_wire_schema.ml +++ b/src/lui_wire_schema.ml @@ -25,6 +25,7 @@ let node_kind_name kind = | RadioGroup -> "radio-group" | Radio -> "radio" | Slider -> "slider" + | NumberStepper -> "number-stepper" | TextField -> "text-field" | SecureField -> "secure-field" | Input -> "input" @@ -100,6 +101,7 @@ let standard_node_name name = | "radio-group" -> true | "radio" -> true | "slider" -> true + | "number-stepper" -> true | "text-field" -> true | "secure-field" -> true | "input" -> true @@ -229,6 +231,11 @@ let property_name property = | ResizeOrigin -> "resize-origin" | ThemeValue -> "theme" | ThemeMode -> "theme-mode" + | MinValue -> "min" + | MaxValue -> "max" + | StepValue -> "step" + | Detents -> "detents" + | Sizing -> "sizing" let kind_property_matrix kind = match kind with @@ -251,6 +258,6 @@ let kind_extra_properties kind = | Dialog -> [ DescriptionValue ] | _ -> [] -let all_node_kinds = [ Root; Row; Column; Grid; Stack; Panel; Card; Alert; Bubble; Box; Text; Heading; Paragraph; Label; Button; ToggleButton; Toggle; RadioGroup; Radio; Slider; TextField; SecureField; Input; SearchField; Textarea; Checkbox; SwitchControl; Progress; Divider; Scroll; ListContainer; VirtualList; Tabs; BottomTabs; BottomTab; ButtonGroup; ToggleGroup; Spacer; Spinner; Icon; Select; Combobox; DropdownMenu; ContextMenu; MenuItem; MenuTrigger; ListItem; Avatar; Image; MediaSurface; Stepper; Step; Timeline; TimelineItem; InputGroup; InputGroupActions; Breadcrumb; Pagination; Accordion; Table; TableRow; TableCell; Tree; Resizable; Split; Dialog; Drawer; Sheet; Tooltip; Toast; Toolbar; StatusBar ] +let all_node_kinds = [ Root; Row; Column; Grid; Stack; Panel; Card; Alert; Bubble; Box; Text; Heading; Paragraph; Label; Button; ToggleButton; Toggle; RadioGroup; Radio; Slider; NumberStepper; TextField; SecureField; Input; SearchField; Textarea; Checkbox; SwitchControl; Progress; Divider; Scroll; ListContainer; VirtualList; Tabs; BottomTabs; BottomTab; ButtonGroup; ToggleGroup; Spacer; Spinner; Icon; Select; Combobox; DropdownMenu; ContextMenu; MenuItem; MenuTrigger; ListItem; Avatar; Image; MediaSurface; Stepper; Step; Timeline; TimelineItem; InputGroup; InputGroupActions; Breadcrumb; Pagination; Accordion; Table; TableRow; TableCell; Tree; Resizable; Split; Dialog; Drawer; Sheet; Tooltip; Toast; Toolbar; StatusBar ] -let all_properties = [ TextValue; Enabled; Gap; MainAlignment; CrossAlignment; GrowValue; GridColumns; PaddingValue; PaddingHorizontal; PaddingVertical; BackgroundValue; ForegroundValue; BorderColorValue; BorderWidth; CornerRadius; WidthValue; HeightValue; MinWidth; MaxWidth; MinHeight; MaxHeight; ContainerRelativeFrameValue; ContainerRelativeFrameInset; PlaceholderValue; AccessibilityLabel; AccessibilityIdentifier; StyleClass; HeadingLevel; Checked; ProgressValue; OrientationValue; PlacementValue; SizeValue; IconName; VariantValue; InlineIconName; IconPlacementValue; Selected; Autofocus; SubmitOnEnter; LongPressEnabled; ChangeEnabled; ToggleEnabled; PressEnabled; SubmitEnabled; DoublePressEnabled; AppearEnabled; ImageIdValue; SurfaceIdValue; ActiveIndex; TitleValue; DescriptionValue; MetaValue; IndicatorValue; Connector; SourceX; SourceY; SourceWidth; SourceHeight; AnchorValue; AnchorAlignmentValue; AnchorOffset; TooltipDelay; DurationValue; TextAlignment; RoleValue; TreeLevel; Expanded; ResizeDuration; ResizeEasing; ResizeOrigin; ThemeValue; ThemeMode ] +let all_properties = [ TextValue; Enabled; Gap; MainAlignment; CrossAlignment; GrowValue; GridColumns; PaddingValue; PaddingHorizontal; PaddingVertical; BackgroundValue; ForegroundValue; BorderColorValue; BorderWidth; CornerRadius; WidthValue; HeightValue; MinWidth; MaxWidth; MinHeight; MaxHeight; ContainerRelativeFrameValue; ContainerRelativeFrameInset; PlaceholderValue; AccessibilityLabel; AccessibilityIdentifier; StyleClass; HeadingLevel; Checked; ProgressValue; OrientationValue; PlacementValue; SizeValue; IconName; VariantValue; InlineIconName; IconPlacementValue; Selected; Autofocus; SubmitOnEnter; LongPressEnabled; ChangeEnabled; ToggleEnabled; PressEnabled; SubmitEnabled; DoublePressEnabled; AppearEnabled; ImageIdValue; SurfaceIdValue; ActiveIndex; TitleValue; DescriptionValue; MetaValue; IndicatorValue; Connector; SourceX; SourceY; SourceWidth; SourceHeight; AnchorValue; AnchorAlignmentValue; AnchorOffset; TooltipDelay; DurationValue; TextAlignment; RoleValue; TreeLevel; Expanded; ResizeDuration; ResizeEasing; ResizeOrigin; ThemeValue; ThemeMode; MinValue; MaxValue; StepValue; Detents; Sizing ] diff --git a/test/test_lui.ml b/test/test_lui.ml index 1cb50bb2..aa589dc5 100644 --- a/test/test_lui.ml +++ b/test/test_lui.ml @@ -992,6 +992,167 @@ let test_property_matrix_sync () = Lui_wire_schema.all_properties) Lui_wire_schema.all_node_kinds +let test_number_stepper_props () = + let changes = ref [] in + let stepper_node = ref 0 in + let app = + Lui_app.create (recording_backend ()) () + (fun model _action -> model) + (fun _context _model_source _send -> + Lui_elements.column + [ capture_node stepper_node + (Lui_elements.number_stepper ~value:5.0 ~min:0.0 ~max:3660.0 + ~step:1.0 ~text:"Days" + ~on_value_changed:(fun event -> + match event with + | Lui_protocol.ValueChanged (_, value) -> + changes := value :: !changes + | _ -> ()) + []) ]) + in + ignore (Lui_app.start app); + flush_app app; + let ops = all_ops () in + Alcotest.(check bool) "number-stepper created" true + (List.exists + (function + | Lui_protocol.CreateNode (_, Lui_protocol.NumberStepper) -> true + | _ -> false) + ops); + let emitted property value = + List.exists + (function + | Lui_protocol.SetProp (_, property', value') -> + property' = property && value' = value + | _ -> false) + ops + in + Alcotest.(check bool) "value" true + (emitted Lui_protocol.ProgressValue (Lui_protocol.FloatValue 5.0)); + Alcotest.(check bool) "min" true + (emitted Lui_protocol.MinValue (Lui_protocol.FloatValue 0.0)); + Alcotest.(check bool) "max" true + (emitted Lui_protocol.MaxValue (Lui_protocol.FloatValue 3660.0)); + Alcotest.(check bool) "step" true + (emitted Lui_protocol.StepValue (Lui_protocol.FloatValue 1.0)); + Alcotest.(check bool) "text" true + (emitted Lui_protocol.TextValue (Lui_protocol.StringValue "Days")); + ignore + (Lui_app.dispatch_event app + (Lui_protocol.ValueChanged (!stepper_node, 5.0))); + ignore + (Lui_app.dispatch_event app + (Lui_protocol.ValueChanged (!stepper_node, 6.0))); + flush_app app; + Alcotest.(check (list (float 0.0))) "echo dropped, change delivered" + [ 6.0 ] !changes; + ignore (Lui_app.dispose app) + +let test_number_stepper_schema () = + let open Lui_protocol in + List.iter + (fun (property, kinds) -> + List.iter + (fun kind -> + Alcotest.(check bool) + (Printf.sprintf "%s x %s" + (Lui_wire_schema.property_name property) + (Lui_wire_schema.node_kind_name kind)) + true (property_supported kind property)) + kinds) + [ (MinValue, [ NumberStepper ]); + (MaxValue, [ NumberStepper ]); + (StepValue, [ NumberStepper ]); + (Detents, [ Sheet ]); + (Sizing, [ Sheet ]) ]; + Alcotest.(check bool) "min off slider" false + (property_supported Slider MinValue); + Alcotest.(check bool) "detents off stepper" false + (property_supported NumberStepper Detents); + Alcotest.(check bool) "value on stepper" true + (property_supported NumberStepper ProgressValue); + Alcotest.(check bool) "enabled on stepper" true + (property_supported NumberStepper Enabled); + Alcotest.(check bool) "value-changed on stepper" true + (event_supported NumberStepper (ValueChanged (0, 1.0))); + Alcotest.(check bool) "value-changed off button" false + (event_supported Button (ValueChanged (0, 1.0))); + Alcotest.(check bool) "positive step" true + (property_value_supported StepValue (FloatValue 0.5)); + Alcotest.(check bool) "zero step rejected" false + (property_value_supported StepValue (FloatValue 0.0)); + Alcotest.(check bool) "finite min" true + (property_value_supported MinValue (FloatValue 1.0)); + Alcotest.(check bool) "sizing token" true + (property_value_supported Sizing (StringValue "form")); + Alcotest.(check bool) "unknown sizing rejected" false + (property_value_supported Sizing (StringValue "huge")); + Alcotest.(check bool) "detents is freeform" true + (property_value_supported Detents (StringValue "medium,0.4,large")); + let props entries = List.to_seq entries |> Property_map.of_seq in + Alcotest.(check bool) "stepper node ok" true + (node_properties_supported NumberStepper + (props [ (TextValue, StringValue "Days"); + (ProgressValue, FloatValue 1.0); + (MinValue, FloatValue 0.0); + (MaxValue, FloatValue 10.0) ])); + Alcotest.(check bool) "stepper needs value" false + (node_properties_supported NumberStepper + (props [ (TextValue, StringValue "Days") ])); + Alcotest.(check bool) "stepper needs label or text" false + (node_properties_supported NumberStepper + (props [ (ProgressValue, FloatValue 1.0) ])); + Alcotest.(check bool) "label suffices" true + (node_properties_supported NumberStepper + (props [ (AccessibilityLabel, StringValue "Days"); + (ProgressValue, FloatValue 1.0) ])); + Alcotest.(check bool) "min over max rejected" false + (node_properties_supported NumberStepper + (props [ (TextValue, StringValue "Days"); + (ProgressValue, FloatValue 1.0); + (MinValue, FloatValue 10.0); + (MaxValue, FloatValue 0.0) ])) + +let test_sheet_presentation_props () = + let app = + Lui_app.create (recording_backend ()) () + (fun model _action -> model) + (fun _context _model_source _send -> + Lui_elements.column + [ Lui_elements.sheet ~text:"Settings" + ~detents:"medium,large" ~sizing:"form" [] ]) + in + ignore (Lui_app.start app); + flush_app app; + let ops = all_ops () in + Alcotest.(check bool) "sheet created" true + (List.exists + (function + | Lui_protocol.CreateNode (_, Lui_protocol.Sheet) -> true + | _ -> false) + ops); + Alcotest.(check bool) "detents emitted" true + (List.exists + (function + | Lui_protocol.SetProp (_, Lui_protocol.Detents, + Lui_protocol.StringValue "medium,large") -> + true + | _ -> false) + ops); + Alcotest.(check bool) "sizing emitted" true + (List.exists + (function + | Lui_protocol.SetProp (_, Lui_protocol.Sizing, + Lui_protocol.StringValue "form") -> true + | _ -> false) + ops); + Alcotest.(check bool) "detents only on sheet" true + (Lui_protocol.property_supported Lui_protocol.Sheet Lui_protocol.Detents); + Alcotest.(check bool) "detents off dialog" false + (Lui_protocol.property_supported Lui_protocol.Dialog + Lui_protocol.Detents); + ignore (Lui_app.dispose app) + let test_theme_tokens_json () = Alcotest.(check string) "fixed + adaptive values" {|{"background":{"light":"#fff","dark":"#000"},"primary":"#7c3aed"}|} @@ -1510,6 +1671,15 @@ let () = Alcotest.test_case "unset default echoes dropped" `Quick test_dispatch_drops_unset_default_echoes; ] ); + ( "number stepper + sheet sizing", + [ + Alcotest.test_case "props + value-changed" `Quick + test_number_stepper_props; + Alcotest.test_case "schema surface" `Quick + test_number_stepper_schema; + Alcotest.test_case "sheet detents + sizing" `Quick + test_sheet_presentation_props; + ] ); ( "theming", [ Alcotest.test_case "theme_tokens_json" `Quick From 328cb012b5b722cf44b98f083e13756038fe2626 Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 00:56:53 -0700 Subject: [PATCH 2/4] winui: fix LUIProperty.Grow -> GrowValue enum name --- platform/winui/LUI.Core/LUIBackend.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform/winui/LUI.Core/LUIBackend.cs b/platform/winui/LUI.Core/LUIBackend.cs index 3097f83a..48235184 100644 --- a/platform/winui/LUI.Core/LUIBackend.cs +++ b/platform/winui/LUI.Core/LUIBackend.cs @@ -354,7 +354,7 @@ void ApplyState( LUIWireValue value = WireValue( operation.GetProperty("value"), "value"); if (value is LUIWireValue.Int intValue && - (property == LUIProperty.Grow || + (property == LUIProperty.GrowValue || property == LUIProperty.AnchorOffset || property == LUIProperty.SourceX || property == LUIProperty.SourceY || From 99550485689be9dc11bce4293e5a87f053f817c9 Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 01:26:37 -0700 Subject: [PATCH 3/4] address review: ship Qt number-stepper, sanitize web value events, wire detents/sizing on all backends - add platform/qt/qml/LuiNumberStepper.qml and register it in QML_FILES - web: clamp number-stepper value to retained min/max and drop NaN before dispatching ValueChanged; route aria-label removal to the child input - apply sheet detents (height fraction) and sizing (fitted width) on the web, Qt, Flutter, and WinUI surfaces instead of accepting them as no-ops - winui: copy text to NumberBox.Header; flutter: wrap the stepper in a Semantics label and give the step buttons tooltips - compare stepper min/max after applying defaults so a lone negative max is rejected (ocaml + all backend mirrors) --- .../LUIAppleBackend/LUIWireProtocol.swift | 12 +- platform/flutter/lib/lui_flutter_backend.dart | 117 ++++++++++++++---- platform/qt/lib/CMakeLists.txt | 1 + platform/qt/lib/lui_schema.cpp | 12 +- platform/qt/qml/LuiNumberStepper.qml | 51 ++++++++ platform/qt/qml/LuiSheet.qml | 37 +++++- platform/web/melange/events/lui_web_events.ml | 24 +++- platform/web/melange/render/lui_web_props.ml | 54 +++++++- platform/winui/LUI.Core/LUISchema.cs | 20 +-- platform/winui/LUI.WinUI/LUIElement.Sync.cs | 1 + platform/winui/LUI.WinUI/LUIModalPresenter.cs | 64 +++++++++- src/lui_protocol.ml | 12 +- 12 files changed, 340 insertions(+), 65 deletions(-) create mode 100644 platform/qt/qml/LuiNumberStepper.qml diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift index 67c723e2..05ce3498 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift @@ -944,11 +944,13 @@ struct LUIRetainedTree { value.isFinite else { throw invalid("number-stepper requires a finite value") } - if case let .double(minimum)? = node.properties[.minValue], - case let .double(maximum)? = node.properties[.maxValue] { - guard minimum <= maximum else { - throw invalid("number-stepper min must not exceed max") - } + // min defaults to 0 and max is unbounded; compare the + // effective endpoints so a lone negative max still fails. + let minimum = node.properties[.minValue]?.doubleValue ?? 0.0 + let maximum = node.properties[.maxValue]?.doubleValue + ?? .greatestFiniteMagnitude + guard minimum <= maximum else { + throw invalid("number-stepper min must not exceed max") } } if node.kind == .slider || node.kind == .progress { diff --git a/platform/flutter/lib/lui_flutter_backend.dart b/platform/flutter/lib/lui_flutter_backend.dart index 0989ab20..0983d0eb 100644 --- a/platform/flutter/lib/lui_flutter_backend.dart +++ b/platform/flutter/lib/lui_flutter_backend.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math' as math; import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; @@ -1595,23 +1596,31 @@ final class LUIFlutterBackend { (stepperState.properties['step'] as num?)?.toDouble() ?? 1.0; final current = (stepperState.properties['value'] as num?)?.toDouble() ?? 0.0; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible(child: Text(stepperState.properties['text'] as String? ?? '')), - IconButton( - icon: const Icon(Icons.remove), - onPressed: stepperEnabled - ? () => performValueChange(id, current - step) - : null, - ), - IconButton( - icon: const Icon(Icons.add), - onPressed: stepperEnabled - ? () => performValueChange(id, current + step) - : null, - ), - ], + final text = stepperState.properties['text'] as String? ?? ''; + final a11yLabel = + stepperState.properties['accessibility-label'] as String? ?? text; + return Semantics( + label: a11yLabel, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: Text(text)), + IconButton( + icon: const Icon(Icons.remove), + tooltip: 'Decrease', + onPressed: stepperEnabled + ? () => performValueChange(id, current - step) + : null, + ), + IconButton( + icon: const Icon(Icons.add), + tooltip: 'Increase', + onPressed: stepperEnabled + ? () => performValueChange(id, current + step) + : null, + ), + ], + ), ); } @@ -4020,9 +4029,12 @@ final class LUIFlutterBackend { if (text.isEmpty && label.isEmpty) { throw const LUIBackendException('number-stepper requires a label'); } - final minimum = state.properties['min']; - final maximum = state.properties['max']; - if (minimum is num && maximum is num && minimum > maximum) { + // min defaults to 0 and max is unbounded; compare the effective + // endpoints so a lone negative max still fails. + final minimum = (state.properties['min'] as num?)?.toDouble() ?? 0.0; + final maximum = + (state.properties['max'] as num?)?.toDouble() ?? double.maxFinite; + if (minimum > maximum) { throw const LUIBackendException( 'number-stepper min must not exceed max', ); @@ -4824,6 +4836,27 @@ final class LUIFlutterBackend { }; } + // Parses the `detents` prop into viewport-height fractions + // (`medium` = 0.5, `large` = 1.0, or a literal fraction in (0, 1]). + // Unknown tokens are ignored; returns null when nothing parses. + static List? _sheetDetentFractions(String? detents) { + if (detents == null) return null; + final fractions = detents + .split(',') + .map((token) { + final trimmed = token.trim(); + if (trimmed == 'medium') return 0.5; + if (trimmed == 'large') return 1.0; + final fraction = double.tryParse(trimmed); + return fraction != null && fraction > 0 && fraction <= 1 + ? fraction + : null; + }) + .nonNulls + .toList(growable: false); + return fractions.isEmpty ? null : fractions; + } + Widget _modalSurface(BuildContext context, int node) { final state = _requireState(_states, node); final width = (state.properties['width'] as int?)?.toDouble(); @@ -4837,12 +4870,44 @@ final class LUIFlutterBackend { child: _modalSurfaceBody(context, state), ), ), - _NodeKind.sheet => SizedBox( - key: ValueKey('lui-sheet-surface-$node'), - width: width ?? double.infinity, - height: height, - child: SafeArea(child: _modalSurfaceBody(context, state)), - ), + _NodeKind.sheet => () { + // Approximate iOS presentation options: detents cap/choose the + // sheet height fraction (medium 0.5, large 1.0, or a literal + // fraction); sizing constrains width (form/fitted ~560px, page + // full width). The bottom sheet always opens at the first detent. + final size = MediaQuery.of(context).size; + final fractions = _sheetDetentFractions( + state.properties['detents'] as String?, + ); + final detentMaxHeight = fractions == null + ? null + : size.height * fractions.reduce(math.max); + final sizingMaxWidth = switch (state.properties['sizing']) { + 'form' || 'fitted' => 560.0, + _ => null, + }; + Widget surface = SizedBox( + key: ValueKey('lui-sheet-surface-$node'), + width: width ?? double.infinity, + height: height ?? + (fractions == null ? null : size.height * fractions.first), + child: SafeArea(child: _modalSurfaceBody(context, state)), + ); + if (detentMaxHeight != null || sizingMaxWidth != null) { + surface = Align( + alignment: Alignment.bottomCenter, + heightFactor: 1.0, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: detentMaxHeight ?? double.infinity, + maxWidth: sizingMaxWidth ?? double.infinity, + ), + child: surface, + ), + ); + } + return surface; + }(), _ => throw const LUIBackendException('node is not a modal surface'), }; } diff --git a/platform/qt/lib/CMakeLists.txt b/platform/qt/lib/CMakeLists.txt index 9ca673f2..04db2787 100644 --- a/platform/qt/lib/CMakeLists.txt +++ b/platform/qt/lib/CMakeLists.txt @@ -71,6 +71,7 @@ qt_add_qml_module(lui_qml ../qml/LuiMediaSurface.qml ../qml/LuiMenuItem.qml ../qml/LuiNodeView.qml + ../qml/LuiNumberStepper.qml ../qml/LuiPagination.qml ../qml/LuiPanel.qml ../qml/LuiParagraph.qml diff --git a/platform/qt/lib/lui_schema.cpp b/platform/qt/lib/lui_schema.cpp index a0b1bca7..9bd7b821 100644 --- a/platform/qt/lib/lui_schema.cpp +++ b/platform/qt/lib/lui_schema.cpp @@ -5,6 +5,7 @@ #include #include #include +#include namespace LUI { @@ -952,12 +953,15 @@ bool nodePropertiesSupported(NodeKind kind, const QVariantMap &properties) { !stringPropertyNonempty(properties, "accessibility-label")) { return false; } + // min defaults to 0 and max is unbounded; compare the effective + // endpoints so a lone negative max still fails. const QVariant minimum = properties.value(QStringLiteral("min")); const QVariant maximum = properties.value(QStringLiteral("max")); - if (isNumeric(minimum) && isNumeric(maximum) && - minimum.toDouble() > maximum.toDouble()) { - return false; - } + const double minimumValue = + isNumeric(minimum) ? minimum.toDouble() : 0.0; + const double maximumValue = isNumeric(maximum) + ? maximum.toDouble() : std::numeric_limits::max(); + if (minimumValue > maximumValue) return false; } if (kind == NodeKind::Tree || kind == NodeKind::Toolbar) { if (!stringPropertyNonempty(properties, "accessibility-label")) { diff --git a/platform/qt/qml/LuiNumberStepper.qml b/platform/qt/qml/LuiNumberStepper.qml new file mode 100644 index 00000000..0aba843f --- /dev/null +++ b/platform/qt/qml/LuiNumberStepper.qml @@ -0,0 +1,51 @@ +import QtQuick +import QtQuick.Controls +import "LuiStyle.js" as Style + +// Wire kind: number-stepper — bounded numeric value with -/+ buttons. +// from/step/to come from the min/step/max props; max defaults unbounded. +Row { + id: stepper + required property var node + readonly property var props: node ? node.properties : ({}) + + readonly property real minimum: Style.num(props, "min", 0) + readonly property real maximum: + props["max"] !== undefined ? Number(props["max"]) : Number.MAX_VALUE + readonly property real stepSize: Math.max(Style.num(props, "step", 1), 0) + readonly property real value: Style.num(props, "value", 0) + readonly property string label: props["text"] !== undefined ? String(props["text"]) : "" + + enabled: props["enabled"] !== false + spacing: 4 + + function applyStep(delta) { + var next = Math.min(Math.max(value + delta, minimum), maximum); + if (next !== value && node) node.valueChanged(next); + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: stepper.label + visible: stepper.label !== "" + } + + Button { + anchors.verticalCenter: parent.verticalCenter + text: "−" + enabled: stepper.enabled && stepper.value > stepper.minimum + onClicked: stepper.applyStep(-stepper.stepSize) + } + + Text { + anchors.verticalCenter: parent.verticalCenter + text: stepper.value + } + + Button { + anchors.verticalCenter: parent.verticalCenter + text: "+" + enabled: stepper.enabled && stepper.value < stepper.maximum + onClicked: stepper.applyStep(stepper.stepSize) + } +} diff --git a/platform/qt/qml/LuiSheet.qml b/platform/qt/qml/LuiSheet.qml index dd2f0c67..01b54c66 100644 --- a/platform/qt/qml/LuiSheet.qml +++ b/platform/qt/qml/LuiSheet.qml @@ -12,15 +12,44 @@ Item { implicitWidth: 0 implicitHeight: 0 + // detents prop: comma-separated medium|large|fraction — the sheet opens + // at the first detent and caps at the largest (fractions of the overlay + // height). sizing prop: form|fitted narrow the sheet to a centered + // column; page keeps full width. + readonly property var detentFractions: { + var raw = props["detents"]; + if (raw === undefined || raw === null || raw === "") return null; + var parts = String(raw).split(","); + var out = []; + for (var i = 0; i < parts.length; i++) { + var token = parts[i].trim(); + var fraction = token === "medium" ? 0.5 + : token === "large" ? 1.0 + : parseFloat(token); + if (fraction > 0 && fraction <= 1) out.push(fraction); + } + return out.length > 0 ? out : null; + } + readonly property real detentRest: + detentFractions ? detentFractions[0] : 0.9 + readonly property bool sizingNarrow: + Style.str(props, "sizing", "") !== "" && + Style.str(props, "sizing", "") !== "page" + Drawer { id: sheet edge: Qt.BottomEdge modal: true interactive: true - width: Overlay.overlay ? Overlay.overlay.width : parent.width - height: Math.min( - contentColumn.implicitHeight + 32, - Overlay.overlay ? Overlay.overlay.height * 0.9 : 400) + width: host.sizingNarrow && Overlay.overlay + ? Math.min(560, Overlay.overlay.width) + : Overlay.overlay ? Overlay.overlay.width : parent.width + x: host.sizingNarrow && Overlay.overlay + ? Math.round((Overlay.overlay.width - width) / 2) : 0 + height: host.detentFractions && Overlay.overlay + ? Overlay.overlay.height * host.detentRest + : Math.min(contentColumn.implicitHeight + 32, + Overlay.overlay ? Overlay.overlay.height * 0.9 : 400) onClosed: if (host.node) host.node.dismiss() ColumnLayout { diff --git a/platform/web/melange/events/lui_web_events.ml b/platform/web/melange/events/lui_web_events.ml index 491bd782..a56144aa 100644 --- a/platform/web/melange/events/lui_web_events.ml +++ b/platform/web/melange/events/lui_web_events.ml @@ -192,6 +192,26 @@ let attach_slider_event renderer node dom_node = (Util.text_control_node dom_node)))) dom_node +let attach_number_stepper_event renderer node dom_node = + let input = Util.child_element dom_node 0 in + W.Element.addEventListener "input" + (fun _event -> + let raw = + W.HtmlInputElement.valueAsNumber (Util.text_control_node dom_node) + in + (* Clearing the field reports NaN; only finite values in range emit. *) + if Float.is_finite raw then begin + let minimum = Store.float_property renderer node MinValue 0.0 in + let maximum = + Store.float_property renderer node MaxValue Float.max_float + in + emit renderer + (ValueChanged + (node, + Float.min (Float.max raw minimum) (Float.max minimum maximum))) + end) + input + let attach_accordion_event renderer node dom_node = W.Element.addEventListener "click" (fun event -> @@ -458,7 +478,8 @@ let attach_events renderer node kind dom_node = | Checkbox | SwitchControl -> attach_toggle_event renderer node kind dom_node | Radio -> attach_radio_event renderer node dom_node - | Slider | NumberStepper -> attach_slider_event renderer node dom_node + | Slider -> attach_slider_event renderer node dom_node + | NumberStepper -> attach_number_stepper_event renderer node dom_node | Split -> ignore (Lui_web_split.attach_split_events renderer node dom_node) | Tabs | ButtonGroup | ToggleGroup | Breadcrumb | Pagination -> ignore (Lui_web_focus.attach_horizontal_focus renderer node kind dom_node) @@ -470,6 +491,7 @@ let attach_text_events_bang = attach_text_events let attach_toggle_event_bang = attach_toggle_event let attach_radio_event_bang = attach_radio_event let attach_slider_event_bang = attach_slider_event +let attach_number_stepper_event_bang = attach_number_stepper_event let attach_list_item_events_bang = attach_list_item_events let attach_pressable_text_events_bang = attach_pressable_text_events let attach_accordion_event_bang = attach_accordion_event diff --git a/platform/web/melange/render/lui_web_props.ml b/platform/web/melange/render/lui_web_props.ml index 115b5b16..3591a895 100644 --- a/platform/web/melange/render/lui_web_props.ml +++ b/platform/web/melange/render/lui_web_props.ml @@ -538,6 +538,40 @@ let rec apply_property renderer node kind dom_node property value = W.Element.setAttribute "aria-level" (string_of_int level) dom_node | _ -> apply_secondary_property renderer node kind dom_node property value +(* Sheets honour `detents` (medium|large|fraction list — the largest caps + the surface height as a viewport fraction) and `sizing` (form|fitted + narrow the surface to a centered column; page stretches full width). *) +and sheet_detent_max value = + String.split_on_char ',' value + |> List.fold_left + (fun cap token -> + match String.trim token with + | "medium" -> max cap 0.5 + | "large" -> max cap 1.0 + | trimmed -> + (match float_of_string_opt trimmed with + | Some fraction when fraction > 0.0 && fraction <= 1.0 -> + max cap fraction + | _ -> cap)) + 0.0 + +and apply_sheet_detents dom_node value = + let cap = sheet_detent_max value in + if cap > 0.0 then + set_style dom_node "max-height" + (Printf.sprintf "%gdvh" (cap *. 100.0)) + else + set_style dom_node "max-height" "" + +and apply_sheet_sizing dom_node value = + match value with + | "form" | "fitted" -> + set_style dom_node "max-width" "560px"; + set_style dom_node "margin-inline" "auto" + | _ -> + set_style dom_node "max-width" ""; + set_style dom_node "margin-inline" "" + and apply_secondary_property renderer node kind dom_node property value = match (property, value) with | Checked, BoolValue checked -> apply_checked kind dom_node checked @@ -553,9 +587,11 @@ and apply_secondary_property renderer node kind dom_node property value = W.Element.setAttribute "step" (Js.Float.toString value) (Util.child_element dom_node 0) | Detents, StringValue value -> - W.Element.setAttribute "data-detents" value dom_node + W.Element.setAttribute "data-detents" value dom_node; + if kind = Sheet then apply_sheet_detents dom_node value | Sizing, StringValue value -> - W.Element.setAttribute "data-sizing" value dom_node + W.Element.setAttribute "data-sizing" value dom_node; + if kind = Sheet then apply_sheet_sizing dom_node value | ResizeDuration, IntValue _duration -> Lui_web_split.update_split renderer node | ResizeEasing, StringValue _easing -> @@ -705,7 +741,8 @@ let remove_property renderer node kind dom_node property = "" | AccessibilityLabel -> W.Element.removeAttribute "aria-label" - (if Store.direct_toggle kind then Util.child_element dom_node 0 + (if Store.direct_toggle kind || kind = NumberStepper then + Util.child_element dom_node 0 else dom_node) | MinValue -> W.Element.removeAttribute "min" (Util.child_element dom_node 0) @@ -713,8 +750,15 @@ let remove_property renderer node kind dom_node property = W.Element.removeAttribute "max" (Util.child_element dom_node 0) | StepValue -> W.Element.removeAttribute "step" (Util.child_element dom_node 0) - | Detents -> W.Element.removeAttribute "data-detents" dom_node - | Sizing -> W.Element.removeAttribute "data-sizing" dom_node + | Detents -> + W.Element.removeAttribute "data-detents" dom_node; + if kind = Sheet then set_style dom_node "max-height" "" + | Sizing -> + W.Element.removeAttribute "data-sizing" dom_node; + if kind = Sheet then begin + set_style dom_node "max-width" ""; + set_style dom_node "margin-inline" "" + end | AccessibilityIdentifier -> W.Element.removeAttribute "id" dom_node | OrientationValue -> if kind = Tabs then begin diff --git a/platform/winui/LUI.Core/LUISchema.cs b/platform/winui/LUI.Core/LUISchema.cs index f3a067ed..f2e9f881 100644 --- a/platform/winui/LUI.Core/LUISchema.cs +++ b/platform/winui/LUI.Core/LUISchema.cs @@ -1380,15 +1380,17 @@ public static bool NodePropertiesSupported( { return false; } - if (properties.TryGetValue( - LUIProperty.MinValue, - out LUIWireValue? minimum) && - properties.TryGetValue( - LUIProperty.MaxValue, - out LUIWireValue? maximum) && - minimum is LUIWireValue.Float minFloat && - maximum is LUIWireValue.Float maxFloat && - minFloat.Value > maxFloat.Value) + // min defaults to 0.0 and max is unbounded; compare the + // effective endpoints so a lone negative max still fails. + double stepperMinimum = properties.TryGetValue( + LUIProperty.MinValue, out LUIWireValue? minimum) && + minimum is LUIWireValue.Float minFloat + ? minFloat.Value : 0.0; + double stepperMaximum = properties.TryGetValue( + LUIProperty.MaxValue, out LUIWireValue? maximum) && + maximum is LUIWireValue.Float maxFloat + ? maxFloat.Value : double.MaxValue; + if (stepperMinimum > stepperMaximum) { return false; } diff --git a/platform/winui/LUI.WinUI/LUIElement.Sync.cs b/platform/winui/LUI.WinUI/LUIElement.Sync.cs index a71568cb..8b832936 100644 --- a/platform/winui/LUI.WinUI/LUIElement.Sync.cs +++ b/platform/winui/LUI.WinUI/LUIElement.Sync.cs @@ -388,6 +388,7 @@ void OnSliderChanged( void SyncNumberStepper(LUINodeState state, LUISyncContext context) { if (Control is not NumberBox box) return; + box.Header = LUIPropertyApplier.Text(state); box.Minimum = LUIPropertyApplier.Prop( state, LUIProperty.MinValue)?.AsFloat ?? 0.0; box.Maximum = LUIPropertyApplier.Prop( diff --git a/platform/winui/LUI.WinUI/LUIModalPresenter.cs b/platform/winui/LUI.WinUI/LUIModalPresenter.cs index 4041efa6..e7cfacdf 100644 --- a/platform/winui/LUI.WinUI/LUIModalPresenter.cs +++ b/platform/winui/LUI.WinUI/LUIModalPresenter.cs @@ -86,7 +86,7 @@ internal void Sync(IReadOnlyList surfaceIds) : Visibility.Visible; } - static void Position(FrameworkElement control, LUINodeState state) + void Position(FrameworkElement control, LUINodeState state) { switch (state.Kind) { @@ -97,11 +97,67 @@ static void Position(FrameworkElement control, LUINodeState state) control.Margin = new Thickness(24); break; case LUINodeKind.Sheet: - control.HorizontalAlignment = - HorizontalAlignment.Stretch; + { control.VerticalAlignment = VerticalAlignment.Bottom; - control.MaxHeight = 480; + // detents: comma-separated medium|large|fraction — + // the sheet opens at the first detent and can grow to + // the largest (medium = 0.5, large = 1.0 of the + // overlay). sizing: form/fitted center the sheet at a + // fixed width; page stretches edge to edge. + double overlayHeight = _overlay.ActualHeight > 0 + ? _overlay.ActualHeight : 480; + double restFraction = 0.0, maxFraction = 0.0; + string? detents = LUIPropertyApplier.Prop( + state, LUIProperty.Detents)?.AsString; + if (detents != null) + { + bool first = true; + foreach (string token in detents.Split(',')) + { + string trimmed = token.Trim(); + double fraction = + trimmed == "medium" ? 0.5 : + trimmed == "large" ? 1.0 : + (double.TryParse( + trimmed, + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo + .InvariantCulture, + out double parsed) && + parsed > 0.0 && parsed <= 1.0 + ? parsed : -1.0); + if (fraction > 0.0) + { + if (first) + { + restFraction = fraction; + first = false; + } + if (fraction > maxFraction) + { + maxFraction = fraction; + } + } + } + } + string? sizing = LUIPropertyApplier.Prop( + state, LUIProperty.Sizing)?.AsString; + if (sizing == "form" || sizing == "fitted") + { + control.HorizontalAlignment = + HorizontalAlignment.Center; + control.MaxWidth = 640; + } + else + { + control.HorizontalAlignment = + HorizontalAlignment.Stretch; + } + control.MinHeight = overlayHeight * restFraction; + control.MaxHeight = maxFraction > 0.0 + ? overlayHeight * maxFraction : 480; break; + } case LUINodeKind.Drawer: control.HorizontalAlignment = HorizontalAlignment.Right; control.VerticalAlignment = VerticalAlignment.Stretch; diff --git a/src/lui_protocol.ml b/src/lui_protocol.ml index 1323bcbf..27ca70ff 100644 --- a/src/lui_protocol.ml +++ b/src/lui_protocol.ml @@ -1091,13 +1091,11 @@ let node_properties_supported kind properties = | Some (FloatValue value) -> is_finite value | _ -> false) && - match - ( Property_map.find_opt MinValue properties, - Property_map.find_opt MaxValue properties ) - with - | Some (FloatValue minimum), Some (FloatValue maximum) -> - minimum <= maximum - | _ -> true + (* min defaults to 0.0 and max is unbounded; compare endpoints after + those defaults so a lone negative max still fails. *) + let minimum = float_property properties MinValue 0.0 in + let maximum = float_property properties MaxValue Float.max_float in + minimum <= maximum else true) && (if kind = Tree || kind = Toolbar then string_property_nonempty properties AccessibilityLabel From bbf04a712109938c3dfaeab08a5d0409e3ed9cb7 Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 01:43:16 -0700 Subject: [PATCH 4/4] regenerate schema after merge --- .../LUIAppleBackend/LUIWireSchema.swift | 6 +++++ platform/qt/lib/lui_wire_schema.h | 24 +++++++++++++++++++ platform/winui/LUI.Core/LUIWireSchema.g.cs | 21 ++++++++++++++++ src/lui_protocol.mli | 6 +++++ src/lui_wire_schema.ml | 13 ++++++++-- 5 files changed, 68 insertions(+), 2 deletions(-) diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift index ab7c639e..123ab4d5 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift @@ -7,6 +7,9 @@ enum LUINodeKind: String, Decodable, Equatable { case column = "column" case grid = "grid" case stack = "stack" + case edgeInset = "edge-inset" + case overlay = "overlay" + case viewThatFits = "view-that-fits" case panel = "panel" case card = "card" case alert = "alert" @@ -156,6 +159,9 @@ enum LUIProperty: String, Decodable, Hashable { case path = "path" case url = "url" case maxPixelSize = "max-pixel-size" + case edge = "edge" + case visible = "visible" + case alignment = "alignment" } enum LUISchemaMatrix { diff --git a/platform/qt/lib/lui_wire_schema.h b/platform/qt/lib/lui_wire_schema.h index 2b4a20da..f192ad30 100644 --- a/platform/qt/lib/lui_wire_schema.h +++ b/platform/qt/lib/lui_wire_schema.h @@ -11,6 +11,9 @@ enum class NodeKind { Column, Grid, Stack, + EdgeInset, + Overlay, + ViewThatFits, Panel, Card, Alert, @@ -160,6 +163,9 @@ enum class Property { PathValue, UrlValue, MaxPixelSize, + EdgeValue, + Visible, + AlignmentValue, }; inline const char *nodeKindWireName(NodeKind kind) { @@ -169,6 +175,9 @@ inline const char *nodeKindWireName(NodeKind kind) { case NodeKind::Column: return "column"; case NodeKind::Grid: return "grid"; case NodeKind::Stack: return "stack"; + case NodeKind::EdgeInset: return "edge-inset"; + case NodeKind::Overlay: return "overlay"; + case NodeKind::ViewThatFits: return "view-that-fits"; case NodeKind::Panel: return "panel"; case NodeKind::Card: return "card"; case NodeKind::Alert: return "alert"; @@ -249,6 +258,9 @@ inline bool decodeNodeKind(const char *name, NodeKind *kind) { if (std::strcmp(name, "column") == 0) { *kind = NodeKind::Column; return true; } if (std::strcmp(name, "grid") == 0) { *kind = NodeKind::Grid; return true; } if (std::strcmp(name, "stack") == 0) { *kind = NodeKind::Stack; return true; } + if (std::strcmp(name, "edge-inset") == 0) { *kind = NodeKind::EdgeInset; return true; } + if (std::strcmp(name, "overlay") == 0) { *kind = NodeKind::Overlay; return true; } + if (std::strcmp(name, "view-that-fits") == 0) { *kind = NodeKind::ViewThatFits; return true; } if (std::strcmp(name, "panel") == 0) { *kind = NodeKind::Panel; return true; } if (std::strcmp(name, "card") == 0) { *kind = NodeKind::Card; return true; } if (std::strcmp(name, "alert") == 0) { *kind = NodeKind::Alert; return true; } @@ -334,6 +346,9 @@ inline bool containerNodeKind(NodeKind kind) { case NodeKind::Column: case NodeKind::Grid: case NodeKind::Stack: + case NodeKind::EdgeInset: + case NodeKind::Overlay: + case NodeKind::ViewThatFits: case NodeKind::Panel: case NodeKind::Card: case NodeKind::Alert: @@ -385,6 +400,9 @@ inline const char *nodeKindComponentName(NodeKind kind) { case NodeKind::Column: return "LuiColumn.qml"; case NodeKind::Grid: return "LuiGrid.qml"; case NodeKind::Stack: return "LuiStack.qml"; + case NodeKind::EdgeInset: return "LuiEdgeInset.qml"; + case NodeKind::Overlay: return "LuiOverlay.qml"; + case NodeKind::ViewThatFits: return "LuiViewThatFits.qml"; case NodeKind::Panel: return "LuiPanel.qml"; case NodeKind::Card: return "LuiCard.qml"; case NodeKind::Alert: return "LuiAlert.qml"; @@ -537,6 +555,9 @@ inline const char *propertyWireName(Property property) { case Property::PathValue: return "path"; case Property::UrlValue: return "url"; case Property::MaxPixelSize: return "max-pixel-size"; + case Property::EdgeValue: return "edge"; + case Property::Visible: return "visible"; + case Property::AlignmentValue: return "alignment"; } return "unknown"; } @@ -618,6 +639,9 @@ inline bool decodePropertyWireName(const char *name, Property *property) { if (std::strcmp(name, "path") == 0) { *property = Property::PathValue; return true; } if (std::strcmp(name, "url") == 0) { *property = Property::UrlValue; return true; } if (std::strcmp(name, "max-pixel-size") == 0) { *property = Property::MaxPixelSize; return true; } + if (std::strcmp(name, "edge") == 0) { *property = Property::EdgeValue; return true; } + if (std::strcmp(name, "visible") == 0) { *property = Property::Visible; return true; } + if (std::strcmp(name, "alignment") == 0) { *property = Property::AlignmentValue; return true; } return false; } diff --git a/platform/winui/LUI.Core/LUIWireSchema.g.cs b/platform/winui/LUI.Core/LUIWireSchema.g.cs index cc7c97a4..dabafb51 100644 --- a/platform/winui/LUI.Core/LUIWireSchema.g.cs +++ b/platform/winui/LUI.Core/LUIWireSchema.g.cs @@ -13,6 +13,9 @@ public enum LUINodeKind Column, Grid, Stack, + EdgeInset, + Overlay, + ViewThatFits, Panel, Card, Alert, @@ -163,6 +166,9 @@ public enum LUIProperty PathValue, UrlValue, MaxPixelSize, + EdgeValue, + Visible, + AlignmentValue, } public static class LUIWireSchema @@ -175,6 +181,9 @@ public static class LUIWireSchema { "column", LUINodeKind.Column }, { "grid", LUINodeKind.Grid }, { "stack", LUINodeKind.Stack }, + { "edge-inset", LUINodeKind.EdgeInset }, + { "overlay", LUINodeKind.Overlay }, + { "view-that-fits", LUINodeKind.ViewThatFits }, { "panel", LUINodeKind.Panel }, { "card", LUINodeKind.Card }, { "alert", LUINodeKind.Alert }, @@ -326,6 +335,9 @@ public static class LUIWireSchema { "path", LUIProperty.PathValue }, { "url", LUIProperty.UrlValue }, { "max-pixel-size", LUIProperty.MaxPixelSize }, + { "edge", LUIProperty.EdgeValue }, + { "visible", LUIProperty.Visible }, + { "alignment", LUIProperty.AlignmentValue }, }; public static string WireName(this LUINodeKind kind) => kind switch @@ -335,6 +347,9 @@ public static class LUIWireSchema LUINodeKind.Column => "column", LUINodeKind.Grid => "grid", LUINodeKind.Stack => "stack", + LUINodeKind.EdgeInset => "edge-inset", + LUINodeKind.Overlay => "overlay", + LUINodeKind.ViewThatFits => "view-that-fits", LUINodeKind.Panel => "panel", LUINodeKind.Card => "card", LUINodeKind.Alert => "alert", @@ -421,6 +436,9 @@ LUINodeKind.Row or LUINodeKind.Column or LUINodeKind.Grid or LUINodeKind.Stack or + LUINodeKind.EdgeInset or + LUINodeKind.Overlay or + LUINodeKind.ViewThatFits or LUINodeKind.Panel or LUINodeKind.Card or LUINodeKind.Alert or @@ -539,6 +557,9 @@ LUINodeKind.Toolbar or LUIProperty.PathValue => "path", LUIProperty.UrlValue => "url", LUIProperty.MaxPixelSize => "max-pixel-size", + LUIProperty.EdgeValue => "edge", + LUIProperty.Visible => "visible", + LUIProperty.AlignmentValue => "alignment", _ => "unknown", }; diff --git a/src/lui_protocol.mli b/src/lui_protocol.mli index bcb06470..c4c88236 100644 --- a/src/lui_protocol.mli +++ b/src/lui_protocol.mli @@ -7,6 +7,9 @@ type node_kind = | Column | Grid | Stack + | EdgeInset + | Overlay + | ViewThatFits | Panel | Card | Alert @@ -177,6 +180,9 @@ type property = | PathValue | UrlValue | MaxPixelSize + | EdgeValue + | Visible + | AlignmentValue module Property_map : Map.S with type key = property diff --git a/src/lui_wire_schema.ml b/src/lui_wire_schema.ml index b99a8af1..1a7e5f43 100644 --- a/src/lui_wire_schema.ml +++ b/src/lui_wire_schema.ml @@ -10,6 +10,9 @@ let node_kind_name kind = | Column -> "column" | Grid -> "grid" | Stack -> "stack" + | EdgeInset -> "edge-inset" + | Overlay -> "overlay" + | ViewThatFits -> "view-that-fits" | Panel -> "panel" | Card -> "card" | Alert -> "alert" @@ -88,6 +91,9 @@ let standard_node_name name = | "column" -> true | "grid" -> true | "stack" -> true + | "edge-inset" -> true + | "overlay" -> true + | "view-that-fits" -> true | "panel" -> true | "card" -> true | "alert" -> true @@ -238,6 +244,9 @@ let property_name property = | PathValue -> "path" | UrlValue -> "url" | MaxPixelSize -> "max-pixel-size" + | EdgeValue -> "edge" + | Visible -> "visible" + | AlignmentValue -> "alignment" let kind_property_matrix kind = match kind with @@ -261,6 +270,6 @@ let kind_extra_properties kind = | Dialog -> [ DescriptionValue ] | _ -> [] -let all_node_kinds = [ Root; Row; Column; Grid; Stack; Panel; Card; Alert; Bubble; Box; Text; Heading; Paragraph; Label; Button; ToggleButton; Toggle; RadioGroup; Radio; Slider; TextField; SecureField; Input; SearchField; Textarea; Checkbox; SwitchControl; Progress; Divider; Scroll; ListContainer; VirtualList; Tabs; BottomTabs; BottomTab; ButtonGroup; ToggleGroup; Spacer; Spinner; Icon; Select; Combobox; DropdownMenu; ContextMenu; MenuItem; MenuTrigger; ListItem; Avatar; Image; MediaSurface; Stepper; Step; Timeline; TimelineItem; InputGroup; InputGroupActions; Breadcrumb; Pagination; Accordion; Table; TableRow; TableCell; Tree; Resizable; Split; Dialog; Drawer; Sheet; Tooltip; Toast; Toolbar; StatusBar; Link; FileImage; FilePreview ] +let all_node_kinds = [ Root; Row; Column; Grid; Stack; EdgeInset; Overlay; ViewThatFits; Panel; Card; Alert; Bubble; Box; Text; Heading; Paragraph; Label; Button; ToggleButton; Toggle; RadioGroup; Radio; Slider; TextField; SecureField; Input; SearchField; Textarea; Checkbox; SwitchControl; Progress; Divider; Scroll; ListContainer; VirtualList; Tabs; BottomTabs; BottomTab; ButtonGroup; ToggleGroup; Spacer; Spinner; Icon; Select; Combobox; DropdownMenu; ContextMenu; MenuItem; MenuTrigger; ListItem; Avatar; Image; MediaSurface; Stepper; Step; Timeline; TimelineItem; InputGroup; InputGroupActions; Breadcrumb; Pagination; Accordion; Table; TableRow; TableCell; Tree; Resizable; Split; Dialog; Drawer; Sheet; Tooltip; Toast; Toolbar; StatusBar; Link; FileImage; FilePreview ] -let all_properties = [ TextValue; Enabled; Gap; MainAlignment; CrossAlignment; GrowValue; GridColumns; PaddingValue; PaddingHorizontal; PaddingVertical; BackgroundValue; ForegroundValue; BorderColorValue; BorderWidth; CornerRadius; WidthValue; HeightValue; MinWidth; MaxWidth; MinHeight; MaxHeight; ContainerRelativeFrameValue; ContainerRelativeFrameInset; PlaceholderValue; AccessibilityLabel; AccessibilityIdentifier; StyleClass; HeadingLevel; Checked; ProgressValue; OrientationValue; PlacementValue; SizeValue; IconName; VariantValue; InlineIconName; IconPlacementValue; Selected; Autofocus; SubmitOnEnter; LongPressEnabled; ChangeEnabled; ToggleEnabled; PressEnabled; SubmitEnabled; DoublePressEnabled; AppearEnabled; ImageIdValue; SurfaceIdValue; ActiveIndex; TitleValue; DescriptionValue; MetaValue; IndicatorValue; Connector; SourceX; SourceY; SourceWidth; SourceHeight; AnchorValue; AnchorAlignmentValue; AnchorOffset; TooltipDelay; DurationValue; TextAlignment; RoleValue; TreeLevel; Expanded; ResizeDuration; ResizeEasing; ResizeOrigin; ThemeValue; ThemeMode; PathValue; UrlValue; MaxPixelSize ] +let all_properties = [ TextValue; Enabled; Gap; MainAlignment; CrossAlignment; GrowValue; GridColumns; PaddingValue; PaddingHorizontal; PaddingVertical; BackgroundValue; ForegroundValue; BorderColorValue; BorderWidth; CornerRadius; WidthValue; HeightValue; MinWidth; MaxWidth; MinHeight; MaxHeight; ContainerRelativeFrameValue; ContainerRelativeFrameInset; PlaceholderValue; AccessibilityLabel; AccessibilityIdentifier; StyleClass; HeadingLevel; Checked; ProgressValue; OrientationValue; PlacementValue; SizeValue; IconName; VariantValue; InlineIconName; IconPlacementValue; Selected; Autofocus; SubmitOnEnter; LongPressEnabled; ChangeEnabled; ToggleEnabled; PressEnabled; SubmitEnabled; DoublePressEnabled; AppearEnabled; ImageIdValue; SurfaceIdValue; ActiveIndex; TitleValue; DescriptionValue; MetaValue; IndicatorValue; Connector; SourceX; SourceY; SourceWidth; SourceHeight; AnchorValue; AnchorAlignmentValue; AnchorOffset; TooltipDelay; DurationValue; TextAlignment; RoleValue; TreeLevel; Expanded; ResizeDuration; ResizeEasing; ResizeOrigin; ThemeValue; ThemeMode; PathValue; UrlValue; MaxPixelSize; EdgeValue; Visible; AlignmentValue ]