diff --git a/CLAUDE.md b/CLAUDE.md index 82ec575..e6f6831 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,6 +162,7 @@ The `<= len` pattern is only correct when `pos` represents a position *after* pr ## Claude Socket Hook - The app creates `${XDG_RUNTIME_DIR:-/tmp}/architect_notify_.sock` and sets `ARCHITECT_SESSION_ID`/`ARCHITECT_NOTIFY_SOCK` for each shell. - Send a single JSON line to signal UI states: `{"session":N,"state":"start"|"awaiting_approval"|"done"}`. The helper `scripts/architect_notify.py` is available if needed. +- Story notifications use the same socket: `{"session":N,"type":"story","path":"/absolute/path/to/story.md"}`. The `architect story ` subcommand sends this automatically. ## Done? Share - Provide a concise summary of edits, test/build outcomes, and documentation updates. diff --git a/README.md b/README.md index fab96b8..9d78bf5 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Architect solves this with a grid view that keeps all your agents visible, with - **Worktree picker** (⌘T) — quickly `cd` into git worktrees for parallel agent work on separate branches - **Recent folders** (⌘O) — quickly `cd` into recently visited directories with arrow key selection - **Diff review comments** — click diff lines in the ⌘D overlay to leave inline comments, then send them all to a running agent (or start one) with the "Send to agent" button +- **Story viewer** — run `architect story ` to open a scrollable overlay that renders PR story files with prose text and diff-colored code blocks ### Terminal Essentials - Smooth animated transitions for grid expansion, contraction, and reflow (cells and borders move/resize together) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2dacd51..001639d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -236,14 +236,16 @@ External tool (Claude Code, Codex, Gemini) v session/notify.zig (background thread) | parse {"session": N, "state": "awaiting_approval"} + | or {"session": N, "type": "story", "path": "/abs/path"} v NotificationQueue (thread-safe) | main loop drains each frame v -SessionStatus updated (idle -> awaiting_approval) +Status notifications -> SessionStatus updated (idle -> awaiting_approval) +Story notifications -> StoryOverlay opens with file content | v -Renderer draws attention border (pulsing yellow / solid green) +Renderer draws attention border / story overlay ``` ### Entry Points @@ -286,17 +288,18 @@ Renderer draws attention border (pulsing yellow / solid green) | `input/mapper.zig` | SDL keycodes to VT escape sequences, shortcut detection | `encodeKey()`, modifier helpers | `c` | | `c.zig` | C FFI re-exports (SDL3, SDL3_ttf constants) | `SDLK_*`, `SDL_*`, `TTF_*` re-exports | SDL3 system libs (via `@cImport`) | | `session/state.zig` | Terminal session lifecycle: PTY, ghostty-vt, process watcher | `SessionState`, `init()`, `deinit()`, `ensureSpawnedWithDir()`, `render_epoch`, `pending_write` | `shell`, `pty`, `vt_stream`, `cwd`, `font`, xev | -| `session/notify.zig` | Background notification socket thread and queue | `NotificationQueue`, `startThread()`, `push()`, `drain()` | std (socket, thread) | +| `session/notify.zig` | Background notification socket thread and queue; handles status and story notifications | `NotificationQueue`, `Notification` (union: status/story), `startThread()`, `push()`, `drain()` | std (socket, thread) | | `session/*` (shell, pty, vt_stream, cwd) | Shell spawning, PTY abstraction, VT parsing, working directory detection | `spawn()`, `Pty`, `VtStream.processBytes()`, `getCwd()` | std (posix), ghostty-vt | | `render/renderer.zig` | Scene rendering: terminals, borders, animations | `render()`, `RenderCache`, per-session texture management | `font`, `font_cache`, `gfx/*`, `anim/easing`, `app/app_state`, `c` | | `font.zig` + `font_cache.zig` | Font rendering, HarfBuzz shaping, glyph LRU cache, shared font cache | `Font`, `openFont()`, `renderGlyph()`, `FontCache`, `getOrCreate()` | `font_paths`, `c` (SDL3_ttf) | -| `gfx/*` (box_drawing, primitives) | Procedural box-drawing characters (U+2500-U+257F), rounded/thick border helpers | `renderBoxDrawing()`, `drawRoundedRect()`, `drawThickBorder()` | `c` | +| `gfx/*` (box_drawing, primitives) | Procedural box-drawing characters (U+2500-U+257F), rounded/thick border helpers, filled circles, bezier arrow rendering | `renderBoxDrawing()`, `drawRoundedRect()`, `drawThickBorder()`, `fillCircle()`, `fillRoundedRect()`, `renderBezierArrow()` | `c` | | `ui/root.zig` | UI component registry, z-index dispatch, action drain | `UiRoot`, `register()`, `handleEvent()`, `update()`, `render()`, `needsFrame()` | `ui/component`, `ui/types` | | `ui/component.zig` | UI component vtable interface | `UiComponent`, `VTable` (handleEvent, update, render, hitTest, wantsFrame, deinit) | `ui/types`, `c` | | `ui/types.zig` | Shared UI type definitions | `UiHost`, `UiAction`, `UiActionQueue`, `UiAssets`, `SessionUiInfo` | `app/app_state`, `colors`, `font`, `geom` | | `ui/session_view_state.zig` | Per-session UI interaction state (selection, scroll, hover, agent status) | `SessionViewState` (selection, scroll offset, hover, status) | `app/app_state` (for `SessionStatus` enum) | | `ui/first_frame_guard.zig` | Idle throttle bypass for visible state transitions | `FirstFrameGuard`, `markTransition()`, `markDrawn()`, `wantsFrame()` | (none) | -| `ui/components/*` | Individual overlay and widget implementations conforming to `UiComponent` vtable. Includes: help overlay, worktree picker, recent folders picker, diff viewer (with inline review comments), session interaction, toast, quit confirm, restart buttons, escape hold indicator, metrics overlay, global shortcuts, pill group, cwd bar, expanding overlay helper, button, confirm dialog, marquee label, hotkey indicator, flowing line, hold gesture detector. | Each component implements the `VTable` interface; overlays toggle via keyboard shortcuts and emit `UiAction` values. | `ui/component`, `ui/types`, `anim/easing`, `font`, `metrics`, `url_matcher`, `ui/session_view_state` | +| `ui/story_parser.zig` | Standalone markdown parser for story files. No SDL/UI dependencies. Parses prose wrapping, `story-diff` fenced blocks, code block metadata, and anchor extraction (`**[N]**` in prose, `` in code). | `parse()`, `freeDisplayRows()`, `DisplayRow`, `LineAnchor`, `CodeBlockMeta` | std | +| `ui/components/*` | Individual overlay and widget implementations conforming to `UiComponent` vtable. Includes: help overlay, worktree picker, recent folders picker, diff viewer (with inline review comments), story viewer (PR story file visualization with anchor badges and bezier arrows), fullscreen overlay helper (shared animation/scroll/close logic embedded by story and diff overlays), session interaction, toast, quit confirm, restart buttons, escape hold indicator, metrics overlay, global shortcuts, pill group, cwd bar, expanding overlay helper, button, confirm dialog, marquee label, hotkey indicator, flowing line, hold gesture detector. | Each component implements the `VTable` interface; overlays toggle via keyboard shortcuts or external commands and emit `UiAction` values. | `ui/component`, `ui/types`, `anim/easing`, `font`, `metrics`, `url_matcher`, `ui/session_view_state` | | Shared Utilities (`geom`, `colors`, `config`, `metrics`, `url_matcher`, `os/open`, `anim/easing`) | Geometry primitives, theme/palette management, TOML config loading/persistence, performance metrics, URL detection, cross-platform URL opening, easing functions | `Rect`, `Theme`, `Config`, `Metrics`, `matchUrl()`, `open()`, `easeInOutCubic()`, `easeOutCubic()` | std, zig-toml, `c` | ## Key Architectural Decisions diff --git a/src/app/runtime.zig b/src/app/runtime.zig index 8c5fe6a..465135b 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -645,6 +645,8 @@ pub fn run() !void { try ui.register(metrics_overlay_component.asComponent()); const diff_overlay_component = try ui_mod.diff_overlay.DiffOverlayComponent.init(allocator); try ui.register(diff_overlay_component.asComponent()); + const story_overlay_component = try ui_mod.story_overlay.StoryOverlayComponent.init(allocator); + try ui.register(story_overlay_component.asComponent()); // Main loop: handle SDL input, feed PTY output into terminals, apply async // notifications, drive animations, and render at ~60 FPS. @@ -1411,15 +1413,25 @@ pub fn run() !void { defer notifications.deinit(allocator); const had_notifications = notifications.items.len > 0; for (notifications.items) |note| { - const session_idx = findSessionIndexById(sessions, note.session) orelse continue; - session_interaction_component.setStatus(session_idx, note.state); - const wants_attention = switch (note.state) { - .awaiting_approval, .done => true, - else => false, - }; - const is_focused_full = anim_state.mode == .Full and anim_state.focused_session == session_idx; - session_interaction_component.setAttention(session_idx, if (is_focused_full) false else wants_attention, now); - std.debug.print("Session {d} (slot {d}) status -> {s}\n", .{ note.session, session_idx, @tagName(note.state) }); + switch (note) { + .status => |s| { + const session_idx = findSessionIndexById(sessions, s.session) orelse continue; + session_interaction_component.setStatus(session_idx, s.state); + const wants_attention = switch (s.state) { + .awaiting_approval, .done => true, + else => false, + }; + const is_focused_full = anim_state.mode == .Full and anim_state.focused_session == session_idx; + session_interaction_component.setAttention(session_idx, if (is_focused_full) false else wants_attention, now); + std.debug.print("Session {d} (slot {d}) status -> {s}\n", .{ s.session, session_idx, @tagName(s.state) }); + }, + .story => |s| { + if (!story_overlay_component.show(s.path, now)) { + ui.showToast("Failed to open story file", now); + } + allocator.free(s.path); + }, + } } if (pending_comment_send) |pcs| { @@ -1887,6 +1899,12 @@ pub fn run() !void { allocator.free(dc_action.comments_text); } }, + .OpenStory => |story_action| { + if (!story_overlay_component.show(story_action.path, now)) { + ui.showToast("Failed to open story file", now); + } + allocator.free(story_action.path); + }, }; if (anim_state.mode == .Expanding or anim_state.mode == .Collapsing or diff --git a/src/c.zig b/src/c.zig index 8374c1a..741dd44 100644 --- a/src/c.zig +++ b/src/c.zig @@ -119,6 +119,8 @@ pub const SDLK_LEFT = c_import.SDLK_LEFT; pub const SDLK_RIGHT = c_import.SDLK_RIGHT; pub const SDLK_HOME = c_import.SDLK_HOME; pub const SDLK_END = c_import.SDLK_END; +pub const SDLK_PAGEUP = c_import.SDLK_PAGEUP; +pub const SDLK_PAGEDOWN = c_import.SDLK_PAGEDOWN; pub const SDLK_AC_HOME = c_import.SDLK_AC_HOME; pub const SDLK_AC_END = c_import.SDLK_AC_END; pub const SDLK_DELETE = c_import.SDLK_DELETE; diff --git a/src/gfx/primitives.zig b/src/gfx/primitives.zig index cea5759..9f33fbe 100644 --- a/src/gfx/primitives.zig +++ b/src/gfx/primitives.zig @@ -112,3 +112,124 @@ pub fn fillRoundedRect(renderer: *c.SDL_Renderer, rect: Rect, radius: c_int) voi } } } + +pub fn fillCircle(renderer: *c.SDL_Renderer, cx: f32, cy: f32, radius: f32) void { + const r_int: c_int = @intFromFloat(radius); + var dy: c_int = -r_int; + while (dy <= r_int) : (dy += 1) { + const dy_f: f32 = @floatFromInt(dy); + const dx_sq = radius * radius - dy_f * dy_f; + if (dx_sq > 0) { + const dx = @sqrt(dx_sq); + _ = c.SDL_RenderLine(renderer, cx - dx, cy + dy_f, cx + dx, cy + dy_f); + } + } +} + +pub fn renderBezierArrow( + renderer: *c.SDL_Renderer, + x1: f32, + y1: f32, + x2: f32, + y2: f32, + color: c.SDL_Color, + time_seconds: f32, +) void { + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + + const dy = y2 - y1; + const dx = x2 - x1; + const dist = @sqrt(dx * dx + dy * dy); + if (dist < 1.0) return; + + // Control point offset: curve bows to the left of the line direction + const cp_offset = @min(dist * 0.4, 120.0); + + // Cubic bezier control points — curve bows leftward + const cp1x = x1 - cp_offset; + const cp1y = y1 + dy * 0.33; + const cp2x = x2 - cp_offset; + const cp2y = y1 + dy * 0.67; + + const num_segments: usize = @max(20, @as(usize, @intFromFloat(dist / 4.0))); + const flow_speed: f32 = 1.5; + const flow_offset = time_seconds * flow_speed; + + const diffusion_layers: usize = 5; + var layer: usize = 0; + while (layer < diffusion_layers) : (layer += 1) { + const layer_f: f32 = @floatFromInt(layer); + const center: f32 = @as(f32, @floatFromInt(diffusion_layers - 1)) / 2.0; + const layer_offset = (layer_f - center) * 0.8; + const dist_from_center = @abs(layer_f - center); + const layer_alpha_mult = 1.0 - (dist_from_center / (center + 1.0)); + + var prev_x: f32 = x1; + var prev_y: f32 = y1; + + for (1..num_segments + 1) |i| { + const t = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(num_segments)); + const inv_t = 1.0 - t; + + // Cubic bezier evaluation + const bx = inv_t * inv_t * inv_t * x1 + 3.0 * inv_t * inv_t * t * cp1x + 3.0 * inv_t * t * t * cp2x + t * t * t * x2; + const by = inv_t * inv_t * inv_t * y1 + 3.0 * inv_t * inv_t * t * cp1y + 3.0 * inv_t * t * t * cp2y + t * t * t * y2; + + // Perpendicular offset for layer spread + const seg_dx = bx - prev_x; + const seg_dy = by - prev_y; + const seg_len = @sqrt(seg_dx * seg_dx + seg_dy * seg_dy); + const nx = if (seg_len > 0.01) -seg_dy / seg_len else 0.0; + const ny = if (seg_len > 0.01) seg_dx / seg_len else 0.0; + + const px = bx + nx * layer_offset; + const py = by + ny * layer_offset; + + // Shimmering alpha + const wave = @sin((t * 8.0 - flow_offset) * std.math.pi); + const wave2 = @sin((t * 13.0 + flow_offset * 1.3) * std.math.pi) * 0.5; + const combined = (wave + wave2) / 1.5; + const base_alpha: f32 = 120.0; + const alpha_var: f32 = 60.0; + const segment_alpha = (base_alpha + combined * alpha_var) * layer_alpha_mult * 0.6; + const final_alpha: u8 = @intFromFloat(@max(0, @min(255.0, segment_alpha))); + + _ = c.SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, final_alpha); + _ = c.SDL_RenderLine(renderer, prev_x + nx * layer_offset, prev_y + ny * layer_offset, px, py); + + prev_x = bx; + prev_y = by; + } + } + + // Arrowhead at the end + const arrow_t = 1.0 - 2.0 / @as(f32, @floatFromInt(num_segments)); + const inv_at = 1.0 - arrow_t; + const tail_x = inv_at * inv_at * inv_at * x1 + 3.0 * inv_at * inv_at * arrow_t * cp1x + 3.0 * inv_at * arrow_t * arrow_t * cp2x + arrow_t * arrow_t * arrow_t * x2; + const tail_y = inv_at * inv_at * inv_at * y1 + 3.0 * inv_at * inv_at * arrow_t * cp1y + 3.0 * inv_at * arrow_t * arrow_t * cp2y + arrow_t * arrow_t * arrow_t * y2; + + const arrow_dx = x2 - tail_x; + const arrow_dy = y2 - tail_y; + const arrow_len = @sqrt(arrow_dx * arrow_dx + arrow_dy * arrow_dy); + if (arrow_len < 0.1) return; + + const adx = arrow_dx / arrow_len; + const ady = arrow_dy / arrow_len; + const arrow_size: f32 = 8.0; + + _ = c.SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, 180); + _ = c.SDL_RenderLine( + renderer, + x2, + y2, + x2 - adx * arrow_size + ady * arrow_size * 0.5, + y2 - ady * arrow_size - adx * arrow_size * 0.5, + ); + _ = c.SDL_RenderLine( + renderer, + x2, + y2, + x2 - adx * arrow_size - ady * arrow_size * 0.5, + y2 - ady * arrow_size + adx * arrow_size * 0.5, + ); +} diff --git a/src/session/notify.zig b/src/session/notify.zig index 2e13aaf..029a9dc 100644 --- a/src/session/notify.zig +++ b/src/session/notify.zig @@ -5,11 +5,22 @@ const atomic = std.atomic; const log = std.log.scoped(.notify); -pub const Notification = struct { +pub const Notification = union(enum) { + status: StatusNotification, + story: StoryNotification, +}; + +pub const StatusNotification = struct { session: usize, state: app_state.SessionStatus, }; +pub const StoryNotification = struct { + session: usize, + /// Heap-allocated path; caller must free after processing. + path: []const u8, +}; + pub const NotificationQueue = struct { mutex: std.Thread.Mutex = .{}, items: std.ArrayListUnmanaged(Notification) = .{}, @@ -64,7 +75,7 @@ pub fn startNotifyThread( }; const handler = struct { - fn parseNotification(bytes: []const u8) ?Notification { + fn parseNotification(bytes: []const u8, persistent_alloc: std.mem.Allocator) ?Notification { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); @@ -76,6 +87,30 @@ pub fn startNotifyThread( if (root != .object) return null; const obj = root.object; + const session_val = obj.get("session") orelse return null; + if (session_val != .integer) return null; + if (session_val.integer < 0) return null; + const session_idx: usize = @intCast(session_val.integer); + + // Check for "type" field to distinguish notification kinds + const type_val = obj.get("type"); + if (type_val) |tv| { + if (tv == .string and std.mem.eql(u8, tv.string, "story")) { + const path_val = obj.get("path") orelse return null; + if (path_val != .string) return null; + // Allocate path with persistent allocator so it survives arena cleanup + const path_dupe = persistent_alloc.dupe(u8, path_val.string) catch |err| { + log.err("failed to duplicate story path for session {d}: {}", .{ session_idx, err }); + return null; + }; + return Notification{ .story = .{ + .session = session_idx, + .path = path_dupe, + } }; + } + } + + // Default: status notification const state_val = obj.get("state") orelse return null; if (state_val != .string) return null; const state_str = state_val.string; @@ -88,14 +123,10 @@ pub fn startNotifyThread( else return null; - const session_val = obj.get("session") orelse return null; - if (session_val != .integer) return null; - if (session_val.integer < 0) return null; - - return Notification{ - .session = @intCast(session_val.integer), + return Notification{ .status = .{ + .session = session_idx, .state = state, - }; + } }; } fn run(ctx: NotifyContext) !void { @@ -170,9 +201,18 @@ pub fn startNotifyThread( if (buffer.items.len == 0) continue; - if (parseNotification(buffer.items)) |note| { + if (parseNotification(buffer.items, ctx.allocator)) |note| { ctx.queue.push(ctx.allocator, note) catch |err| { - log.warn("failed to queue notification for session {d}: {}", .{ note.session, err }); + const session_id = switch (note) { + .status => |s| s.session, + .story => |s| s.session, + }; + log.warn("failed to queue notification for session {d}: {}", .{ session_id, err }); + // Free heap-allocated data on failure + switch (note) { + .story => |s| ctx.allocator.free(s.path), + .status => {}, + } }; } } @@ -188,18 +228,15 @@ test "NotificationQueue - push and drain" { var queue = NotificationQueue{}; defer queue.deinit(allocator); - try queue.push(allocator, .{ .session = 0, .state = .running }); - try queue.push(allocator, .{ .session = 1, .state = .awaiting_approval }); - try queue.push(allocator, .{ .session = 2, .state = .done }); + try queue.push(allocator, .{ .status = .{ .session = 0, .state = .running } }); + try queue.push(allocator, .{ .status = .{ .session = 1, .state = .awaiting_approval } }); + try queue.push(allocator, .{ .status = .{ .session = 2, .state = .done } }); var items = queue.drainAll(); defer items.deinit(allocator); try std.testing.expectEqual(@as(usize, 3), items.items.len); - try std.testing.expectEqual(@as(usize, 0), items.items[0].session); - try std.testing.expectEqual(app_state.SessionStatus.running, items.items[0].state); - try std.testing.expectEqual(@as(usize, 1), items.items[1].session); - try std.testing.expectEqual(app_state.SessionStatus.awaiting_approval, items.items[1].state); - try std.testing.expectEqual(@as(usize, 2), items.items[2].session); - try std.testing.expectEqual(app_state.SessionStatus.done, items.items[2].state); + try std.testing.expectEqual(Notification{ .status = .{ .session = 0, .state = .running } }, items.items[0]); + try std.testing.expectEqual(Notification{ .status = .{ .session = 1, .state = .awaiting_approval } }, items.items[1]); + try std.testing.expectEqual(Notification{ .status = .{ .session = 2, .state = .done } }, items.items[2]); } diff --git a/src/shell.zig b/src/shell.zig index 2ef4eba..b0ab272 100644 --- a/src/shell.zig +++ b/src/shell.zig @@ -150,6 +150,7 @@ const architect_command_script = \\ print("Usage: architect notify ", file=sys.stderr) \\ print(" architect notify (reads stdin)", file=sys.stderr) \\ print(" architect hook claude|codex|gemini", file=sys.stderr) + \\ print(" architect story ", file=sys.stderr) \\ \\def timestamp_suffix() -> str: \\ return datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ") @@ -459,6 +460,33 @@ const architect_command_script = \\ return install_gemini() \\ print_usage() \\ return 1 + \\ if cmd == "story": + \\ if len(sys.argv) < 3: + \\ print("Usage: architect story ", file=sys.stderr) + \\ return 1 + \\ path = os.path.abspath(sys.argv[2]) + \\ if not os.path.isfile(path): + \\ print(f"File not found: {path}", file=sys.stderr) + \\ return 1 + \\ session_id = os.environ.get("ARCHITECT_SESSION_ID") + \\ sock_path = os.environ.get("ARCHITECT_NOTIFY_SOCK") + \\ if not session_id or not sock_path: + \\ print("Not running inside Architect", file=sys.stderr) + \\ return 1 + \\ message = json.dumps({ + \\ "session": int(session_id), + \\ "type": "story", + \\ "path": path + \\ }) + "\n" + \\ try: + \\ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + \\ sock.connect(sock_path) + \\ sock.sendall(message.encode()) + \\ sock.close() + \\ except Exception as e: + \\ print(f"Failed to notify Architect: {e}", file=sys.stderr) + \\ return 1 + \\ return 0 \\ \\ print_usage() \\ return 1 diff --git a/src/ui/components/diff_overlay.zig b/src/ui/components/diff_overlay.zig index b2b8cdb..3740173 100644 --- a/src/ui/components/diff_overlay.zig +++ b/src/ui/components/diff_overlay.zig @@ -5,8 +5,8 @@ const primitives = @import("../../gfx/primitives.zig"); const types = @import("../types.zig"); const UiComponent = @import("../component.zig").UiComponent; const dpi = @import("../scale.zig"); -const FirstFrameGuard = @import("../first_frame_guard.zig").FirstFrameGuard; const easing = @import("../../anim/easing.zig"); +const FullscreenOverlay = @import("fullscreen_overlay.zig").FullscreenOverlay; const log = std.log.scoped(.diff_overlay); @@ -120,8 +120,7 @@ const GitResult = struct { pub const DiffOverlayComponent = struct { allocator: std.mem.Allocator, - visible: bool = false, - first_frame: FirstFrameGuard = .{}, + overlay: FullscreenOverlay = .{}, files: std.ArrayList(DiffFile) = .{}, raw_output: ?[]u8 = null, @@ -129,10 +128,6 @@ pub const DiffOverlayComponent = struct { cache: ?*Cache = null, last_repo_root: ?[]u8 = null, - scroll_offset: f32 = 0, - max_scroll: f32 = 0, - - close_hovered: bool = false, hovered_file: ?usize = null, comments: std.ArrayList(DiffComment) = .{}, @@ -149,19 +144,12 @@ pub const DiffOverlayComponent = struct { text_cursor: ?*c.SDL_Cursor = null, current_cursor: CursorKind = .arrow, - animation_state: AnimationState = .closed, - animation_start_ms: i64 = 0, - render_alpha: f32 = 1.0, - comment_anim: ?CommentAnimKind = null, comment_anim_start_ms: i64 = 0, comment_anim_row: usize = 0, submit_anim_text: ?[]const u8 = null, const CursorKind = enum { arrow, pointer, text }; - const AnimationState = enum { closed, opening, open, closing }; - const animation_duration_ms: i64 = 250; - const scale_from: f32 = 0.97; const CommentAnimKind = enum { editor_opening, @@ -175,14 +163,8 @@ pub const DiffOverlayComponent = struct { const submit_morph_duration_ms: i64 = 300; const submit_glow_duration_ms: i64 = 500; - const margin: c_int = 40; - const title_height: c_int = 50; - const close_btn_size: c_int = 32; - const close_btn_margin: c_int = 12; const line_height: c_int = 22; - const text_padding: c_int = 12; const font_size: c_int = 13; - const scroll_speed: f32 = 40.0; const gutter_width: c_int = 48; const marker_width: c_int = 20; const chevron_size: c_int = 12; @@ -226,24 +208,18 @@ pub const DiffOverlayComponent = struct { pub const ShowResult = enum { opened, not_a_repo, clean }; pub fn show(self: *DiffOverlayComponent, cwd: ?[]const u8, now_ms: i64) ShowResult { - self.visible = true; - self.scroll_offset = 0; - self.animation_state = .opening; - self.animation_start_ms = now_ms; - self.first_frame.markTransition(); + self.overlay.show(now_ms); return self.loadDiff(cwd); } pub fn hide(self: *DiffOverlayComponent, now_ms: i64) void { self.saveCommentsToFile(); self.setCursor(.arrow); - self.animation_state = .closing; - self.animation_start_ms = now_ms; - self.first_frame.markTransition(); + self.overlay.hide(now_ms); } pub fn toggle(self: *DiffOverlayComponent, cwd: ?[]const u8, now_ms: i64) ShowResult { - switch (self.animation_state) { + switch (self.overlay.animation_state) { .open, .opening => { self.hide(now_ms); return .opened; @@ -254,8 +230,8 @@ pub const DiffOverlayComponent = struct { } fn cancelShow(self: *DiffOverlayComponent) void { - self.visible = false; - self.animation_state = .closed; + self.overlay.visible = false; + self.overlay.animation_state = .closed; } fn loadDiff(self: *DiffOverlayComponent, cwd: ?[]const u8) ShowResult { @@ -791,7 +767,7 @@ pub const DiffOverlayComponent = struct { self.allocator.free(output); self.raw_output = null; } - self.scroll_offset = 0; + self.overlay.scroll_offset = 0; } fn clearDisplayRows(self: *DiffOverlayComponent) void { @@ -911,13 +887,6 @@ pub const DiffOverlayComponent = struct { // --- Animation helpers --- - fn animationProgress(self: *const DiffOverlayComponent, now_ms: i64) f32 { - const elapsed = now_ms - self.animation_start_ms; - const clamped = @max(@as(i64, 0), elapsed); - const t = @min(1.0, @as(f32, @floatFromInt(clamped)) / @as(f32, @floatFromInt(animation_duration_ms))); - return easing.easeInOutCubic(t); - } - fn commentAnimProgress(self: *const DiffOverlayComponent, now_ms: i64) f32 { const anim = self.comment_anim orelse return 1.0; const duration: i64 = switch (anim) { @@ -958,47 +927,8 @@ pub const DiffOverlayComponent = struct { } } - fn animatedOverlayRect(host: *const types.UiHost, progress: f32) geom.Rect { - const base = overlayRect(host); - const scale = scale_from + (1.0 - scale_from) * progress; - const base_w: f32 = @floatFromInt(base.w); - const base_h: f32 = @floatFromInt(base.h); - const base_x: f32 = @floatFromInt(base.x); - const base_y: f32 = @floatFromInt(base.y); - const new_w = base_w * scale; - const new_h = base_h * scale; - return .{ - .x = @intFromFloat(base_x + (base_w - new_w) / 2.0), - .y = @intFromFloat(base_y + (base_h - new_h) / 2.0), - .w = @intFromFloat(new_w), - .h = @intFromFloat(new_h), - }; - } - // --- Layout helpers --- - fn closeButtonRect(host: *const types.UiHost) geom.Rect { - const scaled_margin = dpi.scale(margin, host.ui_scale); - const scaled_btn_size = dpi.scale(close_btn_size, host.ui_scale); - const scaled_btn_margin = dpi.scale(close_btn_margin, host.ui_scale); - return .{ - .x = host.window_w - scaled_margin - scaled_btn_size - scaled_btn_margin, - .y = scaled_margin + scaled_btn_margin, - .w = scaled_btn_size, - .h = scaled_btn_size, - }; - } - - fn overlayRect(host: *const types.UiHost) geom.Rect { - const scaled_margin = dpi.scale(margin, host.ui_scale); - return .{ - .x = scaled_margin, - .y = scaled_margin, - .w = host.window_w - scaled_margin * 2, - .h = host.window_h - scaled_margin * 2, - }; - } - fn lineHeight(self: *DiffOverlayComponent, host: *const types.UiHost) c_int { if (self.cache) |cache| { return cache.line_height; @@ -1011,7 +941,7 @@ pub const DiffOverlayComponent = struct { fn handleEventFn(self_ptr: *anyopaque, host: *const types.UiHost, event: *const c.SDL_Event, actions: *types.UiActionQueue) bool { const self: *DiffOverlayComponent = @ptrCast(@alignCast(self_ptr)); - if (!self.visible) { + if (!self.overlay.visible) { if (event.type == c.SDL_EVENT_KEY_DOWN) { const key = event.key.key; const mod = event.key.mod; @@ -1030,7 +960,7 @@ pub const DiffOverlayComponent = struct { // During close animation, consume all input events to prevent // key repeats (e.g. Escape) from leaking to the terminal. - if (self.animation_state == .closing) { + if (self.overlay.animation_state == .closing) { return switch (event.type) { c.SDL_EVENT_KEY_DOWN, c.SDL_EVENT_KEY_UP, c.SDL_EVENT_TEXT_INPUT, c.SDL_EVENT_TEXT_EDITING, c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP, c.SDL_EVENT_MOUSE_WHEEL, c.SDL_EVENT_MOUSE_MOTION => true, else => false, @@ -1109,14 +1039,7 @@ pub const DiffOverlayComponent = struct { return true; } - if (key == c.SDLK_UP) { - self.scroll_offset = @max(0, self.scroll_offset - scroll_speed); - return true; - } - if (key == c.SDLK_DOWN) { - self.scroll_offset = @min(self.max_scroll, self.scroll_offset + scroll_speed); - return true; - } + if (self.overlay.handleScrollKey(key, host)) return true; return true; }, @@ -1134,9 +1057,7 @@ pub const DiffOverlayComponent = struct { return true; }, c.SDL_EVENT_MOUSE_WHEEL => { - const wheel_y = event.wheel.y; - self.scroll_offset = @max(0, self.scroll_offset - wheel_y * scroll_speed); - self.scroll_offset = @min(self.max_scroll, self.scroll_offset); + self.overlay.handleMouseWheel(event.wheel.y); return true; }, c.SDL_EVENT_MOUSE_BUTTON_DOWN => { @@ -1145,7 +1066,7 @@ pub const DiffOverlayComponent = struct { // Agent dropdown click if (self.show_agent_dropdown) { - const dd = agentDropdownRect(host, overlayRect(host)); + const dd = agentDropdownRect(host, FullscreenOverlay.overlayRect(host)); if (geom.containsPoint(dd, mouse_x, mouse_y)) { const item_h = dpi.scale(agent_dropdown_item_height, host.ui_scale); const rel_y = mouse_y - dd.y; @@ -1165,7 +1086,7 @@ pub const DiffOverlayComponent = struct { return true; } - const close_rect = closeButtonRect(host); + const close_rect = FullscreenOverlay.closeButtonRect(host); if (geom.containsPoint(close_rect, mouse_x, mouse_y)) { actions.append(.ToggleDiffOverlay) catch |err| { log.warn("failed to queue ToggleDiffOverlay action: {}", .{err}); @@ -1175,7 +1096,7 @@ pub const DiffOverlayComponent = struct { // Send to agent button if (self.hasUnsentComments()) { - const sb = sendButtonRect(host, overlayRect(host)); + const sb = sendButtonRect(host, FullscreenOverlay.overlayRect(host)); if (geom.containsPoint(sb, mouse_x, mouse_y)) { if (host.focused_has_foreground_process) { self.sendCommentsToAgent(host, actions, null); @@ -1188,14 +1109,14 @@ pub const DiffOverlayComponent = struct { // Comment editing button clicks if (self.editing) |ed| { - const rect = overlayRect(host); - const scaled_title_h = dpi.scale(title_height, host.ui_scale); + const rect = FullscreenOverlay.overlayRect(host); + const scaled_title_h = dpi.scale(FullscreenOverlay.title_height, host.ui_scale); const scaled_line_h = self.lineHeight(host); const total_h = dpi.scale(editing_comment_height, host.ui_scale); const btn_h = dpi.scale(comment_button_height, host.ui_scale); const btn_w = dpi.scale(comment_button_width, host.ui_scale); - const scaled_padding = dpi.scale(text_padding, host.ui_scale); - const scroll_int: c_int = @intFromFloat(self.scroll_offset); + const scaled_padding = dpi.scale(FullscreenOverlay.text_padding, host.ui_scale); + const scroll_int: c_int = @intFromFloat(self.overlay.scroll_offset); const content_top = rect.y + scaled_title_h; const comment_y_base = self.computeRowY(ed.target_display_row, scaled_line_h, host.ui_scale, host.now_ms) + scaled_line_h; @@ -1224,11 +1145,11 @@ pub const DiffOverlayComponent = struct { } } - const rect = overlayRect(host); - const scaled_title_h = dpi.scale(title_height, host.ui_scale); + const rect = FullscreenOverlay.overlayRect(host); + const scaled_title_h = dpi.scale(FullscreenOverlay.title_height, host.ui_scale); const scaled_line_h = self.lineHeight(host); const content_top = rect.y + scaled_title_h; - const scroll_int: c_int = @intFromFloat(self.scroll_offset); + const scroll_int: c_int = @intFromFloat(self.overlay.scroll_offset); if (mouse_y >= content_top and scaled_line_h > 0) { const relative_y = mouse_y - content_top + scroll_int; @@ -1284,7 +1205,7 @@ pub const DiffOverlayComponent = struct { self.comment_anim = .editor_opening; self.comment_anim_start_ms = host.now_ms; self.comment_anim_row = row_idx; - self.first_frame.markTransition(); + self.overlay.first_frame.markTransition(); break; } } @@ -1300,16 +1221,16 @@ pub const DiffOverlayComponent = struct { c.SDL_EVENT_MOUSE_MOTION => { const mouse_x: c_int = @intFromFloat(event.motion.x); const mouse_y: c_int = @intFromFloat(event.motion.y); - const close_rect = closeButtonRect(host); - self.close_hovered = geom.containsPoint(close_rect, mouse_x, mouse_y); + const close_rect = FullscreenOverlay.closeButtonRect(host); + self.overlay.close_hovered = geom.containsPoint(close_rect, mouse_x, mouse_y); self.send_button_hovered = if (self.hasUnsentComments()) - geom.containsPoint(sendButtonRect(host, overlayRect(host)), mouse_x, mouse_y) + geom.containsPoint(sendButtonRect(host, FullscreenOverlay.overlayRect(host)), mouse_x, mouse_y) else false; // Agent dropdown hover if (self.show_agent_dropdown) { - const dd = agentDropdownRect(host, overlayRect(host)); + const dd = agentDropdownRect(host, FullscreenOverlay.overlayRect(host)); if (geom.containsPoint(dd, mouse_x, mouse_y)) { const item_h = dpi.scale(agent_dropdown_item_height, host.ui_scale); const rel_y = mouse_y - dd.y; @@ -1320,16 +1241,16 @@ pub const DiffOverlayComponent = struct { } } - const rect = overlayRect(host); - const scaled_title_h = dpi.scale(title_height, host.ui_scale); + const rect = FullscreenOverlay.overlayRect(host); + const scaled_title_h = dpi.scale(FullscreenOverlay.title_height, host.ui_scale); const scaled_line_h = self.lineHeight(host); const content_top = rect.y + scaled_title_h; - const scroll_int: c_int = @intFromFloat(self.scroll_offset); + const scroll_int: c_int = @intFromFloat(self.overlay.scroll_offset); self.hovered_file = null; self.delete_hovered_comment = null; var want_cursor: CursorKind = .arrow; - if (self.close_hovered or self.send_button_hovered) { + if (self.overlay.close_hovered or self.send_button_hovered) { want_cursor = .pointer; } else if (self.show_agent_dropdown and self.agent_dropdown_hovered != null) { want_cursor = .pointer; @@ -1378,21 +1299,10 @@ pub const DiffOverlayComponent = struct { fn updateFn(self_ptr: *anyopaque, host: *const types.UiHost, _: *types.UiActionQueue) void { const self: *DiffOverlayComponent = @ptrCast(@alignCast(self_ptr)); - const elapsed = host.now_ms - self.animation_start_ms; - switch (self.animation_state) { - .opening => { - if (elapsed >= animation_duration_ms) { - self.animation_state = .open; - } - }, - .closing => { - if (elapsed >= animation_duration_ms) { - self.animation_state = .closed; - self.visible = false; - self.clearContent(); - } - }, - .open, .closed => {}, + if (self.overlay.updateAnimation(host.now_ms)) |event| { + if (event == .became_closed) { + self.clearContent(); + } } if (self.comment_anim) |anim| { @@ -1430,75 +1340,45 @@ pub const DiffOverlayComponent = struct { fn hitTestFn(self_ptr: *anyopaque, host: *const types.UiHost, x: c_int, y: c_int) bool { const self: *DiffOverlayComponent = @ptrCast(@alignCast(self_ptr)); - if (!self.visible or self.animation_state == .closing) return false; - const rect = overlayRect(host); - return geom.containsPoint(rect, x, y); + return self.overlay.hitTest(host, x, y); } fn wantsFrameFn(self_ptr: *anyopaque, _: *const types.UiHost) bool { const self: *DiffOverlayComponent = @ptrCast(@alignCast(self_ptr)); - return self.first_frame.wantsFrame() or self.visible or self.animation_state == .closing or self.comment_anim != null; + return self.overlay.wantsFrame() or self.overlay.visible or self.comment_anim != null; } // --- Rendering --- fn renderFn(self_ptr: *anyopaque, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets) void { const self: *DiffOverlayComponent = @ptrCast(@alignCast(self_ptr)); - if (!self.visible) return; - - // Compute animation progress - const raw_progress = self.animationProgress(host.now_ms); - const progress: f32 = switch (self.animation_state) { - .opening => raw_progress, - .closing => 1.0 - raw_progress, - .open => 1.0, - .closed => 0.0, - }; - self.render_alpha = progress; + if (!self.overlay.visible) return; + + const progress = self.overlay.renderProgress(host.now_ms); + self.overlay.render_alpha = progress; if (progress <= 0.001) return; const cache = self.ensureCache(renderer, host, assets) orelse return; - const rect = animatedOverlayRect(host, progress); - const scaled_title_h = dpi.scale(title_height, host.ui_scale); - const scaled_padding = dpi.scale(text_padding, host.ui_scale); - const scaled_font_size = dpi.scale(font_size, host.ui_scale); - const radius: c_int = dpi.scale(12, host.ui_scale); - + const rect = FullscreenOverlay.animatedOverlayRect(host, progress); + const scaled_title_h = dpi.scale(FullscreenOverlay.title_height, host.ui_scale); + const scaled_padding = dpi.scale(FullscreenOverlay.text_padding, host.ui_scale); const row_count_f: f32 = @floatFromInt(self.display_rows.items.len); const scaled_line_h_f: f32 = @floatFromInt(cache.line_height); const total_comment_h: f32 = @floatFromInt(self.totalCommentPixelHeight(host)); const content_height: f32 = row_count_f * scaled_line_h_f + total_comment_h; const viewport_height: f32 = @floatFromInt(rect.h - scaled_title_h); - self.max_scroll = @max(0, content_height - viewport_height); - self.scroll_offset = @min(self.max_scroll, self.scroll_offset); + self.overlay.max_scroll = @max(0, content_height - viewport_height); + self.overlay.scroll_offset = @min(self.overlay.max_scroll, self.overlay.scroll_offset); - _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); - const bg = host.theme.background; - const bg_alpha: u8 = @intFromFloat(240.0 * progress); - _ = c.SDL_SetRenderDrawColor(renderer, bg.r, bg.g, bg.b, bg_alpha); - primitives.fillRoundedRect(renderer, rect, radius); - - const accent = host.theme.accent; - const border_alpha: u8 = @intFromFloat(180.0 * progress); - _ = c.SDL_SetRenderDrawColor(renderer, accent.r, accent.g, accent.b, border_alpha); - primitives.drawRoundedBorder(renderer, rect, radius); + self.overlay.renderFrame(renderer, host, rect, progress); self.renderTitle(renderer, rect, scaled_title_h, scaled_padding, cache); - - const line_alpha: u8 = @intFromFloat(80.0 * progress); - _ = c.SDL_SetRenderDrawColor(renderer, accent.r, accent.g, accent.b, line_alpha); - _ = c.SDL_RenderLine( - renderer, - @floatFromInt(rect.x + scaled_padding), - @floatFromInt(rect.y + scaled_title_h), - @floatFromInt(rect.x + rect.w - scaled_padding), - @floatFromInt(rect.y + scaled_title_h), - ); + FullscreenOverlay.renderTitleSeparator(renderer, host, rect, progress); self.renderSendButton(host, renderer, assets, rect); - self.renderCloseButton(host, renderer, assets, rect, scaled_font_size); + self.overlay.renderCloseButton(renderer, host, rect); const content_clip = c.SDL_Rect{ .x = rect.x, @@ -1512,14 +1392,14 @@ pub const DiffOverlayComponent = struct { _ = c.SDL_SetRenderClipRect(renderer, null); - self.renderScrollbar(host, renderer, rect, scaled_title_h, content_height, viewport_height); + self.overlay.renderScrollbar(renderer, host, rect, scaled_title_h, content_height, viewport_height); self.renderAgentDropdown(host, renderer, assets, rect); - self.first_frame.markDrawn(); + self.overlay.first_frame.markDrawn(); } fn renderTitle(self: *DiffOverlayComponent, renderer: *c.SDL_Renderer, rect: geom.Rect, title_h: c_int, padding: c_int, cache: *Cache) void { - const tex_alpha: u8 = @intFromFloat(255.0 * self.render_alpha); + const tex_alpha: u8 = @intFromFloat(255.0 * self.overlay.render_alpha); _ = c.SDL_SetTextureAlphaMod(cache.title.tex, tex_alpha); const text_y = rect.y + @divFloor(title_h - cache.title.h, 2); @@ -1531,50 +1411,14 @@ pub const DiffOverlayComponent = struct { }); } - fn renderCloseButton(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, _: *types.UiAssets, overlay_rect: geom.Rect, _: c_int) void { - const scaled_btn_size = dpi.scale(close_btn_size, host.ui_scale); - const scaled_btn_margin = dpi.scale(close_btn_margin, host.ui_scale); - const btn_rect = geom.Rect{ - .x = overlay_rect.x + overlay_rect.w - scaled_btn_size - scaled_btn_margin, - .y = overlay_rect.y + scaled_btn_margin, - .w = scaled_btn_size, - .h = scaled_btn_size, - }; - - const fg = host.theme.foreground; - const alpha: u8 = @intFromFloat(if (self.close_hovered) 255.0 * self.render_alpha else 160.0 * self.render_alpha); - _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); - _ = c.SDL_SetRenderDrawColor(renderer, fg.r, fg.g, fg.b, alpha); - - const cross_size: c_int = @divFloor(btn_rect.w * 6, 10); - const cross_x = btn_rect.x + @divFloor(btn_rect.w - cross_size, 2); - const cross_y = btn_rect.y + @divFloor(btn_rect.h - cross_size, 2); - - const x1: f32 = @floatFromInt(cross_x); - const y1: f32 = @floatFromInt(cross_y); - const x2: f32 = @floatFromInt(cross_x + cross_size); - const y2: f32 = @floatFromInt(cross_y + cross_size); - - _ = c.SDL_RenderLine(renderer, x1, y1, x2, y2); - _ = c.SDL_RenderLine(renderer, x2, y1, x1, y2); - - if (self.close_hovered) { - const bold_offset: f32 = 1.0; - _ = c.SDL_RenderLine(renderer, x1 + bold_offset, y1, x2 + bold_offset, y2); - _ = c.SDL_RenderLine(renderer, x2 + bold_offset, y1, x1 + bold_offset, y2); - _ = c.SDL_RenderLine(renderer, x1, y1 + bold_offset, x2, y2 + bold_offset); - _ = c.SDL_RenderLine(renderer, x2, y1 + bold_offset, x1, y2 + bold_offset); - } - } - fn updateWrapCols(self: *DiffOverlayComponent, renderer: *c.SDL_Renderer, host: *const types.UiHost, mono_font: *c.TTF_Font) void { const char_w = measureCharWidth(renderer, mono_font) orelse return; if (char_w <= 0) return; - const rect = overlayRect(host); + const rect = FullscreenOverlay.overlayRect(host); const scaled_gutter_w = dpi.scale(gutter_width, host.ui_scale); const scaled_marker_w = dpi.scale(marker_width, host.ui_scale); - const scaled_padding = dpi.scale(text_padding, host.ui_scale); + const scaled_padding = dpi.scale(FullscreenOverlay.text_padding, host.ui_scale); const scrollbar_w = dpi.scale(10, host.ui_scale); const text_area_w = rect.w - scaled_gutter_w * 2 - scaled_marker_w - scaled_padding - scrollbar_w; if (text_area_w <= 0) return; @@ -1737,7 +1581,7 @@ pub const DiffOverlayComponent = struct { const scaled_marker_w = dpi.scale(marker_width, host.ui_scale); const scaled_chevron_sz = dpi.scale(chevron_size, host.ui_scale); const scaled_fh_pad = dpi.scale(file_header_pad, host.ui_scale); - const scaled_padding = dpi.scale(text_padding, host.ui_scale); + const scaled_padding = dpi.scale(FullscreenOverlay.text_padding, host.ui_scale); const gutter_total_w = scaled_gutter_w * 2; const text_start_x = gutter_total_w + scaled_marker_w; @@ -1929,8 +1773,8 @@ pub const DiffOverlayComponent = struct { } fn renderDiffContent(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, rect: geom.Rect, title_h: c_int, padding: c_int, cache: *Cache, assets: *types.UiAssets) void { - const alpha = self.render_alpha; - const scroll_int: c_int = @intFromFloat(self.scroll_offset); + const alpha = self.overlay.render_alpha; + const scroll_int: c_int = @intFromFloat(self.overlay.scroll_offset); const content_top = rect.y + title_h; const content_h = rect.h - title_h; @@ -2184,37 +2028,6 @@ pub const DiffOverlayComponent = struct { _ = c.SDL_RenderGeometry(renderer, null, &verts, 3, &indices, 3); } - fn renderScrollbar(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, rect: geom.Rect, title_h: c_int, content_height: f32, viewport_height: f32) void { - if (content_height <= viewport_height) return; - - const scrollbar_width = dpi.scale(6, host.ui_scale); - const scrollbar_margin = dpi.scale(4, host.ui_scale); - const track_height = rect.h - title_h - scrollbar_margin * 2; - const thumb_ratio = viewport_height / content_height; - const thumb_height: c_int = @max(dpi.scale(20, host.ui_scale), @as(c_int, @intFromFloat(@as(f32, @floatFromInt(track_height)) * thumb_ratio))); - const scroll_ratio = if (self.max_scroll > 0) self.scroll_offset / self.max_scroll else 0; - const thumb_y: c_int = @intFromFloat(@as(f32, @floatFromInt(track_height - thumb_height)) * scroll_ratio); - - _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); - const alpha = self.render_alpha; - _ = c.SDL_SetRenderDrawColor(renderer, 128, 128, 128, @intFromFloat(30.0 * alpha)); - _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ - .x = @floatFromInt(rect.x + rect.w - scrollbar_width - scrollbar_margin), - .y = @floatFromInt(rect.y + title_h + scrollbar_margin), - .w = @floatFromInt(scrollbar_width), - .h = @floatFromInt(track_height), - }); - - const accent_col = host.theme.accent; - _ = c.SDL_SetRenderDrawColor(renderer, accent_col.r, accent_col.g, accent_col.b, @intFromFloat(120.0 * alpha)); - _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ - .x = @floatFromInt(rect.x + rect.w - scrollbar_width - scrollbar_margin), - .y = @floatFromInt(rect.y + title_h + scrollbar_margin + thumb_y), - .w = @floatFromInt(scrollbar_width), - .h = @floatFromInt(thumb_height), - }); - } - // --- Comment management --- fn freeComments(self: *DiffOverlayComponent) void { @@ -2336,7 +2149,7 @@ pub const DiffOverlayComponent = struct { self.comment_anim = .editor_opening; self.comment_anim_start_ms = now_ms; self.comment_anim_row = attach_row; - self.first_frame.markTransition(); + self.overlay.first_frame.markTransition(); } fn submitComment(self: *DiffOverlayComponent, now_ms: i64) void { @@ -2604,11 +2417,11 @@ pub const DiffOverlayComponent = struct { } fn findCommentDeleteTarget(self: *DiffOverlayComponent, host: *const types.UiHost, row_idx: usize, mouse_x: c_int, mouse_y: c_int) ?usize { - const rect = overlayRect(host); - const scaled_title_h = dpi.scale(title_height, host.ui_scale); + const rect = FullscreenOverlay.overlayRect(host); + const scaled_title_h = dpi.scale(FullscreenOverlay.title_height, host.ui_scale); const scaled_line_h = self.lineHeight(host); const content_top = rect.y + scaled_title_h; - const scroll_int: c_int = @intFromFloat(self.scroll_offset); + const scroll_int: c_int = @intFromFloat(self.overlay.scroll_offset); const row_y = content_top + self.computeRowY(row_idx, scaled_line_h, host.ui_scale, host.now_ms) - scroll_int; var comment_y = row_y + scaled_line_h; const saved_h = dpi.scale(saved_comment_height, host.ui_scale); @@ -2631,8 +2444,8 @@ pub const DiffOverlayComponent = struct { fn sendButtonRect(host: *const types.UiHost, overlay_rect: geom.Rect) geom.Rect { const btn_w = dpi.scale(send_button_width, host.ui_scale); const btn_h = dpi.scale(send_button_height, host.ui_scale); - const btn_margin = dpi.scale(close_btn_margin, host.ui_scale); - const close_w = dpi.scale(close_btn_size, host.ui_scale); + const btn_margin = dpi.scale(FullscreenOverlay.close_btn_margin, host.ui_scale); + const close_w = dpi.scale(FullscreenOverlay.close_btn_size, host.ui_scale); return geom.Rect{ .x = overlay_rect.x + overlay_rect.w - close_w - btn_margin * 2 - btn_w, .y = overlay_rect.y + btn_margin, @@ -2840,9 +2653,9 @@ pub const DiffOverlayComponent = struct { fn renderSavedComment(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets, rect: geom.Rect, y_pos: c_int, comment: DiffComment, comment_idx: usize) void { const comment_h = dpi.scale(saved_comment_height, host.ui_scale); - const scaled_padding = dpi.scale(text_padding, host.ui_scale); + const scaled_padding = dpi.scale(FullscreenOverlay.text_padding, host.ui_scale); const accent_w = dpi.scale(4, host.ui_scale); - const alpha = self.render_alpha; + const alpha = self.overlay.render_alpha; const del_btn = commentDeleteBtnRect(host, rect, y_pos); _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); @@ -2894,11 +2707,11 @@ pub const DiffOverlayComponent = struct { fn renderEditingComment(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets, rect: geom.Rect, y_pos: c_int) void { const ed = self.editing orelse return; const total_h = dpi.scale(editing_comment_height, host.ui_scale); - const scaled_padding = dpi.scale(text_padding, host.ui_scale); + const scaled_padding = dpi.scale(FullscreenOverlay.text_padding, host.ui_scale); const input_h = dpi.scale(comment_input_height, host.ui_scale); const btn_h = dpi.scale(comment_button_height, host.ui_scale); const btn_w = dpi.scale(comment_button_width, host.ui_scale); - const alpha = self.render_alpha; + const alpha = self.overlay.render_alpha; _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); @@ -3030,9 +2843,9 @@ pub const DiffOverlayComponent = struct { const anim_h: c_int = @intFromFloat(anim_h_f); if (anim_h <= 0) return; - const scaled_padding = dpi.scale(text_padding, host.ui_scale); + const scaled_padding = dpi.scale(FullscreenOverlay.text_padding, host.ui_scale); const input_h = dpi.scale(comment_input_height, host.ui_scale); - const alpha = self.render_alpha * anim_alpha; + const alpha = self.overlay.render_alpha * anim_alpha; _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); @@ -3161,9 +2974,9 @@ pub const DiffOverlayComponent = struct { const morph_h: c_int = @intFromFloat(edit_h_f + (saved_h_f - edit_h_f) * progress); if (morph_h <= 0) return; - const scaled_padding = dpi.scale(text_padding, host.ui_scale); + const scaled_padding = dpi.scale(FullscreenOverlay.text_padding, host.ui_scale); const accent_w = dpi.scale(4, host.ui_scale); - const alpha = self.render_alpha; + const alpha = self.overlay.render_alpha; // Interpolate background: editing rgb(40,44,52) → saved rgb(180,140,40) const bg_r: u8 = @intFromFloat(40.0 + (180.0 - 40.0) * progress); @@ -3263,9 +3076,9 @@ pub const DiffOverlayComponent = struct { fn renderSavedCommentWithGlow(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets, rect: geom.Rect, y_pos: c_int, comment: DiffComment, glow_progress: f32, comment_idx: usize) void { const comment_h = dpi.scale(saved_comment_height, host.ui_scale); - const scaled_padding = dpi.scale(text_padding, host.ui_scale); + const scaled_padding = dpi.scale(FullscreenOverlay.text_padding, host.ui_scale); const accent_w = dpi.scale(4, host.ui_scale); - const alpha = self.render_alpha; + const alpha = self.overlay.render_alpha; const del_btn = commentDeleteBtnRect(host, rect, y_pos); // Glow effect: pulse peaks at the start and fades out @@ -3333,7 +3146,7 @@ pub const DiffOverlayComponent = struct { } fn renderCommentDeleteBtn(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, btn: geom.Rect, comment_idx: usize) void { - const alpha = self.render_alpha; + const alpha = self.overlay.render_alpha; const is_hovered = if (self.delete_hovered_comment) |hc| hc == comment_idx else false; const btn_alpha: f32 = if (is_hovered) 220.0 else 100.0; @@ -3374,7 +3187,7 @@ pub const DiffOverlayComponent = struct { if (!self.hasUnsentComments()) return; const btn = sendButtonRect(host, overlay_rect); - const alpha = self.render_alpha; + const alpha = self.overlay.render_alpha; const radius = dpi.scale(4, host.ui_scale); _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); @@ -3401,7 +3214,7 @@ pub const DiffOverlayComponent = struct { const dd = agentDropdownRect(host, overlay_rect); const item_h = dpi.scale(agent_dropdown_item_height, host.ui_scale); - const alpha = self.render_alpha; + const alpha = self.overlay.render_alpha; const radius = dpi.scale(4, host.ui_scale); _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); @@ -3440,7 +3253,7 @@ pub const DiffOverlayComponent = struct { defer c.SDL_DestroyTexture(tex.tex); _ = c.SDL_SetTextureAlphaMod(tex.tex, @intFromFloat(255.0 * alpha)); _ = c.SDL_RenderTexture(renderer, tex.tex, null, &c.SDL_FRect{ - .x = @floatFromInt(dd.x + dpi.scale(text_padding, host.ui_scale)), + .x = @floatFromInt(dd.x + dpi.scale(FullscreenOverlay.text_padding, host.ui_scale)), .y = @floatFromInt(item_y + @divFloor(item_h - tex.h, 2)), .w = @floatFromInt(tex.w), .h = @floatFromInt(tex.h), diff --git a/src/ui/components/fullscreen_overlay.zig b/src/ui/components/fullscreen_overlay.zig new file mode 100644 index 0000000..d72c0d2 --- /dev/null +++ b/src/ui/components/fullscreen_overlay.zig @@ -0,0 +1,312 @@ +const c = @import("../../c.zig"); +const geom = @import("../../geom.zig"); +const primitives = @import("../../gfx/primitives.zig"); +const types = @import("../types.zig"); +const dpi = @import("../scale.zig"); +const FirstFrameGuard = @import("../first_frame_guard.zig").FirstFrameGuard; +const easing = @import("../../anim/easing.zig"); + +pub const AnimationEvent = enum { + became_open, + became_closed, +}; + +pub const FullscreenOverlay = struct { + visible: bool = false, + animation_state: AnimationState = .closed, + animation_start_ms: i64 = 0, + render_alpha: f32 = 1.0, + + scroll_offset: f32 = 0, + max_scroll: f32 = 0, + close_hovered: bool = false, + + first_frame: FirstFrameGuard = .{}, + + pub const AnimationState = enum { closed, opening, open, closing }; + + pub const animation_duration_ms: i64 = 250; + pub const scale_from: f32 = 0.97; + pub const scroll_speed: f32 = 40.0; + pub const outer_margin: c_int = 40; + pub const title_height: c_int = 50; + pub const close_btn_size: c_int = 32; + pub const close_btn_margin: c_int = 12; + pub const text_padding: c_int = 12; + + // --- Lifecycle --- + + pub fn show(self: *FullscreenOverlay, now_ms: i64) void { + self.visible = true; + self.scroll_offset = 0; + self.animation_state = .opening; + self.animation_start_ms = now_ms; + self.first_frame.markTransition(); + } + + pub fn hide(self: *FullscreenOverlay, now_ms: i64) void { + self.animation_state = .closing; + self.animation_start_ms = now_ms; + self.first_frame.markTransition(); + } + + pub fn updateAnimation(self: *FullscreenOverlay, now_ms: i64) ?AnimationEvent { + const elapsed = now_ms - self.animation_start_ms; + switch (self.animation_state) { + .opening => { + if (elapsed >= animation_duration_ms) { + self.animation_state = .open; + return .became_open; + } + }, + .closing => { + if (elapsed >= animation_duration_ms) { + self.animation_state = .closed; + self.visible = false; + return .became_closed; + } + }, + .open, .closed => {}, + } + return null; + } + + // --- Animation --- + + pub fn animationProgress(self: *const FullscreenOverlay, now_ms: i64) f32 { + const elapsed = now_ms - self.animation_start_ms; + if (elapsed >= animation_duration_ms) return 1.0; + if (elapsed <= 0) return 0.0; + const t: f32 = @as(f32, @floatFromInt(elapsed)) / @as(f32, @floatFromInt(animation_duration_ms)); + return easing.easeInOutCubic(t); + } + + pub fn renderProgress(self: *const FullscreenOverlay, now_ms: i64) f32 { + const raw = self.animationProgress(now_ms); + return switch (self.animation_state) { + .opening => raw, + .closing => 1.0 - raw, + .open => 1.0, + .closed => 0.0, + }; + } + + // --- Layout --- + + pub fn overlayRect(host: *const types.UiHost) geom.Rect { + const scaled_margin = dpi.scale(outer_margin, host.ui_scale); + return .{ + .x = scaled_margin, + .y = scaled_margin, + .w = host.window_w - scaled_margin * 2, + .h = host.window_h - scaled_margin * 2, + }; + } + + pub fn animatedOverlayRect(host: *const types.UiHost, progress: f32) geom.Rect { + const base = overlayRect(host); + const scale = scale_from + (1.0 - scale_from) * progress; + const base_w: f32 = @floatFromInt(base.w); + const base_h: f32 = @floatFromInt(base.h); + const base_x: f32 = @floatFromInt(base.x); + const base_y: f32 = @floatFromInt(base.y); + const new_w = base_w * scale; + const new_h = base_h * scale; + return .{ + .x = @intFromFloat(base_x + (base_w - new_w) / 2.0), + .y = @intFromFloat(base_y + (base_h - new_h) / 2.0), + .w = @intFromFloat(new_w), + .h = @intFromFloat(new_h), + }; + } + + pub fn closeButtonRect(host: *const types.UiHost) geom.Rect { + const scaled_margin = dpi.scale(outer_margin, host.ui_scale); + const scaled_btn_size = dpi.scale(close_btn_size, host.ui_scale); + const scaled_btn_margin = dpi.scale(close_btn_margin, host.ui_scale); + return .{ + .x = host.window_w - scaled_margin - scaled_btn_size - scaled_btn_margin, + .y = scaled_margin + scaled_btn_margin, + .w = scaled_btn_size, + .h = scaled_btn_size, + }; + } + + // --- Input --- + + pub fn handleScrollKey(self: *FullscreenOverlay, key: c.SDL_Keycode, host: *const types.UiHost) bool { + if (key == c.SDLK_UP) { + self.scroll_offset = @max(0, self.scroll_offset - scroll_speed); + return true; + } + if (key == c.SDLK_DOWN) { + self.scroll_offset = @min(self.max_scroll, self.scroll_offset + scroll_speed); + return true; + } + if (key == c.SDLK_PAGEUP) { + const page: f32 = @floatFromInt(host.window_h - dpi.scale(title_height + outer_margin * 2, host.ui_scale)); + self.scroll_offset = @max(0, self.scroll_offset - page); + return true; + } + if (key == c.SDLK_PAGEDOWN) { + const page: f32 = @floatFromInt(host.window_h - dpi.scale(title_height + outer_margin * 2, host.ui_scale)); + self.scroll_offset = @min(self.max_scroll, self.scroll_offset + page); + return true; + } + if (key == c.SDLK_HOME) { + self.scroll_offset = 0; + return true; + } + if (key == c.SDLK_END) { + self.scroll_offset = self.max_scroll; + return true; + } + return false; + } + + pub fn handleMouseWheel(self: *FullscreenOverlay, wheel_y: f32) void { + self.scroll_offset = @max(0, self.scroll_offset - wheel_y * scroll_speed); + self.scroll_offset = @min(self.max_scroll, self.scroll_offset); + } + + pub fn isCloseButtonHit(mouse_x: c_int, mouse_y: c_int, host: *const types.UiHost) bool { + const close_rect = closeButtonRect(host); + return geom.containsPoint(close_rect, mouse_x, mouse_y); + } + + pub fn updateCloseHover(self: *FullscreenOverlay, mouse_x: c_int, mouse_y: c_int, host: *const types.UiHost) void { + const close_rect = closeButtonRect(host); + self.close_hovered = geom.containsPoint(close_rect, mouse_x, mouse_y); + } + + pub fn hitTest(self: *const FullscreenOverlay, host: *const types.UiHost, x: c_int, y: c_int) bool { + if (!self.visible or self.animation_state == .closing) return false; + const rect = overlayRect(host); + return geom.containsPoint(rect, x, y); + } + + pub fn wantsFrame(self: *const FullscreenOverlay) bool { + return self.first_frame.wantsFrame() or self.animation_state == .opening or self.animation_state == .closing; + } + + /// Returns true if the overlay is visible and should consume input events. + pub fn isConsuming(self: *const FullscreenOverlay) bool { + return self.visible and self.animation_state != .closed; + } + + // --- Rendering --- + + pub fn renderFrame(self: *const FullscreenOverlay, renderer: *c.SDL_Renderer, host: *const types.UiHost, rect: geom.Rect, progress: f32) void { + _ = self; + const radius: c_int = dpi.scale(12, host.ui_scale); + + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + const bg = host.theme.background; + const bg_alpha: u8 = @intFromFloat(240.0 * progress); + _ = c.SDL_SetRenderDrawColor(renderer, bg.r, bg.g, bg.b, bg_alpha); + primitives.fillRoundedRect(renderer, rect, radius); + + const accent = host.theme.accent; + const border_alpha: u8 = @intFromFloat(180.0 * progress); + _ = c.SDL_SetRenderDrawColor(renderer, accent.r, accent.g, accent.b, border_alpha); + primitives.drawRoundedBorder(renderer, rect, radius); + } + + pub fn renderTitleSeparator(renderer: *c.SDL_Renderer, host: *const types.UiHost, rect: geom.Rect, progress: f32) void { + const scaled_title_h = dpi.scale(title_height, host.ui_scale); + const scaled_padding = dpi.scale(text_padding, host.ui_scale); + const accent = host.theme.accent; + const line_alpha: u8 = @intFromFloat(80.0 * progress); + _ = c.SDL_SetRenderDrawColor(renderer, accent.r, accent.g, accent.b, line_alpha); + _ = c.SDL_RenderLine( + renderer, + @floatFromInt(rect.x + scaled_padding), + @floatFromInt(rect.y + scaled_title_h), + @floatFromInt(rect.x + rect.w - scaled_padding), + @floatFromInt(rect.y + scaled_title_h), + ); + } + + pub fn renderCloseButton(self: *const FullscreenOverlay, renderer: *c.SDL_Renderer, host: *const types.UiHost, overlay_rect: geom.Rect) void { + const scaled_btn_size = dpi.scale(close_btn_size, host.ui_scale); + const scaled_btn_margin = dpi.scale(close_btn_margin, host.ui_scale); + const btn_rect = geom.Rect{ + .x = overlay_rect.x + overlay_rect.w - scaled_btn_size - scaled_btn_margin, + .y = overlay_rect.y + scaled_btn_margin, + .w = scaled_btn_size, + .h = scaled_btn_size, + }; + + const fg = host.theme.foreground; + const alpha: u8 = @intFromFloat(if (self.close_hovered) 255.0 * self.render_alpha else 160.0 * self.render_alpha); + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + _ = c.SDL_SetRenderDrawColor(renderer, fg.r, fg.g, fg.b, alpha); + + const cross_size: c_int = @divFloor(btn_rect.w * 6, 10); + const cross_x = btn_rect.x + @divFloor(btn_rect.w - cross_size, 2); + const cross_y = btn_rect.y + @divFloor(btn_rect.h - cross_size, 2); + + const x1: f32 = @floatFromInt(cross_x); + const y1: f32 = @floatFromInt(cross_y); + const x2: f32 = @floatFromInt(cross_x + cross_size); + const y2: f32 = @floatFromInt(cross_y + cross_size); + + _ = c.SDL_RenderLine(renderer, x1, y1, x2, y2); + _ = c.SDL_RenderLine(renderer, x2, y1, x1, y2); + + if (self.close_hovered) { + const bold_offset: f32 = 1.0; + _ = c.SDL_RenderLine(renderer, x1 + bold_offset, y1, x2 + bold_offset, y2); + _ = c.SDL_RenderLine(renderer, x2 + bold_offset, y1, x1 + bold_offset, y2); + _ = c.SDL_RenderLine(renderer, x1, y1 + bold_offset, x2, y2 + bold_offset); + _ = c.SDL_RenderLine(renderer, x2, y1 + bold_offset, x1, y2 + bold_offset); + } + } + + pub fn renderScrollbar(self: *const FullscreenOverlay, renderer: *c.SDL_Renderer, host: *const types.UiHost, rect: geom.Rect, title_h: c_int, content_height: f32, viewport_height: f32) void { + if (content_height <= viewport_height) return; + + const scrollbar_width = dpi.scale(6, host.ui_scale); + const scrollbar_margin = dpi.scale(4, host.ui_scale); + const track_height = rect.h - title_h - scrollbar_margin * 2; + const thumb_ratio = viewport_height / content_height; + const thumb_height: c_int = @max(dpi.scale(20, host.ui_scale), @as(c_int, @intFromFloat(@as(f32, @floatFromInt(track_height)) * thumb_ratio))); + const scroll_ratio = if (self.max_scroll > 0) self.scroll_offset / self.max_scroll else 0; + const thumb_y: c_int = @intFromFloat(@as(f32, @floatFromInt(track_height - thumb_height)) * scroll_ratio); + + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + const bar_alpha = self.render_alpha; + _ = c.SDL_SetRenderDrawColor(renderer, 128, 128, 128, @intFromFloat(30.0 * bar_alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + rect.w - scrollbar_width - scrollbar_margin), + .y = @floatFromInt(rect.y + title_h + scrollbar_margin), + .w = @floatFromInt(scrollbar_width), + .h = @floatFromInt(track_height), + }); + + const accent_col = host.theme.accent; + _ = c.SDL_SetRenderDrawColor(renderer, accent_col.r, accent_col.g, accent_col.b, @intFromFloat(120.0 * bar_alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + rect.w - scrollbar_width - scrollbar_margin), + .y = @floatFromInt(rect.y + title_h + scrollbar_margin + thumb_y), + .w = @floatFromInt(scrollbar_width), + .h = @floatFromInt(thumb_height), + }); + } + + /// Render a title texture centered vertically in the title area. + pub fn renderTitle(self: *const FullscreenOverlay, renderer: *c.SDL_Renderer, rect: geom.Rect, title_tex: *c.SDL_Texture, title_w: c_int, title_h: c_int, host: *const types.UiHost) void { + const scaled_title_h = dpi.scale(title_height, host.ui_scale); + const scaled_padding = dpi.scale(text_padding, host.ui_scale); + const tex_alpha: u8 = @intFromFloat(255.0 * self.render_alpha); + _ = c.SDL_SetTextureAlphaMod(title_tex, tex_alpha); + + const text_y = rect.y + @divFloor(scaled_title_h - title_h, 2); + _ = c.SDL_RenderTexture(renderer, title_tex, null, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + scaled_padding), + .y = @floatFromInt(text_y), + .w = @floatFromInt(title_w), + .h = @floatFromInt(title_h), + }); + } +}; diff --git a/src/ui/components/story_overlay.zig b/src/ui/components/story_overlay.zig new file mode 100644 index 0000000..4588c08 --- /dev/null +++ b/src/ui/components/story_overlay.zig @@ -0,0 +1,850 @@ +const std = @import("std"); +const c = @import("../../c.zig"); +const geom = @import("../../geom.zig"); +const primitives = @import("../../gfx/primitives.zig"); +const types = @import("../types.zig"); +const UiComponent = @import("../component.zig").UiComponent; +const dpi = @import("../scale.zig"); +const FullscreenOverlay = @import("fullscreen_overlay.zig").FullscreenOverlay; +const story_parser = @import("../story_parser.zig"); + +const log = std.log.scoped(.story_overlay); + +// === Texture types === + +const SegmentKind = enum { + text, + marker, +}; + +const SegmentTexture = struct { + tex: *c.SDL_Texture, + kind: SegmentKind, + x_offset: c_int, + w: c_int, + h: c_int, +}; + +const LineTexture = struct { + segments: []SegmentTexture, +}; + +const TextTex = struct { + tex: *c.SDL_Texture, + w: c_int, + h: c_int, +}; + +const Cache = struct { + ui_scale: f32, + font_generation: u64, + line_height: c_int, + char_width: c_int, + title: TextTex, + lines: []LineTexture, + bold_font: *c.TTF_Font, +}; + +// === Anchor tracking === + +const AnchorPosition = struct { + number: u8, + x: c_int, + y: c_int, + is_code: bool, +}; + +// === Component === + +pub const StoryOverlayComponent = struct { + allocator: std.mem.Allocator, + overlay: FullscreenOverlay = .{}, + + raw_content: ?[]u8 = null, + display_rows: std.ArrayList(story_parser.DisplayRow) = .{}, + cache: ?*Cache = null, + file_path: ?[]u8 = null, + + wrap_cols: usize = 0, + + anchor_positions: std.ArrayList(AnchorPosition) = .{}, + hovered_anchor: ?u8 = null, + hover_start_ms: i64 = 0, + + pointer_cursor: ?*c.SDL_Cursor = null, + arrow_cursor: ?*c.SDL_Cursor = null, + + const row_height: c_int = 22; + const font_size: c_int = 13; + const marker_width: c_int = 20; + const code_indent: c_int = 8; + const max_display_buffer: usize = 520; + + pub fn init(allocator: std.mem.Allocator) !*StoryOverlayComponent { + const comp = try allocator.create(StoryOverlayComponent); + comp.* = .{ + .allocator = allocator, + .pointer_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_POINTER), + .arrow_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_DEFAULT), + }; + return comp; + } + + pub fn asComponent(self: *StoryOverlayComponent) UiComponent { + return .{ + .ptr = self, + .vtable = &vtable, + .z_index = 1200, + }; + } + + pub fn show(self: *StoryOverlayComponent, path: []const u8, now_ms: i64) bool { + self.clearContent(); + + const content = self.readFile(path) orelse { + log.warn("failed to read story file: {s}", .{path}); + return false; + }; + self.raw_content = content; + + const path_dupe = self.allocator.dupe(u8, path) catch |err| { + log.warn("failed to duplicate story path: {}", .{err}); + return false; + }; + if (self.file_path) |old| self.allocator.free(old); + self.file_path = path_dupe; + + self.display_rows = story_parser.parse(self.allocator, content, self.wrap_cols); + + if (self.display_rows.items.len == 0) { + log.warn("story file is empty: {s}", .{path}); + return false; + } + + self.overlay.show(now_ms); + return true; + } + + pub fn hide(self: *StoryOverlayComponent, now_ms: i64) void { + self.overlay.hide(now_ms); + self.hovered_anchor = null; + if (self.arrow_cursor) |cur| _ = c.SDL_SetCursor(cur); + } + + fn readFile(self: *StoryOverlayComponent, path: []const u8) ?[]u8 { + const file = std.fs.openFileAbsolute(path, .{}) catch |err| { + log.warn("failed to open story file {s}: {}", .{ path, err }); + return null; + }; + defer file.close(); + + const max_size: usize = 4 * 1024 * 1024; + return file.readToEndAlloc(self.allocator, max_size) catch |err| { + log.warn("failed to read story file {s}: {}", .{ path, err }); + return null; + }; + } + + fn clearContent(self: *StoryOverlayComponent) void { + story_parser.freeDisplayRows(self.allocator, &self.display_rows); + + if (self.raw_content) |content| { + self.allocator.free(content); + self.raw_content = null; + } + + self.destroyCache(); + } + + // --- Event handling --- + + fn handleEventFn(self_ptr: *anyopaque, host: *const types.UiHost, event: *const c.SDL_Event, _: *types.UiActionQueue) bool { + const self: *StoryOverlayComponent = @ptrCast(@alignCast(self_ptr)); + + if (!self.overlay.visible) return false; + + if (self.overlay.animation_state == .closing or self.overlay.animation_state == .opening) return true; + + switch (event.type) { + c.SDL_EVENT_KEY_DOWN => { + const key = event.key.key; + + if (key == c.SDLK_ESCAPE) { + self.hide(host.now_ms); + return true; + } + + if (self.overlay.handleScrollKey(key, host)) return true; + + return true; + }, + c.SDL_EVENT_MOUSE_WHEEL => { + self.overlay.handleMouseWheel(event.wheel.y); + return true; + }, + c.SDL_EVENT_MOUSE_BUTTON_DOWN => { + const mouse_x: c_int = @intFromFloat(event.button.x); + const mouse_y: c_int = @intFromFloat(event.button.y); + + if (FullscreenOverlay.isCloseButtonHit(mouse_x, mouse_y, host)) { + self.hide(host.now_ms); + return true; + } + return true; + }, + c.SDL_EVENT_MOUSE_MOTION => { + const mouse_x: c_int = @intFromFloat(event.motion.x); + const mouse_y: c_int = @intFromFloat(event.motion.y); + self.overlay.updateCloseHover(mouse_x, mouse_y, host); + const prev_hovered = self.hovered_anchor; + self.updateAnchorHover(mouse_x, mouse_y, host); + if (self.hovered_anchor != prev_hovered) { + const cursor = if (self.hovered_anchor != null) self.pointer_cursor else self.arrow_cursor; + if (cursor) |cur| _ = c.SDL_SetCursor(cur); + } + return true; + }, + else => return false, + } + } + + fn updateFn(self_ptr: *anyopaque, host: *const types.UiHost, _: *types.UiActionQueue) void { + const self: *StoryOverlayComponent = @ptrCast(@alignCast(self_ptr)); + _ = self.overlay.updateAnimation(host.now_ms); + } + + fn hitTestFn(self_ptr: *anyopaque, host: *const types.UiHost, x: c_int, y: c_int) bool { + const self: *StoryOverlayComponent = @ptrCast(@alignCast(self_ptr)); + return self.overlay.hitTest(host, x, y); + } + + fn wantsFrameFn(self_ptr: *anyopaque, _: *const types.UiHost) bool { + const self: *StoryOverlayComponent = @ptrCast(@alignCast(self_ptr)); + return self.overlay.wantsFrame() or self.hovered_anchor != null; + } + + // --- Anchor hover --- + + fn updateAnchorHover(self: *StoryOverlayComponent, mouse_x: c_int, mouse_y: c_int, host: *const types.UiHost) void { + const hit_radius: i64 = if (self.cache) |ch| @as(i64, ch.line_height) else 12; + const hit_radius_sq: i64 = hit_radius * hit_radius; + var found: ?u8 = null; + + for (self.anchor_positions.items) |ap| { + const dx: i64 = @as(i64, mouse_x) - @as(i64, ap.x); + const dy: i64 = @as(i64, mouse_y) - @as(i64, ap.y); + if (dx * dx + dy * dy <= hit_radius_sq) { + found = ap.number; + break; + } + } + + if (found != self.hovered_anchor) { + self.hovered_anchor = found; + if (found != null) { + self.hover_start_ms = host.now_ms; + } + } + } + + // --- Rendering --- + + fn renderFn(self_ptr: *anyopaque, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets) void { + const self: *StoryOverlayComponent = @ptrCast(@alignCast(self_ptr)); + if (!self.overlay.visible) return; + + const progress = self.overlay.renderProgress(host.now_ms); + self.overlay.render_alpha = progress; + + if (progress <= 0.001) return; + + const cache_result = self.ensureCache(renderer, host, assets); + const cache = cache_result orelse return; + + const rect = FullscreenOverlay.animatedOverlayRect(host, progress); + const scaled_title_h = dpi.scale(FullscreenOverlay.title_height, host.ui_scale); + + const row_count_f: f32 = @floatFromInt(self.display_rows.items.len); + const scaled_line_h_f: f32 = @floatFromInt(cache.line_height); + const content_height: f32 = row_count_f * scaled_line_h_f; + const viewport_height: f32 = @floatFromInt(rect.h - scaled_title_h); + self.overlay.max_scroll = @max(0, content_height - viewport_height); + self.overlay.scroll_offset = @min(self.overlay.max_scroll, self.overlay.scroll_offset); + + self.overlay.renderFrame(renderer, host, rect, progress); + self.overlay.renderTitle(renderer, rect, cache.title.tex, cache.title.w, cache.title.h, host); + FullscreenOverlay.renderTitleSeparator(renderer, host, rect, progress); + self.overlay.renderCloseButton(renderer, host, rect); + + const content_clip = c.SDL_Rect{ + .x = rect.x, + .y = rect.y + scaled_title_h, + .w = rect.w, + .h = rect.h - scaled_title_h, + }; + _ = c.SDL_SetRenderClipRect(renderer, &content_clip); + + self.anchor_positions.clearRetainingCapacity(); + self.renderContent(host, renderer, rect, scaled_title_h, cache); + self.renderBezierArrows(renderer, host); + + _ = c.SDL_SetRenderClipRect(renderer, null); + + self.overlay.renderScrollbar(renderer, host, rect, scaled_title_h, content_height, viewport_height); + + self.overlay.first_frame.markDrawn(); + } + + fn renderContent(self: *StoryOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, rect: geom.Rect, title_h: c_int, cache: *Cache) void { + const alpha = self.overlay.render_alpha; + const scroll_int: c_int = @intFromFloat(self.overlay.scroll_offset); + const content_top = rect.y + title_h; + const content_h = rect.h - title_h; + + const line_h = cache.line_height; + if (line_h <= 0 or content_h <= 0) return; + + const first_visible: usize = @intCast(@divFloor(scroll_int, line_h)); + const fg = host.theme.foreground; + const accent = host.theme.accent; + + var row_index: usize = first_visible; + while (row_index < self.display_rows.items.len) : (row_index += 1) { + const row = self.display_rows.items[row_index]; + const y_pos: c_int = content_top + @as(c_int, @intCast(row_index)) * line_h - scroll_int; + + if (y_pos > content_top + content_h) break; + if (y_pos + line_h < content_top) continue; + + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + switch (row.kind) { + .diff_header => { + _ = c.SDL_SetRenderDrawColor(renderer, accent.r, accent.g, accent.b, @intFromFloat(20.0 * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + 1), + .y = @floatFromInt(y_pos), + .w = @floatFromInt(rect.w - 2), + .h = @floatFromInt(line_h), + }); + }, + .diff_line => { + switch (row.code_line_kind) { + .add => { + _ = c.SDL_SetRenderDrawColor(renderer, 0, 80, 0, @intFromFloat(60.0 * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + 1), + .y = @floatFromInt(y_pos), + .w = @floatFromInt(rect.w - 2), + .h = @floatFromInt(line_h), + }); + }, + .remove => { + _ = c.SDL_SetRenderDrawColor(renderer, 80, 0, 0, @intFromFloat(60.0 * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + 1), + .y = @floatFromInt(y_pos), + .w = @floatFromInt(rect.w - 2), + .h = @floatFromInt(line_h), + }); + }, + .context => {}, + } + }, + .code_line => { + _ = c.SDL_SetRenderDrawColor(renderer, fg.r, fg.g, fg.b, @intFromFloat(8.0 * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + 1), + .y = @floatFromInt(y_pos), + .w = @floatFromInt(rect.w - 2), + .h = @floatFromInt(line_h), + }); + }, + .separator, .prose_line => {}, + } + + // Render text segments + if (row_index < cache.lines.len) { + const line_tex = cache.lines[row_index]; + for (line_tex.segments) |segment| { + const tex_alpha: u8 = @intFromFloat(255.0 * alpha); + _ = c.SDL_SetTextureAlphaMod(segment.tex, tex_alpha); + + const dest_x: c_int = rect.x + segment.x_offset; + const dest_y: c_int = y_pos; + + var render_w: c_int = segment.w; + const render_h: c_int = segment.h; + var clip_src: c.SDL_FRect = undefined; + var src_ptr: ?*const c.SDL_FRect = null; + + const used = dest_x - rect.x; + const scaled_padding = dpi.scale(FullscreenOverlay.text_padding, host.ui_scale); + const max_width = rect.w - used - scaled_padding; + if (max_width <= 0) continue; + if (segment.w > max_width) { + render_w = max_width; + clip_src = c.SDL_FRect{ + .x = 0, + .y = 0, + .w = @floatFromInt(render_w), + .h = @floatFromInt(render_h), + }; + src_ptr = &clip_src; + } + + _ = c.SDL_RenderTexture(renderer, segment.tex, src_ptr, &c.SDL_FRect{ + .x = @floatFromInt(dest_x), + .y = @floatFromInt(dest_y), + .w = @floatFromInt(render_w), + .h = @floatFromInt(render_h), + }); + } + } + + // Render anchor circles and track positions for bezier arrows + if (row.anchors.len > 0 and cache.char_width > 0) { + const is_code = row.kind == .diff_line or row.kind == .code_line; + const is_diff = row.kind == .diff_line; + for (row.anchors) |anc| { + const scaled_padding = dpi.scale(FullscreenOverlay.text_padding, host.ui_scale); + const scaled_code_indent = dpi.scale(code_indent, host.ui_scale); + + // For diff lines, the first character (+/-/space) uses a fixed-width + // marker slot, not char_width. Account for this offset difference. + const char_off: c_int = @intCast(anc.char_offset); + const anchor_x: c_int = if (is_diff) blk: { + const scaled_marker_w = dpi.scale(marker_width, host.ui_scale); + break :blk rect.x + scaled_padding + scaled_code_indent + scaled_marker_w + (char_off - 1) * cache.char_width + @divFloor(cache.char_width, 2); + } else if (is_code) blk: { + break :blk rect.x + scaled_padding + scaled_code_indent + char_off * cache.char_width + @divFloor(cache.char_width, 2); + } else blk: { + break :blk rect.x + scaled_padding + char_off * cache.char_width + @divFloor(cache.char_width, 2); + }; + const anchor_y: c_int = y_pos + @divFloor(line_h * 9, 20); + const base_radius = @divFloor(line_h * 2, 5); + const is_hovered = self.hovered_anchor != null and self.hovered_anchor.? == anc.number; + const radius = if (is_hovered) base_radius + dpi.scale(2, host.ui_scale) else base_radius; + + renderAnchorBadge(renderer, host, anchor_x, anchor_y, radius, anc.number, alpha, cache.bold_font); + + self.anchor_positions.append(self.allocator, .{ + .number = anc.number, + .x = anchor_x, + .y = anchor_y, + .is_code = is_code, + }) catch |err| { + log.warn("failed to track anchor position: {}", .{err}); + }; + } + } + } + } + + fn renderAnchorBadge(renderer: *c.SDL_Renderer, host: *const types.UiHost, cx: c_int, cy: c_int, half_h: c_int, number: u8, alpha: f32, font: *c.TTF_Font) void { + // Render the number as text to get its dimensions + var num_buf: [4]u8 = undefined; + const num_str = std.fmt.bufPrint(&num_buf, "{d}", .{number}) catch return; + num_buf[num_str.len] = 0; + const num_z: [*c]const u8 = @ptrCast(num_str.ptr); + + const bg = host.theme.background; + const surface = c.TTF_RenderText_Blended(font, num_z, num_str.len, c.SDL_Color{ .r = bg.r, .g = bg.g, .b = bg.b, .a = 255 }) orelse return; + defer c.SDL_DestroySurface(surface); + const tex = c.SDL_CreateTextureFromSurface(renderer, surface) orelse return; + defer c.SDL_DestroyTexture(tex); + + var tex_w_f: f32 = 0; + var tex_h_f: f32 = 0; + _ = c.SDL_GetTextureSize(tex, &tex_w_f, &tex_h_f); + const tex_w: c_int = @intFromFloat(tex_w_f); + const tex_h: c_int = @intFromFloat(tex_h_f); + + // Pill dimensions: height = 2 * half_h, width stretches to fit text + padding + const pad_x: c_int = @max(half_h, @divFloor(tex_w, 2) + @divFloor(half_h, 2)); + const pill_w = pad_x * 2; + const pill_h = half_h * 2; + const pill_x = cx - @divFloor(pill_w, 2); + const pill_y = cy - half_h; + const corner_r: c_int = half_h; + + // Draw the pill background + const accent = host.theme.accent; + const bg_alpha: u8 = @intFromFloat(200.0 * alpha); + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + _ = c.SDL_SetRenderDrawColor(renderer, accent.r, accent.g, accent.b, bg_alpha); + primitives.fillRoundedRect(renderer, .{ .x = pill_x, .y = pill_y, .w = pill_w, .h = pill_h }, corner_r); + + // Draw the number text centered in the pill + const tex_alpha: u8 = @intFromFloat(255.0 * alpha); + _ = c.SDL_SetTextureAlphaMod(tex, tex_alpha); + _ = c.SDL_RenderTexture(renderer, tex, null, &c.SDL_FRect{ + .x = @floatFromInt(cx - @divFloor(tex_w, 2)), + .y = @floatFromInt(cy - @divFloor(tex_h, 2)), + .w = @floatFromInt(tex_w), + .h = @floatFromInt(tex_h), + }); + } + + fn renderBezierArrows(self: *StoryOverlayComponent, renderer: *c.SDL_Renderer, host: *const types.UiHost) void { + const hovered = self.hovered_anchor orelse return; + + var prose_pos: ?AnchorPosition = null; + var code_pos: ?AnchorPosition = null; + + for (self.anchor_positions.items) |ap| { + if (ap.number == hovered) { + if (ap.is_code) { + code_pos = ap; + } else { + prose_pos = ap; + } + } + } + + const from = prose_pos orelse return; + const to = code_pos orelse return; + + const elapsed_ms = host.now_ms - self.hover_start_ms; + const time_seconds: f32 = @as(f32, @floatFromInt(elapsed_ms)) / 1000.0; + + primitives.renderBezierArrow( + renderer, + @floatFromInt(from.x), + @floatFromInt(from.y), + @floatFromInt(to.x), + @floatFromInt(to.y), + host.theme.accent, + time_seconds, + ); + } + + // --- Cache management --- + + fn ensureCache(self: *StoryOverlayComponent, renderer: *c.SDL_Renderer, host: *const types.UiHost, assets: *types.UiAssets) ?*Cache { + const font_cache_ptr = assets.font_cache orelse return null; + const generation = font_cache_ptr.generation; + + if (self.cache) |existing| { + if (existing.ui_scale == host.ui_scale and existing.font_generation == generation) { + return existing; + } + } + + self.destroyCache(); + + const scaled_font_size = dpi.scale(font_size, host.ui_scale); + const title_font_size = scaled_font_size + dpi.scale(4, host.ui_scale); + const line_fonts = font_cache_ptr.get(scaled_font_size) catch return null; + const title_fonts = font_cache_ptr.get(title_font_size) catch return null; + + const mono_font = line_fonts.regular; + const bold_font = line_fonts.bold orelse line_fonts.regular; + + const char_w = measureCharWidth(renderer, mono_font) orelse 0; + self.updateWrapCols(renderer, host, mono_font); + + const title_text_str = self.buildTitleText() catch return null; + defer self.allocator.free(title_text_str); + const title_tex = self.makeTextTexture( + renderer, + title_fonts.bold orelse title_fonts.regular, + title_text_str, + host.theme.foreground, + ) catch return null; + + const line_height_scaled = dpi.scale(row_height, host.ui_scale); + const line_textures = self.allocator.alloc(LineTexture, self.display_rows.items.len) catch { + c.SDL_DestroyTexture(title_tex.tex); + return null; + }; + + var idx: usize = 0; + while (idx < self.display_rows.items.len) : (idx += 1) { + line_textures[idx] = self.buildLineTexture(renderer, host, mono_font, bold_font, self.display_rows.items[idx]) catch |err| blk: { + log.warn("failed to build story line texture: {}", .{err}); + break :blk LineTexture{ .segments = &.{} }; + }; + } + + const new_cache = self.allocator.create(Cache) catch { + self.destroyLineTextures(line_textures); + c.SDL_DestroyTexture(title_tex.tex); + self.allocator.free(line_textures); + return null; + }; + new_cache.* = .{ + .ui_scale = host.ui_scale, + .font_generation = generation, + .line_height = line_height_scaled, + .char_width = char_w, + .title = title_tex, + .lines = line_textures, + .bold_font = bold_font, + }; + self.cache = new_cache; + return new_cache; + } + + fn updateWrapCols(self: *StoryOverlayComponent, renderer: *c.SDL_Renderer, host: *const types.UiHost, mono_font: *c.TTF_Font) void { + const char_w = measureCharWidth(renderer, mono_font) orelse return; + if (char_w <= 0) return; + + const rect = FullscreenOverlay.overlayRect(host); + const scaled_padding = dpi.scale(FullscreenOverlay.text_padding, host.ui_scale); + const scrollbar_w = dpi.scale(10, host.ui_scale); + const text_area_w = rect.w - scaled_padding * 2 - scrollbar_w; + if (text_area_w <= 0) return; + + const new_wrap: usize = @intCast(@divFloor(text_area_w, char_w)); + if (new_wrap != self.wrap_cols and new_wrap > 0) { + self.wrap_cols = new_wrap; + if (self.raw_content) |content| { + story_parser.freeDisplayRows(self.allocator, &self.display_rows); + self.display_rows = story_parser.parse(self.allocator, content, self.wrap_cols); + } + } + } + + fn measureCharWidth(renderer: *c.SDL_Renderer, font: *c.TTF_Font) ?c_int { + const probe = "0"; + var buf: [2]u8 = .{ probe[0], 0 }; + const surface = c.TTF_RenderText_Blended(font, @ptrCast(&buf), 1, c.SDL_Color{ .r = 255, .g = 255, .b = 255, .a = 255 }) orelse return null; + defer c.SDL_DestroySurface(surface); + const tex = c.SDL_CreateTextureFromSurface(renderer, surface) orelse return null; + defer c.SDL_DestroyTexture(tex); + var w: f32 = 0; + var h: f32 = 0; + _ = c.SDL_GetTextureSize(tex, &w, &h); + return @intFromFloat(w); + } + + fn buildTitleText(self: *StoryOverlayComponent) ![]const u8 { + const prefix = "Story"; + const file_path = self.file_path orelse return self.allocator.dupe(u8, prefix); + const base = std.fs.path.basename(file_path); + + const max_len: usize = 120; + if (prefix.len + 3 + base.len <= max_len) { + return std.fmt.allocPrint(self.allocator, "{s} \xe2\x80\x94 {s}", .{ prefix, base }); + } + + if (max_len <= prefix.len + 3) { + return self.allocator.dupe(u8, prefix); + } + + const tail_len = max_len - prefix.len - 3; + const tail = base[base.len - tail_len ..]; + return std.fmt.allocPrint(self.allocator, "{s} \xe2\x80\x94 ...{s}", .{ prefix, tail }); + } + + fn buildLineTexture( + self: *StoryOverlayComponent, + renderer: *c.SDL_Renderer, + host: *const types.UiHost, + mono_font: *c.TTF_Font, + bold_font: *c.TTF_Font, + d_row: story_parser.DisplayRow, + ) !LineTexture { + var segments = try std.ArrayList(SegmentTexture).initCapacity(self.allocator, 2); + errdefer { + for (segments.items) |segment| { + c.SDL_DestroyTexture(segment.tex); + } + segments.deinit(self.allocator); + } + + const scaled_padding = dpi.scale(FullscreenOverlay.text_padding, host.ui_scale); + const scaled_marker_w = dpi.scale(marker_width, host.ui_scale); + const scaled_code_indent = dpi.scale(code_indent, host.ui_scale); + const fg = host.theme.foreground; + + switch (d_row.kind) { + .separator => {}, + .prose_line => { + if (d_row.text.len == 0) return LineTexture{ .segments = &.{} }; + var buf: [max_display_buffer]u8 = undefined; + const text = sanitizeText(d_row.text, &buf); + if (text.len == 0) return LineTexture{ .segments = &.{} }; + const font = if (d_row.bold) bold_font else mono_font; + try self.appendSegmentTexture(&segments, renderer, font, text, fg, .text, scaled_padding); + }, + .diff_header => { + if (d_row.text.len == 0) return LineTexture{ .segments = &.{} }; + var buf: [max_display_buffer]u8 = undefined; + const text = sanitizeText(d_row.text, &buf); + if (text.len == 0) return LineTexture{ .segments = &.{} }; + try self.appendSegmentTexture(&segments, renderer, bold_font, text, host.theme.accent, .text, scaled_padding + scaled_code_indent); + }, + .diff_line => { + const marker_str: []const u8 = switch (d_row.code_line_kind) { + .add => "+", + .remove => "-", + .context => " ", + }; + const marker_color: c.SDL_Color = switch (d_row.code_line_kind) { + .add => host.theme.palette[2], + .remove => host.theme.palette[1], + .context => fg, + }; + + try self.appendSegmentTexture(&segments, renderer, mono_font, marker_str, marker_color, .marker, scaled_padding + scaled_code_indent); + + const text_slice = if (d_row.text.len > 1) d_row.text[1..] else ""; + if (text_slice.len > 0) { + var buf: [max_display_buffer]u8 = undefined; + const text = sanitizeText(text_slice, &buf); + if (text.len > 0) { + const text_color: c.SDL_Color = switch (d_row.code_line_kind) { + .add => host.theme.palette[2], + .remove => host.theme.palette[1], + .context => fg, + }; + try self.appendSegmentTexture(&segments, renderer, mono_font, text, text_color, .text, scaled_padding + scaled_code_indent + scaled_marker_w); + } + } + }, + .code_line => { + if (d_row.text.len == 0) return LineTexture{ .segments = &.{} }; + var buf: [max_display_buffer]u8 = undefined; + const text = sanitizeText(d_row.text, &buf); + if (text.len == 0) return LineTexture{ .segments = &.{} }; + try self.appendSegmentTexture(&segments, renderer, mono_font, text, fg, .text, scaled_padding + scaled_code_indent); + }, + } + + return LineTexture{ .segments = try segments.toOwnedSlice(self.allocator) }; + } + + fn appendSegmentTexture( + self: *StoryOverlayComponent, + segments: *std.ArrayList(SegmentTexture), + renderer: *c.SDL_Renderer, + font: *c.TTF_Font, + text: []const u8, + color: c.SDL_Color, + kind: SegmentKind, + x_offset: c_int, + ) !void { + if (text.len == 0) return; + const tex = try self.makeTextTexture(renderer, font, text, color); + errdefer c.SDL_DestroyTexture(tex.tex); + try segments.append(self.allocator, .{ + .tex = tex.tex, + .kind = kind, + .x_offset = x_offset, + .w = tex.w, + .h = tex.h, + }); + } + + fn makeTextTexture( + self: *StoryOverlayComponent, + renderer: *c.SDL_Renderer, + font: *c.TTF_Font, + text: []const u8, + color: c.SDL_Color, + ) !TextTex { + if (text.len == 0) return error.EmptyText; + + var buf: [128]u8 = undefined; + var surface: *c.SDL_Surface = undefined; + if (text.len < buf.len) { + @memcpy(buf[0..text.len], text); + buf[text.len] = 0; + surface = c.TTF_RenderText_Blended(font, @ptrCast(&buf), @intCast(text.len), color) orelse return error.SurfaceFailed; + } else { + const heap_buf = try self.allocator.alloc(u8, text.len + 1); + defer self.allocator.free(heap_buf); + @memcpy(heap_buf[0..text.len], text); + heap_buf[text.len] = 0; + surface = c.TTF_RenderText_Blended(font, @ptrCast(heap_buf.ptr), @intCast(text.len), color) orelse return error.SurfaceFailed; + } + defer c.SDL_DestroySurface(surface); + + const tex = c.SDL_CreateTextureFromSurface(renderer, surface) orelse return error.TextureFailed; + var w: f32 = 0; + var h: f32 = 0; + _ = c.SDL_GetTextureSize(tex, &w, &h); + _ = c.SDL_SetTextureBlendMode(tex, c.SDL_BLENDMODE_BLEND); + return TextTex{ + .tex = tex, + .w = @intFromFloat(w), + .h = @intFromFloat(h), + }; + } + + fn sanitizeText(text: []const u8, buf: []u8) []const u8 { + const max_chars: usize = 512; + const display_len = @min(text.len, max_chars); + var buf_pos: usize = 0; + + for (text[0..display_len]) |ch| { + if (ch == '\t') { + if (buf_pos + 1 >= buf.len) break; + const remaining = buf.len - buf_pos - 1; + const spaces_to_add = @min(4, remaining); + var idx: usize = 0; + while (idx < spaces_to_add) : (idx += 1) { + buf[buf_pos] = ' '; + buf_pos += 1; + } + } else if (ch >= 32 or ch == 0) { + if (buf_pos + 1 >= buf.len) break; + buf[buf_pos] = ch; + buf_pos += 1; + } + } + + return buf[0..buf_pos]; + } + + fn destroyCache(self: *StoryOverlayComponent) void { + const cache_ptr = self.cache orelse return; + c.SDL_DestroyTexture(cache_ptr.title.tex); + self.destroyLineTextures(cache_ptr.lines); + self.allocator.free(cache_ptr.lines); + self.allocator.destroy(cache_ptr); + self.cache = null; + } + + fn destroyLineTextures(self: *StoryOverlayComponent, lines: []LineTexture) void { + for (lines) |line| { + for (line.segments) |segment| { + c.SDL_DestroyTexture(segment.tex); + } + if (line.segments.len > 0) { + self.allocator.free(line.segments); + } + } + } + + // --- Deinit --- + + fn destroy(self: *StoryOverlayComponent, renderer: *c.SDL_Renderer) void { + _ = renderer; + self.clearContent(); + self.display_rows.deinit(self.allocator); + self.anchor_positions.deinit(self.allocator); + if (self.file_path) |path| { + self.allocator.free(path); + self.file_path = null; + } + if (self.pointer_cursor) |cur| c.SDL_DestroyCursor(cur); + if (self.arrow_cursor) |cur| c.SDL_DestroyCursor(cur); + self.allocator.destroy(self); + } + + fn deinitComp(self_ptr: *anyopaque, renderer: *c.SDL_Renderer) void { + const self: *StoryOverlayComponent = @ptrCast(@alignCast(self_ptr)); + self.destroy(renderer); + } + + const vtable = UiComponent.VTable{ + .handleEvent = handleEventFn, + .hitTest = hitTestFn, + .update = updateFn, + .render = renderFn, + .deinit = deinitComp, + .wantsFrame = wantsFrameFn, + }; +}; diff --git a/src/ui/mod.zig b/src/ui/mod.zig index 50b324e..a0fed51 100644 --- a/src/ui/mod.zig +++ b/src/ui/mod.zig @@ -21,3 +21,4 @@ pub const global_shortcuts = @import("components/global_shortcuts.zig"); pub const cwd_bar = @import("components/cwd_bar.zig"); pub const metrics_overlay = @import("components/metrics_overlay.zig"); pub const diff_overlay = @import("components/diff_overlay.zig"); +pub const story_overlay = @import("components/story_overlay.zig"); diff --git a/src/ui/story_parser.zig b/src/ui/story_parser.zig new file mode 100644 index 0000000..1f51982 --- /dev/null +++ b/src/ui/story_parser.zig @@ -0,0 +1,660 @@ +const std = @import("std"); + +const log = std.log.scoped(.story_parser); + +// === Public types === + +pub const CodeLineKind = enum { context, add, remove }; + +pub const CodeBlockMeta = struct { + file: ?[]const u8 = null, + commit: ?[]const u8 = null, + change_type: ?[]const u8 = null, + description: ?[]const u8 = null, +}; + +pub const DisplayRowKind = enum { + prose_line, + diff_header, + diff_line, + code_line, + separator, +}; + +pub const LineAnchor = struct { + number: u8, + char_offset: usize, +}; + +pub const DisplayRow = struct { + kind: DisplayRowKind, + text: []const u8 = "", + code_line_kind: CodeLineKind = .context, + bold: bool = false, + anchors: []const LineAnchor = &.{}, + owns_text: bool = false, +}; + +// === Public API === + +pub fn parse(allocator: std.mem.Allocator, content: []const u8, wrap_cols: usize) std.ArrayList(DisplayRow) { + var rows = std.ArrayList(DisplayRow){}; + var ctx = ParseContext{ + .allocator = allocator, + .rows = &rows, + .wrap_cols = wrap_cols, + }; + ctx.parseContent(content); + return rows; +} + +pub fn freeDisplayRows(allocator: std.mem.Allocator, rows: *std.ArrayList(DisplayRow)) void { + for (rows.items) |row| { + if (row.owns_text) { + allocator.free(row.text); + } + if (row.anchors.len > 0) { + allocator.free(row.anchors); + } + } + rows.deinit(allocator); + rows.* = .{}; +} + +// === Internal parsing context === + +const ParseContext = struct { + allocator: std.mem.Allocator, + rows: *std.ArrayList(DisplayRow), + wrap_cols: usize, + + fn parseContent(self: *ParseContext, content: []const u8) void { + var pos: usize = 0; + + while (pos < content.len) { + const fence_start = findFenceStart(content, pos); + + if (fence_start) |fs| { + if (fs.start > pos) { + self.addProseRows(content[pos..fs.start]); + } + + const close = findFenceClose(content, fs.content_start); + const block_end = if (close) |cl| cl.after else content.len; + const block_content = if (close) |cl| content[fs.content_start..cl.start] else content[fs.content_start..]; + + if (fs.is_story_diff) { + self.addDiffBlock(block_content); + } else { + self.addCodeBlock(block_content); + } + + pos = block_end; + } else { + if (pos < content.len) { + self.addProseRows(content[pos..]); + } + break; + } + } + } + + // --- Fence detection --- + + const FenceStart = struct { + start: usize, + content_start: usize, + is_story_diff: bool, + }; + + const FenceClose = struct { + start: usize, + after: usize, + }; + + fn findFenceStart(content: []const u8, from: usize) ?FenceStart { + var pos = from; + while (pos < content.len) { + if (pos > 0 and content[pos - 1] != '\n') { + const nl = std.mem.indexOfScalarPos(u8, content, pos, '\n') orelse return null; + pos = nl + 1; + continue; + } + + if (pos + 3 <= content.len and std.mem.eql(u8, content[pos..][0..3], "```")) { + const line_end = std.mem.indexOfScalarPos(u8, content, pos + 3, '\n') orelse content.len; + const info_string = std.mem.trim(u8, content[pos + 3 .. line_end], " \t\r"); + const is_diff = std.mem.eql(u8, info_string, "story-diff"); + return FenceStart{ + .start = pos, + .content_start = if (line_end < content.len) line_end + 1 else content.len, + .is_story_diff = is_diff, + }; + } + + const nl = std.mem.indexOfScalarPos(u8, content, pos, '\n'); + if (nl) |n| { + pos = n + 1; + } else { + break; + } + } + return null; + } + + fn findFenceClose(content: []const u8, from: usize) ?FenceClose { + var pos = from; + while (pos < content.len) { + if (pos == 0 or (pos > 0 and content[pos - 1] == '\n')) { + if (pos + 3 <= content.len and std.mem.eql(u8, content[pos..][0..3], "```")) { + const line_end = std.mem.indexOfScalarPos(u8, content, pos + 3, '\n') orelse content.len; + const rest = std.mem.trim(u8, content[pos + 3 .. line_end], " \t\r"); + if (rest.len == 0) { + return FenceClose{ + .start = pos, + .after = if (line_end < content.len) line_end + 1 else content.len, + }; + } + } + } + + const nl = std.mem.indexOfScalarPos(u8, content, pos, '\n'); + if (nl) |n| { + pos = n + 1; + } else { + break; + } + } + return null; + } + + // --- Prose parsing --- + + fn addProseRows(self: *ParseContext, text: []const u8) void { + var line_start: usize = 0; + while (line_start < text.len) { + const line_end = std.mem.indexOfScalarPos(u8, text, line_start, '\n') orelse text.len; + const line = std.mem.trimRight(u8, text[line_start..line_end], " \t\r"); + + if (line.len == 0) { + self.rows.append(self.allocator, .{ + .kind = .separator, + }) catch |err| { + log.warn("failed to append separator row: {}", .{err}); + return; + }; + } else { + var heading_level: usize = 0; + var content_start: usize = 0; + while (content_start < line.len and line[content_start] == '#') { + heading_level += 1; + content_start += 1; + } + if (heading_level > 0 and content_start < line.len and line[content_start] == ' ') { + content_start += 1; + } + const is_heading = heading_level > 0 and heading_level <= 6; + + const display_text = if (is_heading) line[content_start..] else line; + self.addWrappedProseRows(display_text, is_heading); + } + + if (line_end >= text.len) break; + line_start = line_end + 1; + } + } + + fn addWrappedProseRows(self: *ParseContext, text: []const u8, bold: bool) void { + if (text.len == 0) return; + + // Check for anchors. If none, use original text directly (it's a slice of + // the content buffer and outlives the display rows). If anchors exist, strip + // them into a heap-allocated buffer so the rows don't hold dangling pointers. + var anchors_buf: [32]LineAnchor = undefined; + var anchor_count: usize = 0; + var stripped: []const u8 = text; + var stripped_is_owned = false; + + if (std.mem.indexOf(u8, text, "**[") != null) { + var stack_buf: [4096]u8 = undefined; + const strip_result = stripProseAnchors(text, &stack_buf, &anchors_buf); + anchor_count = strip_result.anchor_count; + if (anchor_count > 0) { + if (self.allocator.dupe(u8, strip_result.text)) |duped| { + stripped = duped; + stripped_is_owned = true; + } else |err| { + log.warn("failed to allocate stripped prose text: {}", .{err}); + anchor_count = 0; + } + } + } + + const max_cols = if (self.wrap_cols > 0) self.wrap_cols else 120; + + if (stripped.len <= max_cols) { + const anchors = if (anchor_count > 0) + self.allocator.dupe(LineAnchor, anchors_buf[0..anchor_count]) catch |err| blk: { + log.warn("failed to dupe anchor: {}", .{err}); + break :blk &[0]LineAnchor{}; + } + else + &[0]LineAnchor{}; + + self.rows.append(self.allocator, .{ + .kind = .prose_line, + .text = stripped, + .bold = bold, + .anchors = anchors, + .owns_text = stripped_is_owned, + }) catch |err| { + log.warn("failed to append prose row: {}", .{err}); + if (stripped_is_owned) self.allocator.free(stripped); + }; + return; + } + + // Word-wrap with anchor position tracking. + // When stripped_is_owned, each segment gets its own heap allocation + // so it can be freed independently in freeDisplayRows. + var pos: usize = 0; + while (pos < stripped.len) { + var end = @min(pos + max_cols, stripped.len); + if (end < stripped.len) { + var space_pos = end; + while (space_pos > pos) { + space_pos -= 1; + if (stripped[space_pos] == ' ') break; + } + if (space_pos > pos) { + end = space_pos; + } + } + + // Collect anchors that fall within this wrapped line + var line_anchor_count: usize = 0; + var line_anchors_buf: [16]LineAnchor = undefined; + for (anchors_buf[0..anchor_count]) |anchor| { + if (anchor.char_offset >= pos and anchor.char_offset < end) { + if (line_anchor_count < line_anchors_buf.len) { + line_anchors_buf[line_anchor_count] = .{ + .number = anchor.number, + .char_offset = anchor.char_offset - pos, + }; + line_anchor_count += 1; + } + } + } + + const line_anchors = if (line_anchor_count > 0) + self.allocator.dupe(LineAnchor, line_anchors_buf[0..line_anchor_count]) catch |err| blk: { + log.warn("failed to dupe anchor: {}", .{err}); + break :blk &[0]LineAnchor{}; + } + else + &[0]LineAnchor{}; + + const segment = stripped[pos..end]; + const row_text = if (stripped_is_owned) + self.allocator.dupe(u8, segment) catch |err| blk: { + log.warn("failed to allocate wrapped segment: {}", .{err}); + break :blk segment; + } + else + segment; + const segment_owned = stripped_is_owned and row_text.ptr != segment.ptr; + + self.rows.append(self.allocator, .{ + .kind = .prose_line, + .text = row_text, + .bold = bold, + .anchors = line_anchors, + .owns_text = segment_owned, + }) catch |err| { + log.warn("failed to append wrapped prose row: {}", .{err}); + if (segment_owned) self.allocator.free(row_text); + return; + }; + + pos = end; + if (pos < stripped.len and stripped[pos] == ' ') pos += 1; + } + + // Free the original stripped buffer now that all segments have their own copies + if (stripped_is_owned) self.allocator.free(stripped); + } + + // --- Code block parsing --- + + fn addDiffBlock(self: *ParseContext, content: []const u8) void { + self.rows.append(self.allocator, .{ .kind = .separator }) catch |err| { + log.warn("failed to append separator: {}", .{err}); + return; + }; + + var lines_start: usize = 0; + var meta = CodeBlockMeta{}; + + const first_line_end = std.mem.indexOfScalar(u8, content, '\n') orelse content.len; + const first_line = std.mem.trim(u8, content[0..first_line_end], " \t\r"); + + if (std.mem.startsWith(u8, first_line, "")) { + const json_start = 4; + const json_end = first_line.len - 3; + if (json_start < json_end) { + const json_str = std.mem.trim(u8, first_line[json_start..json_end], " "); + meta = parseMetaJson(json_str); + } + lines_start = if (first_line_end < content.len) first_line_end + 1 else content.len; + } + + self.addDiffHeaderRow(meta); + + var pos = lines_start; + while (pos < content.len) { + const line_end = std.mem.indexOfScalarPos(u8, content, pos, '\n') orelse content.len; + const raw_line = content[pos..line_end]; + + const kind: CodeLineKind = if (raw_line.len > 0 and raw_line[0] == '+') + .add + else if (raw_line.len > 0 and raw_line[0] == '-') + .remove + else + .context; + + // Strip from line end + const ref_result = stripCodeRef(raw_line); + var row_text: []const u8 = ref_result.text; + var text_owned = false; + var anchors: []const LineAnchor = &.{}; + + if (ref_result.ref_number) |num| { + const base = ref_result.text; + if (self.allocator.alloc(u8, base.len + 3)) |buf| { + @memcpy(buf[0..base.len], base); + buf[base.len] = ' '; + buf[base.len + 1] = ' '; + buf[base.len + 2] = ' '; + row_text = buf; + text_owned = true; + const cp_offset = bytesToCodepoints(base) + 1; + anchors = self.allocator.dupe(LineAnchor, &[1]LineAnchor{.{ + .number = num, + .char_offset = cp_offset, + }}) catch |err| blk: { + log.warn("failed to dupe anchor: {}", .{err}); + break :blk &[0]LineAnchor{}; + }; + } else |err| { + log.warn("failed to allocate code line with anchor padding: {}", .{err}); + } + } + + self.rows.append(self.allocator, .{ + .kind = .diff_line, + .text = row_text, + .code_line_kind = kind, + .anchors = anchors, + .owns_text = text_owned, + }) catch |err| { + log.warn("failed to append diff line: {}", .{err}); + if (text_owned) self.allocator.free(@constCast(row_text)); + return; + }; + + if (line_end >= content.len) break; + pos = line_end + 1; + } + + self.rows.append(self.allocator, .{ .kind = .separator }) catch |err| { + log.warn("failed to append separator: {}", .{err}); + }; + } + + fn addDiffHeaderRow(self: *ParseContext, meta: CodeBlockMeta) void { + if (meta.file == null and meta.description == null) return; + + var buf: [512]u8 = undefined; + var buf_pos: usize = 0; + + if (meta.file) |file| { + const copy_len = @min(file.len, buf.len - buf_pos); + @memcpy(buf[buf_pos..][0..copy_len], file[0..copy_len]); + buf_pos += copy_len; + } + + if (meta.file != null and meta.description != null) { + const sep = " \xe2\x80\x94 "; // " — " UTF-8 + const sep_len = @min(sep.len, buf.len - buf_pos); + @memcpy(buf[buf_pos..][0..sep_len], sep[0..sep_len]); + buf_pos += sep_len; + } + + if (meta.description) |desc| { + const copy_len = @min(desc.len, buf.len - buf_pos); + @memcpy(buf[buf_pos..][0..copy_len], desc[0..copy_len]); + buf_pos += copy_len; + } + + if (buf_pos == 0) return; + + const header_text = self.allocator.dupe(u8, buf[0..buf_pos]) catch |err| { + log.warn("failed to allocate diff header text: {}", .{err}); + return; + }; + self.rows.append(self.allocator, .{ + .kind = .diff_header, + .text = header_text, + .bold = true, + .owns_text = true, + }) catch |err| { + log.warn("failed to append diff header: {}", .{err}); + self.allocator.free(header_text); + }; + } + + fn addCodeBlock(self: *ParseContext, content: []const u8) void { + self.rows.append(self.allocator, .{ .kind = .separator }) catch |err| { + log.warn("failed to append separator: {}", .{err}); + return; + }; + + var pos: usize = 0; + while (pos < content.len) { + const line_end = std.mem.indexOfScalarPos(u8, content, pos, '\n') orelse content.len; + const raw_line = content[pos..line_end]; + + // Strip from line end + const ref_result = stripCodeRef(raw_line); + var row_text: []const u8 = ref_result.text; + var text_owned = false; + var anchors: []const LineAnchor = &.{}; + + if (ref_result.ref_number) |num| { + const base = ref_result.text; + if (self.allocator.alloc(u8, base.len + 3)) |buf| { + @memcpy(buf[0..base.len], base); + buf[base.len] = ' '; + buf[base.len + 1] = ' '; + buf[base.len + 2] = ' '; + row_text = buf; + text_owned = true; + const cp_offset = bytesToCodepoints(base) + 1; + anchors = self.allocator.dupe(LineAnchor, &[1]LineAnchor{.{ + .number = num, + .char_offset = cp_offset, + }}) catch |err| blk: { + log.warn("failed to dupe anchor: {}", .{err}); + break :blk &[0]LineAnchor{}; + }; + } else |err| { + log.warn("failed to allocate code line with anchor padding: {}", .{err}); + } + } + + self.rows.append(self.allocator, .{ + .kind = .code_line, + .text = row_text, + .anchors = anchors, + .owns_text = text_owned, + }) catch |err| { + log.warn("failed to append code line: {}", .{err}); + if (text_owned) self.allocator.free(@constCast(row_text)); + return; + }; + + if (line_end >= content.len) break; + pos = line_end + 1; + } + + self.rows.append(self.allocator, .{ .kind = .separator }) catch |err| { + log.warn("failed to append separator: {}", .{err}); + }; + } +}; + +// === Anchor stripping helpers === + +const StripResult = struct { + text: []const u8, + anchor_count: usize, +}; + +/// Replace **[N]** markers with circled-number emoji, recording their codepoint positions. +fn stripProseAnchors(text: []const u8, out_buf: []u8, anchors: []LineAnchor) StripResult { + var out_pos: usize = 0; + var cp_pos: usize = 0; + var anchor_count: usize = 0; + var i: usize = 0; + + while (i < text.len) { + if (i + 5 <= text.len and std.mem.eql(u8, text[i..][0..3], "**[")) { + const num_start = i + 3; + var num_end = num_start; + while (num_end < text.len and text[num_end] >= '0' and text[num_end] <= '9') { + num_end += 1; + } + if (num_end > num_start and num_end + 3 <= text.len and std.mem.eql(u8, text[num_end..][0..3], "]**")) { + const num = std.fmt.parseInt(u8, text[num_start..num_end], 10) catch |err| { + log.warn("failed to parse anchor number: {}", .{err}); + if (out_pos < out_buf.len) { + out_buf[out_pos] = text[i]; + out_pos += 1; + if (text[i] & 0xC0 != 0x80) cp_pos += 1; + } + i += 1; + continue; + }; + + if (anchor_count < anchors.len and out_pos + 3 <= out_buf.len) { + out_buf[out_pos] = ' '; + out_buf[out_pos + 1] = ' '; + out_buf[out_pos + 2] = ' '; + anchors[anchor_count] = .{ + .number = num, + .char_offset = cp_pos + 1, + }; + anchor_count += 1; + out_pos += 3; + cp_pos += 3; + } + i = num_end + 3; + continue; + } + } + + if (out_pos < out_buf.len) { + out_buf[out_pos] = text[i]; + out_pos += 1; + if (text[i] & 0xC0 != 0x80) cp_pos += 1; + } + i += 1; + } + + return .{ + .text = out_buf[0..out_pos], + .anchor_count = anchor_count, + }; +} + +fn bytesToCodepoints(text: []const u8) usize { + var count: usize = 0; + for (text) |byte| { + if (byte & 0xC0 != 0x80) count += 1; + } + return count; +} + +const CodeRefResult = struct { + text: []const u8, + ref_number: ?u8, +}; + +/// Strip trailing from a code line. +fn stripCodeRef(line: []const u8) CodeRefResult { + const trimmed = std.mem.trimRight(u8, line, " \t\r"); + + // Look for at the end + if (trimmed.len >= 13) { // minimum: + if (std.mem.endsWith(u8, trimmed, "-->")) { + // Find + if (num_end > num_start) { + const num = std.fmt.parseInt(u8, trimmed[num_start..num_end], 10) catch { + return .{ .text = line, .ref_number = null }; + }; + const stripped = std.mem.trimRight(u8, trimmed[0..abs_pos], " \t"); + return .{ .text = stripped, .ref_number = num }; + } + } + } + } + + return .{ .text = line, .ref_number = null }; +} + +// === JSON helpers === + +fn parseMetaJson(json_str: []const u8) CodeBlockMeta { + var meta = CodeBlockMeta{}; + meta.file = extractJsonString(json_str, "file"); + meta.commit = extractJsonString(json_str, "commit"); + meta.change_type = extractJsonString(json_str, "type"); + meta.description = extractJsonString(json_str, "description"); + return meta; +} + +fn extractJsonString(json: []const u8, key: []const u8) ?[]const u8 { + var needle_buf: [128]u8 = undefined; + if (key.len + 3 > needle_buf.len) return null; + needle_buf[0] = '"'; + @memcpy(needle_buf[1..][0..key.len], key); + needle_buf[1 + key.len] = '"'; + const needle = needle_buf[0 .. key.len + 2]; + + const key_pos = std.mem.indexOf(u8, json, needle) orelse return null; + var pos = key_pos + needle.len; + + while (pos < json.len and (json[pos] == ' ' or json[pos] == ':')) : (pos += 1) {} + + if (pos >= json.len or json[pos] != '"') return null; + pos += 1; + + const value_start = pos; + while (pos < json.len) { + if (json[pos] == '\\' and pos + 1 < json.len) { + pos += 2; + continue; + } + if (json[pos] == '"') break; + pos += 1; + } + + if (pos >= json.len) return null; + return json[value_start..pos]; +} diff --git a/src/ui/types.zig b/src/ui/types.zig index 47b2504..d5bcb48 100644 --- a/src/ui/types.zig +++ b/src/ui/types.zig @@ -56,6 +56,7 @@ pub const UiAction = union(enum) { ToggleMetrics: void, ToggleDiffOverlay: void, SendDiffComments: SendDiffCommentsAction, + OpenStory: OpenStoryAction, }; pub const SwitchWorktreeAction = struct { @@ -87,6 +88,11 @@ pub const SendDiffCommentsAction = struct { agent_command: ?[]const u8, }; +pub const OpenStoryAction = struct { + /// Heap-allocated path; ownership transfers to runtime, which frees after use. + path: []const u8, +}; + pub const UiAssets = struct { ui_font: ?*font_mod.Font = null, font_cache: ?*font_cache.FontCache = null,