From 14975d2ab8db632e836452efbb83030c27475f1e Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sat, 17 Oct 2026 13:14:35 -0700 Subject: [PATCH] jumpy scroll 4 --- docs/macos.md | 4 +- docs/operations.md | 2 +- mac/Sources/Byre/Chat.swift | 143 +++++++++++++++++++++++------------- 3 files changed, 93 insertions(+), 56 deletions(-) diff --git a/docs/macos.md b/docs/macos.md index d42fcbf..c5a303f 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -93,7 +93,7 @@ Closing the window leaves the app running with its store and streams (`applicati ## Views -- **Transcript and Lead.** `TranscriptBuilder` is a pure function from events to a list of rows: user and agent messages, consecutive tool calls folded into one group with its summary ("Read file ×3"), subagents nested with their status, pending tool calls and running subagents (background ones included after the turn ends) outlined in the accent colour with a 0.6s fade, thinking, the byre prompt collapsed, held turns, `NO_REPLY` turns folded away, handoffs, snapshot rows with changes, fork and review, and notes for the other event kinds. It is incremental: a new batch extends the existing rows. The view is a `ScrollView` with a `LazyVStack`, stuck to the bottom unless the user has scrolled more than 80 points up. While the user is scrolling (`onScrollPhaseChange`), content height changes from rows the `LazyVStack` lays out late only update whether it is stuck and never pull it back to the bottom. Each Markdown text run's `NSTextView` measures its height with one TextKit stack kept in its coordinator, and a repeat measurement at the same width and text returns the stored size. Pending sends show at the end until the server echoes them. A turn summary counts as read when its row is on screen and the app is active (`markSummaryRead`). The head carries Compact (with its context-usage fill), Sync from main, Merge and Discard. +- **Transcript and Lead.** `TranscriptBuilder` is a pure function from events to a list of rows: user and agent messages, consecutive tool calls folded into one group with its summary ("Read file ×3"), subagents nested with their status, pending tool calls and running subagents (background ones included after the turn ends) outlined in the accent colour with a 0.6s fade, thinking, the byre prompt collapsed, held turns, `NO_REPLY` turns folded away, handoffs, snapshot rows with changes, fork and review, and notes for the other event kinds. It is incremental: a new batch extends the existing rows. The view is a `ScrollView` with a plain `VStack`, stuck to the bottom unless the user has scrolled more than 80 points up. The stack isn't lazy because a `LazyVStack` estimates the height of rows it hasn't shown yet and corrects it when they scroll into view, without moving the scroll offset, so scrolling up through rows not yet shown jumped by each correction; SwiftUI can't move the offset mid-scroll without stopping its momentum. While the user is scrolling (`onScrollPhaseChange`), content height changes only update whether it is stuck and never pull it back to the bottom. The lead pages back when the view comes within one screen of the top: the fetched page is held while the scroll phase is scrolling and inserted once it is idle (`ChatModel.older`, `prependOlder`), and the next geometry change moves the offset down by the height added, so the rows on screen stay put; a page that adds no rows (all folded `NO_REPLY` turns) fetches the next one at once. Each Markdown text run's `NSTextView` measures its height with one TextKit stack kept in its coordinator, and a repeat measurement at the same width and text returns the stored size. Pending sends show at the end until the server echoes them. A turn summary counts as read when its row is on screen and the app is active (`markSummaryRead`). The head carries Compact (with its context-usage fill), Sync from main, Merge and Discard. - **Composer.** A `TextEditor`, with paste and drop handled as in "Gaps in SwiftUI". Text, the model menu (filtered to the live session's vendor), Interrupt and Send. Enter sends, Shift-Enter adds a newline, and Enter during IME composition does neither. Pasted or dropped PNG, JPEG, GIF and WebP images become thumbnails with ×, uploaded to `POST /api/blobs` on send; any other image `NSBitmapImageRep` decodes, such as TIFF, is converted to PNG first, its size checked after conversion. A failed send puts the text and images back and shows the error. - **Inbox.** The selected task's oldest open item, rendered from its Markdown context with each choice's marker replaced by a clickable block (pick-one, sends at once) or checkbox (pick-any, with Submit), the "recommended" tag, image-choice grids, attachments, Let the agent decide, Other… and, for errors, Reply… and Dismiss. Arrow keys move between choices. A 409 shows its error. The Changes summary line sits above it. - **Changes, Files, Reviews, Workflow, Project, Status, Settings.** Including the pinned-snapshot stepper, open-diff memory, per-task place in Files, finding diffs with Fix and Ignore, nested subtasks, TODO Start and Discuss, rate-limit pace bars and lead notes. The Workflow tab draws its stage graph natively from the `WorkflowRun` (stages as boxes coloured by state, `after` as edges, laid out top to bottom by depth) with the layout and drawing code from "Diagrams", fed from the run's data rather than Mermaid text. @@ -163,7 +163,7 @@ Places where SwiftUI alone may fall short, each checked side by side against its | --- | --- | --- | --- | | Enter sends, Shift-Enter newline, nothing during IME composition | `TextEditor` with `onKeyPress` | `NSTextView` in an `NSViewRepresentable`, sending from `doCommand(by: insertNewline:)`, which the input method never reaches while it holds marked text | No fallback: `onKeyPress(.return)` runs before the `TextEditor`'s `NSTextView` and its input context. It sends, and returns `.ignored` for Shift so the text view adds the newline (checked by hand). Because it runs first, it must also return `.ignored` while the focused text view's `hasMarkedText()` is true, or Return sends the draft mid-composition. An in-process test that set marked text with `setMarkedText` and delivered a Return `NSEvent` to the window showed both: an unguarded handler sent the draft with the marked text still pending, and the guarded one sent nothing and passed Return on to the text view. From there the input method handles Return as it does in any `NSTextView`, so the `NSTextView` fallback would behave the same. The guard reads the key window's first responder, which is the composer while the user types | | Image paste and drop into the composer | `onPasteCommand` and `onDrop` | The same `NSTextView`, overriding `paste(_:)` and drag handling | No fallback, but not through these two modifiers: `TextEditor`'s own text view takes ⌘V and drops before `onPasteCommand` and `onDrop` and inserts the file's path as text. Instead the app replaces Edit's pasteboard group (`CommandGroup(replacing: .pasteboard)` with Cut, Copy, Paste and Select All sent to the first responder): Paste attaches the clipboard's image when the composer has focus (`focusedValue`) and otherwise sends the ordinary `paste:`, so text paste works in every field and the terminal. Drops land on a `Color.clear` overlay on the `TextEditor` with `onDrop` and `allowsHitTesting(false)`: it still receives the drag, and clicks and typing pass through to the editor (with hit testing on, the editor can't be clicked). Both attach thumbnails and upload to `POST /api/blobs` | -| Transcript of a long task (thousands of events) scrolling smoothly and staying stuck to the bottom | `LazyVStack` in a `ScrollView` | `List` with its own row reuse | No fallback: on a 5258-event task, `LazyVStack` scrolls smoothly, stays at the bottom while rows arrive and a message streams (`ScrollPosition.scrollTo(edge: .bottom)` on each change while within 80 points of the bottom, tracked with `onScrollGeometryChange`), and stays put when scrolled up. `List` passed too | +| Transcript of a long task (thousands of events) scrolling smoothly and staying stuck to the bottom | `VStack` in a `ScrollView` | `List` with its own row reuse | No fallback: the stack stays at the bottom while rows arrive and a message streams (`ScrollPosition.scrollTo(edge: .bottom)` on each change while within 80 points of the bottom, tracked with `onScrollGeometryChange`), and stays put when scrolled up. A `LazyVStack` jumped while scrolling up through rows it hadn't shown yet, as it corrected their estimated heights | | Selecting text across blocks and messages | `textSelection(.enabled)` selects within one `Text` only | A selectable `NSTextView` in an `NSViewRepresentable` | Fallback used within a message: `MarkdownView` draws each run of consecutive paragraphs, headings and lists (bullets as text, hanging indents from tab stops and paragraph styles) as one `NSAttributedString` in one non-editable `FlowText` text view, so a drag crosses blocks and bullets. Code blocks, diffs, tables, quotes, diagrams, `
`, paragraphs with images and choice lists stay SwiftUI views and end a run. Messages are still separate views, so a drag selects within one run of text in one message and each message is copied on its own | | The terminal | No terminal view in SwiftUI | SwiftTerm's AppKit `TerminalView` in an `NSViewRepresentable` | Fallback used, as planned: SwiftTerm over `URLSessionWebSocketTask` gives a working shell; Ctrl-C, Ctrl-L, Ctrl-K and Ctrl-R reach it. A local `keyDown` monitor takes Ctrl-K/P/L/S// as Go shortcuts except while the first responder is the `TerminalView` | | Font from `mac/fonts/` | Core Text | Convert the files to TTF in the build script if Core Text refuses them | No fallback: Core Text reads the woff2 files and their `wght` axis (200–800) | diff --git a/docs/operations.md b/docs/operations.md index 3426641..3718d2a 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -189,7 +189,7 @@ Agent activity becomes events with `agent_session_id` set: `user.message`, `agen Event payloads are stored whole in `events.payload`, and `agent.tool_result` content (full command output, file reads, diffs) dominates the log's size; the app loads every active task's whole history when it starts (see "Events"), so this is what its startup costs. `sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events GROUP BY kind ORDER BY 3 DESC"` shows the split, and `sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events WHERE task_id = '' GROUP BY kind ORDER BY 3 DESC"` one task's. -Messages byre itself sends an agent (actor `byre`: the restart notice, Sync and Merge prompts, Compact's handoff request) show collapsed like a tool call, labelled `byre` with the first line as the brief; expanding it shows the full text. The transcript view has a border in the task's colour (see "Task colours"; the accent in the lead pane) while the session is `running` and its transcript has an open turn (`open` in `TranscriptBuilder`: a drawn turn-starting `user.message`, not mid-turn, with no `turn.ended` after it), and a Cylon-style scanner in the same colour: a row of 2pt dots, 5pt apart along an 84pt track, where a glow sweeping back and forth every 1.2s lights up the dots under it, drawn below the last message, in the transcript's bottom padding, so it scrolls with the transcript and sits just above the composer's top border when scrolled to the bottom. It is drawn as an overlay on the transcript's scroll view rather than inside its content, at the view's bottom-left (16pt in, 8pt up) and pushed down by the scroll view's distance from the bottom (`ChatModel.scannerDrop`, set from `onScrollGeometryChange` and read only by `ScannerOverlay` in `mac/Sources/Byre/Chat.swift`), so it moves with the transcript, and showing or hiding it never changes the transcript's scroll height or position. Its per-frame redraw repaints only the scanner; inside the scroll content each frame re-rendered the whole transcript and made long transcripts scroll slowly. The session list (`GET /api/me/lead/sessions` or the task's sessions) is fetched separately from the event stream, so it says `running` a little before the turn's `user.message` arrives and a little after its `turn.ended`; requiring the open turn as well keeps the scanner and border from flashing on in those gaps. Consecutive tool calls (with any thinking between them) collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. While the scanner shows, a tool call still waiting for its result, and any group or subagent row holding one, gets a border in the task's colour and the braille spinner at the end of its summary, so a running tool reads apart from waiting on the model without expanding anything (`working` in `mac/Sources/Byre/Chat.swift`). When a turn ends, any call still without a result, other than those inside an active subagent row (below), reads "no result" instead of "running…", so an interrupted call never spins during a later turn. byre's own tools show without their `mcp__byre__` prefix, in title case (`mcp__byre__create_task` shows as Create Task; `toolLabel` in `mac/Sources/Byre/TranscriptBuilder.swift`); other tools show under the name the agent used. A Claude Code subagent call (the `Agent` or `Task` tool) stands on its own, collapsed, labelled with its `subagent_type` and `description`; expanding it shows the prompt and the call's result. Its summary carries a status from the `agent.vendor` `system` events whose `tool_use_id` names it (`task_started`: running, or running in the background; `task_progress`: its `description`; `task_notification`: its `status`, red unless `completed`). From its `task_started` to its `task_notification` the row is active, and an active row gets the task-coloured border and the braille spinner while the scanner shows or while the session's `subagents` count is above zero, as do the waiting tool calls inside it in the latter case; so a background subagent still reads as running after the turn that started it has ended. A turn's end leaves the waiting calls inside an active row waiting; the row's end (its `task_notification`, or a `session.subagents` event with 0, which ends every still-active row as "ended with its process", replayed history included) marks the calls inside it still waiting "no result". The subagent's own messages, thinking and tool calls (events whose `parent_tool_use_id` names the call) go inside that call's row, between the prompt and the result, so parallel subagents don't interleave in the main stream; its consecutive tool calls group there the same way, its messages get an accent rule down their left edge, and a subagent it starts nests inside it in turn. An event naming a subagent call the transcript never saw stays in the main stream marked `↳ subagent`. A subagent call ends the main agent's run of tool calls, so it never joins a group. A subagent's messages never end the main agent's live message, count as a turn's final message or hide a quiet turn. Claude Code streams a background subagent's activity, but for a foreground one only its prompt, so a foreground subagent shows only as its call, its status and its result. Selecting a task that has never had a session focuses the composer, and so does picking any task in the ⌘K switcher when it lands on the transcript (`Store.composerFocus`). In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task (see "Drafts" below), so it survives switching tabs or tasks and relaunching the app. The composer has no `@`-file mentions or path picker: text reaches the agent exactly as typed (stream-json doesn't expand `@path` the way the interactive CLI does), and the agent reads any file the message names with its own tools. The composer is the same whether or not the task has a live session, and stays enabled while a turn runs: Send posts to the session's messages, or starts a session with the text when there is none or the last one completed. A sent message shows at once as a bubble reading "queued…" while the session is running and "sending…" otherwise, replaced by the server's `user.message` event with the same text when it arrives, and removed (text and images back in the composer) if the request fails. +Messages byre itself sends an agent (actor `byre`: the restart notice, Sync and Merge prompts, Compact's handoff request) show collapsed like a tool call, labelled `byre` with the first line as the brief; expanding it shows the full text. The transcript view has a border in the task's colour (see "Task colours"; the accent in the lead pane) while the session is `running` and its transcript has an open turn (`open` in `TranscriptBuilder`: a drawn turn-starting `user.message`, not mid-turn, with no `turn.ended` after it), and a Cylon-style scanner in the same colour: a row of 2pt dots, 5pt apart along an 84pt track, where a glow sweeping back and forth every 1.2s lights up the dots under it, drawn below the last message, in the transcript's bottom padding, so it scrolls with the transcript and sits just above the composer's top border when scrolled to the bottom. It is an overlay 8pt below the transcript's last row, so showing or hiding it never changes the transcript's scroll height or position, and it is drawn with Core Animation (`ScannerView` in `mac/Sources/Byre/Chat.swift`: a gradient layer masked by the dots, its position animated by a repeating `CABasicAnimation`), so the render server animates it and SwiftUI never redraws the transcript for it. The session list (`GET /api/me/lead/sessions` or the task's sessions) is fetched separately from the event stream, so it says `running` a little before the turn's `user.message` arrives and a little after its `turn.ended`; requiring the open turn as well keeps the scanner and border from flashing on in those gaps. Consecutive tool calls (with any thinking between them) collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. While the scanner shows, a tool call still waiting for its result, and any group or subagent row holding one, gets a border in the task's colour and the braille spinner at the end of its summary, so a running tool reads apart from waiting on the model without expanding anything (`working` in `mac/Sources/Byre/Chat.swift`). When a turn ends, any call still without a result, other than those inside an active subagent row (below), reads "no result" instead of "running…", so an interrupted call never spins during a later turn. byre's own tools show without their `mcp__byre__` prefix, in title case (`mcp__byre__create_task` shows as Create Task; `toolLabel` in `mac/Sources/Byre/TranscriptBuilder.swift`); other tools show under the name the agent used. A Claude Code subagent call (the `Agent` or `Task` tool) stands on its own, collapsed, labelled with its `subagent_type` and `description`; expanding it shows the prompt and the call's result. Its summary carries a status from the `agent.vendor` `system` events whose `tool_use_id` names it (`task_started`: running, or running in the background; `task_progress`: its `description`; `task_notification`: its `status`, red unless `completed`). From its `task_started` to its `task_notification` the row is active, and an active row gets the task-coloured border and the braille spinner while the scanner shows or while the session's `subagents` count is above zero, as do the waiting tool calls inside it in the latter case; so a background subagent still reads as running after the turn that started it has ended. A turn's end leaves the waiting calls inside an active row waiting; the row's end (its `task_notification`, or a `session.subagents` event with 0, which ends every still-active row as "ended with its process", replayed history included) marks the calls inside it still waiting "no result". The subagent's own messages, thinking and tool calls (events whose `parent_tool_use_id` names the call) go inside that call's row, between the prompt and the result, so parallel subagents don't interleave in the main stream; its consecutive tool calls group there the same way, its messages get an accent rule down their left edge, and a subagent it starts nests inside it in turn. An event naming a subagent call the transcript never saw stays in the main stream marked `↳ subagent`. A subagent call ends the main agent's run of tool calls, so it never joins a group. A subagent's messages never end the main agent's live message, count as a turn's final message or hide a quiet turn. Claude Code streams a background subagent's activity, but for a foreground one only its prompt, so a foreground subagent shows only as its call, its status and its result. Selecting a task that has never had a session focuses the composer, and so does picking any task in the ⌘K switcher when it lands on the transcript (`Store.composerFocus`). In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task (see "Drafts" below), so it survives switching tabs or tasks and relaunching the app. The composer has no `@`-file mentions or path picker: text reaches the agent exactly as typed (stream-json doesn't expand `@path` the way the interactive CLI does), and the agent reads any file the message names with its own tools. The composer is the same whether or not the task has a live session, and stays enabled while a turn runs: Send posts to the session's messages, or starts a session with the text when there is none or the last one completed. A sent message shows at once as a bubble reading "queued…" while the session is running and "sending…" otherwise, replaced by the server's `user.message` event with the same text when it arrives, and removed (text and images back in the composer) if the request fails. **Drafts.** Unsent composer text (task and lead) and unsent inbox answers (checked choices and Other/Reply text) are kept in memory by `Drafts` (`mac/Sources/Byre/Drafts.swift`) and saved to a SwiftData `DraftRecord` per server and login (`docs/macos.md`, "Drafts"), one JSON object with `composer` (keyed by `message:` or `message:lead`, each `{text, images}`), `checked` and `texts` (both keyed by inbox item id). Writes are debounced (400 ms) and flushed at once when a composer or answer box loses focus, on send and on quit. A composer draft is cleared only once its send succeeds, and an inbox answer only once its response is accepted; each send in flight is tracked on its own and the stored draft holds the text of every unconfirmed send (oldest first, then the composer's current text, separated by blank lines) and counts its images, so a relaunch mid-send brings back everything not yet confirmed. A failed send's text goes back into the composer ahead of whatever is there now (text typed since, or earlier failures), separated by a blank line, rather than replacing it. Drafts for tasks that are no longer active and for inbox items no longer open are pruned whenever the task list or inbox reloads. A record that doesn't decode is logged and ignored, as is a write that fails; drafts then live only in memory. Pending images are not stored, only their count: after a relaunch the composer shows how many need re-attaching, until dismissed or the next successful send. diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index e205c9f..e2eefaf 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -15,10 +15,10 @@ final class ChatModel { let builder: TranscriptBuilder private(set) var scrollRequest: (seq: Int, id: Int, anchor: UnitPoint)? private(set) var fetching = false - private(set) var olderGeneration = 0 + private(set) var older: [ByreEvent]? private(set) var hasUnmerged = false - var scannerDrop: CGFloat = 0 @ObservationIgnored var stuck = true + @ObservationIgnored var prepended = false @ObservationIgnored private var unmergedTimer: Task? @ObservationIgnored private var offs: [() -> Void] = [] @ObservationIgnored private var focusStart = true @@ -223,23 +223,30 @@ final class ChatModel { } func loadOlder() { - if fetching || !hasOlder { + if fetching || older != nil || !hasOlder { return } fetching = true Task { do { - let events = try await store.loadOlderLead() - let first = builder.root.rows.first?.id - builder.prepend(events) - if let first { - requestScroll(first, .top) - } + older = try await store.loadOlderLead() } catch { log.error("load earlier events: \(errorText(error), privacy: .public)") } fetching = false - olderGeneration += 1 + } + } + + func prependOlder() { + guard let events = older else { + return + } + older = nil + let n = builder.root.rows.count + builder.prepend(events) + prepended = builder.root.rows.count > n || !hasOlder + if !prepended { + loadOlder() } } @@ -490,6 +497,7 @@ private struct ScrollMetrics: Equatable { let content: CGFloat let visible: CGFloat let distance: CGFloat + let offset: CGFloat } private struct TranscriptScroll: View { @@ -502,23 +510,16 @@ private struct TranscriptScroll: View { var body: some View { let running = model.running ScrollView { - LazyVStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { if model.key == nil { Muted(text: model.empty) } else { if model.loading { Muted(text: "loading…") } - if model.fetching { + if model.hasOlder || model.older != nil { Muted(text: "loading earlier…") - } - if model.hasOlder && !model.fetching { - Color.clear - .frame(height: 1) - .id("older-\(model.olderGeneration)") - .onAppear { - model.loadOlder() - } + .opacity(model.fetching || model.older != nil ? 1 : 0) } ForEach(model.builder.root.rows) { row in RowView(row: row, model: model, nested: false) @@ -529,19 +530,28 @@ private struct TranscriptScroll: View { } } .scrollTargetLayout() - .padding(16) - } - .overlay(alignment: .bottomLeading) { - if running && model.key != nil { - ScannerOverlay(model: model, color: accent) + .overlay(alignment: .bottomLeading) { + if running && model.key != nil { + Scanner(color: accent) + .frame(width: 84, height: 2) + .offset(y: 8) + } } + .padding(16) } .scrollPosition($position) .defaultScrollAnchor(.bottom) .onScrollGeometryChange(for: ScrollMetrics.self) { g in - ScrollMetrics(content: g.contentSize.height, visible: g.visibleRect.height, distance: g.contentSize.height - g.visibleRect.maxY) + ScrollMetrics(content: g.contentSize.height, visible: g.visibleRect.height, distance: g.contentSize.height - g.visibleRect.maxY, offset: g.contentOffset.y) } action: { old, new in - model.scannerDrop = new.distance + if model.prepended && new.content != old.content { + model.prepended = false + position.scrollTo(y: new.offset + new.content - old.content) + return + } + if new.offset < new.visible { + model.loadOlder() + } if scrolling { model.stuck = new.distance < 80 return @@ -552,6 +562,14 @@ private struct TranscriptScroll: View { } .onScrollPhaseChange { _, phase in scrolling = phase.isScrolling + if !scrolling { + model.prependOlder() + } + } + .onChange(of: model.older == nil) { + if !scrolling { + model.prependOlder() + } } .onChange(of: model.builder.pending.last?.id) { old, new in guard let new, new > old ?? 0 else { @@ -572,39 +590,58 @@ private struct TranscriptScroll: View { } } -private struct ScannerOverlay: View { - let model: ChatModel +private struct Scanner: NSViewRepresentable { let color: Color - var body: some View { - Scanner(color: color) - .padding(.leading, 16) - .padding(.bottom, 8) - .offset(y: model.scannerDrop) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) - .clipped() - .allowsHitTesting(false) + func makeNSView(context: Context) -> ScannerView { + ScannerView() + } + + func updateNSView(_ v: ScannerView, context: Context) { + let c = NSColor(color) + CATransaction.begin() + CATransaction.setDisableActions(true) + v.glow.colors = [c.withAlphaComponent(0).cgColor, c.cgColor, c.withAlphaComponent(0).cgColor] + CATransaction.commit() } } -private struct Scanner: View { - let color: Color +private final class ScannerView: NSView { + let glow = CAGradientLayer() - var body: some View { - TimelineView(.animation) { context in - Canvas { gc, _ in - let t = context.date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 2.4) / 1.2 - let leg = t < 1 ? t : 2 - t - let center = 15 + 54 * (1 - cos(.pi * leg)) / 2 - for x in stride(from: 0.0, through: 80, by: 5) { - let alpha = max(0, 1 - abs(x + 1 - center) / 15) - if alpha > 0 { - gc.fill(Path(CGRect(x: x, y: 0, width: 2, height: 2)), with: .color(color.opacity(alpha))) - } - } - } + override init(frame: NSRect) { + super.init(frame: frame) + wantsLayer = true + let dots = CALayer() + dots.frame = CGRect(x: 0, y: 0, width: 84, height: 2) + for x in stride(from: 0.0, through: 80, by: 5) { + let dot = CALayer() + dot.frame = CGRect(x: x, y: 0, width: 2, height: 2) + dot.backgroundColor = NSColor.black.cgColor + dots.addSublayer(dot) } - .frame(width: 84, height: 2) + glow.bounds = CGRect(x: 0, y: 0, width: 30, height: 2) + glow.position = CGPoint(x: 15, y: 1) + glow.startPoint = CGPoint(x: 0, y: 0.5) + glow.endPoint = CGPoint(x: 1, y: 0.5) + let strip = CALayer() + strip.frame = dots.frame + strip.mask = dots + strip.addSublayer(glow) + layer?.addSublayer(strip) + let sweep = CABasicAnimation(keyPath: "position.x") + sweep.fromValue = 15 + sweep.toValue = 69 + sweep.duration = 1.2 + sweep.autoreverses = true + sweep.repeatCount = .infinity + sweep.timingFunction = CAMediaTimingFunction(controlPoints: 0.37, 0, 0.63, 1) + sweep.isRemovedOnCompletion = false + glow.add(sweep, forKey: "sweep") + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") } }