From 3e7e9b9571a6461441ff05f24e73eaea0527526d Mon Sep 17 00:00:00 2001 From: Kyle Date: Wed, 5 Aug 2026 00:11:04 +0800 Subject: [PATCH 1/3] Add DisplayList visual preview --- AGENTS.md | 1 + README.md | 2 + .../DisplayListPreviewConverter.swift | 643 ++++++++++++++++++ Sources/DisplayListWeb/PreviewRenderer.swift | 567 +++++++++++++++ Sources/DisplayListWeb/main.swift | 77 ++- .../DisplayListPreviewConverterTests.swift | 132 ++++ index.html | 53 ++ styles.css | 180 ++++- 8 files changed, 1652 insertions(+), 3 deletions(-) create mode 100644 Sources/DisplayListDescription/DisplayListPreviewConverter.swift create mode 100644 Sources/DisplayListWeb/PreviewRenderer.swift create mode 100644 Tests/DisplayListDescriptionTests/DisplayListPreviewConverterTests.swift diff --git a/AGENTS.md b/AGENTS.md index 0e9fc85..0dd3c2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,3 +16,4 @@ - Keep the preview server running after verification so the user can open it. Report the complete HTTP URL, including the `/DisplayListExplorer/` base path. - If the default preview port is occupied, reuse the existing server when it serves this worktree; otherwise choose another port and report it explicitly. +- Open the exact local URL printed by Vite (normally `http://127.0.0.1:4173/DisplayListExplorer/`) rather than a `file://` URL. diff --git a/README.md b/README.md index 7b563d3..fc8a850 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ The parser and converter are written in Swift. [JavaScriptKit](https://github.co - Reproduces the single-line formatting emitted by `SExpPrinter`. - Links source and `minimalDescription` ranges with bidirectional hover highlighting. - Provides dedicated `minimalDesc`, encoding info, and occurrence statistics tabs. +- Previews DisplayList frames on a zoomable canvas with fit and actual-size controls, a point ruler, + resolved colors, text, paths, opacity, transforms, clips, and placeholders for omitted payloads. - Highlights every matching source range when a statistics row is hovered or focused. - Copies compact, self-contained links that reopen a shared `minimalDescription`. - Runs entirely in the browser; pasted descriptions are not uploaded. diff --git a/Sources/DisplayListDescription/DisplayListPreviewConverter.swift b/Sources/DisplayListDescription/DisplayListPreviewConverter.swift new file mode 100644 index 0000000..49258ae --- /dev/null +++ b/Sources/DisplayListDescription/DisplayListPreviewConverter.swift @@ -0,0 +1,643 @@ +public struct DisplayListPreview: Equatable, Sendable { + public let items: [DisplayListPreviewItem] + public let approximations: [String] + + public init(items: [DisplayListPreviewItem], approximations: [String] = []) { + self.items = items + self.approximations = approximations + } +} + +public struct DisplayListPreviewPoint: Equatable, Sendable { + public let x: Double + public let y: Double + + public init(x: Double, y: Double) { + self.x = x + self.y = y + } + + public static let zero = DisplayListPreviewPoint(x: 0, y: 0) +} + +public struct DisplayListPreviewSize: Equatable, Sendable { + public let width: Double + public let height: Double + + public init(width: Double, height: Double) { + self.width = width + self.height = height + } + + public static let zero = DisplayListPreviewSize(width: 0, height: 0) +} + +public struct DisplayListPreviewRect: Equatable, Sendable { + public let x: Double + public let y: Double + public let width: Double + public let height: Double + + public init(x: Double, y: Double, width: Double, height: Double) { + self.x = x + self.y = y + self.width = width + self.height = height + } + + public static let zero = DisplayListPreviewRect(x: 0, y: 0, width: 0, height: 0) +} + +public struct DisplayListPreviewTransform: Equatable, Sendable { + public let a: Double + public let b: Double + public let c: Double + public let d: Double + public let tx: Double + public let ty: Double + + public init(a: Double, b: Double, c: Double, d: Double, tx: Double, ty: Double) { + self.a = a + self.b = b + self.c = c + self.d = d + self.tx = tx + self.ty = ty + } +} + +public enum DisplayListPreviewPathCommand: Equatable, Sendable { + case move(DisplayListPreviewPoint) + case line(DisplayListPreviewPoint) + case quad(control: DisplayListPreviewPoint, end: DisplayListPreviewPoint) + case cubic( + control1: DisplayListPreviewPoint, + control2: DisplayListPreviewPoint, + end: DisplayListPreviewPoint + ) + case close +} + +public struct DisplayListPreviewPath: Equatable, Sendable { + public let commands: [DisplayListPreviewPathCommand] + + public init(commands: [DisplayListPreviewPathCommand]) { + self.commands = commands + } +} + +public struct DisplayListPreviewShadow: Equatable, Sendable { + public let color: String + public let radius: Double + public let offset: DisplayListPreviewPoint + + public init(color: String, radius: Double, offset: DisplayListPreviewPoint) { + self.color = color + self.radius = radius + self.offset = offset + } +} + +public indirect enum DisplayListPreviewContent: Equatable, Sendable { + case color(String) + case text(String, size: DisplayListPreviewSize) + case image + case shape(path: DisplayListPreviewPath, color: String?, evenOdd: Bool) + case shadow(path: DisplayListPreviewPath, style: DisplayListPreviewShadow) + case placeholder(String) + case flattened(origin: DisplayListPreviewPoint, items: [DisplayListPreviewItem]) +} + +public enum DisplayListPreviewEffect: Equatable, Sendable { + case identity + case opacity(Double) + case blendMode(String) + case clip(path: DisplayListPreviewPath, evenOdd: Bool) + case transform(DisplayListPreviewTransform) + case filter(String) +} + +public indirect enum DisplayListPreviewItemValue: Equatable, Sendable { + case empty + case content(DisplayListPreviewContent) + case effect(DisplayListPreviewEffect, children: [DisplayListPreviewItem]) + case states(children: [DisplayListPreviewItem]) +} + +public struct DisplayListPreviewItem: Equatable, Sendable { + public let identity: String + public let frame: DisplayListPreviewRect + public let value: DisplayListPreviewItemValue + + public init(identity: String, frame: DisplayListPreviewRect, value: DisplayListPreviewItemValue) { + self.identity = identity + self.frame = frame + self.value = value + } +} + +public enum DisplayListPreviewConverter { + public static func convert(_ source: String) throws -> DisplayListPreview { + guard source.contains(where: { !$0.isWhitespace }) else { + throw DisplayListDescriptionError.emptyInput + } + + var lexer = Lexer(source: source) + let tokens = try lexer.lex() + var parser = Parser(tokens: tokens) + let expression = try parser.parse() + var builder = DisplayListPreviewBuilder() + return try builder.render(expression) + } +} + +private struct DisplayListPreviewBuilder { + private var approximations: [String] = [] + + mutating func render(_ expression: SExpression) throws -> DisplayListPreview { + guard expression.head == "display-list" else { + throw DisplayListDescriptionError.expectedDisplayList(actual: expression.summary) + } + + let items = renderItems(directLists(in: expression, headed: "item")) + return DisplayListPreview(items: items, approximations: approximations) + } + + private mutating func renderItem(_ item: SExpression) -> DisplayListPreviewItem { + let identity = atom(after: "#:identity", in: item) ?? "0" + let frame = directLists(in: item, headed: "frame").first.flatMap(parseRect) ?? .zero + let children = directLists(in: item) + + let value: DisplayListPreviewItemValue + if children.contains(where: { $0.head == "content-seed" }) { + let content = children.first { + $0.head != "frame" && $0.head != "content-seed" + } + value = content.flatMap { renderContent($0) }.map(DisplayListPreviewItemValue.content) ?? .empty + } else if let effect = children.first(where: { $0.head == "effect" }) { + let nestedItems = renderItems(directLists(in: effect, headed: "item")) + value = .effect(renderEffect(effect), children: nestedItems) + } else if let states = children.first(where: { $0.head == "states" }) { + let variants = directLists(in: states, headed: "state") + if variants.count > 1 { + note("State variants cannot be selected from a static description; the first variant is shown.") + } + let nestedItems = variants.first.map { renderItems(directLists(in: $0, headed: "item")) } ?? [] + value = .states(children: nestedItems) + } else { + value = .empty + } + + return DisplayListPreviewItem(identity: identity, frame: frame, value: value) + } + + private mutating func renderContent(_ content: SExpression) -> DisplayListPreviewContent? { + switch content.head { + case "color": + guard let color = firstColor(in: content) else { + note("A color value could not be decoded and was left empty.") + return nil + } + return .color(color) + case "chameleon-color": + guard let color = directLists(in: content, headed: "color").first.flatMap(firstColor) else { + note("A chameleon color without a readable fallback was left empty.") + return nil + } + note("Chameleon colors use their fallback color; runtime filters are not available in the description.") + return .color(color) + case "backdrop": + if let color = directLists(in: content, headed: "color").first.flatMap(firstColor) { + note("Backdrop content uses its fallback color; sampled backdrop pixels are unavailable.") + return .color(color) + } + note("Backdrop content could not be reproduced and was left empty.") + return nil + case "text": + note("Text uses the recorded size to infer a macOS system font point size; its color is approximate.") + let quoted = directAtoms(in: content).dropFirst().first ?? "\"\"" + let recordedSize = point(after: "#:size", in: content).map { + DisplayListPreviewSize(width: $0.x, height: $0.y) + } ?? .zero + return .text(unquote(quoted), size: recordedSize) + case "image": + note("Image pixels are omitted from DisplayList.description, so a placeholder is shown.") + return .image + case "shape": + guard let pathExpression = directLists(in: content, headed: "path").first, + let path = parsePath(pathExpression) else { + note("A shape path could not be decoded and was left empty.") + return nil + } + let paint = directLists(in: content, headed: "paint").first.flatMap(firstColor) + if paint == nil { + note("Shape paint details are omitted from the description; a neutral preview color is used.") + } + return .shape(path: path, color: paint, evenOdd: fillIsEvenOdd(in: content)) + case "shadow": + guard let pathExpression = directLists(in: content, headed: "path").first, + let path = parsePath(pathExpression) else { + note("A shadow path could not be decoded and was left empty.") + return nil + } + let shadowExpression = directLists(in: content, headed: "shadow").first + let style = shadowExpression.map(parseShadow) ?? DisplayListPreviewShadow( + color: "#00000066", + radius: 4, + offset: .zero + ) + note("Shadows are approximated with the browser canvas shadow model.") + return .shadow(path: path, style: style) + case "flattened": + let origin = point(after: "#:origin", in: content) ?? .zero + return .flattened( + origin: origin, + items: renderItems(directLists(in: content, headed: "item")) + ) + case "platform-view": + note("Platform views are represented by labeled placeholders.") + return .placeholder("Platform view") + case "platform-layer": + note("Platform layers are represented by labeled placeholders.") + return .placeholder("Platform layer") + case "drawing": + note("Drawing payloads are omitted from the description and use a placeholder.") + return .placeholder("Drawing") + case "view": + note("View factory payloads cannot be reconstructed and use a placeholder.") + return .placeholder("View · \(atom(after: "#:type", in: content) ?? "unknown")") + case "placeholder": + return .placeholder("Placeholder · \(directAtoms(in: content).dropFirst().first ?? "unknown")") + default: + note("Unsupported content “\(content.head ?? "unknown")” was left empty.") + return nil + } + } + + private mutating func renderEffect(_ effect: SExpression) -> DisplayListPreviewEffect { + if let opacity = number(after: "#:opacity", in: effect) { + return .opacity(max(0, min(1, opacity))) + } + + if let blend = atom(after: "#:blend-mode", in: effect) { + if let mode = canvasBlendMode(from: blend, atoms: allAtoms(in: effect)) { + return .blendMode(mode) + } + note("An unknown blend mode was ignored.") + return .identity + } + + if let clip = directLists(in: effect, headed: "clip").first, + let pathExpression = directLists(in: clip, headed: "path").first, + let path = parsePath(pathExpression) { + if allAtoms(in: clip).contains("ClipOptions") || allAtoms(in: clip).contains(where: { $0.contains("rawValue:") }) { + note("Inverse clip options are not reproduced in the browser preview.") + } + return .clip(path: path, evenOdd: fillIsEvenOdd(in: clip)) + } + + if let transform = directLists(in: effect, headed: "transform").first, + let affine = parseTransform(transform) { + return .transform(affine) + } + + if let filter = directLists(in: effect, headed: "filter").first { + if let cssFilter = parseFilter(filter) { + note("Graphics filters are approximated with browser canvas filters.") + return .filter(cssFilter) + } + note("This graphics filter cannot be reproduced and was ignored.") + } + + if directLists(in: effect, headed: "mask").first != nil { + note("Display-list masks cannot be reconstructed from the static preview and were ignored.") + } + return .identity + } + + private func parseRect(_ expression: SExpression) -> DisplayListPreviewRect? { + let values = allAtoms(in: expression).dropFirst().compactMap(parseNumber) + guard values.count >= 4 else { return nil } + return DisplayListPreviewRect(x: values[0], y: values[1], width: values[2], height: values[3]) + } + + private func parseTransform(_ expression: SExpression) -> DisplayListPreviewTransform? { + let atoms = allAtoms(in: expression) + guard let a = namedNumber("a:", in: atoms), + let b = namedNumber("b:", in: atoms), + let c = namedNumber("c:", in: atoms), + let d = namedNumber("d:", in: atoms), + let tx = namedNumber("tx:", in: atoms), + let ty = namedNumber("ty:", in: atoms) else { + return nil + } + return DisplayListPreviewTransform(a: a, b: b, c: c, d: d, tx: tx, ty: ty) + } + + private func parseFilter(_ expression: SExpression) -> String? { + guard let value = directLists(in: expression).first else { return nil } + let amount = directAtoms(in: value).dropFirst().compactMap(parseNumber).first + switch value.head { + case "blur", "variable-blur": + return "blur(\(number(after: "#:radius", in: value) ?? 0)px)" + case "saturation": + return "saturate(\(amount ?? 1))" + case "brightness": + return "brightness(\(max(0, 1 + (amount ?? 0))))" + case "contrast": + return "contrast(\(max(0, amount ?? 1)))" + case "grayscale": + return "grayscale(\(max(0, min(1, amount ?? 1))))" + case "hue-rotation": + let degrees = directAtoms(in: value).dropFirst().first.flatMap(parseNumber) ?? 0 + return "hue-rotate(\(degrees)deg)" + case "color-invert": + return "invert(1)" + case "shadow": + let atoms = allAtoms(in: value) + let offset = namedPoint("offset", in: atoms) ?? .zero + let radius = namedNumber("radius", in: atoms) ?? 0 + let color = atoms.compactMap(normalizedColor).first ?? "#00000066" + return "drop-shadow(\(offset.x)px \(offset.y)px \(radius)px \(color))" + default: + return nil + } + } + + private func parseShadow(_ expression: SExpression) -> DisplayListPreviewShadow { + let atoms = allAtoms(in: expression) + let color = atoms.compactMap(normalizedColor).first ?? "#00000066" + let radius = namedNumber("radius:", in: atoms) ?? 4 + let offset = namedPoint("offset:", in: atoms) ?? .zero + return DisplayListPreviewShadow(color: color, radius: radius, offset: offset) + } + + private func fillIsEvenOdd(in expression: SExpression) -> Bool { + let atoms = allAtoms(in: expression) + guard let index = atoms.firstIndex(where: { $0 == "isEOFilled:" || $0.hasPrefix("isEOFilled:") }) else { + return false + } + if atoms[index] != "isEOFilled:" { + return atoms[index].dropFirst("isEOFilled:".count).hasPrefix("true") + } + return atoms.indices.contains(index + 1) && atoms[index + 1].hasPrefix("true") + } + + private func parsePath(_ expression: SExpression) -> DisplayListPreviewPath? { + var commands: [DisplayListPreviewPathCommand] = [] + var numbers: [Double] = [] + var current = DisplayListPreviewPoint.zero + var lastControl = DisplayListPreviewPoint.zero + + func point(_ xIndex: Int, _ yIndex: Int) -> DisplayListPreviewPoint { + DisplayListPreviewPoint(x: numbers[xIndex], y: numbers[yIndex]) + } + + for atom in directAtoms(in: expression).dropFirst() { + if let number = parseNumber(atom) { + numbers.append(number) + guard numbers.count <= 6 else { return nil } + continue + } + + switch atom { + case "m" where numbers.count == 2: + current = point(0, 1) + lastControl = current + commands.append(.move(current)) + case "l" where numbers.count == 2: + current = point(0, 1) + lastControl = current + commands.append(.line(current)) + case "q" where numbers.count == 4: + let control = point(0, 1) + current = point(2, 3) + lastControl = control + commands.append(.quad(control: control, end: current)) + case "c" where numbers.count == 6: + let control1 = point(0, 1) + let control2 = point(2, 3) + current = point(4, 5) + lastControl = control2 + commands.append(.cubic(control1: control1, control2: control2, end: current)) + case "t" where numbers.count == 2: + let control = DisplayListPreviewPoint( + x: current.x * 2 - lastControl.x, + y: current.y * 2 - lastControl.y + ) + current = point(0, 1) + lastControl = control + commands.append(.quad(control: control, end: current)) + case "v" where numbers.count == 4: + let control1 = current + let control2 = point(0, 1) + current = point(2, 3) + lastControl = control2 + commands.append(.cubic(control1: control1, control2: control2, end: current)) + case "y" where numbers.count == 4: + let control1 = point(0, 1) + current = point(2, 3) + lastControl = current + commands.append(.cubic(control1: control1, control2: current, end: current)) + case "re" where numbers.count == 4: + let x = numbers[0] + let y = numbers[1] + let width = numbers[2] + let height = numbers[3] + commands += [ + .move(DisplayListPreviewPoint(x: x, y: y)), + .line(DisplayListPreviewPoint(x: x + width, y: y)), + .line(DisplayListPreviewPoint(x: x + width, y: y + height)), + .line(DisplayListPreviewPoint(x: x, y: y + height)), + .close, + ] + current = DisplayListPreviewPoint(x: x, y: y) + lastControl = current + case "h" where numbers.isEmpty: + commands.append(.close) + lastControl = .zero + default: + return nil + } + numbers.removeAll(keepingCapacity: true) + } + + guard numbers.isEmpty, !commands.isEmpty else { return nil } + return DisplayListPreviewPath(commands: commands) + } + + private func canvasBlendMode(from value: String, atoms: [String]) -> String? { + let namedModes: [(String, String)] = [ + ("multiply", "multiply"), ("screen", "screen"), ("overlay", "overlay"), + ("darken", "darken"), ("lighten", "lighten"), ("colorDodge", "color-dodge"), + ("colorBurn", "color-burn"), ("softLight", "soft-light"), + ("hardLight", "hard-light"), ("difference", "difference"), + ("exclusion", "exclusion"), ("hue", "hue"), ("saturation", "saturation"), + ("luminosity", "luminosity"), ("sourceIn", "source-in"), + ("sourceOut", "source-out"), ("sourceAtop", "source-atop"), + ("destinationOver", "destination-over"), ("destinationIn", "destination-in"), + ("destinationOut", "destination-out"), ("destinationAtop", "destination-atop"), + ("xor", "xor"), ("plusLighter", "lighter"), ("normal", "source-over"), + ] + let joined = ([value] + atoms).joined(separator: " ") + if let match = namedModes.first(where: { joined.contains($0.0) }) { + return match.1 + } + + guard let rawValue = namedNumber("rawValue:", in: atoms).map(Int.init) else { + return nil + } + let rawModes = [ + 0: "source-over", 1: "multiply", 2: "screen", 3: "overlay", 4: "darken", + 5: "lighten", 6: "color-dodge", 7: "color-burn", 8: "soft-light", + 9: "hard-light", 10: "difference", 11: "exclusion", 12: "hue", + 13: "saturation", 14: "color", 15: "luminosity", 18: "source-in", + 19: "source-out", 20: "source-atop", 21: "destination-over", + 22: "destination-in", 23: "destination-out", 24: "destination-atop", + 25: "xor", 27: "lighter", + ] + return rawModes[rawValue] + } + + private mutating func note(_ text: String) { + if !approximations.contains(text) { + approximations.append(text) + } + } + + private mutating func renderItems(_ expressions: [SExpression]) -> [DisplayListPreviewItem] { + var items: [DisplayListPreviewItem] = [] + items.reserveCapacity(expressions.count) + for expression in expressions { + items.append(renderItem(expression)) + } + return items + } + + private func directLists(in expression: SExpression, headed head: String? = nil) -> [SExpression] { + guard let elements = expression.elements else { return [] } + return elements.dropFirst().filter { + guard $0.elements != nil else { return false } + return head == nil || $0.head == head + } + } + + private func directAtoms(in expression: SExpression) -> [String] { + guard let elements = expression.elements else { return [] } + return elements.compactMap(\.atom) + } + + private func allAtoms(in expression: SExpression) -> [String] { + switch expression { + case let .atom(value, _): + return [value] + case let .list(elements, _): + return elements.flatMap(allAtoms) + } + } + + private func atom(after keyword: String, in expression: SExpression) -> String? { + let atoms = directAtoms(in: expression) + guard let index = atoms.firstIndex(of: keyword), atoms.indices.contains(index + 1) else { + return nil + } + return atoms[index + 1] + } + + private func number(after keyword: String, in expression: SExpression) -> Double? { + atom(after: keyword, in: expression).flatMap(parseNumber) + } + + private func point(after keyword: String, in expression: SExpression) -> DisplayListPreviewPoint? { + guard let elements = expression.elements, + let index = elements.firstIndex(where: { $0.atom == keyword }), + elements.indices.contains(index + 1) else { + return nil + } + let values = allAtoms(in: elements[index + 1]).compactMap(parseNumber) + guard values.count >= 2 else { return nil } + return DisplayListPreviewPoint(x: values[0], y: values[1]) + } + + private func namedNumber(_ name: String, in atoms: [String]) -> Double? { + guard let index = atoms.firstIndex(where: { $0 == name || $0.hasPrefix(name) }) else { + return nil + } + let atom = atoms[index] + if atom.count > name.count, let value = parseNumber(String(atom.dropFirst(name.count))) { + return value + } + guard atoms.indices.contains(index + 1) else { return nil } + return parseNumber(atoms[index + 1]) + } + + private func namedPoint(_ name: String, in atoms: [String]) -> DisplayListPreviewPoint? { + guard let index = atoms.firstIndex(where: { $0 == name || $0.hasPrefix(name) }) else { + return nil + } + var values: [Double] = [] + if atoms[index].count > name.count, + let value = parseNumber(String(atoms[index].dropFirst(name.count))) { + values.append(value) + } + var next = index + 1 + while next < atoms.count, values.count < 2 { + if let value = parseNumber(atoms[next]) { + values.append(value) + } + next += 1 + } + guard values.count == 2 else { return nil } + return DisplayListPreviewPoint(x: values[0], y: values[1]) + } + + private func parseNumber(_ token: String) -> Double? { + var value = token + while let first = value.first, first == "(" || first == "[" { + value.removeFirst() + } + while let last = value.last, + last == "," || last == ";" || last == ")" || last == "]" { + value.removeLast() + } + if value.hasSuffix("deg") { + value.removeLast(3) + } + return Double(value) + } + + private func firstColor(in expression: SExpression) -> String? { + allAtoms(in: expression).compactMap(normalizedColor).first + } + + private func normalizedColor(_ atom: String) -> String? { + let characters = Array(atom) + guard characters.count >= 9, characters[0] == "#" else { return nil } + let digits = characters[1...8] + guard digits.allSatisfy({ $0.isHexDigit }) else { return nil } + return "#" + String(digits) + } + + private func unquote(_ atom: String) -> String { + guard atom.count >= 2, atom.first == "\"", atom.last == "\"" else { return atom } + var result = "" + var escaped = false + for character in atom.dropFirst().dropLast() { + if escaped { + switch character { + case "n": result.append("\n") + case "r": result.append("\r") + case "t": result.append("\t") + default: result.append(character) + } + escaped = false + } else if character == "\\" { + escaped = true + } else { + result.append(character) + } + } + if escaped { result.append("\\") } + return result + } +} diff --git a/Sources/DisplayListWeb/PreviewRenderer.swift b/Sources/DisplayListWeb/PreviewRenderer.swift new file mode 100644 index 0000000..8a45c44 --- /dev/null +++ b/Sources/DisplayListWeb/PreviewRenderer.swift @@ -0,0 +1,567 @@ +import DisplayListDescription +import JavaScriptKit + +final class DisplayListCanvasRenderer { + private let surface: JSObject + private let canvas: JSObject + private let emptyState: JSObject + private let summary: JSObject + private let zoomOutButton: JSObject + private let zoomValueButton: JSObject + private let zoomInButton: JSObject + private let fitButton: JSObject + private let scaleIndicator: JSObject + private let scaleLabel: JSObject + private let scaleRule: JSObject + private var preview: DisplayListPreview? + private var manualScale: Double? + private var renderedScale: Double? + + init( + surface: JSObject, + canvas: JSObject, + emptyState: JSObject, + summary: JSObject, + zoomOutButton: JSObject, + zoomValueButton: JSObject, + zoomInButton: JSObject, + fitButton: JSObject, + scaleIndicator: JSObject, + scaleLabel: JSObject, + scaleRule: JSObject + ) { + self.surface = surface + self.canvas = canvas + self.emptyState = emptyState + self.summary = summary + self.zoomOutButton = zoomOutButton + self.zoomValueButton = zoomValueButton + self.zoomInButton = zoomInButton + self.fitButton = fitButton + self.scaleIndicator = scaleIndicator + self.scaleLabel = scaleLabel + self.scaleRule = scaleRule + updateScaleInterface() + } + + func show(_ preview: DisplayListPreview) { + self.preview = preview + let layers = layerCount(in: preview.items) + let approximationCount = preview.approximations.count + summary.textContent = .string( + approximationCount == 0 + ? "\(layers) visible layers" + : "\(layers) visible layers · \(approximationCount) approximation types" + ) + emptyState.hidden = .boolean(true) + redraw() + } + + func showEmpty(_ message: String, summary summaryText: String = "No preview") { + preview = nil + summary.textContent = .string(summaryText) + emptyState.textContent = .string(message) + emptyState.hidden = .boolean(false) + renderedScale = nil + updateScaleInterface() + clearCanvas() + } + + func zoomIn() { + zoom(by: 1.41421356237) + } + + func zoomOut() { + zoom(by: 1 / 1.41421356237) + } + + func showActualSize() { + guard preview != nil else { return } + manualScale = 1 + redraw() + } + + func fitToSurface() { + guard preview != nil else { return } + manualScale = nil + redraw() + } + + func redraw() { + guard let preview, + let context = canvas.getContext!("2d").object, + let bounds = bounds(of: preview.items, origin: .zero) else { + if preview != nil { + showEmpty( + "This DisplayList has no renderable frames.", + summary: "No visible layers" + ) + } + return + } + + let width = Double(surface.clientWidth.number ?? 0) + let height = Double(surface.clientHeight.number ?? 0) + guard width > 0, height > 0 else { return } + + let deviceScale = max(1, min(3, JSObject.global.window.devicePixelRatio.number ?? 1)) + canvas.width = .number((width * deviceScale).rounded()) + canvas.height = .number((height * deviceScale).rounded()) + + _ = context.setTransform!(deviceScale, 0, 0, deviceScale, 0, 0) + _ = context.clearRect!(0, 0, width, height) + + let viewportPadding = 32.0 + let availableWidth = max(1, width - viewportPadding * 2) + let availableHeight = max(1, height - viewportPadding * 2) + let fitScale = max( + 0.01, + min(4, min(availableWidth / max(bounds.width, 1), availableHeight / max(bounds.height, 1))) + ) + let scale = min(16, max(0.01, manualScale ?? fitScale)) + renderedScale = scale + updateScaleInterface() + let stageWidth = bounds.width * scale + let stageHeight = bounds.height * scale + let offsetX = (width - stageWidth) * 0.5 - bounds.minX * scale + let offsetY = (height - stageHeight) * 0.5 - bounds.minY * scale + + _ = context.save!() + _ = context.transform!(scale, 0, 0, scale, offsetX, offsetY) + + context.shadowColor = .string("rgba(23, 32, 51, 0.16)") + context.shadowBlur = .number(18 / scale) + context.shadowOffsetY = .number(7 / scale) + context.fillStyle = .string("#ffffff") + _ = context.fillRect!(bounds.minX, bounds.minY, bounds.width, bounds.height) + context.shadowColor = .string("transparent") + context.shadowBlur = .number(0) + context.shadowOffsetY = .number(0) + + draw(preview.items, in: context) + _ = context.restore!() + } + + private func zoom(by factor: Double) { + guard preview != nil, let renderedScale else { return } + manualScale = min(16, max(0.01, renderedScale * factor)) + redraw() + } + + private func updateScaleInterface() { + let hasPreview = renderedScale != nil + zoomOutButton.disabled = .boolean(!hasPreview || (renderedScale ?? 0) <= 0.010001) + zoomValueButton.disabled = .boolean(!hasPreview) + zoomInButton.disabled = .boolean(!hasPreview || (renderedScale ?? 0) >= 15.999) + fitButton.disabled = .boolean(!hasPreview) + + let isFitted = manualScale == nil + fitButton.ariaPressed = .string(isFitted ? "true" : "false") + _ = fitButton.classList.toggle("is-active", isFitted) + + guard let renderedScale else { + zoomValueButton.textContent = "—" + zoomValueButton.ariaLabel = "Preview scale unavailable" + scaleIndicator.hidden = .boolean(true) + return + } + + let percentage = formattedPercentage(renderedScale) + zoomValueButton.textContent = .string("\(percentage)%") + zoomValueButton.ariaLabel = .string( + "Preview scale \(percentage) percent. Reset to 100 percent" + ) + + let measurement = scaleMeasurement(for: renderedScale) + let label = formattedPoints(measurement.points) + scaleLabel.textContent = .string("\(label) pt") + scaleRule.style.width = .string("\(measurement.pixelWidth)px") + scaleIndicator.ariaLabel = .string( + "Scale ruler: \(label) DisplayList points" + ) + scaleIndicator.hidden = .boolean(false) + } + + private func formattedPercentage(_ scale: Double) -> String { + let percentage = scale * 100 + if percentage >= 10 { + return String(Int(percentage.rounded())) + } + let rounded = (percentage * 10).rounded() / 10 + return rounded == rounded.rounded() + ? String(Int(rounded)) + : String(rounded) + } + + private func formattedPoints(_ points: Double) -> String { + points == points.rounded() ? String(Int(points)) : String(points) + } + + private func scaleMeasurement(for scale: Double) -> (points: Double, pixelWidth: Double) { + let candidates = [ + 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500, + 1_000, 2_000, 5_000, 10_000, 20_000, 50_000, 100_000, + ] + let targetWidth = 72.0 + let points = candidates.min { + abs($0 * scale - targetWidth) < abs($1 * scale - targetWidth) + } ?? 100 + return (points, points * scale) + } + + private func clearCanvas() { + guard let context = canvas.getContext!("2d").object else { return } + let width = Double(surface.clientWidth.number ?? 0) + let height = Double(surface.clientHeight.number ?? 0) + let deviceScale = max(1, min(3, JSObject.global.window.devicePixelRatio.number ?? 1)) + canvas.width = .number((width * deviceScale).rounded()) + canvas.height = .number((height * deviceScale).rounded()) + _ = context.setTransform!(deviceScale, 0, 0, deviceScale, 0, 0) + _ = context.clearRect!(0, 0, width, height) + } + + private func draw(_ items: [DisplayListPreviewItem], in context: JSObject) { + for item in items { + _ = context.save!() + _ = context.translate!(item.frame.x, item.frame.y) + + switch item.value { + case .empty: + break + case let .content(content): + draw(content, frame: item.frame, in: context) + case let .effect(effect, children): + apply(effect, in: context) + draw(children, in: context) + case let .states(children): + draw(children, in: context) + } + + _ = context.restore!() + } + } + + private func draw( + _ content: DisplayListPreviewContent, + frame: DisplayListPreviewRect, + in context: JSObject + ) { + let width = max(0, frame.width) + let height = max(0, frame.height) + guard width > 0, height > 0 || isFlattened(content) else { return } + + switch content { + case let .color(color): + context.fillStyle = .string(color) + _ = context.fillRect!(0, 0, width, height) + case let .text(text, recordedSize): + _ = context.save!() + _ = context.beginPath!() + _ = context.rect!(0, 0, width, height) + _ = context.clip!() + let textWidth = recordedSize.width > 0 ? recordedSize.width : width + let textHeight = recordedSize.height > 0 ? recordedSize.height : height + let fontSize = inferredSystemFontPointSize( + for: text, + width: textWidth, + height: textHeight, + in: context + ) + context.fillStyle = .string("#172033") + context.font = .string("400 \(fontSize)px -apple-system, BlinkMacSystemFont, sans-serif") + context.textAlign = .string("center") + context.textBaseline = .string("middle") + _ = context.fillText!(text, width * 0.5, height * 0.5, width) + _ = context.restore!() + case .image: + drawImagePlaceholder(width: width, height: height, in: context) + case let .shape(path, color, evenOdd): + _ = context.save!() + _ = context.beginPath!() + _ = context.rect!(0, 0, width, height) + _ = context.clip!() + trace(path, in: context) + context.fillStyle = .string(color ?? "#635bff") + _ = context.fill!(evenOdd ? "evenodd" : "nonzero") + _ = context.restore!() + case let .shadow(path, style): + _ = context.save!() + _ = context.beginPath!() + _ = context.rect!(-style.radius * 3, -style.radius * 3, width + style.radius * 6, height + style.radius * 6) + _ = context.clip!() + trace(path, in: context) + context.fillStyle = .string("rgba(0, 0, 0, 0.02)") + context.shadowColor = .string(style.color) + context.shadowBlur = .number(style.radius) + context.shadowOffsetX = .number(style.offset.x) + context.shadowOffsetY = .number(style.offset.y) + _ = context.fill!() + _ = context.restore!() + case let .placeholder(label): + drawPlaceholder(label, width: width, height: height, in: context) + case let .flattened(origin, items): + _ = context.save!() + _ = context.translate!(origin.x, origin.y) + draw(items, in: context) + _ = context.restore!() + } + } + + private func apply(_ effect: DisplayListPreviewEffect, in context: JSObject) { + switch effect { + case .identity: + break + case let .opacity(opacity): + let current = context.globalAlpha.number ?? 1 + context.globalAlpha = .number(current * opacity) + case let .blendMode(mode): + context.globalCompositeOperation = .string(mode) + case let .clip(path, evenOdd): + trace(path, in: context) + _ = context.clip!(evenOdd ? "evenodd" : "nonzero") + case let .transform(transform): + _ = context.transform!( + transform.a, + transform.b, + transform.c, + transform.d, + transform.tx, + transform.ty + ) + case let .filter(filter): + context.filter = .string(filter) + } + } + + private func trace(_ path: DisplayListPreviewPath, in context: JSObject) { + _ = context.beginPath!() + for command in path.commands { + switch command { + case let .move(point): + _ = context.moveTo!(point.x, point.y) + case let .line(point): + _ = context.lineTo!(point.x, point.y) + case let .quad(control, end): + _ = context.quadraticCurveTo!(control.x, control.y, end.x, end.y) + case let .cubic(control1, control2, end): + _ = context.bezierCurveTo!( + control1.x, + control1.y, + control2.x, + control2.y, + end.x, + end.y + ) + case .close: + _ = context.closePath!() + } + } + } + + private func drawImagePlaceholder(width: Double, height: Double, in context: JSObject) { + drawPlaceholder("Image", width: width, height: height, in: context, showLabel: false) + + let inset = max(3, min(width, height) * 0.14) + let iconWidth = max(0, width - inset * 2) + let iconHeight = max(0, height - inset * 2) + guard iconWidth >= 12, iconHeight >= 12 else { return } + + context.strokeStyle = .string("#778399") + context.lineWidth = .number(max(1, min(width, height) * 0.025)) + _ = context.strokeRect!(inset, inset, iconWidth, iconHeight) + + _ = context.beginPath!() + _ = context.moveTo!(inset + iconWidth * 0.1, inset + iconHeight * 0.82) + _ = context.lineTo!(inset + iconWidth * 0.38, inset + iconHeight * 0.5) + _ = context.lineTo!(inset + iconWidth * 0.55, inset + iconHeight * 0.68) + _ = context.lineTo!(inset + iconWidth * 0.72, inset + iconHeight * 0.42) + _ = context.lineTo!(inset + iconWidth * 0.92, inset + iconHeight * 0.82) + _ = context.stroke!() + + _ = context.beginPath!() + _ = context.arc!( + inset + iconWidth * 0.72, + inset + iconHeight * 0.25, + max(1.5, min(iconWidth, iconHeight) * 0.075), + 0, + Double.pi * 2 + ) + context.fillStyle = .string("#778399") + _ = context.fill!() + } + + private func inferredSystemFontPointSize( + for text: String, + width: Double, + height: Double, + in context: JSObject + ) -> Double { + guard !text.isEmpty, width > 0, height > 0 else { return 13 } + + // Canvas logical pixels correspond to DisplayList layout points. Fit the + // recorded text size using the default macOS system font and its usual + // line-height ratio, rather than treating the item height as the font size. + var lowerBound = 4.0 + var upperBound = max(lowerBound, min(96, height / 1.18)) + for _ in 0..<12 { + let candidate = (lowerBound + upperBound) * 0.5 + context.font = .string("400 \(candidate)px -apple-system, BlinkMacSystemFont, sans-serif") + let measuredWidth = context.measureText!(text).object?.width.number ?? .infinity + if measuredWidth <= width { + lowerBound = candidate + } else { + upperBound = candidate + } + } + return lowerBound + } + + private func drawPlaceholder( + _ label: String, + width: Double, + height: Double, + in context: JSObject, + showLabel: Bool = true + ) { + context.fillStyle = .string("#eef1f5") + _ = context.fillRect!(0, 0, width, height) + + _ = context.save!() + _ = context.beginPath!() + _ = context.rect!(0, 0, width, height) + _ = context.clip!() + context.strokeStyle = .string("rgba(105, 115, 134, 0.16)") + context.lineWidth = .number(1) + let spacing = max(10, min(width, height) * 0.22) + var offset = -height + while offset < width { + _ = context.beginPath!() + _ = context.moveTo!(offset, height) + _ = context.lineTo!(offset + height, 0) + _ = context.stroke!() + offset += spacing + } + _ = context.restore!() + + context.strokeStyle = .string("#c7cdd6") + context.lineWidth = .number(1) + _ = context.strokeRect!(0.5, 0.5, max(0, width - 1), max(0, height - 1)) + + guard showLabel, width >= 44, height >= 20 else { return } + let fontSize = max(8, min(12, height * 0.22)) + context.fillStyle = .string("#657084") + context.font = .string("650 \(fontSize)px -apple-system, BlinkMacSystemFont, sans-serif") + context.textAlign = .string("center") + context.textBaseline = .string("middle") + _ = context.fillText!(label, width * 0.5, height * 0.5, max(0, width - 12)) + } + + private func layerCount(in items: [DisplayListPreviewItem]) -> Int { + items.reduce(into: 0) { count, item in + switch item.value { + case .empty: + break + case let .content(.flattened(_, children)): + count += layerCount(in: children) + case .content: + if item.frame.width > 0, item.frame.height > 0 { count += 1 } + case let .effect(_, children), let .states(children): + count += layerCount(in: children) + } + } + } + + private func bounds( + of items: [DisplayListPreviewItem], + origin: DisplayListPreviewPoint + ) -> PreviewBounds? { + var result: PreviewBounds? + for item in items { + let itemOrigin = DisplayListPreviewPoint( + x: origin.x + item.frame.x, + y: origin.y + item.frame.y + ) + let itemBounds: PreviewBounds? + switch item.value { + case .empty: + itemBounds = nil + case let .content(.flattened(flattenedOrigin, children)): + itemBounds = bounds( + of: children, + origin: DisplayListPreviewPoint( + x: itemOrigin.x + flattenedOrigin.x, + y: itemOrigin.y + flattenedOrigin.y + ) + ) + case .content: + if item.frame.width > 0, item.frame.height > 0 { + itemBounds = PreviewBounds( + minX: itemOrigin.x, + minY: itemOrigin.y, + maxX: itemOrigin.x + item.frame.width, + maxY: itemOrigin.y + item.frame.height + ) + } else { + itemBounds = nil + } + case let .effect(.transform(transform), children): + itemBounds = bounds(of: children, origin: .zero) + .map { $0.applying(transform).offsetBy(dx: itemOrigin.x, dy: itemOrigin.y) } + case let .effect(_, children), let .states(children): + itemBounds = bounds(of: children, origin: itemOrigin) + } + if let itemBounds { + result = result.map { $0.union(itemBounds) } ?? itemBounds + } + } + return result + } + + private func isFlattened(_ content: DisplayListPreviewContent) -> Bool { + if case .flattened = content { return true } + return false + } +} + +private struct PreviewBounds { + let minX: Double + let minY: Double + let maxX: Double + let maxY: Double + + var width: Double { maxX - minX } + var height: Double { maxY - minY } + + func union(_ other: PreviewBounds) -> PreviewBounds { + PreviewBounds( + minX: min(minX, other.minX), + minY: min(minY, other.minY), + maxX: max(maxX, other.maxX), + maxY: max(maxY, other.maxY) + ) + } + + func offsetBy(dx: Double, dy: Double) -> PreviewBounds { + PreviewBounds(minX: minX + dx, minY: minY + dy, maxX: maxX + dx, maxY: maxY + dy) + } + + func applying(_ transform: DisplayListPreviewTransform) -> PreviewBounds { + let points = [ + DisplayListPreviewPoint(x: minX, y: minY), + DisplayListPreviewPoint(x: maxX, y: minY), + DisplayListPreviewPoint(x: minX, y: maxY), + DisplayListPreviewPoint(x: maxX, y: maxY), + ].map { point in + DisplayListPreviewPoint( + x: transform.a * point.x + transform.c * point.y + transform.tx, + y: transform.b * point.x + transform.d * point.y + transform.ty + ) + } + return PreviewBounds( + minX: points.map(\.x).min()!, + minY: points.map(\.y).min()!, + maxX: points.map(\.x).max()!, + maxY: points.map(\.y).max()! + ) + } +} diff --git a/Sources/DisplayListWeb/main.swift b/Sources/DisplayListWeb/main.swift index 4fafc6a..5aef038 100644 --- a/Sources/DisplayListWeb/main.swift +++ b/Sources/DisplayListWeb/main.swift @@ -23,6 +23,30 @@ private let outputTabLabel = document.getElementById("output-tab-label").object! private let forwardLimitation = document.getElementById("forward-limitation").object! private let reverseLimitation = document.getElementById("reverse-limitation").object! private let urlState = JSObject.global.displayListURLState.object! +private let previewSurface = document.getElementById("preview-surface").object! +private let previewCanvas = document.getElementById("preview-canvas").object! +private let previewEmpty = document.getElementById("preview-empty").object! +private let previewSummary = document.getElementById("preview-summary").object! +private let previewZoomOut = document.getElementById("preview-zoom-out").object! +private let previewZoomValue = document.getElementById("preview-zoom-value").object! +private let previewZoomIn = document.getElementById("preview-zoom-in").object! +private let previewZoomFit = document.getElementById("preview-zoom-fit").object! +private let previewScale = document.getElementById("preview-scale").object! +private let previewScaleLabel = document.getElementById("preview-scale-label").object! +private let previewScaleRule = document.getElementById("preview-scale-rule").object! +private let previewRenderer = DisplayListCanvasRenderer( + surface: previewSurface, + canvas: previewCanvas, + emptyState: previewEmpty, + summary: previewSummary, + zoomOutButton: previewZoomOut, + zoomValueButton: previewZoomValue, + zoomInButton: previewZoomIn, + fitButton: previewZoomFit, + scaleIndicator: previewScale, + scaleLabel: previewScaleLabel, + scaleRule: previewScaleRule +) private enum ConversionDirection: Equatable { case descriptionToMinimal @@ -47,7 +71,7 @@ private struct ExplorerConversion { } } -private let tabNames = ["minimal", "info", "statistics"] +private let tabNames = ["minimal", "preview", "info", "statistics"] private var retainedClosures: [JSClosure] = [] private var outputClosures: [JSClosure] = [] private var statisticsClosures: [JSClosure] = [] @@ -71,7 +95,7 @@ private let sampleDescription = """ (frame (8.0 8.0; 104.0 28.0)) (effect #:opacity 0.72 (item #:identity 44 #:version 1 - (frame (8.0 8.0; 104.0 28.0)) + (frame (0.0 0.0; 104.0 28.0)) (content-seed 2) (text "Hello, DisplayList" #:size (104.0, 28.0)))))) """ @@ -449,6 +473,7 @@ private func convert(_ source: String) { shareButton.disabled = .boolean(true) renderStatistics(nil) syncSharedEncodingURL() + previewRenderer.showEmpty("Paste a DisplayList description to preview it.") return } @@ -479,6 +504,15 @@ private func convert(_ source: String) { shareButton.disabled = .boolean(false) renderStatistics(conversion) syncSharedEncodingURL() + switch direction { + case .descriptionToMinimal: + previewRenderer.show(try DisplayListPreviewConverter.convert(source)) + case .minimalToDescription: + previewRenderer.showEmpty( + "minimalDescription omits frames and rendering payloads. Switch to Forward to preview a full description.", + summary: "Unavailable for minimalDescription" + ) + } } catch { latestConversion = nil latestOutput = "" @@ -491,6 +525,7 @@ private func convert(_ source: String) { shareButton.disabled = .boolean(true) renderStatistics(nil) syncSharedEncodingURL() + previewRenderer.showEmpty("Fix the conversion error to update the preview.", summary: "Preview unavailable") } } @@ -508,6 +543,9 @@ private func selectTab(_ name: String) { panel.hidden = .boolean(!isSelected) } copyButton.hidden = .boolean(name != "minimal") + if name == "preview" { + previewRenderer.redraw() + } } private func installEventHandlers() { @@ -597,6 +635,41 @@ private func installEventHandlers() { directionToggle.onclick = .object(directionClosure) retainedClosures.append(directionClosure) + let zoomOutClosure = JSClosure { _ in + previewRenderer.zoomOut() + return .undefined + } + previewZoomOut.onclick = .object(zoomOutClosure) + retainedClosures.append(zoomOutClosure) + + let actualSizeClosure = JSClosure { _ in + previewRenderer.showActualSize() + return .undefined + } + previewZoomValue.onclick = .object(actualSizeClosure) + retainedClosures.append(actualSizeClosure) + + let zoomInClosure = JSClosure { _ in + previewRenderer.zoomIn() + return .undefined + } + previewZoomIn.onclick = .object(zoomInClosure) + retainedClosures.append(zoomInClosure) + + let fitClosure = JSClosure { _ in + previewRenderer.fitToSurface() + return .undefined + } + previewZoomFit.onclick = .object(fitClosure) + retainedClosures.append(fitClosure) + + let resizeClosure = JSClosure { _ in + previewRenderer.redraw() + return .undefined + } + JSObject.global.window.onresize = .object(resizeClosure) + retainedClosures.append(resizeClosure) + for tabName in tabNames { guard let button = document.getElementById("\(tabName)-tab").object else { continue } let tabClosure = JSClosure { _ in diff --git a/Tests/DisplayListDescriptionTests/DisplayListPreviewConverterTests.swift b/Tests/DisplayListDescriptionTests/DisplayListPreviewConverterTests.swift new file mode 100644 index 0000000..6ef0280 --- /dev/null +++ b/Tests/DisplayListDescriptionTests/DisplayListPreviewConverterTests.swift @@ -0,0 +1,132 @@ +import XCTest +@testable import DisplayListDescription + +final class DisplayListPreviewConverterTests: XCTestCase { + func testBuildsPreviewFromCapturedDisplayList() throws { + let description = try fixture( + "DisplayList.description", + directory: "ContentView-iPhone-17-Pro" + ) + + let preview = try DisplayListPreviewConverter.convert(description) + + XCTAssertEqual(preview.items.count, 9) + XCTAssertEqual( + preview.items[0].frame, + DisplayListPreviewRect( + x: 69.33333333333333, + y: 285, + width: 263.66666666666663, + height: 33.666666666666664 + ) + ) + guard case let .effect(_, children) = preview.items[0].value, + case let .content(.text(text, size)) = children.first?.value else { + return XCTFail("Expected the first effect to contain text.") + } + XCTAssertEqual(text, "OpenSwiftUI Example") + XCTAssertEqual( + size, + DisplayListPreviewSize( + width: 263.66666666666663, + height: 33.666666666666664 + ) + ) + + guard case let .content(.color(color)) = preview.items[2].value else { + return XCTFail("Expected the third item to contain color.") + } + XCTAssertEqual(color, "#FF383CFF") + + guard case let .content(.shape(path, color, evenOdd)) = preview.items[5].value else { + return XCTFail("Expected the sixth item to contain a shape.") + } + XCTAssertNil(color) + XCTAssertFalse(evenOdd) + XCTAssertEqual(path.commands.count, 6) + XCTAssertEqual(path.commands.first, .move(.init(x: 60, y: 30))) + XCTAssertEqual(path.commands.last, .close) + XCTAssertTrue(preview.approximations.contains { $0.contains("Shape paint") }) + } + + func testDecodesEveryCoreGraphicsPathDescriptionCommand() throws { + let description = """ + (display-list + (item #:identity 1 #:version 1 + (frame (0 0; 120 120)) + (content-seed 1) + (shape + (path 0 0 m 10 10 l 20 0 30 10 q 40 0 50 20 60 10 c 70 20 t 80 10 90 20 v 100 10 110 20 y 1 2 3 4 re h) + (paint unknown) + (style FillStyle(isEOFilled: true, isAntialiased: true))))) + """ + + let preview = try DisplayListPreviewConverter.convert(description) + guard case let .content(.shape(path, _, evenOdd)) = preview.items.first?.value else { + return XCTFail("Expected a parsed shape.") + } + + XCTAssertTrue(evenOdd) + XCTAssertEqual(path.commands.count, 13) + XCTAssertEqual(path.commands[0], .move(.init(x: 0, y: 0))) + XCTAssertEqual( + path.commands[2], + .quad(control: .init(x: 20, y: 0), end: .init(x: 30, y: 10)) + ) + XCTAssertEqual( + path.commands[3], + .cubic( + control1: .init(x: 40, y: 0), + control2: .init(x: 50, y: 20), + end: .init(x: 60, y: 10) + ) + ) + } + + func testDecodesRenderableEffectsAndFallbackContent() throws { + let description = """ + (display-list + (item #:identity 1 #:version 1 + (frame (10 20; 100 80)) + (effect #:opacity 0.4 + (item #:version 1 + (frame (0 0; 100 80)) + (content-seed 1) + (image #:size (100, 80))))) + (item #:identity 2 #:version 1 + (frame (0 0; 50 50)) + (effect + (transform affine(__C.CGAffineTransform(a: 1.0, b: 0.0, c: 0.0, d: 1.0, tx: 4.0, ty: 8.0))) + (item #:version 1 + (frame (0 0; 50 50)) + (content-seed 1) + (platform-view))))) + """ + + let preview = try DisplayListPreviewConverter.convert(description) + guard case let .effect(.opacity(opacity), children) = preview.items[0].value else { + return XCTFail("Expected opacity effect.") + } + XCTAssertEqual(opacity, 0.4) + XCTAssertEqual(children.count, 1) + XCTAssertEqual(children[0].value, .content(.image)) + + guard case let .effect(.transform(transform), transformedChildren) = preview.items[1].value else { + return XCTFail("Expected affine transform effect.") + } + XCTAssertEqual(transform.tx, 4) + XCTAssertEqual(transform.ty, 8) + XCTAssertEqual(transformedChildren[0].value, .content(.placeholder("Platform view"))) + } + + private func fixture(_ name: String, directory: String) throws -> String { + let url = try XCTUnwrap( + Bundle.module.url( + forResource: name, + withExtension: "txt", + subdirectory: "Fixtures/\(directory)" + ) + ) + return try String(contentsOf: url, encoding: .utf8) + } +} diff --git a/index.html b/index.html index a46d4a3..96f6a0e 100644 --- a/index.html +++ b/index.html @@ -110,6 +110,17 @@

DisplayList Description

> Info + + + + + + + +
+ +

Paste a DisplayList description to preview it.

+ +
+

+ Layout, resolved colors, opacity, transforms, clips, and path geometry are reproduced when present. + Missing image, font, paint, and platform payloads use approximations or placeholders. +

+ +
p { + margin: 0; + color: #7a8392; + font-size: 11px; + line-height: 1.5; + text-align: right; +} + +.preview-zoom-controls { + display: inline-flex; + align-items: center; + overflow: hidden; + border: 1px solid #d7dce3; + border-radius: 8px; + background: #fff; + box-shadow: 0 1px 2px rgb(23 32 51 / 5%); +} + +.preview-zoom-controls button { + min-width: 29px; + height: 28px; + border: 0; + border-right: 1px solid #e2e5ea; + padding: 0 8px; + color: #536075; + cursor: pointer; + background: transparent; + font-size: 11px; + font-weight: 680; +} + +.preview-zoom-controls button:last-child { + border-right: 0; +} + +.preview-zoom-controls button:hover, +.preview-zoom-controls button:focus-visible { + color: var(--blue); + background: var(--blue-soft); + outline: none; +} + +.preview-zoom-controls button:disabled { + color: #a8afb9; + cursor: default; + background: transparent; +} + +.preview-zoom-controls .preview-zoom-value { + min-width: 51px; + color: #28344b; + font-variant-numeric: tabular-nums; +} + +.preview-zoom-controls .preview-zoom-fit { + min-width: 38px; +} + +.preview-zoom-controls .preview-zoom-fit.is-active { + color: var(--blue); + background: var(--blue-soft); +} + +.preview-surface { + min-height: 260px; + flex: 1; + position: relative; + overflow: hidden; + background: + radial-gradient(circle at center, rgb(109 120 139 / 16%) 0 0.7px, transparent 0.8px) 0 0 / 14px 14px, + #eef1f4; +} + +.preview-canvas { + width: 100%; + height: 100%; + display: block; +} + +.preview-empty { + position: absolute; + inset: 0; + display: grid; + place-items: center; + margin: 0; + padding: 28px; + color: #7d8797; + font-size: 12px; + line-height: 1.6; + text-align: center; +} + +.preview-empty[hidden] { + display: none; +} + +.preview-scale { + min-width: 72px; + position: absolute; + bottom: 16px; + left: 16px; + display: flex; + align-items: flex-start; + flex-direction: column; + gap: 4px; + border: 1px solid rgb(23 32 51 / 12%); + border-radius: 7px; + padding: 7px 9px 8px; + color: #536075; + background: rgb(255 255 255 / 88%); + box-shadow: 0 3px 12px rgb(23 32 51 / 9%); + backdrop-filter: blur(8px); + pointer-events: none; +} + +.preview-scale[hidden] { + display: none; +} + +.preview-scale-label { + font-size: 9px; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1; +} + +.preview-scale-rule { + width: 64px; + height: 6px; + display: block; + border-right: 1px solid currentcolor; + border-bottom: 1px solid currentcolor; + border-left: 1px solid currentcolor; +} + +.preview-footnote { + flex: 0 0 auto; + margin: 0; + border-top: 1px solid var(--line); + padding: 10px 22px; + color: #737d8e; + background: #fbfcfd; + font-size: 10.5px; + line-height: 1.5; +} + .mapped-output { cursor: default; transition: background 100ms ease; @@ -902,10 +1060,30 @@ footer { max-width: 46%; } + .preview-heading { + gap: 14px; + } + + .preview-heading-meta { + align-items: flex-end; + } + + .preview-heading-meta > p { + max-width: none; + } + .minimal-output { padding: 20px 16px; } + .preview-surface { + min-height: 360px; + } + + .preview-footnote { + padding-inline: 16px; + } + .encoding-info-row { grid-template-columns: 72px 1fr; gap: 10px 14px; From 4c10ee54222f07b432a526ecf7336b79c906286b Mon Sep 17 00:00:00 2001 From: Kyle Date: Wed, 5 Aug 2026 01:12:37 +0800 Subject: [PATCH 2/3] Add device preview frames and panning --- README.md | 4 +- Sources/DisplayListWeb/PreviewRenderer.swift | 299 +++++++++++++++++- Sources/DisplayListWeb/main.swift | 96 +++++- .../DisplayListPreviewConverterTests.swift | 37 +++ index.html | 75 +++-- styles.css | 87 +++++ 6 files changed, 550 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index fc8a850..1bd5129 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ The parser and converter are written in Swift. [JavaScriptKit](https://github.co - Reproduces the single-line formatting emitted by `SExpPrinter`. - Links source and `minimalDescription` ranges with bidirectional hover highlighting. - Provides dedicated `minimalDesc`, encoding info, and occurrence statistics tabs. -- Previews DisplayList frames on a zoomable canvas with fit and actual-size controls, a point ruler, - resolved colors, text, paths, opacity, transforms, clips, and placeholders for omitted payloads. +- Previews DisplayList frames inside draggable iPhone presets or a custom-size Window frame, with + zoom, fit, actual-size controls, a point ruler, resolved content, effects, and payload placeholders. - Highlights every matching source range when a statistics row is hovered or focused. - Copies compact, self-contained links that reopen a shared `minimalDescription`. - Runs entirely in the browser; pasted descriptions are not uploaded. diff --git a/Sources/DisplayListWeb/PreviewRenderer.swift b/Sources/DisplayListWeb/PreviewRenderer.swift index 8a45c44..e88bfb0 100644 --- a/Sources/DisplayListWeb/PreviewRenderer.swift +++ b/Sources/DisplayListWeb/PreviewRenderer.swift @@ -14,8 +14,12 @@ final class DisplayListCanvasRenderer { private let scaleLabel: JSObject private let scaleRule: JSObject private var preview: DisplayListPreview? + private var device = PreviewDevice.iPhone17Pro private var manualScale: Double? private var renderedScale: Double? + private var panOffsetX = 0.0 + private var panOffsetY = 0.0 + private var lastPanPoint: (x: Double, y: Double)? init( surface: JSObject, @@ -63,10 +67,28 @@ final class DisplayListCanvasRenderer { emptyState.textContent = .string(message) emptyState.hidden = .boolean(false) renderedScale = nil + lastPanPoint = nil updateScaleInterface() clearCanvas() } + func selectDevice( + _ identifier: String, + windowWidth: Double, + windowHeight: Double + ) { + let nextDevice = PreviewDevice( + identifier: identifier, + windowWidth: windowWidth, + windowHeight: windowHeight + ) + guard nextDevice != device else { return } + device = nextDevice + manualScale = nil + resetPan() + redraw() + } + func zoomIn() { zoom(by: 1.41421356237) } @@ -78,19 +100,39 @@ final class DisplayListCanvasRenderer { func showActualSize() { guard preview != nil else { return } manualScale = 1 + resetPan() redraw() } func fitToSurface() { guard preview != nil else { return } manualScale = nil + resetPan() + redraw() + } + + func beginPan(x: Double, y: Double) -> Bool { + guard preview != nil, renderedScale != nil else { return false } + lastPanPoint = (x, y) + return true + } + + func updatePan(x: Double, y: Double) { + guard let lastPanPoint else { return } + panOffsetX += x - lastPanPoint.x + panOffsetY += y - lastPanPoint.y + self.lastPanPoint = (x, y) redraw() } + func endPan() { + lastPanPoint = nil + } + func redraw() { guard let preview, let context = canvas.getContext!("2d").object, - let bounds = bounds(of: preview.items, origin: .zero) else { + bounds(of: preview.items, origin: .zero) != nil else { if preview != nil { showEmpty( "This DisplayList has no renderable frames.", @@ -114,32 +156,36 @@ final class DisplayListCanvasRenderer { let viewportPadding = 32.0 let availableWidth = max(1, width - viewportPadding * 2) let availableHeight = max(1, height - viewportPadding * 2) + let stageBounds = device.stageBounds let fitScale = max( 0.01, - min(4, min(availableWidth / max(bounds.width, 1), availableHeight / max(bounds.height, 1))) + min( + 4, + min( + availableWidth / max(stageBounds.width, 1), + availableHeight / max(stageBounds.height, 1) + ) + ) ) let scale = min(16, max(0.01, manualScale ?? fitScale)) renderedScale = scale updateScaleInterface() - let stageWidth = bounds.width * scale - let stageHeight = bounds.height * scale - let offsetX = (width - stageWidth) * 0.5 - bounds.minX * scale - let offsetY = (height - stageHeight) * 0.5 - bounds.minY * scale + let stageWidth = stageBounds.width * scale + let stageHeight = stageBounds.height * scale + let offsetX = (width - stageWidth) * 0.5 - stageBounds.minX * scale + panOffsetX + let offsetY = (height - stageHeight) * 0.5 - stageBounds.minY * scale + panOffsetY _ = context.save!() _ = context.transform!(scale, 0, 0, scale, offsetX, offsetY) - context.shadowColor = .string("rgba(23, 32, 51, 0.16)") - context.shadowBlur = .number(18 / scale) - context.shadowOffsetY = .number(7 / scale) - context.fillStyle = .string("#ffffff") - _ = context.fillRect!(bounds.minX, bounds.minY, bounds.width, bounds.height) - context.shadowColor = .string("transparent") - context.shadowBlur = .number(0) - context.shadowOffsetY = .number(0) - + drawDeviceBackground(device, scale: scale, in: context) + _ = context.save!() + traceRoundedRect(device.screenBounds, radius: device.screenCornerRadius, in: context) + _ = context.clip!() draw(preview.items, in: context) _ = context.restore!() + drawDeviceOverlay(device, scale: scale, in: context) + _ = context.restore!() } private func zoom(by factor: Double) { @@ -148,6 +194,12 @@ final class DisplayListCanvasRenderer { redraw() } + private func resetPan() { + panOffsetX = 0 + panOffsetY = 0 + lastPanPoint = nil + } + private func updateScaleInterface() { let hasPreview = renderedScale != nil zoomOutButton.disabled = .boolean(!hasPreview || (renderedScale ?? 0) <= 0.010001) @@ -209,6 +261,136 @@ final class DisplayListCanvasRenderer { return (points, points * scale) } + private func drawDeviceBackground( + _ device: PreviewDevice, + scale: Double, + in context: JSObject + ) { + let screen = device.screenBounds + _ = context.save!() + context.shadowColor = .string("rgba(23, 32, 51, 0.24)") + context.shadowBlur = .number(22 / scale) + context.shadowOffsetY = .number(10 / scale) + + switch device { + case .iPhone17Pro, .iPhone17ProMax, .iPhoneAir, .iPhone16e, .iPhone15Pro: + let shell = PreviewBounds( + minX: -8, + minY: -8, + maxX: screen.maxX + 8, + maxY: screen.maxY + 8 + ) + traceRoundedRect(shell, radius: 63, in: context) + context.fillStyle = .string("#15171a") + _ = context.fill!() + + context.shadowColor = .string("transparent") + traceRoundedRect(screen, radius: device.screenCornerRadius, in: context) + context.fillStyle = .string("#ffffff") + _ = context.fill!() + case .window: + let windowShell = PreviewBounds( + minX: -8, + minY: -38, + maxX: screen.maxX + 8, + maxY: screen.maxY + 8 + ) + traceRoundedRect(windowShell, radius: 12, in: context) + context.fillStyle = .string("#d8dce3") + _ = context.fill!() + + context.shadowColor = .string("transparent") + context.fillStyle = .string("#f3f4f6") + _ = context.fillRect!(0, -30, screen.width, 30) + + traceRoundedRect(screen, radius: device.screenCornerRadius, in: context) + context.fillStyle = .string("#ffffff") + _ = context.fill!() + } + + _ = context.restore!() + } + + private func drawDeviceOverlay( + _ device: PreviewDevice, + scale: Double, + in context: JSObject + ) { + let screen = device.screenBounds + _ = context.save!() + + switch device { + case .iPhone17Pro, .iPhone17ProMax, .iPhoneAir, .iPhone16e, .iPhone15Pro: + let topCutout = device.hasDynamicIsland + ? PreviewBounds( + minX: (screen.width - 126) * 0.5, + minY: 11, + maxX: (screen.width + 126) * 0.5, + maxY: 48 + ) + : PreviewBounds( + minX: (screen.width - 164) * 0.5, + minY: -8, + maxX: (screen.width + 164) * 0.5, + maxY: 31 + ) + traceRoundedRect(topCutout, radius: device.hasDynamicIsland ? 19 : 13, in: context) + context.fillStyle = .string("#050506") + _ = context.fill!() + + let homeIndicator = PreviewBounds( + minX: (screen.width - 140) * 0.5, + minY: screen.height - 14, + maxX: (screen.width + 140) * 0.5, + maxY: screen.height - 9 + ) + traceRoundedRect(homeIndicator, radius: 2.5, in: context) + context.fillStyle = .string("rgba(8, 10, 12, 0.82)") + _ = context.fill!() + case .window: + for (x, color) in [(15.0, "#ff5f57"), (35.0, "#febc2e"), (55.0, "#28c840")] { + _ = context.beginPath!() + _ = context.arc!(x, -15, 6, 0, Double.pi * 2) + context.fillStyle = .string(color) + _ = context.fill!() + } + + context.strokeStyle = .string("rgba(23, 32, 51, 0.14)") + context.lineWidth = .number(1 / scale) + _ = context.beginPath!() + _ = context.moveTo!(0, 0) + _ = context.lineTo!(screen.maxX, 0) + _ = context.stroke!() + } + + traceRoundedRect(screen, radius: device.screenCornerRadius, in: context) + context.strokeStyle = .string( + device.isPhone ? "rgba(255, 255, 255, 0.22)" : "rgba(23, 32, 51, 0.14)" + ) + context.lineWidth = .number(1 / scale) + _ = context.stroke!() + _ = context.restore!() + } + + private func traceRoundedRect( + _ bounds: PreviewBounds, + radius: Double, + in context: JSObject + ) { + let radius = max(0, min(radius, min(bounds.width, bounds.height) * 0.5)) + _ = context.beginPath!() + _ = context.moveTo!(bounds.minX + radius, bounds.minY) + _ = context.lineTo!(bounds.maxX - radius, bounds.minY) + _ = context.quadraticCurveTo!(bounds.maxX, bounds.minY, bounds.maxX, bounds.minY + radius) + _ = context.lineTo!(bounds.maxX, bounds.maxY - radius) + _ = context.quadraticCurveTo!(bounds.maxX, bounds.maxY, bounds.maxX - radius, bounds.maxY) + _ = context.lineTo!(bounds.minX + radius, bounds.maxY) + _ = context.quadraticCurveTo!(bounds.minX, bounds.maxY, bounds.minX, bounds.maxY - radius) + _ = context.lineTo!(bounds.minX, bounds.minY + radius) + _ = context.quadraticCurveTo!(bounds.minX, bounds.minY, bounds.minX + radius, bounds.minY) + _ = context.closePath!() + } + private func clearCanvas() { guard let context = canvas.getContext!("2d").object else { return } let width = Double(surface.clientWidth.number ?? 0) @@ -523,6 +705,93 @@ final class DisplayListCanvasRenderer { } } +private enum PreviewDevice: Equatable { + case iPhone17Pro + case iPhone17ProMax + case iPhoneAir + case iPhone16e + case iPhone15Pro + case window(width: Double, height: Double) + + init(identifier: String, windowWidth: Double, windowHeight: Double) { + switch identifier { + case "iphone17promax": self = .iPhone17ProMax + case "iphoneair": self = .iPhoneAir + case "iphone16e": self = .iPhone16e + case "iphone15pro": self = .iPhone15Pro + case "window": + self = .window( + width: Self.sanitized(windowWidth, fallback: 800), + height: Self.sanitized(windowHeight, fallback: 600) + ) + default: self = .iPhone17Pro + } + } + + var screenBounds: PreviewBounds { + switch self { + case .iPhone17Pro: + PreviewBounds(minX: 0, minY: 0, maxX: 402, maxY: 874) + case .iPhone17ProMax: + PreviewBounds(minX: 0, minY: 0, maxX: 440, maxY: 956) + case .iPhoneAir: + PreviewBounds(minX: 0, minY: 0, maxX: 420, maxY: 912) + case .iPhone16e: + PreviewBounds(minX: 0, minY: 0, maxX: 390, maxY: 844) + case .iPhone15Pro: + PreviewBounds(minX: 0, minY: 0, maxX: 393, maxY: 852) + case let .window(width, height): + PreviewBounds(minX: 0, minY: 0, maxX: width, maxY: height) + } + } + + var stageBounds: PreviewBounds { + let screen = screenBounds + if isPhone { + return PreviewBounds( + minX: -8, + minY: -8, + maxX: screen.maxX + 8, + maxY: screen.maxY + 8 + ) + } + return PreviewBounds( + minX: -8, + minY: -38, + maxX: screen.maxX + 8, + maxY: screen.maxY + 8 + ) + } + + var screenCornerRadius: Double { + switch self { + case .iPhone17Pro: 55 + case .iPhone17ProMax: 59 + case .iPhoneAir: 57 + case .iPhone16e: 51 + case .iPhone15Pro: 54 + case .window: 5 + } + } + + var hasDynamicIsland: Bool { + switch self { + case .iPhone16e, .window: false + default: true + } + } + + var isPhone: Bool { + if case .window = self { return false } + return true + } + + private static func sanitized(_ value: Double, fallback: Double) -> Double { + guard value.isFinite else { return fallback } + return min(10_000, max(100, value.rounded())) + } +} + private struct PreviewBounds { let minX: Double let minY: Double diff --git a/Sources/DisplayListWeb/main.swift b/Sources/DisplayListWeb/main.swift index 5aef038..7e505c2 100644 --- a/Sources/DisplayListWeb/main.swift +++ b/Sources/DisplayListWeb/main.swift @@ -27,6 +27,10 @@ private let previewSurface = document.getElementById("preview-surface").object! private let previewCanvas = document.getElementById("preview-canvas").object! private let previewEmpty = document.getElementById("preview-empty").object! private let previewSummary = document.getElementById("preview-summary").object! +private let previewDevice = document.getElementById("preview-device").object! +private let previewWindowSize = document.getElementById("preview-window-size").object! +private let previewWindowWidth = document.getElementById("preview-window-width").object! +private let previewWindowHeight = document.getElementById("preview-window-height").object! private let previewZoomOut = document.getElementById("preview-zoom-out").object! private let previewZoomValue = document.getElementById("preview-zoom-value").object! private let previewZoomIn = document.getElementById("preview-zoom-in").object! @@ -87,20 +91,34 @@ private var isURLStateActive = false private let sampleDescription = """ (display-list - (item #:identity 42 #:version 1 - (frame (0.0 0.0; 120.0 44.0)) - (content-seed 1) - (color #007AFFFF)) - (item #:identity 43 #:version 1 - (frame (8.0 8.0; 104.0 28.0)) - (effect #:opacity 0.72 - (item #:identity 44 #:version 1 - (frame (0.0 0.0; 104.0 28.0)) - (content-seed 2) - (text "Hello, DisplayList" #:size (104.0, 28.0)))))) + (item #:version 9 + (frame (189.33333333333331 430.0; 23.0 22.0)) + (effect + (item #:version 8 + (frame (0.0 0.0; 23.0 22.0)) + (content-seed 17) + (drawing #:offset (-1.0 0.0))))) + (item #:identity 2 #:version 7 + (frame (153.66666666666666 453.66666666666663; 94.66666666666666 20.333333333333332)) + (effect + (item #:version 4 + (frame (0.0 0.0; 94.66666666666666 20.333333333333332)) + (content-seed 9) + (text "Hello, world!" #:size (94.66666666666666, 20.333333333333332)))))) """ -private let sampleMinimalDescription = "(DL(I:42 C)(I:43(E O(I:44 T))))" +private let sampleMinimalDescription = "(DL(I:0(E(I:0 D)))(I:2(E(I:0 T))))" + +private func updatePreviewDevice() { + let identifier = previewDevice.value.string ?? "iphone17pro" + let isWindow = identifier == "window" + previewWindowSize.hidden = .boolean(!isWindow) + previewRenderer.selectDevice( + identifier, + windowWidth: previewWindowWidth.valueAsNumber.number ?? 800, + windowHeight: previewWindowHeight.valueAsNumber.number ?? 600 + ) +} private func reference(for encodingID: String) -> DisplayListEncodingReference? { DisplayListEncodingReference.all.first { $0.id == encodingID } @@ -663,6 +681,60 @@ private func installEventHandlers() { previewZoomFit.onclick = .object(fitClosure) retainedClosures.append(fitClosure) + let deviceClosure = JSClosure { _ in + updatePreviewDevice() + return .undefined + } + previewDevice.onchange = .object(deviceClosure) + retainedClosures.append(deviceClosure) + + let windowSizeClosure = JSClosure { _ in + updatePreviewDevice() + return .undefined + } + previewWindowWidth.oninput = .object(windowSizeClosure) + previewWindowHeight.oninput = .object(windowSizeClosure) + retainedClosures.append(windowSizeClosure) + + let panStartClosure = JSClosure { arguments in + guard let event = arguments.first?.object, + (event.button.number ?? 0) == 0, + let x = event.clientX.number, + let y = event.clientY.number, + previewRenderer.beginPan(x: x, y: y) else { + return .undefined + } + if let pointerID = event.pointerId.number { + _ = previewSurface.setPointerCapture!(pointerID) + } + _ = previewSurface.classList.add("is-panning") + _ = event.preventDefault!() + return .undefined + } + previewSurface.onpointerdown = .object(panStartClosure) + retainedClosures.append(panStartClosure) + + let panMoveClosure = JSClosure { arguments in + guard let event = arguments.first?.object, + let x = event.clientX.number, + let y = event.clientY.number else { + return .undefined + } + previewRenderer.updatePan(x: x, y: y) + return .undefined + } + previewSurface.onpointermove = .object(panMoveClosure) + retainedClosures.append(panMoveClosure) + + let panEndClosure = JSClosure { _ in + previewRenderer.endPan() + _ = previewSurface.classList.remove("is-panning") + return .undefined + } + previewSurface.onpointerup = .object(panEndClosure) + previewSurface.onpointercancel = .object(panEndClosure) + retainedClosures.append(panEndClosure) + let resizeClosure = JSClosure { _ in previewRenderer.redraw() return .undefined diff --git a/Tests/DisplayListDescriptionTests/DisplayListPreviewConverterTests.swift b/Tests/DisplayListDescriptionTests/DisplayListPreviewConverterTests.swift index 6ef0280..a6f88b0 100644 --- a/Tests/DisplayListDescriptionTests/DisplayListPreviewConverterTests.swift +++ b/Tests/DisplayListDescriptionTests/DisplayListPreviewConverterTests.swift @@ -2,6 +2,43 @@ import XCTest @testable import DisplayListDescription final class DisplayListPreviewConverterTests: XCTestCase { + func testBuildsDefaultIPhone17ProSample() throws { + let description = """ + (display-list + (item #:version 9 + (frame (189.33333333333331 430.0; 23.0 22.0)) + (effect + (item #:version 8 + (frame (0.0 0.0; 23.0 22.0)) + (content-seed 17) + (drawing #:offset (-1.0 0.0))))) + (item #:identity 2 #:version 7 + (frame (153.66666666666666 453.66666666666663; 94.66666666666666 20.333333333333332)) + (effect + (item #:version 4 + (frame (0.0 0.0; 94.66666666666666 20.333333333333332)) + (content-seed 9) + (text "Hello, world!" #:size (94.66666666666666, 20.333333333333332)))))) + """ + + let conversion = try DisplayListDescriptionConverter.convert(description) + let preview = try DisplayListPreviewConverter.convert(description) + + XCTAssertEqual(conversion.minimalDescription, "(DL(I:0(E(I:0 D)))(I:2(E(I:0 T))))") + XCTAssertEqual(preview.items.count, 2) + XCTAssertEqual(preview.items[0].frame.x, 189.33333333333331) + XCTAssertEqual(preview.items[0].frame.y, 430) + guard case let .effect(_, imageChildren) = preview.items[0].value, + case .content(.placeholder("Drawing")) = imageChildren.first?.value, + case let .effect(_, textChildren) = preview.items[1].value, + case let .content(.text(text, size)) = textChildren.first?.value else { + return XCTFail("Expected the sample drawing and text layers.") + } + XCTAssertEqual(text, "Hello, world!") + XCTAssertEqual(size.width, 94.66666666666666) + XCTAssertEqual(size.height, 20.333333333333332) + } + func testBuildsPreviewFromCapturedDisplayList() throws { let description = try fixture( "DisplayList.description", diff --git a/index.html b/index.html index 96f6a0e..2224cad 100644 --- a/index.html +++ b/index.html @@ -189,27 +189,64 @@

DisplayList Preview

No preview

-
- - - - +
+ + +
+ + + + +
-
+

Paste a DisplayList description to preview it.

Layout, resolved colors, opacity, transforms, clips, and path geometry are reproduced when present. - Missing image, font, paint, and platform payloads use approximations or placeholders. + Drag to move the selected device. Missing image, font, paint, and platform payloads use approximations or placeholders.

diff --git a/styles.css b/styles.css index fdb93fe..a65cb0c 100644 --- a/styles.css +++ b/styles.css @@ -552,6 +552,80 @@ body.is-resizing-panes { text-align: right; } +.preview-control-row { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.preview-device-control { + height: 30px; + display: inline-flex; + align-items: center; + gap: 7px; + border: 1px solid #d7dce3; + border-radius: 8px; + padding: 0 8px; + color: #7a8392; + background: #fff; + box-shadow: 0 1px 2px rgb(23 32 51 / 5%); + font-size: 9px; + font-weight: 760; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.preview-device-control select { + max-width: 190px; + border: 0; + padding: 0; + color: #28344b; + cursor: pointer; + background: transparent; + font-size: 10.5px; + font-weight: 680; + letter-spacing: 0; + outline: 0; + text-transform: none; +} + +.preview-window-size { + height: 30px; + display: inline-flex; + align-items: center; + gap: 5px; + border: 1px solid #d7dce3; + border-radius: 8px; + padding: 0 8px; + color: #7a8392; + background: #fff; + box-shadow: 0 1px 2px rgb(23 32 51 / 5%); + font-size: 9px; + font-weight: 700; +} + +.preview-window-size[hidden] { + display: none; +} + +.preview-window-size input { + width: 48px; + border: 0; + padding: 0; + color: #28344b; + background: transparent; + font-size: 10.5px; + font-variant-numeric: tabular-nums; + font-weight: 680; + outline: 0; + text-align: right; +} + +.preview-window-size input:focus { + color: var(--blue); +} + .preview-zoom-controls { display: inline-flex; align-items: center; @@ -612,11 +686,18 @@ body.is-resizing-panes { flex: 1; position: relative; overflow: hidden; + cursor: grab; + touch-action: none; + user-select: none; background: radial-gradient(circle at center, rgb(109 120 139 / 16%) 0 0.7px, transparent 0.8px) 0 0 / 14px 14px, #eef1f4; } +.preview-surface.is-panning { + cursor: grabbing; +} + .preview-canvas { width: 100%; height: 100%; @@ -633,6 +714,7 @@ body.is-resizing-panes { color: #7d8797; font-size: 12px; line-height: 1.6; + pointer-events: none; text-align: center; } @@ -1068,6 +1150,11 @@ footer { align-items: flex-end; } + .preview-control-row { + align-items: flex-end; + flex-direction: column; + } + .preview-heading-meta > p { max-width: none; } From 722c72954d4bdb8e03268c5af82e7e47411c5a44 Mon Sep 17 00:00:00 2001 From: Kyle Date: Wed, 5 Aug 2026 23:51:40 +0800 Subject: [PATCH 3/3] Refine preview controls and add pinch zoom --- Sources/DisplayListWeb/PreviewRenderer.swift | 167 ++++++++++++-- Sources/DisplayListWeb/main.swift | 188 ++++++++++++++-- index.html | 109 +++++++--- styles.css | 215 ++++++++++++++----- 4 files changed, 552 insertions(+), 127 deletions(-) diff --git a/Sources/DisplayListWeb/PreviewRenderer.swift b/Sources/DisplayListWeb/PreviewRenderer.swift index e88bfb0..02ae019 100644 --- a/Sources/DisplayListWeb/PreviewRenderer.swift +++ b/Sources/DisplayListWeb/PreviewRenderer.swift @@ -2,6 +2,8 @@ import DisplayListDescription import JavaScriptKit final class DisplayListCanvasRenderer { + private typealias PointerLocation = (x: Double, y: Double) + private let surface: JSObject private let canvas: JSObject private let emptyState: JSObject @@ -19,7 +21,11 @@ final class DisplayListCanvasRenderer { private var renderedScale: Double? private var panOffsetX = 0.0 private var panOffsetY = 0.0 - private var lastPanPoint: (x: Double, y: Double)? + private var lastPanPoint: PointerLocation? + private var activePointers: [Double: PointerLocation] = [:] + private var activePointerIDs: [Double] = [] + private var lastPinchDistance: Double? + private var lastPinchMidpoint: PointerLocation? init( surface: JSObject, @@ -67,7 +73,7 @@ final class DisplayListCanvasRenderer { emptyState.textContent = .string(message) emptyState.hidden = .boolean(false) renderedScale = nil - lastPanPoint = nil + resetGesture() updateScaleInterface() clearCanvas() } @@ -97,10 +103,9 @@ final class DisplayListCanvasRenderer { zoom(by: 1 / 1.41421356237) } - func showActualSize() { - guard preview != nil else { return } - manualScale = 1 - resetPan() + func setScalePercentage(_ percentage: Double) { + guard preview != nil, percentage.isFinite else { return } + manualScale = min(16, max(0.01, percentage / 100)) redraw() } @@ -111,22 +116,85 @@ final class DisplayListCanvasRenderer { redraw() } - func beginPan(x: Double, y: Double) -> Bool { + func beginPointer(id: Double, x: Double, y: Double) -> Bool { guard preview != nil, renderedScale != nil else { return false } - lastPanPoint = (x, y) + if activePointers[id] == nil { + activePointerIDs.append(id) + } + activePointers[id] = (x, y) + if activePointerIDs.count == 1 { + lastPanPoint = (x, y) + lastPinchDistance = nil + lastPinchMidpoint = nil + } else { + lastPanPoint = nil + updatePinchReference() + } return true } - func updatePan(x: Double, y: Double) { - guard let lastPanPoint else { return } - panOffsetX += x - lastPanPoint.x - panOffsetY += y - lastPanPoint.y - self.lastPanPoint = (x, y) - redraw() + func updatePointer(id: Double, x: Double, y: Double) -> Bool { + guard activePointers[id] != nil else { return false } + + if activePointerIDs.count == 1 { + activePointers[id] = (x, y) + guard let lastPanPoint else { return true } + panOffsetX += x - lastPanPoint.x + panOffsetY += y - lastPanPoint.y + self.lastPanPoint = (x, y) + redraw() + return true + } + + let previousDistance = lastPinchDistance + let previousMidpoint = lastPinchMidpoint + activePointers[id] = (x, y) + guard let geometry = pinchGeometry() else { return true } + + if let previousDistance, + previousDistance > 0, + let previousMidpoint { + applyZoom( + factor: geometry.distance / previousDistance, + aroundClientX: previousMidpoint.x, + clientY: previousMidpoint.y, + translationX: geometry.midpoint.x - previousMidpoint.x, + translationY: geometry.midpoint.y - previousMidpoint.y + ) + } + lastPinchDistance = geometry.distance + lastPinchMidpoint = geometry.midpoint + return true } - func endPan() { - lastPanPoint = nil + func endPointer(id: Double) -> Bool { + activePointers[id] = nil + activePointerIDs.removeAll { $0 == id } + + if activePointerIDs.count == 1, + let remaining = activePointers[activePointerIDs[0]] { + lastPanPoint = remaining + lastPinchDistance = nil + lastPinchMidpoint = nil + } else if activePointerIDs.count >= 2 { + lastPanPoint = nil + updatePinchReference() + } else { + lastPanPoint = nil + lastPinchDistance = nil + lastPinchMidpoint = nil + } + return !activePointerIDs.isEmpty + } + + func zoomAround(clientX: Double, clientY: Double, factor: Double) { + applyZoom( + factor: factor, + aroundClientX: clientX, + clientY: clientY, + translationX: 0, + translationY: 0 + ) } func redraw() { @@ -194,10 +262,71 @@ final class DisplayListCanvasRenderer { redraw() } + private func applyZoom( + factor: Double, + aroundClientX clientX: Double, + clientY: Double, + translationX: Double, + translationY: Double + ) { + guard preview != nil, + let renderedScale, + renderedScale > 0, + factor.isFinite, + factor > 0 else { + return + } + + let nextScale = min(16, max(0.01, renderedScale * factor)) + let appliedFactor = nextScale / renderedScale + let bounds = surface.getBoundingClientRect!().object + let localX = clientX - (bounds?.left.number ?? 0) + let localY = clientY - (bounds?.top.number ?? 0) + let centerX = Double(surface.clientWidth.number ?? 0) * 0.5 + let centerY = Double(surface.clientHeight.number ?? 0) * 0.5 + + panOffsetX = (1 - appliedFactor) * (localX - centerX) + + appliedFactor * panOffsetX + + translationX + panOffsetY = (1 - appliedFactor) * (localY - centerY) + + appliedFactor * panOffsetY + + translationY + manualScale = nextScale + redraw() + } + + private func pinchGeometry() -> (distance: Double, midpoint: PointerLocation)? { + guard activePointerIDs.count >= 2, + let first = activePointers[activePointerIDs[0]], + let second = activePointers[activePointerIDs[1]] else { + return nil + } + let dx = second.x - first.x + let dy = second.y - first.y + return ( + distance: (dx * dx + dy * dy).squareRoot(), + midpoint: ((first.x + second.x) * 0.5, (first.y + second.y) * 0.5) + ) + } + + private func updatePinchReference() { + let geometry = pinchGeometry() + lastPinchDistance = geometry?.distance + lastPinchMidpoint = geometry?.midpoint + } + private func resetPan() { panOffsetX = 0 panOffsetY = 0 + resetGesture() + } + + private func resetGesture() { + activePointers.removeAll() + activePointerIDs.removeAll() lastPanPoint = nil + lastPinchDistance = nil + lastPinchMidpoint = nil } private func updateScaleInterface() { @@ -212,16 +341,16 @@ final class DisplayListCanvasRenderer { _ = fitButton.classList.toggle("is-active", isFitted) guard let renderedScale else { - zoomValueButton.textContent = "—" + zoomValueButton.value = "" zoomValueButton.ariaLabel = "Preview scale unavailable" scaleIndicator.hidden = .boolean(true) return } let percentage = formattedPercentage(renderedScale) - zoomValueButton.textContent = .string("\(percentage)%") + zoomValueButton.value = .string(percentage) zoomValueButton.ariaLabel = .string( - "Preview scale \(percentage) percent. Reset to 100 percent" + "Preview scale \(percentage) percent" ) let measurement = scaleMeasurement(for: renderedScale) diff --git a/Sources/DisplayListWeb/main.swift b/Sources/DisplayListWeb/main.swift index 7e505c2..7da3885 100644 --- a/Sources/DisplayListWeb/main.swift +++ b/Sources/DisplayListWeb/main.swift @@ -27,7 +27,10 @@ private let previewSurface = document.getElementById("preview-surface").object! private let previewCanvas = document.getElementById("preview-canvas").object! private let previewEmpty = document.getElementById("preview-empty").object! private let previewSummary = document.getElementById("preview-summary").object! -private let previewDevice = document.getElementById("preview-device").object! +private let previewDeviceButton = document.getElementById("preview-device-button").object! +private let previewDeviceLabel = document.getElementById("preview-device-label").object! +private let previewDeviceDimensions = document.getElementById("preview-device-dimensions").object! +private let previewDeviceMenu = document.getElementById("preview-device-menu").object! private let previewWindowSize = document.getElementById("preview-window-size").object! private let previewWindowWidth = document.getElementById("preview-window-width").object! private let previewWindowHeight = document.getElementById("preview-window-height").object! @@ -38,6 +41,53 @@ private let previewZoomFit = document.getElementById("preview-zoom-fit").object! private let previewScale = document.getElementById("preview-scale").object! private let previewScaleLabel = document.getElementById("preview-scale-label").object! private let previewScaleRule = document.getElementById("preview-scale-rule").object! + +private struct PreviewDeviceChoice { + let identifier: String + let label: String + let dimensions: String + let button: JSObject +} + +private let previewDeviceChoices = [ + PreviewDeviceChoice( + identifier: "iphone17pro", + label: "iPhone 17 Pro", + dimensions: "402 × 874", + button: document.getElementById("preview-device-iphone17pro").object! + ), + PreviewDeviceChoice( + identifier: "iphone17promax", + label: "iPhone 17 Pro Max", + dimensions: "440 × 956", + button: document.getElementById("preview-device-iphone17promax").object! + ), + PreviewDeviceChoice( + identifier: "iphoneair", + label: "iPhone Air", + dimensions: "420 × 912", + button: document.getElementById("preview-device-iphoneair").object! + ), + PreviewDeviceChoice( + identifier: "iphone16e", + label: "iPhone 16e", + dimensions: "390 × 844", + button: document.getElementById("preview-device-iphone16e").object! + ), + PreviewDeviceChoice( + identifier: "iphone15pro", + label: "iPhone 15 Pro", + dimensions: "393 × 852", + button: document.getElementById("preview-device-iphone15pro").object! + ), + PreviewDeviceChoice( + identifier: "window", + label: "Window", + dimensions: "Custom size", + button: document.getElementById("preview-device-window").object! + ), +] + private let previewRenderer = DisplayListCanvasRenderer( surface: previewSurface, canvas: previewCanvas, @@ -88,6 +138,7 @@ private var highlightedOutputElements: [JSObject] = [] private var activeHighlightKey: String? private var isInitializingEditor = true private var isURLStateActive = false +private var previewDeviceIdentifier = "iphone17pro" private let sampleDescription = """ (display-list @@ -109,14 +160,41 @@ private let sampleDescription = """ private let sampleMinimalDescription = "(DL(I:0(E(I:0 D)))(I:2(E(I:0 T))))" +private func setPreviewDeviceMenuOpen(_ isOpen: Bool) { + previewDeviceMenu.hidden = .boolean(!isOpen) + previewDeviceButton.ariaExpanded = .string(isOpen ? "true" : "false") +} + +private func selectPreviewDevice(_ choice: PreviewDeviceChoice) { + previewDeviceIdentifier = choice.identifier + previewDeviceLabel.textContent = .string(choice.label) + previewDeviceDimensions.textContent = .string(choice.dimensions) + for candidate in previewDeviceChoices { + candidate.button.ariaSelected = .string( + candidate.identifier == choice.identifier ? "true" : "false" + ) + } + setPreviewDeviceMenuOpen(false) + updatePreviewDevice() + _ = previewDeviceButton.focus!() +} + private func updatePreviewDevice() { - let identifier = previewDevice.value.string ?? "iphone17pro" - let isWindow = identifier == "window" + let isWindow = previewDeviceIdentifier == "window" previewWindowSize.hidden = .boolean(!isWindow) + let rawWidth = previewWindowWidth.valueAsNumber.number ?? 800 + let rawHeight = previewWindowHeight.valueAsNumber.number ?? 600 + let width = rawWidth.isFinite ? min(10_000, max(100, rawWidth.rounded())) : 800 + let height = rawHeight.isFinite ? min(10_000, max(100, rawHeight.rounded())) : 600 + if isWindow { + previewDeviceDimensions.textContent = .string( + "\(Int(width)) × \(Int(height))" + ) + } previewRenderer.selectDevice( - identifier, - windowWidth: previewWindowWidth.valueAsNumber.number ?? 800, - windowHeight: previewWindowHeight.valueAsNumber.number ?? 600 + previewDeviceIdentifier, + windowWidth: width, + windowHeight: height ) } @@ -660,12 +738,16 @@ private func installEventHandlers() { previewZoomOut.onclick = .object(zoomOutClosure) retainedClosures.append(zoomOutClosure) - let actualSizeClosure = JSClosure { _ in - previewRenderer.showActualSize() + let zoomValueClosure = JSClosure { _ in + guard let percentage = previewZoomValue.valueAsNumber.number, + percentage.isFinite else { + return .undefined + } + previewRenderer.setScalePercentage(percentage) return .undefined } - previewZoomValue.onclick = .object(actualSizeClosure) - retainedClosures.append(actualSizeClosure) + previewZoomValue.oninput = .object(zoomValueClosure) + retainedClosures.append(zoomValueClosure) let zoomInClosure = JSClosure { _ in previewRenderer.zoomIn() @@ -681,12 +763,50 @@ private func installEventHandlers() { previewZoomFit.onclick = .object(fitClosure) retainedClosures.append(fitClosure) - let deviceClosure = JSClosure { _ in - updatePreviewDevice() + let deviceButtonClosure = JSClosure { arguments in + if let event = arguments.first?.object { + _ = event.stopPropagation!() + } + let shouldOpen = previewDeviceMenu.hidden.boolean ?? true + setPreviewDeviceMenuOpen(shouldOpen) + if shouldOpen, + let selectedChoice = previewDeviceChoices.first(where: { + $0.identifier == previewDeviceIdentifier + }) { + _ = selectedChoice.button.focus!() + } + return .undefined + } + previewDeviceButton.onclick = .object(deviceButtonClosure) + retainedClosures.append(deviceButtonClosure) + + for choice in previewDeviceChoices { + let choiceClosure = JSClosure { _ in + selectPreviewDevice(choice) + return .undefined + } + choice.button.onclick = .object(choiceClosure) + retainedClosures.append(choiceClosure) + } + + let dismissDeviceMenuClosure = JSClosure { _ in + setPreviewDeviceMenuOpen(false) return .undefined } - previewDevice.onchange = .object(deviceClosure) - retainedClosures.append(deviceClosure) + document.onclick = .object(dismissDeviceMenuClosure) + retainedClosures.append(dismissDeviceMenuClosure) + + let deviceMenuKeyClosure = JSClosure { arguments in + guard arguments.first?.object?.key.string == "Escape", + !(previewDeviceMenu.hidden.boolean ?? true) else { + return .undefined + } + setPreviewDeviceMenuOpen(false) + _ = previewDeviceButton.focus!() + return .undefined + } + document.onkeydown = .object(deviceMenuKeyClosure) + retainedClosures.append(deviceMenuKeyClosure) let windowSizeClosure = JSClosure { _ in updatePreviewDevice() @@ -699,14 +819,13 @@ private func installEventHandlers() { let panStartClosure = JSClosure { arguments in guard let event = arguments.first?.object, (event.button.number ?? 0) == 0, + let pointerID = event.pointerId.number, let x = event.clientX.number, let y = event.clientY.number, - previewRenderer.beginPan(x: x, y: y) else { + previewRenderer.beginPointer(id: pointerID, x: x, y: y) else { return .undefined } - if let pointerID = event.pointerId.number { - _ = previewSurface.setPointerCapture!(pointerID) - } + _ = previewSurface.setPointerCapture!(pointerID) _ = previewSurface.classList.add("is-panning") _ = event.preventDefault!() return .undefined @@ -716,25 +835,48 @@ private func installEventHandlers() { let panMoveClosure = JSClosure { arguments in guard let event = arguments.first?.object, + let pointerID = event.pointerId.number, let x = event.clientX.number, - let y = event.clientY.number else { + let y = event.clientY.number, + previewRenderer.updatePointer(id: pointerID, x: x, y: y) else { return .undefined } - previewRenderer.updatePan(x: x, y: y) + _ = event.preventDefault!() return .undefined } previewSurface.onpointermove = .object(panMoveClosure) retainedClosures.append(panMoveClosure) - let panEndClosure = JSClosure { _ in - previewRenderer.endPan() - _ = previewSurface.classList.remove("is-panning") + let panEndClosure = JSClosure { arguments in + guard let pointerID = arguments.first?.object?.pointerId.number else { + return .undefined + } + if !previewRenderer.endPointer(id: pointerID) { + _ = previewSurface.classList.remove("is-panning") + } return .undefined } previewSurface.onpointerup = .object(panEndClosure) previewSurface.onpointercancel = .object(panEndClosure) + previewSurface.onlostpointercapture = .object(panEndClosure) retainedClosures.append(panEndClosure) + let pinchWheelClosure = JSClosure { arguments in + guard let event = arguments.first?.object, + event.ctrlKey.boolean == true, + let deltaY = event.deltaY.number, + let x = event.clientX.number, + let y = event.clientY.number, + let factor = JSObject.global.Math.exp(-deltaY / 100).number else { + return .undefined + } + previewRenderer.zoomAround(clientX: x, clientY: y, factor: factor) + _ = event.preventDefault!() + return .undefined + } + previewSurface.onwheel = .object(pinchWheelClosure) + retainedClosures.append(pinchWheelClosure) + let resizeClosure = JSClosure { _ in previewRenderer.redraw() return .undefined diff --git a/index.html b/index.html index 2224cad..82f1bcd 100644 --- a/index.html +++ b/index.html @@ -97,7 +97,12 @@

DisplayList Description

aria-controls="minimal-panel" data-tab="minimal" > - minimalDesc + + minimalDesc
@@ -190,17 +209,42 @@

DisplayList Preview

No preview

- +
+ + +
- + -
+
diff --git a/styles.css b/styles.css index a65cb0c..7df8d2e 100644 --- a/styles.css +++ b/styles.css @@ -441,7 +441,7 @@ body.is-resizing-panes { align-self: stretch; display: flex; align-items: stretch; - gap: 4px; + gap: 6px; overflow-x: auto; } @@ -451,32 +451,32 @@ body.is-resizing-panes { display: inline-flex; align-items: center; gap: 7px; + margin: 10px 0; + border-radius: 8px; padding: 0 13px; color: #7a8392; background: transparent; } -.tab::after { - position: absolute; - right: 10px; - bottom: 0; - left: 10px; - height: 2px; - border-radius: 2px 2px 0 0; - background: transparent; - content: ""; +.tab-icon { + width: 16px; + height: 16px; + flex: 0 0 auto; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.8; } .tab:hover { color: var(--ink); + background: #f0f3f8; } .tab.is-active { color: var(--blue); -} - -.tab.is-active::after { - background: var(--blue); + background: #eaf1ff; } .tab-panel { @@ -535,6 +535,7 @@ body.is-resizing-panes { .preview-heading { flex: 0 0 auto; + z-index: 2; } .preview-heading-meta { @@ -559,35 +560,98 @@ body.is-resizing-panes { gap: 8px; } -.preview-device-control { +.preview-device-picker { + position: relative; +} + +.preview-device-button { height: 30px; display: inline-flex; align-items: center; - gap: 7px; - border: 1px solid #d7dce3; - border-radius: 8px; - padding: 0 8px; - color: #7a8392; + gap: 8px; + border: 0; + border-radius: 999px; + padding: 0 12px; + color: #28344b; + cursor: pointer; + background: #f0f4fb; + font-size: 10.5px; + font-weight: 680; +} + +.preview-device-button:hover, +.preview-device-button[aria-expanded="true"] { + color: var(--blue); + background: #e6efff; +} + +.preview-device-icon, +.preview-device-chevron, +.preview-device-dimensions { + color: #7a8496; +} + +.preview-device-dimensions { + font-variant-numeric: tabular-nums; + font-weight: 580; +} + +.preview-device-menu { + min-width: 254px; + position: absolute; + z-index: 20; + top: calc(100% + 7px); + right: 0; + display: grid; + gap: 2px; + border: 1px solid #d5dae2; + border-radius: 12px; + padding: 6px; background: #fff; - box-shadow: 0 1px 2px rgb(23 32 51 / 5%); - font-size: 9px; - font-weight: 760; - letter-spacing: 0.06em; - text-transform: uppercase; + box-shadow: 0 16px 38px rgb(25 37 58 / 18%); } -.preview-device-control select { - max-width: 190px; +.preview-device-menu[hidden] { + display: none; +} + +.preview-device-menu button { + width: 100%; + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto; + gap: 8px; + align-items: center; border: 0; - padding: 0; + border-radius: 7px; + padding: 8px; color: #28344b; cursor: pointer; background: transparent; - font-size: 10.5px; - font-weight: 680; - letter-spacing: 0; - outline: 0; - text-transform: none; + font-size: 11px; + text-align: left; +} + +.preview-device-menu button::before { + color: var(--blue); + content: ""; + font-weight: 800; +} + +.preview-device-menu button[aria-selected="true"]::before { + content: "✓"; +} + +.preview-device-menu button:hover, +.preview-device-menu button:focus-visible, +.preview-device-menu button[aria-selected="true"] { + background: #edf3ff; + outline: none; +} + +.preview-device-menu button span:last-child { + color: #7a8496; + font-variant-numeric: tabular-nums; + font-weight: 580; } .preview-window-size { @@ -595,12 +659,11 @@ body.is-resizing-panes { display: inline-flex; align-items: center; gap: 5px; - border: 1px solid #d7dce3; - border-radius: 8px; - padding: 0 8px; + border: 0; + border-radius: 999px; + padding: 0 10px; color: #7a8392; - background: #fff; - box-shadow: 0 1px 2px rgb(23 32 51 / 5%); + background: #f0f4fb; font-size: 9px; font-weight: 700; } @@ -612,7 +675,8 @@ body.is-resizing-panes { .preview-window-size input { width: 48px; border: 0; - padding: 0; + border-bottom: 1px solid #cbd2dc; + padding: 2px 0; color: #28344b; background: transparent; font-size: 10.5px; @@ -630,18 +694,17 @@ body.is-resizing-panes { display: inline-flex; align-items: center; overflow: hidden; - border: 1px solid #d7dce3; - border-radius: 8px; - background: #fff; - box-shadow: 0 1px 2px rgb(23 32 51 / 5%); + border: 0; + border-radius: 999px; + padding: 0 5px; + background: #f0f4fb; } .preview-zoom-controls button { min-width: 29px; height: 28px; border: 0; - border-right: 1px solid #e2e5ea; - padding: 0 8px; + padding: 0 6px; color: #536075; cursor: pointer; background: transparent; @@ -649,10 +712,6 @@ body.is-resizing-panes { font-weight: 680; } -.preview-zoom-controls button:last-child { - border-right: 0; -} - .preview-zoom-controls button:hover, .preview-zoom-controls button:focus-visible { color: var(--blue); @@ -666,19 +725,63 @@ body.is-resizing-panes { background: transparent; } -.preview-zoom-controls .preview-zoom-value { - min-width: 51px; - color: #28344b; +.preview-zoom-value { + min-width: 52px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 1px; + color: var(--blue); font-variant-numeric: tabular-nums; + font-size: 11px; + font-weight: 720; } -.preview-zoom-controls .preview-zoom-fit { - min-width: 38px; +.preview-zoom-value input { + width: 34px; + border: 0; + padding: 0; + color: inherit; + background: transparent; + font: inherit; + outline: 0; + text-align: right; +} + +.preview-zoom-value input:disabled { + color: #a8afb9; +} + +.preview-zoom-value input::-webkit-inner-spin-button, +.preview-zoom-value input::-webkit-outer-spin-button { + margin: 0; + appearance: none; +} + +.preview-zoom-fit { + height: 30px; + border: 0; + border-radius: 999px; + padding: 0 13px; + color: var(--blue); + cursor: pointer; + background: #e6efff; + font-size: 11px; + font-weight: 720; } -.preview-zoom-controls .preview-zoom-fit.is-active { +.preview-zoom-fit:hover, +.preview-zoom-fit:focus-visible, +.preview-zoom-fit.is-active { color: var(--blue); - background: var(--blue-soft); + background: #dbe7ff; + outline: none; +} + +.preview-zoom-fit:disabled { + color: #a8afb9; + cursor: default; + background: #f0f2f5; } .preview-surface {