diff --git a/docs/macos.md b/docs/macos.md index 2ae50f3..b8a6285 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -4,7 +4,7 @@ Platform constraints, build requirements and SwiftUI behavior observed live. ## Decision -**No web view, under any circumstances**: no `WKWebView`, no `WebView`, no HTML rendering of any kind, in the window, in a dialog, for Markdown, for diagrams, for the terminal or for previews. A link to a web page opens in the default browser. Mermaid needs a browser DOM, so the app renders diagrams natively. byre's Markdown dialect (choice markers, `
` sections, `#/task/` links, worktree image paths, `NO_REPLY`) matches no library, so the app parses it itself. +**No web view, under any circumstances**: no `WKWebView`, no `WebView`, no HTML rendering of any kind, in the window, in a dialog, for Markdown, for diagrams, for the terminal or for previews. A link to a web page opens in the default browser. Mermaid needs a browser DOM, so the app renders diagrams natively. Mermaid's automatic layout can't give the banded, nested look of a hand-drawn architecture picture, so byre also has its own ```` ```diagram ```` block (rows, nested boxes, a side column, routed arrows, named highlights), laid out on a grid by the app; its syntax is the `diagrams` knowledge-base article. byre's Markdown dialect (choice markers, `
` sections, `#/task/` links, worktree image paths, `NO_REPLY`) matches no library, so the app parses it itself. The app joins the tailnet as its own node with libtailscale and reaches byre's HTTPS origin on byre's node, so byre knows who is calling from the node itself. It never sends identity headers, and certificate validation is never relaxed. diff --git a/internal/agents/kb/diagrams.md b/internal/agents/kb/diagrams.md new file mode 100644 index 0000000..a93a633 --- /dev/null +++ b/internal/agents/kb/diagrams.md @@ -0,0 +1,33 @@ +How to draw a box diagram (architecture, components, data flow) that byre's app renders in the transcript, with zoom and highlighting. + +# Box diagrams + +A fenced block with the language `diagram` draws boxes in rows with arrows between them. The app lays it out itself, so you never place coordinates. Clicking it opens a full-size view: pinch or scroll to zoom and pan; clicking a box highlights it, its arrows and its neighbors; each `highlight` line becomes a button there. Mermaid flowcharts (```` ```mermaid ````) still work for graphs whose shape the layout should find on its own. + +```diagram +row + app "Byre.app" "REST + SSE · WebSockets" +row + server "internal/server" "REST · SSE · WebSockets" blue + aside store "internal/store" "byre.db, SQLite WAL" green +row + manager "agents.Manager" amber + coord "coordinator" "level-triggered" + live "live sessions" "turn end: commit, snapshot" +app -> server +server -> manager "start · message" +manager -> store "writes" +store ..> server "events → SSE" +store ..> coord "poke" +highlight "Event loop" store server coord +``` + +- `row` starts a row; rows stack top to bottom. The boxes indented under it sit side by side, stretched to a common width. +- A box is `id "Title" "detail line" ...`, then optionally a color: `blue`, `green`, `amber`, `red` or `plain` (the default). The id is letters, digits, `_` and `-`, unique in the diagram. Without a title the id is shown. +- A box indented under another box is its child; children sit side by side inside their parent, and nest to any depth. +- `aside` before a top-level box puts it in a column to the right of the rows, level with its row. Use it for shared state (a database, git) that many rows touch. +- `a -> b "label"` draws a solid arrow and `a ..> b "label"` a dashed one; the label is optional. Arrows route around boxes, and may end on a child or a parent. +- `highlight "Name" id id ...` names a set of boxes to light up together, with the arrows between them. +- Lines starting with `#` are comments. A mistake shows the source with the reason it wasn't drawn. + +Keep each box to a title and one or two short detail lines, and draw only the arrows the reader needs; a picture with every dependency in it reads worse than prose. diff --git a/mac/Sources/Byre/BoxDiagram.swift b/mac/Sources/Byre/BoxDiagram.swift new file mode 100644 index 0000000..31e0853 --- /dev/null +++ b/mac/Sources/Byre/BoxDiagram.swift @@ -0,0 +1,941 @@ +import AppKit +import CoreText +import SwiftUI + +enum BoxTint: String { + case plain, blue, green, amber, red +} + +struct BoxNode { + let id: String + var title: String + var details: [String] = [] + var tint = BoxTint.plain + var children: [BoxNode] = [] +} + +struct BoxRow { + var main: [BoxNode] = [] + var aside: [BoxNode] = [] +} + +struct BoxEdge { + let from: String + let to: String + let label: String? + let dashed: Bool +} + +struct BoxPreset { + let name: String + let ids: Set +} + +struct BoxDiagram { + var rows: [BoxRow] = [] + var edges: [BoxEdge] = [] + var presets: [BoxPreset] = [] +} + +private enum Token: Equatable { + case word(String) + case text(String) +} + +private func tokenize(_ line: String) throws(DiagramError) -> [Token] { + var out: [Token] = [] + var rest = Substring(line) + while true { + rest = rest.drop { $0 == " " || $0 == "\t" } + guard let c = rest.first else { + return out + } + if c == "\"" { + var text = "" + var i = rest.index(after: rest.startIndex) + var closed = false + while i < rest.endIndex { + let ch = rest[i] + if ch == "\\", rest.index(after: i) < rest.endIndex { + i = rest.index(after: i) + text.append(rest[i]) + } else if ch == "\"" { + closed = true + break + } else { + text.append(ch) + } + i = rest.index(after: i) + } + guard closed else { + throw DiagramError(reason: "unclosed quote in \"\(line)\"") + } + out.append(.text(text)) + rest = rest[rest.index(after: i)...] + continue + } + let word = rest.prefix { $0 != " " && $0 != "\t" && $0 != "\"" } + out.append(.word(String(word))) + rest = rest.dropFirst(word.count) + } +} + +private let boxId = Re(#"^[A-Za-z0-9_-]+$"#) + +private final class NodeBuilder { + var node: BoxNode + var children: [NodeBuilder] = [] + + init(_ node: BoxNode) { + self.node = node + } + + func build() -> BoxNode { + var n = node + n.children = children.map { $0.build() } + return n + } +} + +func parseBoxDiagram(_ src: String) throws(DiagramError) -> BoxDiagram { + var rows: [(main: [NodeBuilder], aside: [NodeBuilder])] = [] + var stack: [(indent: Int, node: NodeBuilder)] = [] + var ids = Set() + var diagram = BoxDiagram() + for raw in src.components(separatedBy: "\n") { + let line = raw.replacingOccurrences(of: "\t", with: " ") + let body = trimmed(line) + if body.isEmpty || body.hasPrefix("#") { + continue + } + let indent = line.prefix { $0 == " " }.count + var toks = try tokenize(body) + if indent == 0 { + stack = [] + if toks == [.word("row")] { + rows.append(([], [])) + continue + } + if toks.first == .word("highlight") { + guard toks.count >= 3, case .text(let name) = toks[1] else { + throw DiagramError(reason: "a highlight needs a quoted name and box ids: \"\(body)\"") + } + var set = Set() + for t in toks.dropFirst(2) { + guard case .word(let id) = t else { + throw DiagramError(reason: "can't parse \"\(body)\"") + } + set.insert(id) + } + diagram.presets.append(BoxPreset(name: name, ids: set)) + continue + } + guard toks.count == 3 || toks.count == 4, case .word(let from) = toks[0], case .word(let arrow) = toks[1], case .word(let to) = toks[2], arrow == "->" || arrow == "..>" else { + throw DiagramError(reason: "can't parse \"\(body)\"; top-level lines are row, highlight or an edge like a -> b \"label\"") + } + var label: String? + if toks.count == 4 { + guard case .text(let l) = toks[3] else { + throw DiagramError(reason: "an edge's label must be quoted: \"\(body)\"") + } + label = l + } + diagram.edges.append(BoxEdge(from: from, to: to, label: label, dashed: arrow == "..>")) + continue + } + guard !rows.isEmpty else { + throw DiagramError(reason: "boxes go under a row: \"\(body)\"") + } + let aside = toks.first == .word("aside") + if aside { + toks.removeFirst() + } + guard case .word(let id) = toks.first, boxId.test(id) else { + throw DiagramError(reason: "a box starts with its id: \"\(body)\"") + } + guard ids.insert(id).inserted else { + throw DiagramError(reason: "box \(id) is defined twice") + } + var node = BoxNode(id: id, title: id) + var texts: [String] = [] + for t in toks.dropFirst() { + switch t { + case .text(let s): + texts.append(s) + case .word(let w): + guard let tint = BoxTint(rawValue: w) else { + throw DiagramError(reason: "\(w) isn't a color; use blue, green, amber, red or plain") + } + node.tint = tint + } + } + if let first = texts.first { + node.title = first + node.details = Array(texts.dropFirst()) + } + let b = NodeBuilder(node) + while let last = stack.last, last.indent >= indent { + stack.removeLast() + } + if let parent = stack.last { + guard !aside else { + throw DiagramError(reason: "aside boxes sit directly under a row: \"\(body)\"") + } + parent.node.children.append(b) + } else if aside { + rows[rows.count - 1].aside.append(b) + } else { + rows[rows.count - 1].main.append(b) + } + stack.append((indent, b)) + } + diagram.rows = rows.map { BoxRow(main: $0.main.map { $0.build() }, aside: $0.aside.map { $0.build() }) } + if ids.isEmpty { + throw DiagramError(reason: "empty diagram") + } + for e in diagram.edges { + for id in [e.from, e.to] where !ids.contains(id) { + throw DiagramError(reason: "edge \(e.from) -> \(e.to) names no box \(id)") + } + } + for p in diagram.presets { + for id in p.ids where !ids.contains(id) { + throw DiagramError(reason: "highlight \"\(p.name)\" names no box \(id)") + } + } + return diagram +} + +struct PlacedBox { + let node: BoxNode + let rect: CGRect + let depth: Int + let parent: String? + let header: CGRect +} + +struct RoutedEdge { + let edge: BoxEdge + let points: [CGPoint] + let labelRect: CGRect? +} + +struct BoxLayout: Identifiable { + let id = UUID() + let boxes: [PlacedBox] + let index: [String: Int] + let edges: [RoutedEdge] + let presets: [BoxPreset] + let size: CGSize + + func ancestors(_ id: String) -> [String] { + var out: [String] = [] + var p = boxes[index[id]!].parent + while let id = p { + out.append(id) + p = boxes[index[id]!].parent + } + return out + } + + func hit(_ p: CGPoint) -> String? { + boxes.filter { $0.rect.contains(p) }.max { $0.depth < $1.depth }?.node.id + } +} + +struct BoxFocus { + let boxes: Set + let edges: Set + + static func box(_ id: String, _ layout: BoxLayout) -> BoxFocus { + var boxes: Set = [id] + var edges = Set() + for (i, r) in layout.edges.enumerated() where r.edge.from == id || r.edge.to == id { + edges.insert(i) + boxes.insert(r.edge.from) + boxes.insert(r.edge.to) + } + return BoxFocus(boxes: boxes, edges: edges) + } + + static func preset(_ p: BoxPreset, _ layout: BoxLayout) -> BoxFocus { + let edges = layout.edges.indices.filter { p.ids.contains(layout.edges[$0].edge.from) && p.ids.contains(layout.edges[$0].edge.to) } + return BoxFocus(boxes: p.ids, edges: Set(edges)) + } + + func dims(_ b: PlacedBox, _ layout: BoxLayout) -> Bool { + if boxes.contains(b.node.id) || layout.ancestors(b.node.id).contains(where: boxes.contains) { + return false + } + return !boxes.contains { layout.ancestors($0).contains(b.node.id) } + } +} + +private let pad: CGFloat = 12 +private let childGap: CGFloat = 14 +private let boxGap: CGFloat = 20 +private let rowGap: CGFloat = 36 +private let columnGap: CGFloat = 80 +private let margin: CGFloat = 20 + +@MainActor +private var titleFont: CTFont { + Fonts.ns(13, weight: 600) as CTFont +} + +@MainActor +private var detailFont: CTFont { + Fonts.ns(12) as CTFont +} + +@MainActor +private var edgeLabelFont: CTFont { + Fonts.ns(11) as CTFont +} + +@MainActor +private func headerSize(_ n: BoxNode) -> CGSize { + let t = textSize(n.title, titleFont) + var w = t.width + var h = t.height + for d in n.details { + let s = textSize(d, detailFont) + w = max(w, s.width) + h += s.height + } + return CGSize(width: w, height: h + (n.details.isEmpty ? 0 : 2)) +} + +@MainActor +private func naturalSize(_ n: BoxNode) -> CGSize { + let head = headerSize(n) + if n.children.isEmpty { + return CGSize(width: head.width + pad * 2, height: head.height + pad * 2) + } + let kids = n.children.map(naturalSize) + let row = kids.map(\.width).reduce(0, +) + childGap * CGFloat(kids.count - 1) + return CGSize(width: max(head.width, row) + pad * 2, height: pad + head.height + pad + kids.map(\.height).max()! + pad) +} + +@MainActor +private func stretch(_ nodes: [BoxNode], to width: CGFloat, gap: CGFloat) -> [CGFloat] { + let natural = nodes.map { naturalSize($0).width } + let sum = natural.reduce(0, +) + let extra = width - sum - gap * CGFloat(nodes.count - 1) + return natural.map { $0 + extra * $0 / sum } +} + +@MainActor +private func place(_ n: BoxNode, _ rect: CGRect, _ depth: Int, _ parent: String?, _ out: inout [PlacedBox]) { + let head = headerSize(n) + let header = CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: pad + head.height + (n.children.isEmpty ? pad : 0)) + out.append(PlacedBox(node: n, rect: rect, depth: depth, parent: parent, header: header)) + if n.children.isEmpty { + return + } + let widths = stretch(n.children, to: rect.width - pad * 2, gap: childGap) + let top = rect.minY + pad + head.height + pad + var x = rect.minX + pad + for (c, w) in zip(n.children, widths) { + place(c, CGRect(x: x, y: top, width: w, height: rect.maxY - pad - top), depth + 1, n.id, &out) + x += w + childGap + } +} + +@MainActor +private func rowWidth(_ r: BoxRow) -> CGFloat { + let boxes: CGFloat = r.main.map { naturalSize($0).width }.reduce(0, +) + return boxes + boxGap * CGFloat(max(0, r.main.count - 1)) +} + +@MainActor +func layoutBoxDiagram(_ d: BoxDiagram) throws(DiagramError) -> BoxLayout { + let mainWidth: CGFloat = d.rows.map(rowWidth).max() ?? 0 + let asideWidth: CGFloat = d.rows.flatMap(\.aside).map { naturalSize($0).width }.max() ?? 0 + let asideX = margin + mainWidth + columnGap + var boxes: [PlacedBox] = [] + var y = margin + var asideBottom = margin - rowGap + var bottom = margin + for r in d.rows { + let height = r.main.map { naturalSize($0).height }.max() ?? 0 + var x = margin + for (n, w) in zip(r.main, stretch(r.main, to: mainWidth, gap: boxGap)) { + place(n, CGRect(x: x, y: y, width: w, height: height), 0, nil, &boxes) + x += w + boxGap + } + var ay = max(y, asideBottom + rowGap) + for n in r.aside { + let h = naturalSize(n).height + place(n, CGRect(x: asideX, y: ay, width: asideWidth, height: h), 0, nil, &boxes) + ay += h + rowGap + asideBottom = ay - rowGap + } + bottom = max(bottom, y + height, asideBottom) + y += height + (r.main.isEmpty ? 0 : rowGap) + } + let width = (asideWidth > 0 ? asideX + asideWidth : margin + mainWidth) + margin + let size = CGSize(width: width, height: bottom + margin) + let index = Dictionary(uniqueKeysWithValues: boxes.enumerated().map { ($0.element.node.id, $0.offset) }) + var router = Router(boxes: boxes, index: index, size: size) + let order = d.edges.indices.sorted { a, b in + router.span(d.edges[a]) < router.span(d.edges[b]) + } + var points = [[CGPoint]](repeating: [], count: d.edges.count) + for i in order { + points[i] = try router.route(d.edges[i]) + } + var taken: [CGRect] = boxes.map { $0.node.children.isEmpty ? $0.rect : $0.header } + let lines = points.map { pts in zip(pts, pts.dropFirst()).map { a, b in CGRect(x: min(a.x, b.x), y: min(a.y, b.y), width: abs(a.x - b.x), height: abs(a.y - b.y)).insetBy(dx: -1, dy: -1) } } + var routed: [RoutedEdge] = [] + for (i, (e, pts)) in zip(d.edges, points).enumerated() { + var labelRect: CGRect? + if let label = e.label { + let others = lines.enumerated().filter { $0.offset != i }.flatMap(\.element) + let r = labelSpot(textSize(label, edgeLabelFont), pts, taken + others, CGRect(origin: .zero, size: size)) + taken.append(r) + labelRect = r + } + routed.append(RoutedEdge(edge: e, points: pts, labelRect: labelRect)) + } + return BoxLayout(boxes: boxes, index: index, edges: routed, presets: d.presets, size: size) +} + +private func labelSpot(_ size: CGSize, _ pts: [CGPoint], _ taken: [CGRect], _ bounds: CGRect) -> CGRect { + var candidates: [CGRect] = [] + let segs = zip(pts, pts.dropFirst()).sorted { a, b in + abs(a.0.x - a.1.x) + abs(a.0.y - a.1.y) > abs(b.0.x - b.1.x) + abs(b.0.y - b.1.y) + } + for (a, b) in segs { + for t in [0.5, 0.3, 0.7, 0.15, 0.85] { + let p = CGPoint(x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t) + if a.x == b.x { + candidates.append(CGRect(x: p.x + 6, y: p.y - size.height / 2, width: size.width, height: size.height)) + candidates.append(CGRect(x: p.x - 6 - size.width, y: p.y - size.height / 2, width: size.width, height: size.height)) + } else { + candidates.append(CGRect(x: p.x - size.width / 2, y: p.y - 3 - size.height, width: size.width, height: size.height)) + candidates.append(CGRect(x: p.x - size.width / 2, y: p.y + 3, width: size.width, height: size.height)) + } + } + } + func overlap(_ c: CGRect) -> CGFloat { + let outside = c.width * c.height - c.intersection(bounds).width * c.intersection(bounds).height + return taken.reduce(outside) { sum, t in + let i = t.insetBy(dx: -2, dy: -2).intersection(c) + return i.isNull ? sum : sum + max(1, i.width * i.height) + } + } + return candidates.min { overlap($0) < overlap($1) }! +} + +private let cell: CGFloat = 4 +private let stepCost = 10 +private let turnCost = 60 +private let nearCost = 4 +private let parallelCost = 80 +private let crossCost = 15 +private let dx = [1, 0, -1, 0] +private let dy = [0, 1, 0, -1] + +private struct Port { + let cell: Int + let dir: Int + let border: CGPoint + let cost: Int +} + +private struct Router { + let boxes: [PlacedBox] + let index: [String: Int] + let cols: Int + let rows: Int + var used: [UInt8] + + init(boxes: [PlacedBox], index: [String: Int], size: CGSize) { + self.boxes = boxes + self.index = index + cols = Int(ceil(size.width / cell)) + rows = Int(ceil(size.height / cell)) + used = [UInt8](repeating: 0, count: cols * rows) + } + + func span(_ e: BoxEdge) -> CGFloat { + let a = boxes[index[e.from]!].rect + let b = boxes[index[e.to]!].rect + return abs(a.midX - b.midX) + abs(a.midY - b.midY) + } + + private func cells(_ r: CGRect) -> (x0: Int, y0: Int, x1: Int, y1: Int) { + (Int(floor(r.minX / cell)), Int(floor(r.minY / cell)), Int(ceil(r.maxX / cell)) - 1, Int(ceil(r.maxY / cell)) - 1) + } + + private func lineage(_ id: String) -> [String] { + var out: [String] = [] + var p = boxes[index[id]!].parent + while let id = p { + out.append(id) + p = boxes[index[id]!].parent + } + return out + } + + private func blocked(_ e: BoxEdge) -> [Bool] { + let open = Set(lineage(e.from) + lineage(e.to)) + var grid = [Bool](repeating: false, count: cols * rows) + for b in boxes { + let c = cells(open.contains(b.node.id) ? b.header : b.rect) + for y in max(0, c.y0 - 1)...min(rows - 1, c.y1 + 1) { + for x in max(0, c.x0 - 1)...min(cols - 1, c.x1 + 1) { + grid[y * cols + x] = true + } + } + } + return grid + } + + private func ports(_ id: String, _ grid: [Bool], outward: Bool) -> [Port] { + let r = boxes[index[id]!].rect + let c = cells(r) + var out: [Port] = [] + func add(_ x: Int, _ y: Int, _ dir: Int, _ border: CGPoint, _ offset: Int) { + guard x >= 0, y >= 0, x < cols, y < rows, !grid[y * cols + x] else { + return + } + out.append(Port(cell: y * cols + x, dir: outward ? dir : (dir + 2) % 4, border: border, cost: offset * 2)) + } + let midX = (c.x0 + c.x1) / 2 + let midY = (c.y0 + c.y1) / 2 + if c.x1 - c.x0 >= 2 { + for x in (c.x0 + 1)...(c.x1 - 1) { + let px = (CGFloat(x) + 0.5) * cell + add(x, c.y0 - 2, 3, CGPoint(x: px, y: r.minY), abs(x - midX)) + add(x, c.y1 + 2, 1, CGPoint(x: px, y: r.maxY), abs(x - midX)) + } + } + if c.y1 - c.y0 >= 2 { + for y in (c.y0 + 1)...(c.y1 - 1) { + let py = (CGFloat(y) + 0.5) * cell + add(c.x0 - 2, y, 2, CGPoint(x: r.minX, y: py), abs(y - midY)) + add(c.x1 + 2, y, 0, CGPoint(x: r.maxX, y: py), abs(y - midY)) + } + } + return out + } + + mutating func route(_ e: BoxEdge) throws(DiagramError) -> [CGPoint] { + let grid = blocked(e) + var near = [Bool](repeating: false, count: cols * rows) + for i in grid.indices where grid[i] { + let x = i % cols + let y = i / cols + for yy in max(0, y - 2)...min(rows - 1, y + 2) { + for xx in max(0, x - 2)...min(cols - 1, x + 2) { + near[yy * cols + xx] = true + } + } + } + let starts = ports(e.from, grid, outward: true) + let goals = ports(e.to, grid, outward: false) + guard !starts.isEmpty, !goals.isEmpty, e.from != e.to else { + throw DiagramError(reason: "can't route \(e.from) -> \(e.to)") + } + var goalAt: [Int: Port] = [:] + for g in goals { + goalAt[g.cell] = g + } + let gx0 = goals.map { $0.cell % cols }.min()! + let gx1 = goals.map { $0.cell % cols }.max()! + let gy0 = goals.map { $0.cell / cols }.min()! + let gy1 = goals.map { $0.cell / cols }.max()! + func h(_ c: Int) -> Int { + let x = c % cols + let y = c / cols + return stepCost * (max(0, gx0 - x, x - gx1) + max(0, gy0 - y, y - gy1)) + } + let states = cols * rows * 4 + var cost = [Int](repeating: .max, count: states) + var from = [Int](repeating: -1, count: states) + var heap = Heap() + var startAt: [Int: Port] = [:] + for s in starts { + let st = s.cell * 4 + s.dir + if s.cost < cost[st] { + cost[st] = s.cost + startAt[st] = s + heap.push(s.cost + h(s.cell), st) + } + } + var best = Int.max + var bestState = -1 + while let top = heap.pop() { + let (f, st) = top + if f >= best { + break + } + let g = cost[st] + if f > g + h(st / 4) { + continue + } + let c = st / 4 + let dir = st % 4 + if let goal = goalAt[c] { + let total = g + (dir == goal.dir ? 0 : turnCost) + if total < best { + best = total + bestState = st + } + } + let x = c % cols + let y = c / cols + for nd in 0..<4 where nd != (dir + 2) % 4 { + let nx = x + dx[nd] + let ny = y + dy[nd] + guard nx >= 0, ny >= 0, nx < cols, ny < rows else { + continue + } + let n = ny * cols + nx + if grid[n] { + continue + } + var step = stepCost + (nd == dir ? 0 : turnCost) + (near[n] ? nearCost : 0) + let axis: UInt8 = nd % 2 == 0 ? 1 : 2 + if used[n] & axis != 0 { + step += parallelCost + } else if used[n] != 0 { + step += crossCost + } + let ns = n * 4 + nd + if g + step < cost[ns] { + cost[ns] = g + step + from[ns] = st + heap.push(g + step + h(n), ns) + } + } + } + guard bestState >= 0 else { + throw DiagramError(reason: "can't route \(e.from) -> \(e.to)") + } + var path: [Int] = [] + var st = bestState + while st >= 0 { + path.append(st) + st = from[st] + } + path.reverse() + for (a, b) in zip(path, path.dropFirst()) { + let axis: UInt8 = (b % 4) % 2 == 0 ? 1 : 2 + used[a / 4] |= axis + used[b / 4] |= axis + } + func center(_ c: Int) -> CGPoint { + CGPoint(x: (CGFloat(c % cols) + 0.5) * cell, y: (CGFloat(c / cols) + 0.5) * cell) + } + var pts = [startAt[path[0]]!.border] + pts += path.map { center($0 / 4) } + pts.append(goalAt[bestState / 4]!.border) + return simplify(pts) + } +} + +private func simplify(_ pts: [CGPoint]) -> [CGPoint] { + var out: [CGPoint] = [] + for p in pts { + if let last = out.last, last == p { + continue + } + if out.count >= 2 { + let a = out[out.count - 2] + let b = out[out.count - 1] + if (a.x == b.x && b.x == p.x) || (a.y == b.y && b.y == p.y) { + out[out.count - 1] = p + continue + } + } + out.append(p) + } + return out +} + +private struct Heap { + private var items: [(Int, Int)] = [] + + mutating func push(_ key: Int, _ value: Int) { + items.append((key, value)) + var i = items.count - 1 + while i > 0 { + let p = (i - 1) / 2 + if items[p].0 <= items[i].0 { + break + } + items.swapAt(p, i) + i = p + } + } + + mutating func pop() -> (Int, Int)? { + guard let top = items.first else { + return nil + } + let last = items.removeLast() + if !items.isEmpty { + items[0] = last + var i = 0 + while true { + let l = i * 2 + 1 + let r = l + 1 + var m = i + if l < items.count && items[l].0 < items[m].0 { + m = l + } + if r < items.count && items[r].0 < items[m].0 { + m = r + } + if m == i { + break + } + items.swapAt(m, i) + i = m + } + } + return top + } +} + +@MainActor +private var layoutCache: [String: Result] = [:] + +@MainActor +func cachedBoxLayout(_ source: String) -> Result { + let key = "\(FontSize.shared.body)\n\(source)" + if let hit = layoutCache[key] { + return hit + } + let result = Result(catching: { () throws(DiagramError) -> BoxLayout in try layoutBoxDiagram(parseBoxDiagram(source)) }) + if layoutCache.count > 64 { + layoutCache = [:] + } + layoutCache[key] = result + return result +} + +private func fill(_ b: PlacedBox) -> Color { + switch b.node.tint { + case .blue: Palette.accentTint + case .green: Palette.okTint + case .amber: Palette.warnTint + case .red: Palette.dangerTint + case .plain: b.depth == 0 ? Palette.surface : Palette.raised + } +} + +struct BoxCanvas: View { + let layout: BoxLayout + var focus: BoxFocus? + + var body: some View { + Canvas { context, size in + let scale = layout.size.width > 0 ? size.width / layout.size.width : 1 + context.scaleBy(x: scale, y: scale) + draw(&context) + } + } + + private func draw(_ context: inout GraphicsContext) { + let titleLine = textSize("X", titleFont).height + let detailLine = textSize("X", detailFont).height + for b in layout.boxes { + var ctx = context + if let focus, focus.dims(b, layout) { + ctx.opacity = 0.3 + } + let path = Path(roundedRect: b.rect, cornerRadius: b.depth == 0 ? 8 : 6) + ctx.fill(path, with: .color(fill(b))) + let lit = focus?.boxes.contains(b.node.id) == true + ctx.stroke(path, with: .color(lit ? Palette.accent : Palette.border), lineWidth: lit ? 1.5 : 1) + var y = b.rect.minY + pad + ctx.draw(Text(b.node.title).font(Font(titleFont)).foregroundColor(Palette.text), at: CGPoint(x: b.rect.minX + pad, y: y), anchor: .topLeading) + y += titleLine + 2 + for d in b.node.details { + ctx.draw(Text(d).font(Font(detailFont)).foregroundColor(Palette.muted), at: CGPoint(x: b.rect.minX + pad, y: y), anchor: .topLeading) + y += detailLine + } + } + for (i, r) in layout.edges.enumerated() { + var ctx = context + let lit = focus?.edges.contains(i) == true + if focus != nil && !lit { + ctx.opacity = 0.2 + } + let color = lit ? Palette.accent : Palette.muted + ctx.stroke(rounded(r.points), with: .color(color), style: StrokeStyle(lineWidth: lit ? 2 : 1.3, lineCap: .round, lineJoin: .round, dash: r.edge.dashed ? [5, 4] : [])) + if r.points.count >= 2 { + arrow(&ctx, r.points[r.points.count - 2], r.points[r.points.count - 1], color) + } + if let label = r.edge.label, let rect = r.labelRect { + ctx.draw(Text(label).font(Font(edgeLabelFont)).foregroundColor(lit ? Palette.accent : Palette.muted), at: CGPoint(x: rect.minX, y: rect.minY), anchor: .topLeading) + } + } + } + + private func rounded(_ pts: [CGPoint]) -> Path { + var p = Path() + guard let first = pts.first else { + return p + } + p.move(to: first) + if pts.count > 2 { + for i in 1..<(pts.count - 1) { + let a = pts[i - 1] + let b = pts[i] + let c = pts[i + 1] + let r = min(6, hypot(b.x - a.x, b.y - a.y) / 2, hypot(c.x - b.x, c.y - b.y) / 2) + p.addArc(tangent1End: b, tangent2End: c, radius: r) + } + } + p.addLine(to: pts[pts.count - 1]) + return p + } + + private func arrow(_ context: inout GraphicsContext, _ from: CGPoint, _ to: CGPoint, _ color: Color) { + let angle = atan2(to.y - from.y, to.x - from.x) + let size: CGFloat = 8 + var head = Path() + head.move(to: to) + head.addLine(to: CGPoint(x: to.x - size * cos(angle - .pi / 7), y: to.y - size * sin(angle - .pi / 7))) + head.addLine(to: CGPoint(x: to.x - size * cos(angle + .pi / 7), y: to.y - size * sin(angle + .pi / 7))) + head.closeSubpath() + context.fill(head, with: .color(color)) + } +} + +struct BoxDiagramView: View { + let source: String + @Environment(Overlays.self) private var overlays + + var body: some View { + switch cachedBoxLayout(source) { + case .success(let layout): + FitWidth(natural: layout.size) { + BoxCanvas(layout: layout) + } + .contentShape(Rectangle()) + .onTapGesture { + overlays.diagram = layout + } + .help("Open full size") + .frame(maxWidth: .infinity, alignment: .leading) + case .failure(let error): + DiagramFailure(source: source, reason: error.reason) + } + } +} + +struct DiagramViewer: View { + let layout: BoxLayout + @ViewState private var picked: String? + @ViewState private var preset: Int? + + private var focus: BoxFocus? { + if let picked { + return .box(picked, layout) + } + if let preset { + return .preset(layout.presets[preset], layout) + } + return nil + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if !layout.presets.isEmpty { + HStack(spacing: 6) { + ForEach(Array(layout.presets.enumerated()), id: \.offset) { i, p in + Button(p.name) { + picked = nil + preset = preset == i ? nil : i + } + .buttonStyle(WebButtonStyle(primary: preset == i)) + } + } + } + ZoomingCanvas(layout: layout, focus: focus) { id in + preset = nil + picked = id == picked ? nil : id + } + } + .padding(12) + .background(Palette.raised, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border)) + .shadow(color: Palette.overlayShadow, radius: 14, y: 8) + } +} + +private struct TappableCanvas: View { + let layout: BoxLayout + let focus: BoxFocus? + let tap: (String?) -> Void + + var body: some View { + BoxCanvas(layout: layout, focus: focus) + .frame(width: layout.size.width, height: layout.size.height) + .contentShape(Rectangle()) + .onTapGesture(coordinateSpace: .local) { p in + tap(layout.hit(p)) + } + } +} + +private final class CenteringClipView: NSClipView { + override func constrainBoundsRect(_ proposedBounds: NSRect) -> NSRect { + var r = super.constrainBoundsRect(proposedBounds) + guard let doc = documentView else { + return r + } + if r.width > doc.frame.width { + r.origin.x = (doc.frame.width - r.width) / 2 + } + if r.height > doc.frame.height { + r.origin.y = (doc.frame.height - r.height) / 2 + } + return r + } +} + +private final class FittingScrollView: NSScrollView { + var fitted = false + + override func layout() { + super.layout() + guard !fitted, let doc = documentView, bounds.width > 0, bounds.height > 0, doc.frame.width > 0 else { + return + } + fitted = true + magnification = min(2, bounds.width / doc.frame.width, bounds.height / doc.frame.height) + } +} + +private struct ZoomingCanvas: NSViewRepresentable { + let layout: BoxLayout + let focus: BoxFocus? + let tap: (String?) -> Void + + func makeNSView(context: Context) -> FittingScrollView { + let scroll = FittingScrollView() + scroll.contentView = CenteringClipView() + scroll.drawsBackground = false + scroll.hasVerticalScroller = true + scroll.hasHorizontalScroller = true + scroll.autohidesScrollers = true + scroll.allowsMagnification = true + scroll.minMagnification = 0.25 + scroll.maxMagnification = 6 + let host = NSHostingView(rootView: TappableCanvas(layout: layout, focus: focus, tap: tap)) + host.frame = CGRect(origin: .zero, size: layout.size) + scroll.documentView = host + return scroll + } + + func updateNSView(_ scroll: FittingScrollView, context: Context) { + (scroll.documentView as? NSHostingView)?.rootView = TappableCanvas(layout: layout, focus: focus, tap: tap) + } +} diff --git a/mac/Sources/Byre/Diagram.swift b/mac/Sources/Byre/Diagram.swift index 0efb716..4af916d 100644 --- a/mac/Sources/Byre/Diagram.swift +++ b/mac/Sources/Byre/Diagram.swift @@ -606,16 +606,25 @@ struct DiagramView: View { FlowchartCanvas(layout: layoutFlowchart(chart)) .frame(maxWidth: .infinity, alignment: .leading) case .failure(let error): - VStack(alignment: .leading, spacing: 4) { - Text(verbatim: source) - .textSelection(.enabled) - .padding(.horizontal, 8) - .padding(.vertical, 6) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Palette.surface, in: RoundedRectangle(cornerRadius: 6)) - Text(verbatim: "diagram not rendered: \(error.reason)") - .foregroundStyle(Palette.muted) - } + DiagramFailure(source: source, reason: error.reason) + } + } +} + +struct DiagramFailure: View { + let source: String + let reason: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(verbatim: source) + .textSelection(.enabled) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Palette.surface, in: RoundedRectangle(cornerRadius: 6)) + Text(verbatim: "diagram not rendered: \(reason)") + .foregroundStyle(Palette.muted) } } } diff --git a/mac/Sources/Byre/Dialogs.swift b/mac/Sources/Byre/Dialogs.swift index 7b06fcd..c2c9a21 100644 --- a/mac/Sources/Byre/Dialogs.swift +++ b/mac/Sources/Byre/Dialogs.swift @@ -44,6 +44,7 @@ enum LeftTab { final class Overlays { var alerts: [OverlayAlert] = [] var image: NSImage? + var diagram: BoxLayout? var menu: OverlayMenu? { didSet { menuHover = false @@ -64,7 +65,7 @@ final class Overlays { @ObservationIgnored private var ctrl: @MainActor (String) -> Bool = { _ in false } var isOpen: Bool { - !alerts.isEmpty || image != nil || menu != nil || modal != nil || switcher != nil + !alerts.isEmpty || image != nil || diagram != nil || menu != nil || modal != nil || switcher != nil } var dialogOpen: Bool { @@ -121,6 +122,8 @@ final class Overlays { answerModal("cancel") } else if image != nil { image = nil + } else if diagram != nil { + diagram = nil } else if let switcher { switcher.close() } else if menu != nil { @@ -190,7 +193,7 @@ final class Overlays { } return false } - if image == nil, !marked, let switcher { + if image == nil, diagram == nil, !marked, let switcher { if code == 48 { switcher.inList.toggle() return true @@ -276,6 +279,15 @@ struct OverlayHost: View { overlays.image = nil } } + if let diagram = overlays.diagram { + Palette.imageBackdrop + .onTapGesture { + overlays.diagram = nil + } + DiagramViewer(layout: diagram) + .id(diagram.id) + .padding(24) + } if let menu = overlays.menu { VStack(alignment: .leading, spacing: 0) { ForEach(menu.entries) { entry in diff --git a/mac/Sources/Byre/Markdown.swift b/mac/Sources/Byre/Markdown.swift index eb4c6b3..18c763a 100644 --- a/mac/Sources/Byre/Markdown.swift +++ b/mac/Sources/Byre/Markdown.swift @@ -43,6 +43,7 @@ indirect enum MDBlock: Sendable { case diff([String]) case details(summary: [MDInline], body: [MDBlock]) case diagram(String) + case boxDiagram(String) case table(head: [[MDInline]], rows: [[[MDInline]]]) } @@ -126,6 +127,10 @@ private func blocks(_ lines: [String], _ choices: Bool) -> [MDBlock] { out.append(.diagram(body.joined(separator: "\n"))) continue } + if lang == "diagram" { + out.append(.boxDiagram(body.joined(separator: "\n"))) + continue + } if lang == "diff" || lang == "patch" { out.append(.diff(body)) continue diff --git a/mac/Sources/Byre/MarkdownView.swift b/mac/Sources/Byre/MarkdownView.swift index 069db96..ab22de0 100644 --- a/mac/Sources/Byre/MarkdownView.swift +++ b/mac/Sources/Byre/MarkdownView.swift @@ -815,6 +815,8 @@ private struct BlockView: View { DetailsBlock(summary: summary, blocks: body, ctx: ctx) case .diagram(let source): DiagramView(source: source) + case .boxDiagram(let source): + BoxDiagramView(source: source) case .table(let head, let rows): TableBlock(head: head, rows: rows, ctx: ctx) } diff --git a/mac/Sources/Byre/Switcher.swift b/mac/Sources/Byre/Switcher.swift index 18aa5c4..662d85c 100644 --- a/mac/Sources/Byre/Switcher.swift +++ b/mac/Sources/Byre/Switcher.swift @@ -9,6 +9,7 @@ let goKeys: Set = ["k", "n", "p", "l", "s", "/"] @MainActor func runGo(_ key: String, _ store: Store, _ overlays: Overlays) { overlays.image = nil + overlays.diagram = nil switch key { case "k": overlays.openSwitcher(store)