diff --git a/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift b/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift index 38bbd86..4739c01 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 } @@ -751,12 +761,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 a4cd5b2..7cb0d62 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -847,6 +847,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) @@ -2444,22 +2446,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 { @@ -2469,7 +2525,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, @@ -3320,6 +3378,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 50f3cf5..dc3b5e3 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" @@ -751,7 +762,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 || @@ -765,11 +777,13 @@ struct LUIRetainedTree { kind == .tooltip || kind == .tableCell || kind == .alert || kind == .bubble || kind == .statusBar || - isModalSurface(kind) || kind == .drawer || kind == .link + isModalSurface(kind) || kind == .drawer || kind == .numberStepper || + kind == .link 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 || kind == .link case .gap: @@ -793,12 +807,15 @@ struct LUIRetainedTree { kind == .mediaSurface || kind == .tree || kind == .resizable || kind == .split || kind == .drawer || kind == .alert || kind == .bubble || - kind == .listItem || kind == .link || kind == .fileImage || + kind == .listItem || kind == .numberStepper || + kind == .link || kind == .fileImage || 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 || @@ -851,6 +868,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 .path: kind == .fileImage || kind == .filePreview case .url: kind == .link case .maxPixelSize: kind == .fileImage @@ -963,6 +982,25 @@ 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") + } + // 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 { guard case let .double(value)? = node.properties[.progressValue], value.isFinite else { throw invalid("value control requires a finite fractional value") @@ -1272,7 +1310,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 123ab4d..380d2db 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift @@ -25,6 +25,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" @@ -156,6 +157,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" case path = "path" case url = "url" case maxPixelSize = "max-pixel-size" diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift index 1aa07bb..bd2763e 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 d0e66e5..f7e5839 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'; @@ -1463,12 +1464,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) { @@ -1583,6 +1594,39 @@ 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; + 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, + ), + ], + ), + ); + } + Widget textControl({required _NodeKind kind}) { final multiline = kind == _NodeKind.textarea; final combobox = kind == _NodeKind.combobox; @@ -2917,6 +2961,7 @@ final class LUIFlutterBackend { ), onChanged: enabled ? (value) => performValueChange(id, value) : null, ), + _NodeKind.numberStepper => numberStepperView(state, enabled, id), _NodeKind.textField || _NodeKind.secureField || _NodeKind.input || @@ -3730,6 +3775,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 || @@ -3752,6 +3798,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 || @@ -3762,7 +3809,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' => @@ -3935,6 +3995,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 || @@ -4008,6 +4069,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 || @@ -4088,6 +4150,30 @@ 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'); + } + // 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', + ); + } + } if (state.kind == _NodeKind.split) { if (state.children.length != 2) { throw const LUIBackendException( @@ -4548,6 +4634,7 @@ final class LUIFlutterBackend { _NodeKind.toggle, _NodeKind.radio, _NodeKind.slider, + _NodeKind.numberStepper, _NodeKind.textField, _NodeKind.secureField, _NodeKind.input, @@ -4571,6 +4658,7 @@ final class LUIFlutterBackend { _NodeKind.toggle, _NodeKind.radio, _NodeKind.slider, + _NodeKind.numberStepper, _NodeKind.textField, _NodeKind.secureField, _NodeKind.input, @@ -4902,6 +4990,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(); @@ -4915,12 +5024,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/flutter/lib/lui_wire_schema.g.dart b/platform/flutter/lib/lui_wire_schema.g.dart index e495d35..b3cd760 100644 --- a/platform/flutter/lib/lui_wire_schema.g.dart +++ b/platform/flutter/lib/lui_wire_schema.g.dart @@ -25,6 +25,7 @@ enum _NodeKind { radioGroup, radio, slider, + numberStepper, textField, secureField, input, @@ -110,6 +111,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/CMakeLists.txt b/platform/qt/lib/CMakeLists.txt index 5a9254a..741d77d 100644 --- a/platform/qt/lib/CMakeLists.txt +++ b/platform/qt/lib/CMakeLists.txt @@ -75,6 +75,7 @@ qt_add_qml_module(lui_qml ../qml/LuiMediaSurface.qml ../qml/LuiMenuItem.qml ../qml/LuiNodeView.qml + ../qml/LuiNumberStepper.qml ../qml/LuiOverlay.qml ../qml/LuiPagination.qml ../qml/LuiPanel.qml diff --git a/platform/qt/lib/lui_qml_backend.cpp b/platform/qt/lib/lui_qml_backend.cpp index 45fab37..b6c7750 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 { @@ -1301,14 +1302,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 7c7badc..728a24a 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 { @@ -116,7 +117,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 +129,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, @@ -271,7 +274,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, @@ -423,7 +427,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, @@ -450,7 +455,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, @@ -470,8 +476,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, @@ -577,7 +583,7 @@ bool commonPropertySupported(NodeKind kind, Property property) { NodeKind::Avatar, NodeKind::Dialog, NodeKind::Drawer, NodeKind::Sheet, NodeKind::Tooltip, NodeKind::TableCell, NodeKind::Alert, NodeKind::Bubble, NodeKind::StatusBar, - NodeKind::Link}); + NodeKind::NumberStepper, NodeKind::Link}); case Property::Enabled: return oneOf(kind, {NodeKind::Button, NodeKind::ToggleButton, @@ -585,7 +591,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, NodeKind::Link}); case Property::ActiveIndex: @@ -596,6 +603,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, @@ -839,6 +853,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"}); case Property::EdgeValue: return isString(value) && inSet(value.toString(), {"top", "bottom", "leading", "trailing"}); @@ -981,6 +1005,22 @@ 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; + } + // 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")); + 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")) { return false; diff --git a/platform/qt/lib/lui_wire_schema.h b/platform/qt/lib/lui_wire_schema.h index f192ad3..fc4f8b7 100644 --- a/platform/qt/lib/lui_wire_schema.h +++ b/platform/qt/lib/lui_wire_schema.h @@ -29,6 +29,7 @@ enum class NodeKind { RadioGroup, Radio, Slider, + NumberStepper, TextField, SecureField, Input, @@ -160,6 +161,11 @@ enum class Property { ResizeOrigin, ThemeValue, ThemeMode, + MinValue, + MaxValue, + StepValue, + Detents, + Sizing, PathValue, UrlValue, MaxPixelSize, @@ -193,6 +199,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"; @@ -276,6 +283,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; } @@ -418,6 +426,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"; @@ -552,6 +561,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"; case Property::PathValue: return "path"; case Property::UrlValue: return "url"; case Property::MaxPixelSize: return "max-pixel-size"; @@ -636,6 +650,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; } 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; } diff --git a/platform/qt/qml/LuiNumberStepper.qml b/platform/qt/qml/LuiNumberStepper.qml new file mode 100644 index 0000000..0aba843 --- /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 dd2f0c6..01b54c6 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/core/lui_web_store.ml b/platform/web/melange/core/lui_web_store.ml index ebaeb70..2b89e4f 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 648be8e..e908255 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 -> @@ -459,6 +479,7 @@ let attach_events renderer node kind dom_node = attach_toggle_event renderer node kind dom_node | Radio -> attach_radio_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/nodes/lui_web_nodes.ml b/platform/web/melange/nodes/lui_web_nodes.ml index f1847e1..cb823b0 100644 --- a/platform/web/melange/nodes/lui_web_nodes.ml +++ b/platform/web/melange/nodes/lui_web_nodes.ml @@ -34,6 +34,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" @@ -153,6 +154,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" @@ -383,6 +391,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 143c152..9ccb1e2 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 @@ -241,7 +243,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 @@ -300,7 +303,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 = @@ -536,11 +540,60 @@ 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 | 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; + if kind = Sheet then apply_sheet_detents dom_node value + | Sizing, StringValue value -> + 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 -> @@ -708,8 +761,24 @@ 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) + | 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; + 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 | UrlValue -> W.Element.removeAttribute "href" dom_node; W.Element.removeAttribute "target" dom_node diff --git a/platform/winui/LUI.Core/LUIBackend.cs b/platform/winui/LUI.Core/LUIBackend.cs index 48cddd5..796daa0 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.GrowValue || + 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)) @@ -1473,16 +1487,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 5a16343..51e0ef6 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: @@ -204,7 +206,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) { @@ -471,6 +475,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: @@ -518,6 +523,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 || @@ -552,6 +558,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 || @@ -676,6 +683,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); @@ -713,6 +727,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: @@ -737,6 +752,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: @@ -1124,6 +1140,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"); + } case LUIProperty.PathValue: case LUIProperty.UrlValue: { @@ -1408,6 +1445,37 @@ 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; + } + // 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; + } + } 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 dabafb5..0273ed6 100644 --- a/platform/winui/LUI.Core/LUIWireSchema.g.cs +++ b/platform/winui/LUI.Core/LUIWireSchema.g.cs @@ -31,6 +31,7 @@ public enum LUINodeKind RadioGroup, Radio, Slider, + NumberStepper, TextField, SecureField, Input, @@ -163,6 +164,11 @@ public enum LUIProperty ResizeOrigin, ThemeValue, ThemeMode, + MinValue, + MaxValue, + StepValue, + Detents, + Sizing, PathValue, UrlValue, MaxPixelSize, @@ -199,6 +205,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 }, @@ -332,6 +339,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 }, { "path", LUIProperty.PathValue }, { "url", LUIProperty.UrlValue }, { "max-pixel-size", LUIProperty.MaxPixelSize }, @@ -365,6 +377,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", @@ -554,6 +567,11 @@ LUINodeKind.Toolbar 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", LUIProperty.PathValue => "path", LUIProperty.UrlValue => "url", LUIProperty.MaxPixelSize => "max-pixel-size", diff --git a/platform/winui/LUI.WinUI/LUIElement.Sync.cs b/platform/winui/LUI.WinUI/LUIElement.Sync.cs index df80812..84c1a6a 100644 --- a/platform/winui/LUI.WinUI/LUIElement.Sync.cs +++ b/platform/winui/LUI.WinUI/LUIElement.Sync.cs @@ -91,6 +91,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: @@ -387,6 +390,37 @@ 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( + 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 8c153a6..8251faf 100644 --- a/platform/winui/LUI.WinUI/LUIElementFactory.cs +++ b/platform/winui/LUI.WinUI/LUIElementFactory.cs @@ -71,6 +71,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/platform/winui/LUI.WinUI/LUIModalPresenter.cs b/platform/winui/LUI.WinUI/LUIModalPresenter.cs index 4041efa..e7cfacd 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/schema/components.json b/schema/components.json index ab43cf7..79276f4 100644 --- a/schema/components.json +++ b/schema/components.json @@ -237,6 +237,10 @@ "name": "slider", "status": "supported" }, + { + "name": "number-stepper", + "status": "supported" + }, { "name": "progress", "status": "supported" @@ -480,6 +484,13 @@ "swift": "slider", "container": false }, + { + "lg": "NumberStepper", + "wire": "number-stepper", + "dart": "numberStepper", + "swift": "numberStepper", + "container": false + }, { "lg": "TextField", "wire": "text-field", @@ -1232,6 +1243,31 @@ "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" + }, { "lg": "PathValue", "wire": "path", diff --git a/src/lui_elements.ml b/src/lui_elements.ml index 4b62afd..907284d 100644 --- a/src/lui_elements.ml +++ b/src/lui_elements.ml @@ -973,6 +973,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 @@ -1266,12 +1287,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 e275696..95751c9 100644 --- a/src/lui_elements.mli +++ b/src/lui_elements.mli @@ -1685,6 +1685,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 -> @@ -2271,6 +2311,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 a0b40d3..0242794 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 e0f4815..3a7a713 100644 --- a/src/lui_protocol.ml +++ b/src/lui_protocol.ml @@ -27,6 +27,7 @@ type node_kind = | RadioGroup | Radio | Slider + | NumberStepper | TextField | SecureField | Input @@ -179,6 +180,11 @@ type property = | ResizeOrigin | ThemeValue | ThemeMode + | MinValue + | MaxValue + | StepValue + | Detents + | Sizing | PathValue | UrlValue | MaxPixelSize @@ -269,6 +275,7 @@ let context_menu_host_kind kind = || kind = Toggle || kind = Radio || kind = Slider + || kind = NumberStepper || kind = TextField || kind = SecureField || kind = Input @@ -290,6 +297,7 @@ let context_menu_leaf_host_kind kind = || kind = Toggle || kind = Radio || kind = Slider + || kind = NumberStepper || kind = TextField || kind = SecureField || kind = Input @@ -359,6 +367,7 @@ let event_supported kind event = | ValueChanged _ -> (match kind with | Slider + | NumberStepper | Split -> true | _ -> false) | Dismiss _ -> @@ -530,6 +539,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" @@ -620,7 +632,7 @@ let common_property_supported kind property = | EdgeInset | Overlay | ViewThatFits | 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 | Link | FileImage -> true | _ -> false) @@ -649,6 +661,7 @@ let common_property_supported kind property = || kind = RadioGroup || kind = Radio || kind = Slider + || kind = NumberStepper || horizontal_container kind || kind = Avatar || kind = Image @@ -675,7 +688,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 || kind = ViewThatFits | PlacementValue -> kind = Toolbar @@ -749,6 +763,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 @@ -779,6 +795,7 @@ let common_property_supported kind property = | TableCell | Alert | Bubble + | NumberStepper | StatusBar | Link -> true | _ -> false) @@ -796,6 +813,7 @@ let common_property_supported kind property = | Toggle | Radio | Slider + | NumberStepper | Select | Combobox | MenuItem @@ -965,6 +983,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 | PathValue, StringValue _ | UrlValue, StringValue _ -> true | MaxPixelSize, IntValue value -> value > 0 | EdgeValue, StringValue value -> @@ -1121,6 +1144,21 @@ 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) + && + (* 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 else true) diff --git a/src/lui_protocol.mli b/src/lui_protocol.mli index c4c8823..418182e 100644 --- a/src/lui_protocol.mli +++ b/src/lui_protocol.mli @@ -25,6 +25,7 @@ type node_kind = | RadioGroup | Radio | Slider + | NumberStepper | TextField | SecureField | Input @@ -177,6 +178,11 @@ type property = | ResizeOrigin | ThemeValue | ThemeMode + | MinValue + | MaxValue + | StepValue + | Detents + | Sizing | PathValue | UrlValue | MaxPixelSize diff --git a/src/lui_wire_schema.ml b/src/lui_wire_schema.ml index 1a7e5f4..da80a3a 100644 --- a/src/lui_wire_schema.ml +++ b/src/lui_wire_schema.ml @@ -28,6 +28,7 @@ let node_kind_name kind = | RadioGroup -> "radio-group" | Radio -> "radio" | Slider -> "slider" + | NumberStepper -> "number-stepper" | TextField -> "text-field" | SecureField -> "secure-field" | Input -> "input" @@ -109,6 +110,7 @@ let standard_node_name name = | "radio-group" -> true | "radio" -> true | "slider" -> true + | "number-stepper" -> true | "text-field" -> true | "secure-field" -> true | "input" -> true @@ -241,6 +243,11 @@ let property_name property = | ResizeOrigin -> "resize-origin" | ThemeValue -> "theme" | ThemeMode -> "theme-mode" + | MinValue -> "min" + | MaxValue -> "max" + | StepValue -> "step" + | Detents -> "detents" + | Sizing -> "sizing" | PathValue -> "path" | UrlValue -> "url" | MaxPixelSize -> "max-pixel-size" @@ -270,6 +277,6 @@ let kind_extra_properties kind = | Dialog -> [ DescriptionValue ] | _ -> [] -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_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; 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; 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; EdgeValue; Visible; AlignmentValue ] +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; PathValue; UrlValue; MaxPixelSize; EdgeValue; Visible; AlignmentValue ] diff --git a/test/test_lui.ml b/test/test_lui.ml index 2686952..4a54cc3 100644 --- a/test/test_lui.ml +++ b/test/test_lui.ml @@ -1162,6 +1162,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"}|} @@ -1684,6 +1845,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