From bbf1db5220aedebaaf0ec4100e5d4d9ca519d758 Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 00:04:34 -0700 Subject: [PATCH 1/2] media: file-image, file-preview (QuickLook), and link elements Three general-purpose element kinds sufficient to replace journal-media style per-app extensions: - file-image: loads a local path / file:// URL off the main actor via CGImageSource thumbnail decoding (NSCache keyed by path+pixel size, ~32MB cap), with loading spinner, failure icon fallback, and the standard press-enabled/Press path. - file-preview: a non-rendering node that presents QuickLook via .quickLookPreview. Presence in the tree means presented, matching the sheet/dialog 'mount to present' pattern but on its own presentation lane (not isModalSurface, which forces a text title and sheet chrome). Interactive close emits Dismiss and is suppressed from re-asserting until the wire drops the node, same as modal dismissal. - link: a container rendering SwiftUI Link(destination:) from a url prop; children (or text/icon props) form the label, so it composes inside list rows and overlays. New wire properties: path, url, max-pixel-size. file-preview is a restrictive kind (path + accessibility-identifier only). Flutter/Qt/ WinUI/web schema mirrors updated; Qt gains stub QML views; WinUI's restrictive matrix covers file-preview automatically. --- .../LUIAppleBackend/LUIAppleBackend.swift | 20 +- .../LUIAppleBackend/LUIFileImageLoader.swift | 81 +++++++ .../LUIAppleBackend/LUISwiftUIRoot.swift | 201 +++++++++++++++++- .../LUIAppleBackend/LUIWireProtocol.swift | 40 +++- .../LUIAppleBackend/LUIWireSchema.swift | 7 + .../LUIAppleBackendTests.swift | 145 +++++++++++++ platform/flutter/lib/lui_flutter_backend.dart | 42 +++- platform/flutter/lib/lui_wire_schema.g.dart | 6 + platform/qt/lib/CMakeLists.txt | 3 + platform/qt/lib/lui_schema.cpp | 47 +++- platform/qt/lib/lui_wire_schema.h | 22 ++ platform/qt/qml/LuiFileImage.qml | 35 +++ platform/qt/qml/LuiFilePreview.qml | 11 + platform/qt/qml/LuiLink.qml | 50 +++++ platform/web/melange/nodes/lui_web_nodes.ml | 5 + platform/winui/LUI.Core/LUISchema.cs | 43 +++- platform/winui/LUI.Core/LUIWireSchema.g.cs | 22 +- schema/components.json | 51 +++++ src/lui_elements.ml | 50 +++++ src/lui_elements.mli | 79 +++++++ src/lui_protocol.ml | 40 +++- src/lui_protocol.mli | 6 + src/lui_ui.ml | 3 + src/lui_ui.mli | 3 + src/lui_wire_schema.ml | 14 +- test/test_lui.ml | 104 +++++++++ 26 files changed, 1091 insertions(+), 39 deletions(-) create mode 100644 platform/apple/Sources/LUIAppleBackend/LUIFileImageLoader.swift create mode 100644 platform/qt/qml/LuiFileImage.qml create mode 100644 platform/qt/qml/LuiFilePreview.qml create mode 100644 platform/qt/qml/LuiLink.qml diff --git a/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift b/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift index 22fe3d8..38bbd86 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift @@ -440,6 +440,7 @@ public final class LUIAppleBackend { private let extensionRegistry: LUIAppleExtensionRegistry let tooltipSession = LUITooltipSession() let modalPresentation = LUIModalPresentationStore() + let filePreviewPresentation = LUIFilePreviewStore() public init( appIcons: [String: LUIAppleIconSource] = [:], @@ -644,6 +645,7 @@ public final class LUIAppleBackend { model.kind == .combobox || model.kind == .menuItem || model.kind == .listItem || (model.kind == .timelineItem && model.supportsPress) || + (model.kind == .fileImage && model.supportsPress) || (model.isTreeItem && model.supportsPress), model.isEnabled else { throw invalid("node \(node) is not an enabled pressable control") @@ -761,7 +763,8 @@ public final class LUIAppleBackend { guard let model = models[node], model.kind == .select || model.kind == .combobox || model.kind == .dropdownMenu || model.kind == .dialog || - model.kind == .sheet || model.kind == .toast else { + model.kind == .sheet || model.kind == .toast || + model.kind == .filePreview else { throw invalid("node \(node) is not dismissible") } emit(.dismiss(node: node)) @@ -774,7 +777,8 @@ public final class LUIAppleBackend { return } switch model.kind { - case .button, .select, .combobox, .menuItem, .listItem, .timelineItem: + case .button, .select, .combobox, .menuItem, .listItem, .timelineItem, + .fileImage: try performPress(node: node) case .toggleButton: try performToggle(node: node, checked: !model.isSelected) @@ -952,11 +956,22 @@ public final class LUIAppleBackend { private func syncModalPresentation() { var presentation: LUIModalPresentation? var nestedSheets: [Int: LUIModalPresentation] = [:] + var filePreview: LUIFilePreviewPresentation? var visited = Set() func visit(_ nodeID: Int, rootID: Int, dialogAnchorID: Int?, parentSheetID: Int?) { guard visited.insert(nodeID).inserted else { return } if let model = models[nodeID] { + if model.kind == .filePreview, + let url = LUIFilePath.url( + model.property(.path)?.stringValue ?? "" + ) { + filePreview = LUIFilePreviewPresentation( + nodeID: nodeID, + rootID: rootID, + url: url + ) + } if model.kind == .dialog || model.kind == .sheet { let item = LUIModalPresentation( model: model, @@ -994,6 +1009,7 @@ public final class LUIAppleBackend { } modalPresentation.nestedSheets = nestedSheets modalPresentation.synchronize(with: presentation) + filePreviewPresentation.synchronize(with: filePreview) } private func invalidateAvatars(imageID: Int) { diff --git a/platform/apple/Sources/LUIAppleBackend/LUIFileImageLoader.swift b/platform/apple/Sources/LUIAppleBackend/LUIFileImageLoader.swift new file mode 100644 index 0000000..4b7cc69 --- /dev/null +++ b/platform/apple/Sources/LUIAppleBackend/LUIFileImageLoader.swift @@ -0,0 +1,81 @@ +import CoreGraphics +import Foundation +import ImageIO + +/// Resolves the `path` prop shared by `file-image` and `file-preview` nodes: +/// either an absolute filesystem path or a `file://` URL string. Non-file +/// schemes return nil — remote URLs belong to `link` nodes. +enum LUIFilePath { + static func url(_ path: String) -> URL? { + guard !path.isEmpty else { return nil } + if let url = URL(string: path), url.scheme != nil { + return url.isFileURL ? url : nil + } + return URL(fileURLWithPath: path) + } +} + +/// Loads `file-image` pixels. Decoding happens off the main actor through +/// `CGImageSourceCreateThumbnailAtIndex` — the file is never rasterized at +/// native size — and thumbnails are cached by "|" in an +/// NSCache (~32MB cost limit, 32 items). +enum LUIFileImageLoader { + static let defaultMaxPixelSize = 1024 + + private actor Cache { + private let images = NSCache() + + init() { + images.totalCostLimit = 32 * 1024 * 1024 + images.countLimit = 32 + } + + func image(for key: String) -> CGImage? { + images.object(forKey: key as NSString) + } + + func store(_ image: CGImage, for key: String) { + images.setObject( + image, + forKey: key as NSString, + cost: image.bytesPerRow * image.height + ) + } + } + + private static let cache = Cache() + + static func thumbnail(path: String, maxPixelSize: Int) async -> CGImage? { + guard let url = LUIFilePath.url(path) else { return nil } + let key = "\(maxPixelSize)|\(path)" + if let cached = await cache.image(for: key) { return cached } + let decoded = await Task.detached(priority: .userInitiated) { + decodeThumbnail(url: url, maxPixelSize: maxPixelSize) + }.value + if let decoded { + await cache.store(decoded, for: key) + } + return decoded + } + + private static func decodeThumbnail(url: URL, maxPixelSize: Int) -> CGImage? { + let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary + guard let source = CGImageSourceCreateWithURL( + url as CFURL, + sourceOptions + ) else { + return nil + } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + ] + return CGImageSourceCreateThumbnailAtIndex( + source, + 0, + options as CFDictionary + ) + } +} diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index abe710c..37fcb19 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -1,5 +1,6 @@ import Foundation import Observation +import QuickLook import SwiftUI #if os(macOS) import AppKit @@ -68,6 +69,37 @@ final class LUIModalPresentationStore { } } +struct LUIFilePreviewPresentation: Identifiable { + let nodeID: Int + let rootID: Int + let url: URL + + var id: Int { nodeID } +} + +/// Like `LUIModalPresentationStore`, an interactively closed preview stays +/// mounted on the wire until the reducer consumes the Dismiss event, so a +/// re-sync in that window must not reassert it. +@Observable +@MainActor +final class LUIFilePreviewStore { + private(set) var item: LUIFilePreviewPresentation? + private var pendingDismissalID: Int? + + func synchronize(with item: LUIFilePreviewPresentation?) { + if let pendingDismissalID, item?.id == pendingDismissalID { return } + pendingDismissalID = nil + self.item = item + } + + func dismissFromPresentation() -> Int? { + guard let presentedID = item?.id else { return nil } + pendingDismissalID = presentedID + item = nil + return presentedID + } +} + public struct LUISwiftUIRoot: View { private let backend: LUIAppleBackend private let rootID: Int @@ -94,6 +126,7 @@ public struct LUISwiftUIRoot: View { LUIModalSurfaceContent(model: presentation.model, backend: backend) .luiSheetScope(semanticColors: semanticColors, colorScheme: colorScheme) } + .quickLookPreview(filePreviewBinding) .modifier(LUIDialogPresentationModifier(anchorID: rootID, backend: backend)) } @@ -115,6 +148,18 @@ public struct LUISwiftUIRoot: View { } try? backend.performDismiss(node: nodeID) } + + private var filePreviewBinding: Binding { + Binding( + get: { backend.filePreviewPresentation.item?.url }, + set: { url in + guard url == nil, + let nodeID = backend.filePreviewPresentation + .dismissFromPresentation() else { return } + try? backend.performDismiss(node: nodeID) + } + ) + } } /// Hosts modal presentations (dialogs and sheets) for a traversal root on an @@ -140,9 +185,22 @@ public struct LUIModalHostModifier: ViewModifier { LUIModalSurfaceContent(model: presentation.model, backend: backend) .luiSheetScope(semanticColors: semanticColors, colorScheme: colorScheme) } + .quickLookPreview(filePreviewBinding) .modifier(LUIDialogPresentationModifier(anchorID: rootID, backend: backend)) } + private var filePreviewBinding: Binding { + Binding( + get: { backend.filePreviewPresentation.item?.url }, + set: { url in + guard url == nil, + let nodeID = backend.filePreviewPresentation + .dismissFromPresentation() else { return } + try? backend.performDismiss(node: nodeID) + } + ) + } + private var sheetBinding: Binding { Binding( get: { @@ -548,7 +606,7 @@ private struct LUINodeView: View { content .modifier(LUIAccessibilityModifier(model: model, backend: backend)) .modifier(LUIAppearModifier(model: model, backend: backend)) - } else if model.kind.isModalSurface { + } else if model.kind.isModalSurface || model.kind == .filePreview { content } else if model.kind == .resizable { content @@ -710,6 +768,12 @@ private struct LUINodeView: View { return AnyView(LUIImageView(model: model, backend: backend)) case .mediaSurface: return AnyView(LUIMediaSurfaceView(model: model, backend: backend)) + case .link: + return AnyView(LUILinkView(model: model, backend: backend)) + case .fileImage: + return AnyView(LUIFileImageView(model: model, backend: backend)) + case .filePreview: + return AnyView(EmptyView()) case .stepper: return AnyView(LUIStepperView(model: model, backend: backend)) case .step: @@ -2927,6 +2991,141 @@ private struct LUIImageView: View { } } +private struct LUILinkView: View { + let model: LUINodeModel + let backend: LUIAppleBackend + @Environment(\.luiSemanticColors) private var semanticColors + + private var urlString: String { + model.property(.url)?.stringValue ?? "" + } + + private var tint: Color? { + LUIThemeColorResolver.color( + model.property(.foreground)?.stringValue, + semanticColors: semanticColors + ) + } + + var body: some View { + if let url = URL(string: urlString) { + Link(destination: url) { label } + .tint(tint) + .disabled(!model.isEnabled) + } else { + label + } + } + + @ViewBuilder + private var label: some View { + if model.visibleChildren.isEmpty { + labelContent + } else { + ForEach(model.visibleChildren, id: \.self) { childID in + LUIAnyNodeView(nodeID: childID, backend: backend).equatable() + } + } + } + + @ViewBuilder + private var labelContent: some View { + let iconName = model.buttonIconName + let text = model.text.isEmpty ? urlString : model.text + if iconName.isEmpty { + Text(verbatim: text) + } else if LUIButtonIconPlacementPolicy.usesVerticalLayout( + model.buttonIconPlacement + ) { + VStack(spacing: 2) { + linkIcon(iconName) + Text(verbatim: text) + } + } else if model.buttonIconPlacement == "trailing" { + HStack(spacing: 4) { + Text(verbatim: text) + linkIcon(iconName) + } + } else { + HStack(spacing: 4) { + linkIcon(iconName) + Text(verbatim: text) + } + } + } + + private func linkIcon(_ name: String) -> some View { + LUIIconImage(source: backend.iconSource(for: name), bundle: backend.appIconBundle) + .scaledToFit() + .frame(width: 14, height: 14) + } +} + +private struct LUIFileImageView: View { + let model: LUINodeModel + let backend: LUIAppleBackend + @State private var image: CGImage? + @State private var failed = false + + private var path: String { + model.property(.path)?.stringValue ?? "" + } + + private var maxPixelSize: Int { + model.property(.maxPixelSize)?.intValue + ?? LUIFileImageLoader.defaultMaxPixelSize + } + + @ViewBuilder + var body: some View { + content + .task(id: "\(maxPixelSize)|\(path)") { + if let decoded = await LUIFileImageLoader.thumbnail( + path: path, + maxPixelSize: maxPixelSize + ) { + image = decoded + failed = false + } else { + image = nil + failed = true + } + } + } + + @ViewBuilder + private var content: some View { + if model.supportsPress { + Button { + try? backend.performPress(node: model.id) + } label: { + inner + } + .buttonStyle(.plain) + } else { + inner + } + } + + @ViewBuilder + private var inner: some View { + if let image { + Image(decorative: image, scale: 1) + .resizable() + } else if failed { + LUIIconImage( + source: .systemName("photo"), + bundle: backend.appIconBundle + ) + .frame(width: 24, height: 24) + .foregroundStyle(.secondary) + } else { + ProgressView() + .controlSize(.small) + } + } +} + private struct LUIMediaSurfaceView: View { let model: LUINodeModel let backend: LUIAppleBackend diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift index f257137..5602a90 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireProtocol.swift @@ -186,6 +186,11 @@ enum LUIWireValue: Decodable, Equatable { case .tooltipDelay, .duration: guard let value = intValue else { return false } return (0...Int(Int32.max)).contains(value) + case .path, .url: + return stringValue != nil + case .maxPixelSize: + guard let value = intValue else { return false } + return value > 0 case .textAlignment: guard let value = stringValue else { return false } return Self.textAlignments.contains(value) @@ -331,7 +336,8 @@ struct LUIRetainedTree { // `dropNode` erases the state, so its kind must be read first. if case let .dropNode(id) = operation, let kind = nodes[id]?.kind, - kind == .dialog || kind == .sheet || kind == .list { + kind == .dialog || kind == .sheet || kind == .list || + kind == .filePreview { effects.modalRelevant.insert(id) } try apply(operation, extensionRegistry: extensionRegistry) @@ -362,7 +368,8 @@ struct LUIRetainedTree { } effects.modalRelevant.formUnion(effects.touched.filter { let kind = nodes[$0]?.kind - return kind == .dialog || kind == .sheet || kind == .list + return kind == .dialog || kind == .sheet || kind == .list || + kind == .filePreview }) // Revalidate every check whose inputs could have changed: the // touched node itself, its parent (a changed child list or parent @@ -724,7 +731,8 @@ struct LUIRetainedTree { || kind == .select || kind == .combobox || kind == .dropdownMenu || kind == .menuItem || kind == .listItem || kind == .tableCell || kind == .resizable || kind == .split || - kind == .alert || kind == .bubble || kind == .statusBar + kind == .alert || kind == .bubble || kind == .statusBar || + kind == .link || kind == .fileImage case .text: kind == .text || kind == .heading || kind == .paragraph || kind == .label || kind == .button || kind == .toggleButton || isTextEntry(kind) || @@ -733,13 +741,13 @@ struct LUIRetainedTree { kind == .tooltip || kind == .tableCell || kind == .alert || kind == .bubble || kind == .statusBar || - isModalSurface(kind) || kind == .drawer + isModalSurface(kind) || kind == .drawer || kind == .link case .enabled: kind == .button || kind == .toggleButton || isTextEntry(kind) || kind == .checkbox || kind == .switchControl || kind == .toggle || kind == .radio || kind == .slider || kind == .select || kind == .combobox || kind == .menuItem || kind == .listItem || - kind == .drawer + kind == .drawer || kind == .link case .gap: kind == .row || kind == .column || kind == .grid || kind == .list || kind == .virtualList || kind == .scroll || kind == .card || @@ -761,7 +769,7 @@ struct LUIRetainedTree { kind == .mediaSurface || kind == .tree || kind == .resizable || kind == .split || kind == .drawer || kind == .alert || kind == .bubble || - kind == .listItem || + kind == .listItem || kind == .link || kind == .fileImage || isTreeRow(kind) case .headingLevel: kind == .heading case .checked: @@ -779,12 +787,13 @@ struct LUIRetainedTree { kind == .button || kind == .toggleButton || kind == .menuItem || kind == .alert || kind == .bubble case .iconPlacement: - kind == .button || kind == .toggleButton || kind == .listItem + kind == .button || kind == .toggleButton || kind == .listItem || + kind == .link case .longPressEnabled: kind == .button || kind == .toggleButton || kind == .listItem case .icon: kind == .button || kind == .toggleButton || kind == .menuItem || - kind == .listItem + kind == .listItem || kind == .link case .selected: kind == .button || kind == .toggleButton || kind == .menuItem || kind == .listItem || kind == .tableRow || kind == .drawer || @@ -797,7 +806,7 @@ struct LUIRetainedTree { case .pressEnabled: kind == .text || kind == .column || kind == .radio || kind == .select || kind == .combobox || kind == .menuItem || kind == .listItem - || kind == .tableCell || isTreeRow(kind) + || kind == .tableCell || kind == .fileImage || isTreeRow(kind) case .submitEnabled: kind == .combobox || kind == .listItem case .doublePressEnabled: kind == .listItem case .appearEnabled: kind != .root @@ -816,6 +825,9 @@ struct LUIRetainedTree { kind == .tableCell || kind == .bubble || kind == .statusBar case .role: isTreeRow(kind) || kind == .listItem case .treeLevel, .expanded: isTreeRow(kind) + case .path: kind == .fileImage || kind == .filePreview + case .url: kind == .link + case .maxPixelSize: kind == .fileImage case .active, .title, .description, .meta, .indicator, .connector: false } } @@ -836,7 +848,7 @@ struct LUIRetainedTree { kind == .stepper || kind == .timeline || kind == .inputGroup || kind == .inputGroupActions || kind == .toast || kind == .toolbar || kind == .bottomTabs || kind == .bottomTab || - kind == .menuTrigger || isContextMenuLeafHost(kind) + kind == .menuTrigger || isContextMenuLeafHost(kind) || kind == .link } private static func acceptsExtensionChildren(_ kind: LUINodeKind) -> Bool { @@ -1116,6 +1128,14 @@ struct LUIRetainedTree { if node.kind == .mediaSurface, node.properties[.surface]?.intValue == nil { throw invalid("media-surface requires surface") } + if node.kind == .fileImage || node.kind == .filePreview, + (node.properties[.path]?.stringValue ?? "").isEmpty { + throw invalid("file node requires path") + } + if node.kind == .link, + (node.properties[.url]?.stringValue ?? "").isEmpty { + throw invalid("link requires url") + } if node.kind == .stepper, node.properties[.active]?.intValue == nil { throw invalid("stepper requires active") } diff --git a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift index 8e2e550..ab7c639 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUIWireSchema.swift @@ -74,6 +74,9 @@ enum LUINodeKind: String, Decodable, Equatable { case toast = "toast" case toolbar = "toolbar" case statusBar = "status-bar" + case link = "link" + case fileImage = "file-image" + case filePreview = "file-preview" } enum LUIProperty: String, Decodable, Hashable { @@ -150,6 +153,9 @@ enum LUIProperty: String, Decodable, Hashable { case resizeOrigin = "resize-origin" case theme = "theme" case themeMode = "theme-mode" + case path = "path" + case url = "url" + case maxPixelSize = "max-pixel-size" } enum LUISchemaMatrix { @@ -166,6 +172,7 @@ enum LUISchemaMatrix { .bottomTabs: [.accessibilityLabel, .styleClass, .grow, .width, .height, .minWidth, .maxWidth, .minHeight, .maxHeight], .bottomTab: [.title, .icon, .selected, .enabled, .pressEnabled], .menuTrigger: [.text, .icon, .accessibilityLabel, .enabled, .foreground, .styleClass], + .filePreview: [.path], ] static let extra: [LUINodeKind: Set] = [ diff --git a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift index 6f99514..1aa07bb 100644 --- a/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift +++ b/platform/apple/Tests/LUIAppleBackendTests/LUIAppleBackendTests.swift @@ -3792,6 +3792,151 @@ struct LUISwiftUIBackendTests { #expect(capturedAppleEvent == CapturedAppleEvent(kind: 0, node: 1, text: "")) } + @Test("maps Link to a retained node with url") + func mapsLink() throws { + let backend = LUIAppleBackend() + try backend.apply(json: """ + {"generation":1,"ops":[ + {"op":"create-node","id":1,"kind":"column"}, + {"op":"create-node","id":2,"kind":"link"}, + {"op":"create-node","id":3,"kind":"text"}, + {"op":"set-prop","id":2,"property":"url","value":"https://example.com/docs"}, + {"op":"set-prop","id":2,"property":"text","value":"Docs"}, + {"op":"set-prop","id":2,"property":"icon","value":"external-link"}, + {"op":"set-prop","id":3,"property":"text","value":"child"}, + {"op":"insert-child","parent":1,"child":2,"index":0}, + {"op":"insert-child","parent":2,"child":3,"index":0} + ]} + """) + + let link = try #require(backend.model(id: 2)) + #expect(link.kind == .link) + #expect(link.property(.url) == .string("https://example.com/docs")) + #expect(link.children == [3]) + _ = LUISwiftUIRoot(backend: backend, rootID: 1) + + #expect(throws: LUIBackendError.self) { + try backend.apply(json: """ + {"generation":2,"ops":[ + {"op":"create-node","id":4,"kind":"link"}, + {"op":"insert-child","parent":1,"child":4,"index":1} + ]} + """) + } + #expect(backend.generation == 1) + } + + @Test("maps FileImage with thumbnail, sizing, and press support") + func mapsFileImage() 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":"file-image"}, + {"op":"set-prop","id":2,"property":"path","value":"/tmp/pic.png"}, + {"op":"set-prop","id":2,"property":"max-pixel-size","value":512}, + {"op":"set-prop","id":2,"property":"press-enabled","value":true}, + {"op":"set-prop","id":2,"property":"width","value":120}, + {"op":"set-prop","id":2,"property":"height","value":80}, + {"op":"insert-child","parent":1,"child":2,"index":0} + ]} + """) + + let image = try #require(backend.model(id: 2)) + #expect(image.kind == .fileImage) + #expect(image.property(.path) == .string("/tmp/pic.png")) + #expect(image.property(.maxPixelSize) == .int(512)) + _ = LUISwiftUIRoot(backend: backend, rootID: 1) + + try backend.performPress(node: 2) + #expect(events == [.press(node: 2)]) + + try backend.apply(json: """ + {"generation":2,"ops":[ + {"op":"remove-prop","id":2,"property":"press-enabled"} + ]} + """) + #expect(throws: LUIBackendError.self) { + try backend.performPress(node: 2) + } + + #expect(throws: LUIBackendError.self) { + try backend.apply(json: """ + {"generation":3,"ops":[ + {"op":"create-node","id":5,"kind":"file-image"}, + {"op":"insert-child","parent":1,"child":5,"index":1} + ]} + """) + } + #expect(backend.generation == 2) + } + + @Test("file-preview presents through the store and dismiss emits an event") + func mapsFilePreview() 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":"file-preview"}, + {"op":"set-prop","id":2,"property":"path","value":"/tmp/doc.pdf"}, + {"op":"insert-child","parent":1,"child":2,"index":0} + ]} + """) + + #expect(backend.filePreviewPresentation.item?.nodeID == 2) + #expect( + backend.filePreviewPresentation.item?.url + == URL(fileURLWithPath: "/tmp/doc.pdf") + ) + _ = LUISwiftUIRoot(backend: backend, rootID: 1) + + // Interactive close suppresses re-assert until the wire drops the node. + #expect(backend.filePreviewPresentation.dismissFromPresentation() == 2) + try backend.performDismiss(node: 2) + #expect(events == [.dismiss(node: 2)]) + + try backend.apply(json: """ + {"generation":2,"ops":[ + {"op":"set-prop","id":2,"property":"path","value":"/tmp/other.pdf"} + ]} + """) + #expect(backend.filePreviewPresentation.item == nil) + + try backend.apply(json: """ + {"generation":3,"ops":[ + {"op":"remove-child","parent":1,"child":2}, + {"op":"drop-node","id":2} + ]} + """) + #expect(backend.filePreviewPresentation.item == nil) + + try backend.apply(json: """ + {"generation":4,"ops":[ + {"op":"create-node","id":3,"kind":"file-preview"}, + {"op":"set-prop","id":3,"property":"path","value":"file:///tmp/a.pdf"}, + {"op":"insert-child","parent":1,"child":3,"index":0} + ]} + """) + #expect( + backend.filePreviewPresentation.item?.url + == URL(string: "file:///tmp/a.pdf") + ) + + // The kind is restrictive: only `path` is admitted. + #expect(throws: LUIBackendError.self) { + try backend.apply(json: """ + {"generation":5,"ops":[ + {"op":"set-prop","id":3,"property":"text","value":"x"} + ]} + """) + } + #expect(backend.generation == 4) + } + private static let initialBatch = """ {"generation":1,"ops":[ {"op":"create-node","id":1,"kind":"row"}, diff --git a/platform/flutter/lib/lui_flutter_backend.dart b/platform/flutter/lib/lui_flutter_backend.dart index 06a72c3..1512540 100644 --- a/platform/flutter/lib/lui_flutter_backend.dart +++ b/platform/flutter/lib/lui_flutter_backend.dart @@ -1197,6 +1197,8 @@ final class LUIFlutterBackend { (state.kind == _NodeKind.column && state.properties['press-enabled'] == true) || (state.kind == _NodeKind.text && + state.properties['press-enabled'] == true) || + (state.kind == _NodeKind.fileImage && state.properties['press-enabled'] == true); if (!pressable || state.properties['enabled'] == false) { throw LUIBackendException( @@ -1478,6 +1480,7 @@ final class LUIFlutterBackend { state.kind != _NodeKind.combobox && state.kind != _NodeKind.dropdownMenu && state.kind != _NodeKind.toast && + state.kind != _NodeKind.filePreview && !state.kind.isModalSurface) { throw LUIBackendException('node $node is not dismissible'); } @@ -2899,6 +2902,8 @@ final class LUIFlutterBackend { color: foreground, ), _NodeKind.statusBar => statusBar(), + _NodeKind.link => column(), + _NodeKind.fileImage || _NodeKind.filePreview => const SizedBox.shrink(), }; if (state.kind == _NodeKind.root || state.kind.isModalSurface) { @@ -3473,6 +3478,9 @@ final class LUIFlutterBackend { _themeModes.contains(value)); } if (kind == _NodeKind.contextMenu) return false; + if (kind == _NodeKind.filePreview) { + return property == 'path' && value is String; + } if (kind == _NodeKind.accordion) { return switch (property) { 'text' => value is String, @@ -3636,6 +3644,7 @@ final class LUIFlutterBackend { kind == _NodeKind.bubble || kind == _NodeKind.statusBar || kind == _NodeKind.drawer || + kind == _NodeKind.link || kind.isModalSurface), 'enabled' => value is bool && @@ -3649,7 +3658,8 @@ final class LUIFlutterBackend { kind == _NodeKind.select || kind == _NodeKind.menuItem || kind == _NodeKind.listItem || - kind == _NodeKind.drawer), + kind == _NodeKind.drawer || + kind == _NodeKind.link), 'value' => value is double && value.isFinite && @@ -3703,11 +3713,12 @@ final class LUIFlutterBackend { (_isButtonKind(kind) || kind == _NodeKind.menuItem || kind == _NodeKind.menuTrigger || - kind == _NodeKind.listItem), + kind == _NodeKind.listItem || + kind == _NodeKind.link), 'icon-placement' => value is String && - (value == 'leading' || value == 'trailing') && - _isButtonKind(kind), + (value == 'leading' || value == 'trailing' || value == 'top') && + (_isButtonKind(kind) || kind == _NodeKind.link), 'selected' => value is bool && (_isButtonKind(kind) || @@ -3738,6 +3749,7 @@ final class LUIFlutterBackend { kind == _NodeKind.menuItem || kind == _NodeKind.listItem || kind == _NodeKind.tableCell || + kind == _NodeKind.fileImage || _isTreeRowKind(kind)), 'submit-enabled' => value is bool && @@ -3753,6 +3765,10 @@ final class LUIFlutterBackend { value is num && value.isFinite && (kind == _NodeKind.avatar || kind == _NodeKind.image), + 'path' => value is String && kind == _NodeKind.fileImage, + 'url' => value is String && kind == _NodeKind.link, + 'max-pixel-size' => + value is int && value > 0 && kind == _NodeKind.fileImage, 'anchor' => value is String && const {'above', 'below', 'left', 'right'}.contains(value) && @@ -3821,7 +3837,9 @@ final class LUIFlutterBackend { kind == _NodeKind.split || kind == _NodeKind.alert || kind == _NodeKind.bubble || - kind == _NodeKind.statusBar), + kind == _NodeKind.statusBar || + kind == _NodeKind.link || + kind == _NodeKind.fileImage), 'border-color' => value is String && kind != _NodeKind.avatar && @@ -3891,6 +3909,8 @@ final class LUIFlutterBackend { kind == _NodeKind.bubble || kind == _NodeKind.select || kind == _NodeKind.menuTrigger || + kind == _NodeKind.link || + kind == _NodeKind.fileImage || _isTreeRowKind(kind)), 'text-alignment' => value is String && @@ -4194,6 +4214,17 @@ final class LUIFlutterBackend { !state.properties.containsKey('surface')) { throw const LUIBackendException('media-surface requires surface'); } + if ((state.kind == _NodeKind.fileImage || + state.kind == _NodeKind.filePreview) && + (state.properties['path'] as String? ?? '').isEmpty) { + throw LUIBackendException( + '${state.kind == _NodeKind.fileImage ? 'file-image' : 'file-preview'} requires a non-empty path', + ); + } + if (state.kind == _NodeKind.link && + (state.properties['url'] as String? ?? '').isEmpty) { + throw const LUIBackendException('link requires a non-empty url'); + } if (state.kind == _NodeKind.stepper && !state.properties.containsKey('active')) { throw const LUIBackendException('stepper requires active'); @@ -4340,6 +4371,7 @@ final class LUIFlutterBackend { kind == _NodeKind.toolbar || kind == _NodeKind.bottomTabs || kind == _NodeKind.bottomTab || + kind == _NodeKind.link || _isContextMenuLeafHost(kind) || kind.isModalSurface; diff --git a/platform/flutter/lib/lui_wire_schema.g.dart b/platform/flutter/lib/lui_wire_schema.g.dart index 2dd74cf..29d6549 100644 --- a/platform/flutter/lib/lui_wire_schema.g.dart +++ b/platform/flutter/lib/lui_wire_schema.g.dart @@ -74,6 +74,9 @@ enum _NodeKind { toast, toolbar, statusBar, + link, + fileImage, + filePreview, } _NodeKind _decodeNodeKind(Object? value) { @@ -153,6 +156,9 @@ _NodeKind _decodeNodeKind(Object? value) { 'toast' => _NodeKind.toast, 'toolbar' => _NodeKind.toolbar, 'status-bar' => _NodeKind.statusBar, + 'link' => _NodeKind.link, + 'file-image' => _NodeKind.fileImage, + 'file-preview' => _NodeKind.filePreview, _ => throw const LUIBackendException('unknown node kind'), }; } diff --git a/platform/qt/lib/CMakeLists.txt b/platform/qt/lib/CMakeLists.txt index 9ca673f..9093417 100644 --- a/platform/qt/lib/CMakeLists.txt +++ b/platform/qt/lib/CMakeLists.txt @@ -58,6 +58,8 @@ qt_add_qml_module(lui_qml ../qml/LuiDivider.qml ../qml/LuiDrawer.qml ../qml/LuiDropdownMenu.qml + ../qml/LuiFileImage.qml + ../qml/LuiFilePreview.qml ../qml/LuiGrid.qml ../qml/LuiHeading.qml ../qml/LuiIcon.qml @@ -66,6 +68,7 @@ qt_add_qml_module(lui_qml ../qml/LuiInputGroup.qml ../qml/LuiInputGroupActions.qml ../qml/LuiLabel.qml + ../qml/LuiLink.qml ../qml/LuiList.qml ../qml/LuiListItem.qml ../qml/LuiMediaSurface.qml diff --git a/platform/qt/lib/lui_schema.cpp b/platform/qt/lib/lui_schema.cpp index 38ee30c..cce963c 100644 --- a/platform/qt/lib/lui_schema.cpp +++ b/platform/qt/lib/lui_schema.cpp @@ -190,7 +190,7 @@ bool canContainChildren(NodeKind kind) { NodeKind::Stepper, NodeKind::Timeline, NodeKind::InputGroup, NodeKind::InputGroupActions, NodeKind::Toast, NodeKind::Toolbar, NodeKind::Alert, NodeKind::Bubble, - NodeKind::BottomTabs, NodeKind::BottomTab}); + NodeKind::BottomTabs, NodeKind::BottomTab, NodeKind::Link}); } bool acceptsExtensionChildren(NodeKind kind) { @@ -247,7 +247,7 @@ bool eventSupported(NodeKind kind, Event event) { NodeKind::Select, NodeKind::Combobox, NodeKind::MenuItem, NodeKind::ListItem, NodeKind::Text, NodeKind::TableCell, NodeKind::TimelineItem, - NodeKind::BottomTab}); + NodeKind::FileImage, NodeKind::BottomTab}); case Event::LongPress: return oneOf(kind, {NodeKind::Button, NodeKind::ToggleButton, @@ -273,7 +273,8 @@ bool eventSupported(NodeKind kind, Event event) { case Event::Dismiss: return oneOf(kind, {NodeKind::Select, NodeKind::Combobox, NodeKind::DropdownMenu, NodeKind::Toast, - NodeKind::Dialog, NodeKind::Drawer, NodeKind::Sheet}); + NodeKind::Dialog, NodeKind::Drawer, NodeKind::Sheet, + NodeKind::FilePreview}); case Event::DoublePress: return kind == NodeKind::ListItem; case Event::Appear: @@ -423,7 +424,8 @@ bool commonPropertySupported(NodeKind kind, Property property) { NodeKind::DropdownMenu, NodeKind::MenuItem, NodeKind::ListItem, NodeKind::TableCell, NodeKind::Resizable, NodeKind::Split, NodeKind::Alert, - NodeKind::Bubble, NodeKind::StatusBar}); + NodeKind::Bubble, NodeKind::StatusBar, NodeKind::Link, + NodeKind::FileImage}); case Property::WidthValue: case Property::HeightValue: return kind != NodeKind::Tooltip; @@ -448,7 +450,8 @@ bool commonPropertySupported(NodeKind kind, Property property) { NodeKind::Image, NodeKind::MediaSurface, NodeKind::Tree, NodeKind::Resizable, NodeKind::Split, NodeKind::Drawer, NodeKind::Alert, NodeKind::Bubble, - NodeKind::ListItem}) || + NodeKind::ListItem, NodeKind::Link, + NodeKind::FileImage}) || horizontalContainer(kind) || treeRowKind(kind); case Property::AccessibilityIdentifier: return true; @@ -481,11 +484,11 @@ bool commonPropertySupported(NodeKind kind, Property property) { case Property::InlineIconName: return oneOf(kind, {NodeKind::Button, NodeKind::ToggleButton, NodeKind::MenuItem, NodeKind::ListItem, - NodeKind::BottomTab}); + NodeKind::BottomTab, NodeKind::Link}); case Property::IconPlacementValue: return oneOf(kind, {NodeKind::Button, NodeKind::ToggleButton, - NodeKind::ListItem}); + NodeKind::ListItem, NodeKind::Link}); case Property::Selected: return oneOf(kind, {NodeKind::Button, NodeKind::ToggleButton, NodeKind::MenuItem, NodeKind::ListItem, @@ -511,7 +514,7 @@ bool commonPropertySupported(NodeKind kind, Property property) { return oneOf(kind, {NodeKind::Text, NodeKind::Column, NodeKind::Radio, NodeKind::Select, NodeKind::Combobox, NodeKind::MenuItem, NodeKind::ListItem, NodeKind::TableCell, - NodeKind::BottomTab}) || + NodeKind::BottomTab, NodeKind::FileImage}) || treeRowKind(kind); case Property::SubmitEnabled: return kind == NodeKind::Combobox || kind == NodeKind::ListItem; @@ -527,6 +530,12 @@ bool commonPropertySupported(NodeKind kind, Property property) { return kind == NodeKind::Avatar || kind == NodeKind::Image; case Property::SurfaceIdValue: return kind == NodeKind::MediaSurface; + case Property::PathValue: + return kind == NodeKind::FileImage || kind == NodeKind::FilePreview; + case Property::UrlValue: + return kind == NodeKind::Link; + case Property::MaxPixelSize: + return kind == NodeKind::FileImage; case Property::AnchorValue: case Property::AnchorAlignmentValue: case Property::AnchorOffset: @@ -562,7 +571,8 @@ bool commonPropertySupported(NodeKind kind, Property property) { NodeKind::Combobox, NodeKind::MenuItem, NodeKind::ListItem, NodeKind::Avatar, NodeKind::Dialog, NodeKind::Drawer, NodeKind::Sheet, NodeKind::Tooltip, NodeKind::TableCell, - NodeKind::Alert, NodeKind::Bubble, NodeKind::StatusBar}); + NodeKind::Alert, NodeKind::Bubble, NodeKind::StatusBar, + NodeKind::Link}); case Property::Enabled: return oneOf(kind, {NodeKind::Button, NodeKind::ToggleButton, @@ -571,7 +581,8 @@ bool commonPropertySupported(NodeKind kind, Property property) { NodeKind::Checkbox, NodeKind::SwitchControl, NodeKind::Toggle, NodeKind::Radio, NodeKind::Slider, NodeKind::Select, NodeKind::Combobox, NodeKind::MenuItem, - NodeKind::ListItem, NodeKind::Drawer, NodeKind::BottomTab}); + NodeKind::ListItem, NodeKind::Drawer, NodeKind::BottomTab, + NodeKind::Link}); case Property::ActiveIndex: case Property::DescriptionValue: case Property::MetaValue: @@ -655,6 +666,8 @@ bool propertySupported(NodeKind kind, Property property) { case NodeKind::Dialog: return property == Property::DescriptionValue || commonPropertySupported(kind, property); + case NodeKind::FilePreview: + return property == Property::PathValue; default: return commonPropertySupported(kind, property); } @@ -750,10 +763,16 @@ bool propertyValueSupported(Property property, const QVariant &value) { int number = 0; return isIntValue(value, &number) && number >= 0; } + case Property::MaxPixelSize: { + int number = 0; + return isIntValue(value, &number) && number > 0; + } case Property::TitleValue: case Property::DescriptionValue: case Property::MetaValue: case Property::IndicatorValue: + case Property::PathValue: + case Property::UrlValue: return isString(value); case Property::Connector: return isBool(value); @@ -902,6 +921,14 @@ bool nodePropertiesSupported(NodeKind kind, const QVariantMap &properties) { !properties.contains(QStringLiteral("surface"))) { return false; } + if ((kind == NodeKind::FileImage || kind == NodeKind::FilePreview) && + !stringPropertyNonempty(properties, "path")) { + return false; + } + if (kind == NodeKind::Link && + !stringPropertyNonempty(properties, "url")) { + return false; + } if (kind == NodeKind::Stepper && !properties.contains(QStringLiteral("active"))) { return false; diff --git a/platform/qt/lib/lui_wire_schema.h b/platform/qt/lib/lui_wire_schema.h index fd11ca9..2b4a20d 100644 --- a/platform/qt/lib/lui_wire_schema.h +++ b/platform/qt/lib/lui_wire_schema.h @@ -78,6 +78,9 @@ enum class NodeKind { Toast, Toolbar, StatusBar, + Link, + FileImage, + FilePreview, }; enum class Property { @@ -154,6 +157,9 @@ enum class Property { ResizeOrigin, ThemeValue, ThemeMode, + PathValue, + UrlValue, + MaxPixelSize, }; inline const char *nodeKindWireName(NodeKind kind) { @@ -230,6 +236,9 @@ inline const char *nodeKindWireName(NodeKind kind) { case NodeKind::Toast: return "toast"; case NodeKind::Toolbar: return "toolbar"; case NodeKind::StatusBar: return "status-bar"; + case NodeKind::Link: return "link"; + case NodeKind::FileImage: return "file-image"; + case NodeKind::FilePreview: return "file-preview"; } return "unknown"; } @@ -307,6 +316,9 @@ inline bool decodeNodeKind(const char *name, NodeKind *kind) { if (std::strcmp(name, "toast") == 0) { *kind = NodeKind::Toast; return true; } if (std::strcmp(name, "toolbar") == 0) { *kind = NodeKind::Toolbar; return true; } if (std::strcmp(name, "status-bar") == 0) { *kind = NodeKind::StatusBar; return true; } + if (std::strcmp(name, "link") == 0) { *kind = NodeKind::Link; return true; } + if (std::strcmp(name, "file-image") == 0) { *kind = NodeKind::FileImage; return true; } + if (std::strcmp(name, "file-preview") == 0) { *kind = NodeKind::FilePreview; return true; } return false; } @@ -358,6 +370,7 @@ inline bool containerNodeKind(NodeKind kind) { case NodeKind::Sheet: case NodeKind::Toast: case NodeKind::Toolbar: + case NodeKind::Link: return true; default: return false; @@ -439,6 +452,9 @@ inline const char *nodeKindComponentName(NodeKind kind) { case NodeKind::Toast: return "LuiToast.qml"; case NodeKind::Toolbar: return "LuiToolbar.qml"; case NodeKind::StatusBar: return "LuiStatusBar.qml"; + case NodeKind::Link: return "LuiLink.qml"; + case NodeKind::FileImage: return "LuiFileImage.qml"; + case NodeKind::FilePreview: return "LuiFilePreview.qml"; } return "LuiBox.qml"; } @@ -518,6 +534,9 @@ 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::PathValue: return "path"; + case Property::UrlValue: return "url"; + case Property::MaxPixelSize: return "max-pixel-size"; } return "unknown"; } @@ -596,6 +615,9 @@ 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, "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; } return false; } diff --git a/platform/qt/qml/LuiFileImage.qml b/platform/qt/qml/LuiFileImage.qml new file mode 100644 index 0000000..ff8eb0f --- /dev/null +++ b/platform/qt/qml/LuiFileImage.qml @@ -0,0 +1,35 @@ +import QtQuick +import "LuiStyle.js" as Style + +// Wire kind: file-image — loads a local file path (or file:// URL) through +// Qt's image pipeline, downsampled to "max-pixel-size" (default 1024). +Item { + id: wrapper + required property var node + readonly property var props: node ? node.properties : ({}) + + readonly property string path: Style.str(props, "path", "") + readonly property int maxPixelSize: + Style.num(props, "max-pixel-size", 1024) + + implicitWidth: Style.num(props, "width", 48) + implicitHeight: Style.num(props, "height", 48) + + Image { + anchors.fill: parent + source: wrapper.path.indexOf("file://") === 0 + ? wrapper.path : "file://" + wrapper.path + sourceSize.width: wrapper.maxPixelSize + sourceSize.height: wrapper.maxPixelSize + fillMode: Image.PreserveAspectFit + asynchronous: true + } + + TapHandler { + enabled: wrapper.props["press-enabled"] === true + onTapped: { + if (wrapper.node && wrapper.node.backend) + wrapper.node.backend.performPress(wrapper.node.id) + } + } +} diff --git a/platform/qt/qml/LuiFilePreview.qml b/platform/qt/qml/LuiFilePreview.qml new file mode 100644 index 0000000..5249541 --- /dev/null +++ b/platform/qt/qml/LuiFilePreview.qml @@ -0,0 +1,11 @@ +import QtQuick + +// Wire kind: file-preview — non-rendering node; the owning backend presents a +// native preview of the "path" prop while the node is mounted. +Item { + required property var node + readonly property var props: node ? node.properties : ({}) + visible: false + implicitWidth: 0 + implicitHeight: 0 +} diff --git a/platform/qt/qml/LuiLink.qml b/platform/qt/qml/LuiLink.qml new file mode 100644 index 0000000..6c24bcd --- /dev/null +++ b/platform/qt/qml/LuiLink.qml @@ -0,0 +1,50 @@ +import QtQuick +import QtQuick.Layouts +import "LuiStyle.js" as Style + +// Wire kind: link — opens the "url" prop with the system handler +// (Qt.openUrlExternally). Renders children when present, else "text" or the +// raw url. +Item { + id: wrapper + required property var node + readonly property var props: node ? node.properties : ({}) + + readonly property string url: Style.str(props, "url", "") + + implicitWidth: content.implicitWidth + implicitHeight: content.implicitHeight + + RowLayout { + id: content + spacing: Style.num(wrapper.props, "gap", 4) + + Repeater { + model: wrapper.node ? wrapper.node.children : [] + delegate: LuiNodeView { + required property var modelData + node: modelData + } + } + + Image { + visible: Style.str(wrapper.props, "icon", "") !== "" + source: Style.iconSource(Style.str(wrapper.props, "icon", "")) + sourceSize.width: 16 + sourceSize.height: 16 + } + + Text { + visible: (wrapper.node ? wrapper.node.children.length : 0) === 0 + text: Style.str(wrapper.props, "text", wrapper.url) + color: Style.nodeColor(wrapper.node, + wrapper.props["foreground"], "blue") + font.underline: true + } + } + + TapHandler { + enabled: wrapper.props["enabled"] !== false && wrapper.url !== "" + onTapped: Qt.openUrlExternally(wrapper.url) + } +} diff --git a/platform/web/melange/nodes/lui_web_nodes.ml b/platform/web/melange/nodes/lui_web_nodes.ml index a071c9b..6a2e6e7 100644 --- a/platform/web/melange/nodes/lui_web_nodes.ml +++ b/platform/web/melange/nodes/lui_web_nodes.ml @@ -86,6 +86,9 @@ let base_class_name kind = | Split -> "lui-split" | Drawer -> "lui-drawer" | StatusBar -> "lui-status-bar" + | Link -> "lui-link" + | FileImage -> "lui-file-image" + | FilePreview -> "lui-file-preview" let create_split_node renderer = let document = renderer.web_document in @@ -259,6 +262,7 @@ let simple_node_tag kind = | Slider -> "input" | Divider -> "hr" | Tooltip -> "span" + | Link -> "a" | _ -> "div" let simple_node_attributes kind = @@ -306,6 +310,7 @@ let simple_node_attributes kind = ("data-state", "open") ] | Toolbar -> [ ("role", "toolbar"); ("aria-orientation", "horizontal") ] | StatusBar -> [ ("role", "status") ] + | FilePreview -> [ ("hidden", "") ] | _ -> [] let create_simple_node renderer kind = diff --git a/platform/winui/LUI.Core/LUISchema.cs b/platform/winui/LUI.Core/LUISchema.cs index 69449b5..04de5ca 100644 --- a/platform/winui/LUI.Core/LUISchema.cs +++ b/platform/winui/LUI.Core/LUISchema.cs @@ -155,6 +155,7 @@ public static bool EventSupported(LUINodeKind kind, LUIEventKind eventKind) case LUINodeKind.Text: case LUINodeKind.TableCell: case LUINodeKind.TimelineItem: + case LUINodeKind.FileImage: case LUINodeKind.BottomTab: return true; default: @@ -214,6 +215,7 @@ public static bool EventSupported(LUINodeKind kind, LUIEventKind eventKind) case LUINodeKind.Dialog: case LUINodeKind.Drawer: case LUINodeKind.Sheet: + case LUINodeKind.FilePreview: return true; default: return false; @@ -479,6 +481,8 @@ public static bool CommonPropertySupported( case LUINodeKind.Alert: case LUINodeKind.Bubble: case LUINodeKind.StatusBar: + case LUINodeKind.Link: + case LUINodeKind.FileImage: return true; default: return false; @@ -522,6 +526,8 @@ public static bool CommonPropertySupported( kind == LUINodeKind.Alert || kind == LUINodeKind.Bubble || kind == LUINodeKind.ListItem || + kind == LUINodeKind.Link || + kind == LUINodeKind.FileImage || TreeRowKind(kind); case LUIProperty.AccessibilityIdentifier: return true; @@ -570,11 +576,13 @@ public static bool CommonPropertySupported( kind == LUINodeKind.ToggleButton || kind == LUINodeKind.MenuItem || kind == LUINodeKind.ListItem || - kind == LUINodeKind.BottomTab; + kind == LUINodeKind.BottomTab || + kind == LUINodeKind.Link; case LUIProperty.IconPlacementValue: return kind == LUINodeKind.Button || kind == LUINodeKind.ToggleButton || - kind == LUINodeKind.ListItem; + kind == LUINodeKind.ListItem || + kind == LUINodeKind.Link; case LUIProperty.Selected: return kind == LUINodeKind.Button || kind == LUINodeKind.ToggleButton || @@ -614,6 +622,7 @@ public static bool CommonPropertySupported( kind == LUINodeKind.ListItem || kind == LUINodeKind.TableCell || kind == LUINodeKind.BottomTab || + kind == LUINodeKind.FileImage || TreeRowKind(kind); case LUIProperty.SubmitEnabled: return kind == LUINodeKind.Combobox || @@ -631,6 +640,13 @@ public static bool CommonPropertySupported( kind == LUINodeKind.Image; case LUIProperty.SurfaceIdValue: return kind == LUINodeKind.MediaSurface; + case LUIProperty.PathValue: + return kind == LUINodeKind.FileImage || + kind == LUINodeKind.FilePreview; + case LUIProperty.UrlValue: + return kind == LUINodeKind.Link; + case LUIProperty.MaxPixelSize: + return kind == LUINodeKind.FileImage; case LUIProperty.AnchorValue: case LUIProperty.AnchorAlignmentValue: case LUIProperty.AnchorOffset: @@ -690,6 +706,7 @@ public static bool CommonPropertySupported( case LUINodeKind.Alert: case LUINodeKind.Bubble: case LUINodeKind.StatusBar: + case LUINodeKind.Link: return true; default: return false; @@ -715,6 +732,7 @@ public static bool CommonPropertySupported( case LUINodeKind.ListItem: case LUINodeKind.Drawer: case LUINodeKind.BottomTab: + case LUINodeKind.Link: return true; default: return false; @@ -1089,6 +1107,15 @@ public static bool PropertyValueSupported( (text.Value == "system" || text.Value == "light" || text.Value == "dark"); } + case LUIProperty.PathValue: + case LUIProperty.UrlValue: + { + return value is LUIWireValue.String; + } + case LUIProperty.MaxPixelSize: + { + return value is LUIWireValue.Int { Value: > 0 }; + } default: { return false; @@ -1292,6 +1319,17 @@ public static bool NodePropertiesSupported( { return false; } + if ((kind == LUINodeKind.FileImage || + kind == LUINodeKind.FilePreview) && + !StringPropertyNonempty(properties, LUIProperty.PathValue)) + { + return false; + } + if (kind == LUINodeKind.Link && + !StringPropertyNonempty(properties, LUIProperty.UrlValue)) + { + return false; + } if (kind == LUINodeKind.Stepper && !properties.ContainsKey(LUIProperty.ActiveIndex)) { @@ -1410,6 +1448,7 @@ public static bool CanContainChildren(LUINodeKind kind) case LUINodeKind.Bubble: case LUINodeKind.BottomTabs: case LUINodeKind.BottomTab: + case LUINodeKind.Link: return true; default: return false; diff --git a/platform/winui/LUI.Core/LUIWireSchema.g.cs b/platform/winui/LUI.Core/LUIWireSchema.g.cs index 513775f..cc7c97a 100644 --- a/platform/winui/LUI.Core/LUIWireSchema.g.cs +++ b/platform/winui/LUI.Core/LUIWireSchema.g.cs @@ -80,6 +80,9 @@ public enum LUINodeKind Toast, Toolbar, StatusBar, + Link, + FileImage, + FilePreview, } public enum LUIProperty @@ -157,6 +160,9 @@ public enum LUIProperty ResizeOrigin, ThemeValue, ThemeMode, + PathValue, + UrlValue, + MaxPixelSize, } public static class LUIWireSchema @@ -236,6 +242,9 @@ public static class LUIWireSchema { "toast", LUINodeKind.Toast }, { "toolbar", LUINodeKind.Toolbar }, { "status-bar", LUINodeKind.StatusBar }, + { "link", LUINodeKind.Link }, + { "file-image", LUINodeKind.FileImage }, + { "file-preview", LUINodeKind.FilePreview }, }; private static readonly Dictionary PropertiesByWireName = @@ -314,6 +323,9 @@ public static class LUIWireSchema { "resize-origin", LUIProperty.ResizeOrigin }, { "theme", LUIProperty.ThemeValue }, { "theme-mode", LUIProperty.ThemeMode }, + { "path", LUIProperty.PathValue }, + { "url", LUIProperty.UrlValue }, + { "max-pixel-size", LUIProperty.MaxPixelSize }, }; public static string WireName(this LUINodeKind kind) => kind switch @@ -390,6 +402,9 @@ public static class LUIWireSchema LUINodeKind.Toast => "toast", LUINodeKind.Toolbar => "toolbar", LUINodeKind.StatusBar => "status-bar", + LUINodeKind.Link => "link", + LUINodeKind.FileImage => "file-image", + LUINodeKind.FilePreview => "file-preview", _ => "unknown", }; @@ -441,7 +456,8 @@ LUINodeKind.Dialog or LUINodeKind.Drawer or LUINodeKind.Sheet or LUINodeKind.Toast or - LUINodeKind.Toolbar => true, + LUINodeKind.Toolbar or + LUINodeKind.Link => true, _ => false, }; @@ -520,6 +536,9 @@ LUINodeKind.Toast or LUIProperty.ResizeOrigin => "resize-origin", LUIProperty.ThemeValue => "theme", LUIProperty.ThemeMode => "theme-mode", + LUIProperty.PathValue => "path", + LUIProperty.UrlValue => "url", + LUIProperty.MaxPixelSize => "max-pixel-size", _ => "unknown", }; @@ -544,6 +563,7 @@ private static HashSet Set(params LUIProperty[] values) => { LUINodeKind.BottomTabs, Set(LUIProperty.AccessibilityLabel, LUIProperty.StyleClass, LUIProperty.GrowValue, LUIProperty.WidthValue, LUIProperty.HeightValue, LUIProperty.MinWidth, LUIProperty.MaxWidth, LUIProperty.MinHeight, LUIProperty.MaxHeight) }, { LUINodeKind.BottomTab, Set(LUIProperty.TitleValue, LUIProperty.InlineIconName, LUIProperty.Selected, LUIProperty.Enabled, LUIProperty.PressEnabled) }, { LUINodeKind.MenuTrigger, Set(LUIProperty.TextValue, LUIProperty.InlineIconName, LUIProperty.AccessibilityLabel, LUIProperty.Enabled, LUIProperty.ForegroundValue, LUIProperty.StyleClass) }, + { LUINodeKind.FilePreview, Set(LUIProperty.PathValue) }, }; public static readonly IReadOnlyDictionary> ExtraMatrix = diff --git a/schema/components.json b/schema/components.json index 5890108..6d83338 100644 --- a/schema/components.json +++ b/schema/components.json @@ -292,6 +292,18 @@ { "name": "input-group-actions", "status": "supported" + }, + { + "name": "link", + "status": "supported" + }, + { + "name": "file-image", + "status": "supported" + }, + { + "name": "file-preview", + "status": "supported" } ], "nodeKinds": [ @@ -798,6 +810,27 @@ "dart": "statusBar", "swift": "statusBar", "container": false + }, + { + "lg": "Link", + "wire": "link", + "dart": "link", + "swift": "link", + "container": true + }, + { + "lg": "FileImage", + "wire": "file-image", + "dart": "fileImage", + "swift": "fileImage", + "container": false + }, + { + "lg": "FilePreview", + "wire": "file-preview", + "dart": "filePreview", + "swift": "filePreview", + "container": false } ], "properties": [ @@ -1165,6 +1198,21 @@ "lg": "ThemeMode", "wire": "theme-mode", "swift": "themeMode" + }, + { + "lg": "PathValue", + "wire": "path", + "swift": "path" + }, + { + "lg": "UrlValue", + "wire": "url", + "swift": "url" + }, + { + "lg": "MaxPixelSize", + "wire": "max-pixel-size", + "swift": "maxPixelSize" } ], "events": [ @@ -1309,6 +1357,9 @@ "enabled", "foreground", "style-class" + ], + "file-preview": [ + "path" ] }, "kindExtraProperties": { diff --git a/src/lui_elements.ml b/src/lui_elements.ml index b75ee82..6c1217f 100644 --- a/src/lui_elements.ml +++ b/src/lui_elements.ml @@ -1452,6 +1452,56 @@ let media_surface ?key ?gap ?main ?cross ?grow ?columns ?padding ?padding_horizo node +let link ?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 ?url ?url_signal ?text ?text_signal ?icon ?icon_signal ?icon_placement ?label ?disabled ?disabled_signal (children : t list) : t = + fun context parent -> + let node = Lui_ui.create context Link 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 UrlValue) url; + Option.iter (Lui_ui.string_property_signal context node UrlValue) url_signal; + 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 InlineIconName) (Option.map icon_value icon); + Option.iter (fun signal_ -> Lui_ui.string_property_signal context node InlineIconName (Signal.map icon_value signal_)) icon_signal; + Option.iter (Lui_ui.string_property context node IconPlacementValue) (Option.map icon_placement_value icon_placement); + Option.iter (Lui_ui.string_property context node AccessibilityLabel) label; + Option.iter (Lui_ui.disabled context node) disabled; + Option.iter (Lui_ui.disabled_signal context node) disabled_signal; + attach context parent node; + mount_children context node children; + node + +let file_image ?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 ?path ?path_signal ?max_pixel_size ?label ?on_press (_children : nothing list) : t = + fun context parent -> + let node = Lui_ui.create context FileImage 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 PathValue) path; + Option.iter (Lui_ui.string_property_signal context node PathValue) path_signal; + Option.iter (Lui_ui.int_property context node MaxPixelSize) max_pixel_size; + Option.iter (Lui_ui.string_property context node AccessibilityLabel) label; + (match on_press with + | Some handler -> + enable context node PressEnabled; + register_press context node handler + | None -> ()); + attach context parent node; + + node + +let file_preview ?key ?path ?path_signal ?accessibility_identifier ?accessibility_identifier_signal ?on_dismiss (_children : nothing list) : t = + fun context parent -> + let node = Lui_ui.create context FilePreview in + Option.iter (Lui_ui.key context node) key; + Option.iter (Lui_ui.string_property context node PathValue) path; + Option.iter (Lui_ui.string_property_signal context node PathValue) path_signal; + Option.iter (Lui_ui.string_property context node AccessibilityIdentifier) accessibility_identifier; + Option.iter (Lui_ui.string_property_signal context node AccessibilityIdentifier) accessibility_identifier_signal; + (match on_dismiss with + | Some handler -> register_dismiss context node handler + | None -> ()); + attach context parent node; + + node + let 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 ?active ?active_signal ?label (children : step_el list) : t = fun context parent -> let node = Lui_ui.create context Stepper in diff --git a/src/lui_elements.mli b/src/lui_elements.mli index 7f63da1..4a034dc 100644 --- a/src/lui_elements.mli +++ b/src/lui_elements.mli @@ -2480,6 +2480,85 @@ val media_surface : ?on_appear:(Lui_protocol.event -> unit) -> ?surface:int -> ?surface_signal:int Signal.signal -> ?label:string -> nothing list -> t +val link : + ?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) -> + ?url:string -> + ?url_signal:string Signal.signal -> + ?text:string -> + ?text_signal:string Signal.signal -> + ?icon:icon -> + ?icon_signal:icon Signal.signal -> + ?icon_placement:icon_placement -> + ?label:string -> + ?disabled:bool -> ?disabled_signal:bool Signal.signal -> t list -> t +val file_image : + ?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) -> + ?path:string -> + ?path_signal:string Signal.signal -> + ?max_pixel_size:int -> + ?label:string -> + ?on_press:(Lui_protocol.event -> unit) -> nothing list -> t +val file_preview : + ?key:string -> + ?path:string -> + ?path_signal:string Signal.signal -> + ?accessibility_identifier:string -> + ?accessibility_identifier_signal:string Signal.signal -> + ?on_dismiss:(Lui_protocol.event -> unit) -> nothing list -> t val stepper : ?key:string -> ?gap:int -> diff --git a/src/lui_protocol.ml b/src/lui_protocol.ml index ebdfeea..dc4fec0 100644 --- a/src/lui_protocol.ml +++ b/src/lui_protocol.ml @@ -76,6 +76,9 @@ type node_kind = | Toast | Toolbar | StatusBar + | Link + | FileImage + | FilePreview type operating_system = | GenericOS @@ -173,6 +176,9 @@ type property = | ResizeOrigin | ThemeValue | ThemeMode + | PathValue + | UrlValue + | MaxPixelSize module Property_map = Map.Make @@ -305,6 +311,7 @@ let event_supported kind event = | Text | TableCell | TimelineItem + | FileImage | BottomTab -> true | _ -> false) | LongPress _ -> @@ -356,7 +363,8 @@ let event_supported kind event = | Toast | Dialog | Drawer - | Sheet -> true + | Sheet + | FilePreview -> true | _ -> false) | DoublePress _ -> kind = ListItem | Appear _ -> kind <> Root @@ -564,7 +572,8 @@ let can_contain_children kind = | Alert | Bubble | BottomTabs - | BottomTab -> true + | BottomTab + | Link -> true | _ -> false let common_property_supported kind property = @@ -596,7 +605,8 @@ let common_property_supported kind property = | Text | Heading | Paragraph | Label | Button | ToggleButton | TextField | SecureField | Input | SearchField | Textarea | Checkbox | Toggle | Radio | Slider | Spinner | Icon | Select | Combobox | DropdownMenu | MenuItem - | ListItem | TableCell | Resizable | Split | Alert | Bubble | StatusBar -> true + | ListItem | TableCell | Resizable | Split | Alert | Bubble | StatusBar + | Link | FileImage -> true | _ -> false) | WidthValue | HeightValue -> kind <> Tooltip | MinWidth | MaxWidth | MinHeight | MaxHeight -> @@ -630,6 +640,8 @@ let common_property_supported kind property = || kind = Alert || kind = Bubble || kind = ListItem + || kind = Link + || kind = FileImage || tree_row_kind kind | AccessibilityIdentifier -> true | PlaceholderValue -> @@ -659,7 +671,9 @@ let common_property_supported kind property = || kind = MenuItem || kind = ListItem || kind = BottomTab - | IconPlacementValue -> kind = Button || kind = ToggleButton || kind = ListItem + || kind = Link + | IconPlacementValue -> + kind = Button || kind = ToggleButton || kind = ListItem || kind = Link | Selected -> kind = Button || kind = ToggleButton @@ -692,6 +706,7 @@ let common_property_supported kind property = || kind = ListItem || kind = TableCell || kind = BottomTab + || kind = FileImage || tree_row_kind kind | SubmitEnabled -> kind = Combobox || kind = ListItem | DoublePressEnabled -> kind = ListItem @@ -743,7 +758,8 @@ let common_property_supported kind property = | TableCell | Alert | Bubble - | StatusBar -> true + | StatusBar + | Link -> true | _ -> false) | Enabled -> (match kind with @@ -764,8 +780,12 @@ let common_property_supported kind property = | MenuItem | ListItem | Drawer - | BottomTab -> true + | BottomTab + | Link -> true | _ -> false) + | PathValue -> kind = FileImage || kind = FilePreview + | UrlValue -> kind = Link + | MaxPixelSize -> kind = FileImage | ActiveIndex | DescriptionValue | MetaValue | IndicatorValue | Connector -> false | TitleValue -> kind = BottomTab @@ -829,6 +849,7 @@ let property_supported kind property = | InputGroupActions -> property = Gap | Dialog -> property = DescriptionValue || common_property_supported kind property + | FilePreview -> property = PathValue | _ -> common_property_supported kind property let is_finite value = @@ -917,6 +938,8 @@ let property_value_supported property value = | ResizeOrigin, FloatValue value -> is_finite value | ThemeValue, StringValue _ -> true | ThemeMode, StringValue value -> theme_mode_supported value + | PathValue, StringValue _ | UrlValue, StringValue _ -> true + | MaxPixelSize, IntValue value -> value > 0 | _ -> false let property_value_supported_for_kind kind property value = @@ -1039,6 +1062,11 @@ let node_properties_supported kind properties = || (source_x >= 0.0 && source_y >= 0.0 && source_width > 0.0 && source_height > 0.0)) else true) + && (if kind = FileImage || kind = FilePreview then + string_property_nonempty properties PathValue + else true) + && (if kind = Link then string_property_nonempty properties UrlValue + else true) && (if kind = MediaSurface then Property_map.mem SurfaceIdValue properties else true) && (if kind = Stepper then Property_map.mem ActiveIndex properties else true) diff --git a/src/lui_protocol.mli b/src/lui_protocol.mli index 7cb0311..bcb0647 100644 --- a/src/lui_protocol.mli +++ b/src/lui_protocol.mli @@ -74,6 +74,9 @@ type node_kind = | Toast | Toolbar | StatusBar + | Link + | FileImage + | FilePreview type operating_system = | GenericOS @@ -171,6 +174,9 @@ type property = | ResizeOrigin | ThemeValue | ThemeMode + | PathValue + | UrlValue + | MaxPixelSize module Property_map : Map.S with type key = property diff --git a/src/lui_ui.ml b/src/lui_ui.ml index d94e64e..e13c2c9 100644 --- a/src/lui_ui.ml +++ b/src/lui_ui.ml @@ -136,6 +136,9 @@ let list_item context = create context ListItem let avatar context = create context Avatar let image context = create context Image let media_surface context = create context MediaSurface +let link context = create context Link +let file_image context = create context FileImage +let file_preview context = create context FilePreview let stepper context = create context Stepper let step context = create context Step let timeline context = create context Timeline diff --git a/src/lui_ui.mli b/src/lui_ui.mli index 72e11fc..17182d9 100644 --- a/src/lui_ui.mli +++ b/src/lui_ui.mli @@ -92,6 +92,9 @@ val list_item : ui_context -> int val avatar : ui_context -> int val image : ui_context -> int val media_surface : ui_context -> int +val link : ui_context -> int +val file_image : ui_context -> int +val file_preview : ui_context -> int val stepper : ui_context -> int val step : ui_context -> int val timeline : ui_context -> int diff --git a/src/lui_wire_schema.ml b/src/lui_wire_schema.ml index 8185329..b99a8af 100644 --- a/src/lui_wire_schema.ml +++ b/src/lui_wire_schema.ml @@ -77,6 +77,9 @@ let node_kind_name kind = | Toast -> "toast" | Toolbar -> "toolbar" | StatusBar -> "status-bar" + | Link -> "link" + | FileImage -> "file-image" + | FilePreview -> "file-preview" let standard_node_name name = match name with @@ -152,6 +155,9 @@ let standard_node_name name = | "toast" -> true | "toolbar" -> true | "status-bar" -> true + | "link" -> true + | "file-image" -> true + | "file-preview" -> true | _ -> false let property_name property = @@ -229,6 +235,9 @@ let property_name property = | ResizeOrigin -> "resize-origin" | ThemeValue -> "theme" | ThemeMode -> "theme-mode" + | PathValue -> "path" + | UrlValue -> "url" + | MaxPixelSize -> "max-pixel-size" let kind_property_matrix kind = match kind with @@ -244,6 +253,7 @@ let kind_property_matrix kind = | BottomTabs -> Some [ AccessibilityLabel; StyleClass; GrowValue; WidthValue; HeightValue; MinWidth; MaxWidth; MinHeight; MaxHeight ] | BottomTab -> Some [ TitleValue; InlineIconName; Selected; Enabled; PressEnabled ] | MenuTrigger -> Some [ TextValue; InlineIconName; AccessibilityLabel; Enabled; ForegroundValue; StyleClass ] + | FilePreview -> Some [ PathValue ] | _ -> None let kind_extra_properties kind = @@ -251,6 +261,6 @@ let kind_extra_properties kind = | Dialog -> [ DescriptionValue ] | _ -> [] -let all_node_kinds = [ Root; Row; Column; Grid; Stack; Panel; Card; Alert; Bubble; Box; Text; Heading; Paragraph; Label; Button; ToggleButton; Toggle; RadioGroup; Radio; Slider; TextField; SecureField; Input; SearchField; Textarea; Checkbox; SwitchControl; Progress; Divider; Scroll; ListContainer; VirtualList; Tabs; BottomTabs; BottomTab; ButtonGroup; ToggleGroup; Spacer; Spinner; Icon; Select; Combobox; DropdownMenu; ContextMenu; MenuItem; MenuTrigger; ListItem; Avatar; Image; MediaSurface; Stepper; Step; Timeline; TimelineItem; InputGroup; InputGroupActions; Breadcrumb; Pagination; Accordion; Table; TableRow; TableCell; Tree; Resizable; Split; Dialog; Drawer; Sheet; Tooltip; Toast; Toolbar; StatusBar ] +let all_node_kinds = [ Root; Row; Column; Grid; Stack; Panel; Card; Alert; Bubble; Box; Text; Heading; Paragraph; Label; Button; ToggleButton; Toggle; RadioGroup; Radio; Slider; 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 ] +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 ] diff --git a/test/test_lui.ml b/test/test_lui.ml index 1cb50bb..7bc1f3f 100644 --- a/test/test_lui.ml +++ b/test/test_lui.ml @@ -962,6 +962,108 @@ let test_menu_trigger_rules () = (node_properties_supported MenuTrigger (props [ (TextValue, StringValue "More") ])) +let test_media_file_rules () = + let open Lui_protocol in + Alcotest.(check bool) "link is container" true + (can_contain_children Link); + Alcotest.(check bool) "file-image is leaf" false + (can_contain_children FileImage); + Alcotest.(check bool) "file-preview is leaf" false + (can_contain_children FilePreview); + Alcotest.(check bool) "link allows url" true + (property_supported Link UrlValue); + Alcotest.(check bool) "link allows text" true + (property_supported Link TextValue); + Alcotest.(check bool) "link allows icon" true + (property_supported Link InlineIconName); + Alcotest.(check bool) "link drops path" false + (property_supported Link PathValue); + Alcotest.(check bool) "file-image allows path" true + (property_supported FileImage PathValue); + Alcotest.(check bool) "file-image allows max-pixel-size" true + (property_supported FileImage MaxPixelSize); + Alcotest.(check bool) "file-image allows press" true + (property_supported FileImage PressEnabled); + Alcotest.(check bool) "file-image drops url" false + (property_supported FileImage UrlValue); + Alcotest.(check bool) "file-preview allows path" true + (property_supported FilePreview PathValue); + Alcotest.(check bool) "file-preview drops width" false + (property_supported FilePreview WidthValue); + Alcotest.(check bool) "file-preview keeps accessibility-identifier" true + (property_supported FilePreview AccessibilityIdentifier); + Alcotest.(check bool) "max-pixel-size positive" true + (property_value_supported MaxPixelSize (IntValue 1024)); + Alcotest.(check bool) "max-pixel-size rejects zero" false + (property_value_supported MaxPixelSize (IntValue 0)); + Alcotest.(check bool) "path accepts string" true + (property_value_supported PathValue (StringValue "/x.png")); + Alcotest.(check bool) "url accepts string" true + (property_value_supported UrlValue (StringValue "https://e.com")); + Alcotest.(check bool) "file-image press event" true + (event_supported FileImage (Press 0)); + Alcotest.(check bool) "file-preview dismiss event" true + (event_supported FilePreview (Dismiss 0)); + Alcotest.(check bool) "link drops dismiss" false + (event_supported Link (Dismiss 0)); + let props entries = List.to_seq entries |> Property_map.of_seq in + Alcotest.(check bool) "file-image needs path" false + (node_properties_supported FileImage (props [])); + Alcotest.(check bool) "file-image ok" true + (node_properties_supported FileImage + (props [ (PathValue, StringValue "/x.png") ])); + Alcotest.(check bool) "file-preview needs path" false + (node_properties_supported FilePreview (props [])); + Alcotest.(check bool) "link needs url" false + (node_properties_supported Link (props [])); + Alcotest.(check bool) "link ok" true + (node_properties_supported Link + (props [ (UrlValue, StringValue "https://e.com") ])) + +let media_view _context _model _send = + Lui_elements.column + [ + Lui_elements.link ~url:"https://example.com" ~text:"Example" []; + Lui_elements.file_image ~path:"/tmp/pic.png" ~max_pixel_size:512 []; + Lui_elements.file_preview ~path:"/tmp/doc.pdf" []; + ] + +let test_media_file_mount () = + let app = + Lui_app.create (recording_backend ()) () + (fun model _action -> model) + media_view + in + ignore (Lui_app.start app); + flush_app app; + let ops = all_ops () in + let open Lui_protocol in + let creates kind = + List.exists (function CreateNode (_, k) -> k = kind | _ -> false) ops + in + Alcotest.(check bool) "link mounted" true (creates Link); + Alcotest.(check bool) "file-image mounted" true (creates FileImage); + Alcotest.(check bool) "file-preview mounted" true (creates FilePreview); + Alcotest.(check bool) "url prop set" true + (List.exists + (function + | SetProp (_, UrlValue, StringValue "https://example.com") -> true + | _ -> false) + ops); + Alcotest.(check bool) "path prop set" true + (List.exists + (function + | SetProp (_, PathValue, StringValue "/tmp/pic.png") -> true + | _ -> false) + ops); + Alcotest.(check bool) "max-pixel-size prop set" true + (List.exists + (function + | SetProp (_, MaxPixelSize, IntValue 512) -> true + | _ -> false) + ops); + ignore (Lui_app.dispose app) + let test_property_matrix_sync () = (* property_supported's restrictive arms and additive extras must mirror schema/components.json (kindProperties / kindExtraProperties) as emitted @@ -1483,6 +1585,8 @@ let () = Alcotest.test_case "helpers" `Quick test_protocol_helpers; Alcotest.test_case "menu-trigger rules" `Quick test_menu_trigger_rules; + Alcotest.test_case "media file rules" `Quick test_media_file_rules; + Alcotest.test_case "media file mount" `Quick test_media_file_mount; Alcotest.test_case "property matrix sync" `Quick test_property_matrix_sync; ] ); From 72dada92ed03ad432692799558df87a098116924 Mon Sep 17 00:00:00 2001 From: zy C Date: Mon, 28 Sep 2026 00:32:44 -0700 Subject: [PATCH 2/2] review fixes: web link/file props, rootID-scoped previews, qt/winui event gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web: link renders as a structured (icon + content spans) so inline icon, text, and children compose; url maps to href (+target=_blank, rel=noopener), path/max-pixel-size become data-* attributes with matching remove handlers; file-image clicks emit Press when press-enabled. - apple: both filePreviewBindings filter on the presentation's rootID and only dismiss previews owned by that root — multiple LUISwiftUIRoots no longer share/dismiss one global preview. - qt: performPress admits FileImage with the same press-enabled guard; performDismiss admits FilePreview (Dismiss is advertised for it). - winui: PerformAction admits FileImage gated on PressEnabled; PerformDismiss admits FilePreview. --- .agents/skills/testing-lui-demos/SKILL.md | 15 ++++++++++++++ .../LUIAppleBackend/LUISwiftUIRoot.swift | 14 +++++++++++-- platform/qt/lib/lui_qml_backend.cpp | 5 ++++- platform/web/melange/core/lui_web_util.ml | 1 + platform/web/melange/events/lui_web_events.ml | 2 +- platform/web/melange/nodes/lui_web_nodes.ml | 10 +++++++++- platform/web/melange/render/lui_web_props.ml | 16 +++++++++++++++ platform/web/src/lui.css | 20 +++++++++++++++++++ platform/winui/LUI.Core/LUIBackend.cs | 4 ++++ 9 files changed, 82 insertions(+), 5 deletions(-) diff --git a/.agents/skills/testing-lui-demos/SKILL.md b/.agents/skills/testing-lui-demos/SKILL.md index 27f94b8..7d8d4fb 100644 --- a/.agents/skills/testing-lui-demos/SKILL.md +++ b/.agents/skills/testing-lui-demos/SKILL.md @@ -128,3 +128,18 @@ Gotchas: edge drops, but chip/strip targets are more reliable with an explicit `left_mouse_down` → several `mouse_move` steps → pause (`wait` ~0.7s, pane highlight/ghost visible) → `left_mouse_up` sequence. + +### Exercising new/ordinary element kinds end-to-end + +The split demo is the fastest vehicle even for non-split elements: scratch-edit +`examples/split/model.ml` + `view.ml` (uncommitted) so `view` returns a plain +`column` of the elements under test, add model fields/actions for any state you +need to observe (e.g. a press counter rendered via `dyn`/`text` so event +delivery is proven by an OCaml-rendered value, not just pixels), then rerun +`build-app.sh` — it rebuilds `liblui_split.dylib` and the bundle. Revert with +`git checkout` afterwards. `if_ ~test:(model_source >|= ...)` is the way to +mount/unmount presence-driven kinds (dialog/sheet/file-preview). + +QuickLook panels (`file-preview`, `.quickLookPreview`) on macOS: the panel's +close affordance is small/hover-only; **Escape** reliably closes it and drives +the Dismiss binding. Clicking the panel title bar does nothing. diff --git a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift index 37fcb19..e9b6a15 100644 --- a/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift +++ b/platform/apple/Sources/LUIAppleBackend/LUISwiftUIRoot.swift @@ -151,9 +151,14 @@ public struct LUISwiftUIRoot: View { private var filePreviewBinding: Binding { Binding( - get: { backend.filePreviewPresentation.item?.url }, + get: { + guard let item = backend.filePreviewPresentation.item, + item.rootID == rootID else { return nil } + return item.url + }, set: { url in guard url == nil, + backend.filePreviewPresentation.item?.rootID == rootID, let nodeID = backend.filePreviewPresentation .dismissFromPresentation() else { return } try? backend.performDismiss(node: nodeID) @@ -191,9 +196,14 @@ public struct LUIModalHostModifier: ViewModifier { private var filePreviewBinding: Binding { Binding( - get: { backend.filePreviewPresentation.item?.url }, + get: { + guard let item = backend.filePreviewPresentation.item, + item.rootID == rootID else { return nil } + return item.url + }, set: { url in guard url == nil, + backend.filePreviewPresentation.item?.rootID == rootID, let nodeID = backend.filePreviewPresentation .dismissFromPresentation() else { return } try? backend.performDismiss(node: nodeID) diff --git a/platform/qt/lib/lui_qml_backend.cpp b/platform/qt/lib/lui_qml_backend.cpp index 8e92d68..45fab37 100644 --- a/platform/qt/lib/lui_qml_backend.cpp +++ b/platform/qt/lib/lui_qml_backend.cpp @@ -1179,6 +1179,8 @@ bool LuiQmlBackend::performPress(qint64 node) { (state->kind == NodeKind::Column && isTrue(state->properties.value(QStringLiteral("press-enabled")))) || (state->kind == NodeKind::Text && + isTrue(state->properties.value(QStringLiteral("press-enabled")))) || + (state->kind == NodeKind::FileImage && isTrue(state->properties.value(QStringLiteral("press-enabled")))); if (!pressable || isFalse(state->properties.value(QStringLiteral("enabled")))) { return fail(QStringLiteral("node %1 is not an enabled pressable control") @@ -1240,7 +1242,8 @@ bool LuiQmlBackend::performDismiss(qint64 node) { if (state == nullptr) return staleNode(node); if (state->kind != NodeKind::Select && state->kind != NodeKind::Combobox && state->kind != NodeKind::DropdownMenu && - state->kind != NodeKind::Toast && !modalSurface(state->kind)) { + state->kind != NodeKind::Toast && + state->kind != NodeKind::FilePreview && !modalSurface(state->kind)) { return fail(QStringLiteral("node %1 is not dismissible").arg(node)); } emitEvent(node, QStringLiteral("dismiss")); diff --git a/platform/web/melange/core/lui_web_util.ml b/platform/web/melange/core/lui_web_util.ml index 4843c91..e20a817 100644 --- a/platform/web/melange/core/lui_web_util.ml +++ b/platform/web/melange/core/lui_web_util.ml @@ -149,6 +149,7 @@ let web_color_value color = let content_container kind dom_node = match kind with | Lui_protocol.DropdownMenu | Lui_protocol.Split -> child_element dom_node 0 + | Lui_protocol.Link -> child_element dom_node 1 | Lui_protocol.Alert -> child_element dom_node 1 | Lui_protocol.Bubble -> child_element dom_node 0 | Lui_protocol.Accordion -> accordion_panel_node dom_node diff --git a/platform/web/melange/events/lui_web_events.ml b/platform/web/melange/events/lui_web_events.ml index eb1b2ba..648be8e 100644 --- a/platform/web/melange/events/lui_web_events.ml +++ b/platform/web/melange/events/lui_web_events.ml @@ -422,7 +422,7 @@ let attach_events renderer node kind dom_node = if kind = Toolbar then ignore (Lui_web_focus.attach_toolbar_events renderer node dom_node); match kind with - | Text | TableCell | TimelineItem -> + | Text | TableCell | TimelineItem | FileImage -> attach_pressable_text_events renderer node dom_node | Button | ToggleButton | Toggle -> attach_button_events renderer node kind dom_node diff --git a/platform/web/melange/nodes/lui_web_nodes.ml b/platform/web/melange/nodes/lui_web_nodes.ml index 6a2e6e7..e89cba2 100644 --- a/platform/web/melange/nodes/lui_web_nodes.ml +++ b/platform/web/melange/nodes/lui_web_nodes.ml @@ -159,6 +159,14 @@ let create_select_node renderer = ("aria-expanded", "false") ] [ Util.element document "span" "lui-select-value" [] [] ] +let create_link_node renderer = + let document = renderer.web_document in + Util.element document "a" "lui-link" + [ ("data-icon-placement", "leading"); ("rel", "noopener noreferrer") ] + [ Util.element document "span" "lui-link-icon lui-icon" + [ ("aria-hidden", "true") ] []; + Util.element document "span" "lui-link-content" [] [] ] + let create_menu_item_node renderer = let document = renderer.web_document in let hidden = [ ("aria-hidden", "true") ] in @@ -262,7 +270,6 @@ let simple_node_tag kind = | Slider -> "input" | Divider -> "hr" | Tooltip -> "span" - | Link -> "a" | _ -> "div" let simple_node_attributes kind = @@ -379,6 +386,7 @@ let platform_node renderer kind = | MenuItem | MenuTrigger -> create_menu_item_node renderer | Avatar -> create_avatar_node renderer | Image | MediaSurface -> create_media_node renderer kind + | Link -> create_link_node renderer | Step -> create_step_node renderer | TimelineItem -> create_timeline_item_node renderer | BottomTabs -> create_bottom_tabs_node renderer diff --git a/platform/web/melange/render/lui_web_props.ml b/platform/web/melange/render/lui_web_props.ml index 19c5c81..1c1aaec 100644 --- a/platform/web/melange/render/lui_web_props.ml +++ b/platform/web/melange/render/lui_web_props.ml @@ -211,6 +211,8 @@ let apply_text_value renderer node kind dom_node text = W.Element.setTextContent (Util.child_element dom_node 0) (select_display_text renderer node) + | Link -> + W.Element.setTextContent (Util.child_element dom_node 1) text | TextField | SecureField | Input | SearchField | Textarea | Combobox -> set_text_control_value dom_node text | _ -> set_visible_text kind dom_node text @@ -621,6 +623,14 @@ and apply_secondary_property renderer node kind dom_node property value = W.Element.setAttribute "data-anchor-alignment" alignment dom_node | AnchorOffset, FloatValue offset -> apply_anchor_offset kind dom_node offset + | UrlValue, StringValue url -> + W.Element.setAttribute "href" url dom_node; + W.Element.setAttribute "target" "_blank" dom_node + | PathValue, StringValue path -> + W.Element.setAttribute "data-path" path dom_node + | MaxPixelSize, IntValue size -> + W.Element.setAttribute "data-max-pixel-size" (string_of_int size) + dom_node | TooltipDelay, IntValue delay -> W.Element.setAttribute "data-tooltip-delay" (string_of_int delay) dom_node @@ -690,6 +700,12 @@ let remove_property renderer node kind dom_node property = W.Element.removeAttribute "aria-label" (if Store.direct_toggle kind then Util.child_element dom_node 0 else dom_node) + | UrlValue -> + W.Element.removeAttribute "href" dom_node; + W.Element.removeAttribute "target" dom_node + | PathValue -> W.Element.removeAttribute "data-path" dom_node + | MaxPixelSize -> + W.Element.removeAttribute "data-max-pixel-size" dom_node | AccessibilityIdentifier -> W.Element.removeAttribute "id" dom_node | OrientationValue -> if kind = Tabs then begin diff --git a/platform/web/src/lui.css b/platform/web/src/lui.css index ecb4bf8..4e46abc 100644 --- a/platform/web/src/lui.css +++ b/platform/web/src/lui.css @@ -1161,6 +1161,26 @@ body { @apply order-1; } + .lui-link { + @apply inline-flex items-center gap-1 text-primary underline underline-offset-4; + } + + .lui-link-icon { + @apply pointer-events-none size-4 shrink-0; + } + + .lui-link-icon:not([data-name]) { + @apply hidden; + } + + .lui-link[data-icon-placement="trailing"] .lui-link-icon { + @apply order-1; + } + + .lui-link[data-icon-placement="top"] { + @apply flex-col; + } + .lui-button[data-variant="default"] { @apply bg-background text-foreground; } diff --git a/platform/winui/LUI.Core/LUIBackend.cs b/platform/winui/LUI.Core/LUIBackend.cs index f061a4b..5621c03 100644 --- a/platform/winui/LUI.Core/LUIBackend.cs +++ b/platform/winui/LUI.Core/LUIBackend.cs @@ -1351,6 +1351,9 @@ public void PerformAction(long node) LUISchema.TrueProperty( state.Properties, LUIProperty.PressEnabled)) || (state.Kind == LUINodeKind.Text && + LUISchema.TrueProperty( + state.Properties, LUIProperty.PressEnabled)) || + (state.Kind == LUINodeKind.FileImage && LUISchema.TrueProperty( state.Properties, LUIProperty.PressEnabled)); if (!pressable || IsDisabled(state)) @@ -1481,6 +1484,7 @@ public void PerformDismiss(long node) state.Kind != LUINodeKind.Combobox && state.Kind != LUINodeKind.DropdownMenu && state.Kind != LUINodeKind.Toast && + state.Kind != LUINodeKind.FilePreview && !LUISchema.ModalSurface(state.Kind)) { throw new LUIBackendException($"node {node} is not dismissible");