Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Double> {
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 }
Expand Down Expand Up @@ -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 {
Expand Down
91 changes: 86 additions & 5 deletions platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -2444,22 +2446,76 @@ private struct LUITooltipHost<Content: View>: 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<PresentationDetent> {
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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
52 changes: 46 additions & 6 deletions platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 ||
Expand All @@ -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:
Expand All @@ -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 ||
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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))
}
Expand Down
6 changes: 6 additions & 0 deletions platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
Loading
Loading