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
15 changes: 15 additions & 0 deletions .agents/skills/testing-lui-demos/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
20 changes: 18 additions & 2 deletions platform/apple/Sources/LUIAppleBackend/LUIAppleBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [:],
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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))
Expand All @@ -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)
Expand Down Expand Up @@ -952,11 +956,22 @@ public final class LUIAppleBackend {
private func syncModalPresentation() {
var presentation: LUIModalPresentation?
var nestedSheets: [Int: LUIModalPresentation] = [:]
var filePreview: LUIFilePreviewPresentation?
var visited = Set<Int>()

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,
Expand Down Expand Up @@ -994,6 +1009,7 @@ public final class LUIAppleBackend {
}
modalPresentation.nestedSheets = nestedSheets
modalPresentation.synchronize(with: presentation)
filePreviewPresentation.synchronize(with: filePreview)
}

private func invalidateAvatars(imageID: Int) {
Expand Down
81 changes: 81 additions & 0 deletions platform/apple/Sources/LUIAppleBackend/LUIFileImageLoader.swift
Original file line number Diff line number Diff line change
@@ -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 "<max-pixel-size>|<path>" in an
/// NSCache (~32MB cost limit, 32 items).
enum LUIFileImageLoader {
static let defaultMaxPixelSize = 1024

private actor Cache {
private let images = NSCache<NSString, CGImage>()

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
)
}
}
Loading
Loading