diff --git a/README.md b/README.md index 8bbd00b..fab96b8 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Architect solves this with a grid view that keeps all your agents visible, with - **Grid view** — keep all agents visible simultaneously, expand any one to full screen - **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 ### 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 d2df431..2dacd51 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -264,6 +264,7 @@ Renderer draws attention border (pulsing yellow / solid green) | Render cache | GPU textures per session | Cached terminal renders, epoch-invalidated | | config.toml | `~/.config/architect/config.toml` | User preferences (font, theme, UI flags) | | persistence.toml | `~/.config/architect/persistence.toml` | Runtime state (window pos, font size, terminal cwds) | +| diff_comments.json | `/.architect/diff_comments.json` | Per-repo inline diff review comments (unsent) | ### Exit Points @@ -280,7 +281,7 @@ Renderer draws attention border (pulsing yellow / solid green) |--------|---------------|----------------------------------|--------------| | `main.zig` | Thin entrypoint | `main()` | `app/runtime` | | `app/runtime.zig` | Application lifetime, frame loop, session spawning, config persistence | `run()`, frame loop internals | `platform/sdl`, `session/state`, `render/renderer`, `ui/root`, `config`, all `app/*` modules | -| `app/*` (app_state, layout, ui_host, grid_nav, grid_layout, input_keys, input_text, terminal_actions, worktree) | Application logic decomposed by concern: state enums, grid sizing, UI snapshot building, navigation, input encoding, clipboard, worktree commands | `ViewMode`, `AnimationState`, `SessionStatus`, `buildUiHost()`, `applyTerminalResize()`, `encodeKey()`, `paste()`, `clear()` | `geom`, `anim/easing`, `ui/types`, `colors`, `input/mapper`, `session/state`, `c` | +| `app/*` (app_state, layout, ui_host, grid_nav, grid_layout, input_keys, input_text, terminal_actions, worktree) | Application logic decomposed by concern: state enums, grid sizing, UI snapshot building, navigation, input encoding, clipboard, worktree commands | `ViewMode`, `AnimationState`, `SessionStatus`, `buildUiHost()`, `applyTerminalResize()`, `encodeKey()`, `paste()`, `clear()` | `geom`, `anim/easing`, `ui/types`, `ui/session_view_state`, `colors`, `input/mapper`, `session/state`, `c` | | `platform/sdl.zig` | SDL3 initialization, window management, HiDPI | `init()`, `createWindow()`, `createRenderer()` | `c` | | `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`) | @@ -293,10 +294,10 @@ Renderer draws attention border (pulsing yellow / solid green) | `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 | `SessionViewState` (selection, scroll offset, hover) | (none) | +| `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, 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` | -| 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()` | std, zig-toml, `c` | +| `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` | +| 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 @@ -409,3 +410,13 @@ Renderer draws attention border (pulsing yellow / solid green) - *Always render at full rate* -- rejected because it wastes CPU/GPU when nothing is changing, impacting battery life on laptops. - *SDL event injection* -- rejected because synthetic events pollute the event queue and complicate event handling logic. - **Date:** 2025 (UI system refinement) + +### ADR-013: Synchronous I/O in UI Overlays for Git and Small-File Persistence + +- **Decision:** UI overlay components may perform synchronous I/O on the main thread for two categories of operations: (1) running short-lived `git` commands (e.g., `git diff`, `git rev-parse`) whose output is needed immediately for rendering, and (2) reading/writing small per-repo data files (e.g., `/.architect/diff_comments.json`). +- **Context:** The diff overlay needs `git diff` output to render its content and persists inline review comments as a small JSON file. ADR-009 establishes that blocking I/O should go on a background thread, but these operations complete in single-digit milliseconds for typical repositories and small data files. Introducing a background thread with a callback-based rendering pipeline for each git command would add significant complexity (deferred rendering, loading states, race conditions with overlay visibility) for negligible latency improvement. +- **Constraints:** This exception applies only when the data is small and the command is fast. Large or potentially slow operations (e.g., network I/O, cloning, `git log` on deep histories) must still use the background thread pattern from ADR-009. +- **Alternatives considered:** + - *Background thread + queue for all git commands* -- deferred; would require deferred rendering with loading states in the overlay, adding complexity disproportionate to the latency risk. May be revisited if git operations become noticeably slow on large repositories. + - *Lazy/cached persistence* -- partially adopted; comments are only saved on overlay close and on comment submit, not on every keystroke. +- **Date:** 2025 (diff overlay inline comments) diff --git a/src/anim/easing.zig b/src/anim/easing.zig index 23fbe8a..779076a 100644 --- a/src/anim/easing.zig +++ b/src/anim/easing.zig @@ -6,3 +6,8 @@ pub fn easeInOutCubic(t: f32) f32 { const p = 2 * t - 2; return 1 + p * p * p / 2; } + +pub fn easeOutCubic(t: f32) f32 { + const p = t - 1.0; + return 1.0 + p * p * p; +} diff --git a/src/app/runtime.zig b/src/app/runtime.zig index 365c033..ba3984c 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -98,6 +98,10 @@ fn highestSpawnedIndex(sessions: []const *SessionState) ?usize { return null; } +fn agentProcessStarted(session: *const SessionState) bool { + return session.hasForegroundProcess(); +} + fn adjustedRenderHeightForMode(mode: app_state.ViewMode, render_height: c_int, ui_scale: f32, grid_rows: usize) c_int { return switch (mode) { .Grid, .Expanding, .Collapsing, .GridResizing => blk: { @@ -579,6 +583,13 @@ pub fn run() !void { var relaunch_trace_frames: u8 = 0; var window_close_suppress_countdown: u8 = 0; + const PendingCommentSend = struct { + session: usize, + text: []const u8, + send_after_ms: i64, + }; + var pending_comment_send: ?PendingCommentSend = null; + const session_interaction_component = try ui_mod.SessionInteractionComponent.init(allocator, sessions, &font); try ui.register(session_interaction_component.asComponent()); @@ -689,6 +700,7 @@ pub fn run() !void { &anim_state, sessions, session_ui_info, + session_interaction_component.viewSlice(), focused_has_foreground_process, &theme, ); @@ -1410,6 +1422,20 @@ pub fn run() !void { std.debug.print("Session {d} (slot {d}) status -> {s}\n", .{ note.session, session_idx, @tagName(note.state) }); } + if (pending_comment_send) |pcs| { + const prompt_ready = pcs.session < sessions.len and + agentProcessStarted(sessions[pcs.session]); + if (now >= pcs.send_after_ms or prompt_ready) { + if (pcs.session < sessions.len) { + sessions[pcs.session].sendInput(pcs.text) catch |err| { + log.warn("failed to send pending diff comments: {}", .{err}); + }; + } + allocator.free(pcs.text); + pending_comment_send = null; + } + } + var focused_has_foreground_process = foreground_cache.get(now, anim_state.focused_session, sessions); const ui_update_host = ui_host.makeUiHost( now, @@ -1425,6 +1451,7 @@ pub fn run() !void { &anim_state, sessions, session_ui_info, + session_interaction_component.viewSlice(), focused_has_foreground_process, &theme, ); @@ -1832,6 +1859,34 @@ pub fn run() !void { .opened => if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘D", now), } }, + .SendDiffComments => |dc_action| { + if (dc_action.session >= sessions.len) { + allocator.free(dc_action.comments_text); + if (dc_action.agent_command) |cmd| allocator.free(cmd); + continue; + } + var dc_session = sessions[dc_action.session]; + if (dc_action.agent_command) |cmd| { + dc_session.sendInput(cmd) catch |err| { + log.warn("failed to send agent command: {}", .{err}); + allocator.free(dc_action.comments_text); + allocator.free(cmd); + continue; + }; + allocator.free(cmd); + if (pending_comment_send) |prev| allocator.free(prev.text); + pending_comment_send = .{ + .session = dc_action.session, + .text = dc_action.comments_text, + .send_after_ms = now + 2000, + }; + } else { + dc_session.sendInput(dc_action.comments_text) catch |err| { + log.warn("failed to send diff comments: {}", .{err}); + }; + allocator.free(dc_action.comments_text); + } + }, }; if (anim_state.mode == .Expanding or anim_state.mode == .Collapsing or @@ -1896,6 +1951,7 @@ pub fn run() !void { &anim_state, sessions, session_ui_info, + session_interaction_component.viewSlice(), focused_has_foreground_process, &theme, ); diff --git a/src/app/ui_host.zig b/src/app/ui_host.zig index b6bd329..e15e54c 100644 --- a/src/app/ui_host.zig +++ b/src/app/ui_host.zig @@ -3,10 +3,12 @@ const c = @import("../c.zig"); const colors_mod = @import("../colors.zig"); const session_state = @import("../session/state.zig"); const ui_mod = @import("../ui/mod.zig"); +const view_state = @import("../ui/session_view_state.zig"); const AnimationState = app_state.AnimationState; const Rect = app_state.Rect; const SessionState = session_state.SessionState; +const SessionViewState = view_state.SessionViewState; pub fn applyMouseContext(ui: *ui_mod.UiRoot, host: *ui_mod.UiHost, event: *const c.SDL_Event) void { switch (event.type) { @@ -60,6 +62,7 @@ pub fn makeUiHost( anim_state: *const AnimationState, sessions: []const *SessionState, buffer: []ui_mod.SessionUiInfo, + views: []const SessionViewState, focused_has_foreground_process: bool, theme: *const colors_mod.Theme, ) ui_mod.UiHost { @@ -69,6 +72,7 @@ pub fn makeUiHost( .spawned = session.spawned, .cwd_path = session.cwd_path, .cwd_basename = session.cwd_basename, + .session_status = if (i < views.len) views[i].status else .idle, }; } diff --git a/src/c.zig b/src/c.zig index 7ff2ca7..8374c1a 100644 --- a/src/c.zig +++ b/src/c.zig @@ -47,6 +47,7 @@ else 0x0000_0004; pub const SDL_SetRenderDrawBlendMode = c_import.SDL_SetRenderDrawBlendMode; pub const SDL_SetRenderClipRect = c_import.SDL_SetRenderClipRect; +pub const SDL_GetRenderClipRect = c_import.SDL_GetRenderClipRect; pub const SDL_GetTextureSize = c_import.SDL_GetTextureSize; pub const SDL_CreateTextureFromSurface = c_import.SDL_CreateTextureFromSurface; pub const SDL_DestroyTexture = c_import.SDL_DestroyTexture; diff --git a/src/gfx/primitives.zig b/src/gfx/primitives.zig index 1436a61..cea5759 100644 --- a/src/gfx/primitives.zig +++ b/src/gfx/primitives.zig @@ -1,3 +1,4 @@ +const std = @import("std"); const c = @import("../c.zig"); const geom = @import("../geom.zig"); @@ -8,32 +9,37 @@ pub fn drawRoundedBorder(renderer: *c.SDL_Renderer, rect: Rect, radius: c_int) v const fy = @as(f32, @floatFromInt(rect.y)); const fw = @as(f32, @floatFromInt(rect.w)); const fh = @as(f32, @floatFromInt(rect.h)); - const frad = @as(f32, @floatFromInt(radius)); + const clamped = @min(radius, @divFloor(@min(rect.w, rect.h), 2)); + const frad = @as(f32, @floatFromInt(clamped)); + // Straight edges _ = c.SDL_RenderLine(renderer, fx + frad, fy, fx + fw - frad - 1.0, fy); _ = c.SDL_RenderLine(renderer, fx + frad, fy + fh - 1.0, fx + fw - frad - 1.0, fy + fh - 1.0); _ = c.SDL_RenderLine(renderer, fx, fy + frad, fx, fy + fh - frad - 1.0); _ = c.SDL_RenderLine(renderer, fx + fw - 1.0, fy + frad, fx + fw - 1.0, fy + fh - frad - 1.0); - const centers = [_]struct { x: f32, y: f32, sx: f32, sy: f32 }{ - .{ .x = fx + frad, .y = fy + frad, .sx = -1.0, .sy = -1.0 }, - .{ .x = fx + fw - frad - 1.0, .y = fy + frad, .sx = 1.0, .sy = -1.0 }, - .{ .x = fx + frad, .y = fy + fh - frad - 1.0, .sx = -1.0, .sy = 1.0 }, - .{ .x = fx + fw - frad - 1.0, .y = fy + fh - frad - 1.0, .sx = 1.0, .sy = 1.0 }, + // Corner arcs using angle stepping with connected line segments + const corners = [_]struct { cx: f32, cy: f32, sx: f32, sy: f32 }{ + .{ .cx = fx + frad, .cy = fy + frad, .sx = -1.0, .sy = -1.0 }, + .{ .cx = fx + fw - frad - 1.0, .cy = fy + frad, .sx = 1.0, .sy = -1.0 }, + .{ .cx = fx + frad, .cy = fy + fh - frad - 1.0, .sx = -1.0, .sy = 1.0 }, + .{ .cx = fx + fw - frad - 1.0, .cy = fy + fh - frad - 1.0, .sx = 1.0, .sy = 1.0 }, }; - for (centers) |cinfo| { - var i: i32 = 0; - while (i <= radius) : (i += 1) { - const x = @as(f32, @floatFromInt(i)); - const y_sq = frad * frad - x * x; - if (y_sq >= 0) { - const y = @sqrt(y_sq); - _ = c.SDL_RenderPoint(renderer, cinfo.x + cinfo.sx * x, cinfo.y + cinfo.sy * y); - if (i > 0 and i < radius) { - _ = c.SDL_RenderPoint(renderer, cinfo.x + cinfo.sx * y, cinfo.y + cinfo.sy * x); - } - } + const steps: u32 = @max(8, @as(u32, @intCast(clamped)) * 4); + const half_pi = std.math.pi / 2.0; + + for (corners) |corner| { + var prev_x: f32 = corner.cx; + var prev_y: f32 = corner.cy + corner.sy * frad; + var i: u32 = 1; + while (i <= steps) : (i += 1) { + const angle = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(steps)) * half_pi; + const px = corner.cx + corner.sx * frad * @sin(angle); + const py = corner.cy + corner.sy * frad * @cos(angle); + _ = c.SDL_RenderLine(renderer, prev_x, prev_y, px, py); + prev_x = px; + prev_y = py; } } } @@ -70,51 +76,39 @@ pub fn fillRoundedRect(renderer: *c.SDL_Renderer, rect: Rect, radius: c_int) voi const fy = @as(f32, @floatFromInt(rect.y)); const fw = @as(f32, @floatFromInt(rect.w)); const fh = @as(f32, @floatFromInt(rect.h)); - const frad = @as(f32, @floatFromInt(radius)); - - const center_rect = c.SDL_FRect{ - .x = fx + frad, - .y = fy, - .w = fw - 2.0 * frad, - .h = fh, - }; - _ = c.SDL_RenderFillRect(renderer, ¢er_rect); - - const left_rect = c.SDL_FRect{ - .x = fx, - .y = fy + frad, - .w = frad, - .h = fh - 2.0 * frad, - }; - _ = c.SDL_RenderFillRect(renderer, &left_rect); + const clamped = @min(radius, @divFloor(@min(rect.w, rect.h), 2)); + const frad = @as(f32, @floatFromInt(clamped)); - const right_rect = c.SDL_FRect{ - .x = fx + fw - frad, - .y = fy + frad, - .w = frad, - .h = fh - 2.0 * frad, - }; - _ = c.SDL_RenderFillRect(renderer, &right_rect); + // Middle section (full width, between rounded corners) — drawn once, no overdraw + if (fh > 2.0 * frad) { + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = fx, + .y = fy + frad, + .w = fw, + .h = fh - 2.0 * frad, + }); + } - const corners = [_]struct { cx: f32, cy: f32 }{ - .{ .cx = fx + frad, .cy = fy + frad }, - .{ .cx = fx + fw - frad - 1.0, .cy = fy + frad }, - .{ .cx = fx + frad, .cy = fy + fh - frad - 1.0 }, - .{ .cx = fx + fw - frad - 1.0, .cy = fy + fh - frad - 1.0 }, - }; + // Top rounded section: one scanline per row, inset by arc + var y: c_int = 0; + while (y < clamped) : (y += 1) { + const dy = frad - @as(f32, @floatFromInt(y)) - 0.5; + const dx_sq = frad * frad - dy * dy; + if (dx_sq > 0) { + const dx = @sqrt(dx_sq); + _ = c.SDL_RenderLine(renderer, fx + frad - dx, fy + @as(f32, @floatFromInt(y)), fx + fw - frad + dx, fy + @as(f32, @floatFromInt(y))); + } + } - for (corners) |corner| { - var y: c_int = 0; - while (y < radius) : (y += 1) { - const fy_offset = @as(f32, @floatFromInt(y)); - const dist_sq = frad * frad - fy_offset * fy_offset; - if (dist_sq > 0) { - const dx = @sqrt(dist_sq); - _ = c.SDL_RenderLine(renderer, corner.cx - dx, corner.cy - fy_offset, corner.cx + dx, corner.cy - fy_offset); - if (y > 0) { - _ = c.SDL_RenderLine(renderer, corner.cx - dx, corner.cy + fy_offset, corner.cx + dx, corner.cy + fy_offset); - } - } + // Bottom rounded section: one scanline per row, inset by arc + const bottom_start: c_int = @max(clamped, rect.h - clamped); + var by: c_int = bottom_start; + while (by < rect.h) : (by += 1) { + const dy = @as(f32, @floatFromInt(by)) - (fh - frad) + 0.5; + const dx_sq = frad * frad - dy * dy; + if (dx_sq > 0) { + const dx = @sqrt(dx_sq); + _ = c.SDL_RenderLine(renderer, fx + frad - dx, fy + @as(f32, @floatFromInt(by)), fx + fw - frad + dx, fy + @as(f32, @floatFromInt(by))); } } } diff --git a/src/ui/components/diff_overlay.zig b/src/ui/components/diff_overlay.zig index ecad70a..b2b8cdb 100644 --- a/src/ui/components/diff_overlay.zig +++ b/src/ui/components/diff_overlay.zig @@ -48,6 +48,34 @@ const DisplayRow = struct { text_byte_offset: usize = 0, }; +const CommentKey = struct { + file_path: []const u8, + line_number: usize, +}; + +const DiffComment = struct { + key: CommentKey, + text: []const u8, + sent: bool, + display_row_index: ?usize, +}; + +const EditingComment = struct { + target_display_row: usize, + key: CommentKey, + input_buf: std.ArrayList(u8), + cursor_blink_start_ms: i64, + existing_index: ?usize, +}; + +const ClickTarget = union(enum) { + diff_row: usize, + comment_box: usize, + send_button: void, + dropdown_item: usize, + other: void, +}; + const SegmentKind = enum { file_path, hunk_header, @@ -107,16 +135,46 @@ pub const DiffOverlayComponent = struct { close_hovered: bool = false, hovered_file: ?usize = null, + comments: std.ArrayList(DiffComment) = .{}, + editing: ?EditingComment = null, + show_agent_dropdown: bool = false, + agent_dropdown_hovered: ?usize = null, + send_button_hovered: bool = false, + delete_hovered_comment: ?usize = null, + wrap_cols: usize = 0, + arrow_cursor: ?*c.SDL_Cursor = null, + pointer_cursor: ?*c.SDL_Cursor = null, + 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, + editor_closing, + submitting, + submitted_glow, + }; + + const editor_open_duration_ms: i64 = 200; + const editor_close_duration_ms: i64 = 150; + 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; @@ -133,12 +191,27 @@ pub const DiffOverlayComponent = struct { const tab_display_width: usize = 4; const min_printable_char: u8 = 32; + const saved_comment_height: c_int = 32; + const editing_comment_height: c_int = 90; + const comment_input_height: c_int = 44; + const comment_button_height: c_int = 28; + const comment_button_width: c_int = 70; + const comment_delete_btn_size: c_int = 16; + const agent_dropdown_item_height: c_int = 28; + const agent_dropdown_width: c_int = 140; + const send_button_width: c_int = 110; + const send_button_height: c_int = 26; + const dropdown_items = [_][]const u8{ "Paste directly", "claude", "codex", "gemini" }; + // max_chars plus room for tab-to-spaces expansion const max_display_buffer: usize = 520; pub fn init(allocator: std.mem.Allocator) !*DiffOverlayComponent { const comp = try allocator.create(DiffOverlayComponent); comp.* = .{ .allocator = allocator }; + comp.arrow_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_DEFAULT); + comp.pointer_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_POINTER); + comp.text_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_TEXT); return comp; } @@ -162,6 +235,8 @@ pub const DiffOverlayComponent = struct { } 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(); @@ -295,6 +370,8 @@ pub const DiffOverlayComponent = struct { return .opened; }; self.parseDiffOutput(output); + self.loadCommentsFromFile(); + self.resolveCommentPositions(); return .opened; } @@ -701,6 +778,11 @@ pub const DiffOverlayComponent = struct { self.files.deinit(self.allocator); self.files = .{}; self.hovered_file = null; + self.freeComments(); + self.cancelEditingImmediate(); + self.show_agent_dropdown = false; + self.agent_dropdown_hovered = null; + self.send_button_hovered = false; if (self.last_repo_root) |root| { self.allocator.free(root); self.last_repo_root = null; @@ -836,6 +918,46 @@ pub const DiffOverlayComponent = struct { 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) { + .editor_opening => editor_open_duration_ms, + .editor_closing => editor_close_duration_ms, + .submitting => submit_morph_duration_ms, + .submitted_glow => submit_glow_duration_ms, + }; + const elapsed = now_ms - self.comment_anim_start_ms; + const clamped = @max(@as(i64, 0), elapsed); + const t = @min(1.0, @as(f32, @floatFromInt(clamped)) / @as(f32, @floatFromInt(duration))); + return switch (anim) { + .editor_opening => easing.easeOutCubic(t), + .editor_closing => easing.easeInOutCubic(t), + .submitting => easing.easeInOutCubic(t), + .submitted_glow => t, + }; + } + + fn finishCommentAnim(self: *DiffOverlayComponent) void { + if (self.comment_anim) |anim| { + if (anim == .editor_closing) { + self.finishCancelEditing(); + } + } + if (self.submit_anim_text) |txt| { + self.allocator.free(txt); + self.submit_anim_text = null; + } + self.comment_anim = null; + } + + fn finishCancelEditing(self: *DiffOverlayComponent) void { + if (self.editing) |*ed| { + ed.input_buf.deinit(self.allocator); + self.allocator.free(ed.key.file_path); + self.editing = null; + } + } + fn animatedOverlayRect(host: *const types.UiHost, progress: f32) geom.Rect { const base = overlayRect(host); const scale = scale_from + (1.0 - scale_from) * progress; @@ -889,7 +1011,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 or self.animation_state == .closing) { + if (!self.visible) { if (event.type == c.SDL_EVENT_KEY_DOWN) { const key = event.key.key; const mod = event.key.mod; @@ -906,14 +1028,74 @@ pub const DiffOverlayComponent = struct { return false; } + // During close animation, consume all input events to prevent + // key repeats (e.g. Escape) from leaking to the terminal. + if (self.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, + }; + } + switch (event.type) { c.SDL_EVENT_KEY_DOWN => { const key = event.key.key; const mod = event.key.mod; const has_gui = (mod & c.SDL_KMOD_GUI) != 0; + const has_shift = (mod & c.SDL_KMOD_SHIFT) != 0; const has_blocking = (mod & (c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT | c.SDL_KMOD_SHIFT)) != 0; + // Editing text input: handle special keys + const editor_interactive = self.editing != null and + (self.comment_anim == null or self.comment_anim.? != .editor_closing); + if (editor_interactive) { + if (key == c.SDLK_ESCAPE) { + if (self.show_agent_dropdown) { + self.show_agent_dropdown = false; + } else { + self.cancelEditing(host.now_ms); + } + return true; + } + if (key == c.SDLK_RETURN or key == c.SDLK_RETURN2 or key == c.SDLK_KP_ENTER) { + if (has_shift) { + if (self.editing) |*ed| { + ed.input_buf.append(self.allocator, '\n') catch |err| { + log.warn("failed to append newline: {}", .{err}); + }; + ed.cursor_blink_start_ms = host.now_ms; + } + } else { + self.submitComment(host.now_ms); + } + return true; + } + if (key == c.SDLK_BACKSPACE) { + if (self.editing) |*ed| { + if (has_gui) { + ed.input_buf.clearRetainingCapacity(); + } else if (ed.input_buf.items.len > 0) { + // Remove last UTF-8 codepoint + var remove_len: usize = 1; + while (remove_len < ed.input_buf.items.len and + (ed.input_buf.items[ed.input_buf.items.len - remove_len] & 0xC0) == 0x80) + { + remove_len += 1; + } + ed.input_buf.shrinkRetainingCapacity(ed.input_buf.items.len - remove_len); + } + ed.cursor_blink_start_ms = host.now_ms; + } + return true; + } + return true; + } + if (key == c.SDLK_ESCAPE) { + if (self.show_agent_dropdown) { + self.show_agent_dropdown = false; + return true; + } actions.append(.ToggleDiffOverlay) catch |err| { log.warn("failed to queue ToggleDiffOverlay action: {}", .{err}); }; @@ -938,6 +1120,19 @@ pub const DiffOverlayComponent = struct { return true; }, + c.SDL_EVENT_TEXT_INPUT => { + if (self.editing) |*ed| { + const is_closing = if (self.comment_anim) |a| a == .editor_closing else false; + if (!is_closing) { + const text = std.mem.span(event.text.text); + ed.input_buf.appendSlice(self.allocator, text) catch |err| { + log.warn("failed to append text input: {}", .{err}); + }; + ed.cursor_blink_start_ms = host.now_ms; + } + } + 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); @@ -948,6 +1143,28 @@ pub const DiffOverlayComponent = struct { const mouse_x: c_int = @intFromFloat(event.button.x); const mouse_y: c_int = @intFromFloat(event.button.y); + // Agent dropdown click + if (self.show_agent_dropdown) { + const dd = agentDropdownRect(host, 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; + const item_idx: usize = @intCast(@divFloor(rel_y, item_h)); + if (item_idx < dropdown_items.len) { + if (item_idx == 0) { + // "Paste directly" — send to terminal without starting an agent + self.sendCommentsToAgent(host, actions, null); + } else { + self.sendCommentsToAgent(host, actions, dropdown_items[item_idx]); + } + } + self.show_agent_dropdown = false; + return true; + } + self.show_agent_dropdown = false; + return true; + } + const close_rect = closeButtonRect(host); if (geom.containsPoint(close_rect, mouse_x, mouse_y)) { actions.append(.ToggleDiffOverlay) catch |err| { @@ -956,6 +1173,57 @@ pub const DiffOverlayComponent = struct { return true; } + // Send to agent button + if (self.hasUnsentComments()) { + const sb = sendButtonRect(host, overlayRect(host)); + if (geom.containsPoint(sb, mouse_x, mouse_y)) { + if (host.focused_has_foreground_process) { + self.sendCommentsToAgent(host, actions, null); + } else { + self.show_agent_dropdown = true; + } + return true; + } + } + + // Comment editing button clicks + if (self.editing) |ed| { + const rect = overlayRect(host); + const scaled_title_h = dpi.scale(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 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; + // Subtract any saved comment height at this row (editing comes after saved) + var saved_h: c_int = 0; + for (self.comments.items) |comment| { + if (comment.sent) continue; + if (comment.display_row_index) |dri| { + if (dri == ed.target_display_row) { + saved_h += dpi.scale(saved_comment_height, host.ui_scale); + } + } + } + const edit_y = content_top + comment_y_base + saved_h - scroll_int; + const btn_y = edit_y + total_h - btn_h - dpi.scale(6, host.ui_scale); + const submit_x = rect.x + rect.w - scaled_padding - btn_w * 2 - dpi.scale(12, host.ui_scale); + const cancel_x = submit_x + btn_w + dpi.scale(6, host.ui_scale); + + if (geom.containsPoint(.{ .x = submit_x, .y = btn_y, .w = btn_w, .h = btn_h }, mouse_x, mouse_y)) { + self.submitComment(host.now_ms); + return true; + } + if (geom.containsPoint(.{ .x = cancel_x, .y = btn_y, .w = btn_w, .h = btn_h }, mouse_x, mouse_y)) { + self.cancelEditing(host.now_ms); + return true; + } + } + const rect = overlayRect(host); const scaled_title_h = dpi.scale(title_height, host.ui_scale); const scaled_line_h = self.lineHeight(host); @@ -965,15 +1233,64 @@ pub const DiffOverlayComponent = struct { if (mouse_y >= content_top and scaled_line_h > 0) { const relative_y = mouse_y - content_top + scroll_int; if (relative_y >= 0) { - const click_row: usize = @intCast(@divFloor(relative_y, scaled_line_h)); - if (click_row < self.display_rows.items.len) { - const row = self.display_rows.items[click_row]; - if (row.kind == .file_header) { - if (row.file_index) |file_idx| { - self.files.items[file_idx].collapsed = !self.files.items[file_idx].collapsed; - self.rebuildDisplayRows(); + const target = self.resolveClickTarget(relative_y, scaled_line_h, host.ui_scale, host.now_ms); + switch (target) { + .diff_row => |row_idx| { + const row = self.display_rows.items[row_idx]; + if (row.kind == .file_header) { + if (row.file_index) |file_idx| { + self.files.items[file_idx].collapsed = !self.files.items[file_idx].collapsed; + self.rebuildDisplayRows(); + self.resolveCommentPositions(); + } + } else if (row.kind == .diff_line and row.text_byte_offset == 0) { + self.openCommentForRow(row_idx, host.now_ms); } - } + }, + .comment_box => |row_idx| { + // Check if clicking the delete button + if (self.findCommentDeleteTarget(host, row_idx, mouse_x, mouse_y)) |del_idx| { + if (self.editing) |ed| { + if (ed.existing_index != null and ed.existing_index.? == del_idx) { + self.cancelEditingImmediate(); + } + } + self.removeComment(del_idx); + self.destroyCache(); + self.saveCommentsToFile(); + return true; + } + // Click on saved comment opens for editing + for (self.comments.items, 0..) |comment, ci| { + if (comment.sent) continue; + if (comment.display_row_index) |dri| { + if (dri == row_idx) { + // Open this comment for editing + self.cancelEditingImmediate(); + const key_dup = self.allocator.dupe(u8, comment.key.file_path) catch return true; + var input_buf = std.ArrayList(u8){}; + input_buf.appendSlice(self.allocator, comment.text) catch |err| { + log.warn("failed to copy comment: {}", .{err}); + self.allocator.free(key_dup); + return true; + }; + self.editing = EditingComment{ + .target_display_row = row_idx, + .key = .{ .file_path = key_dup, .line_number = comment.key.line_number }, + .input_buf = input_buf, + .cursor_blink_start_ms = host.now_ms, + .existing_index = ci, + }; + self.comment_anim = .editor_opening; + self.comment_anim_start_ms = host.now_ms; + self.comment_anim_row = row_idx; + self.first_frame.markTransition(); + break; + } + } + } + }, + else => {}, } } } @@ -985,6 +1302,23 @@ pub const DiffOverlayComponent = struct { 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); + self.send_button_hovered = if (self.hasUnsentComments()) + geom.containsPoint(sendButtonRect(host, overlayRect(host)), mouse_x, mouse_y) + else + false; + + // Agent dropdown hover + if (self.show_agent_dropdown) { + const dd = agentDropdownRect(host, 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; + const idx: usize = @intCast(@divFloor(rel_y, item_h)); + self.agent_dropdown_hovered = if (idx < dropdown_items.len) idx else null; + } else { + self.agent_dropdown_hovered = null; + } + } const rect = overlayRect(host); const scaled_title_h = dpi.scale(title_height, host.ui_scale); @@ -993,22 +1327,51 @@ pub const DiffOverlayComponent = struct { const scroll_int: c_int = @intFromFloat(self.scroll_offset); self.hovered_file = null; - if (mouse_y >= content_top and scaled_line_h > 0) { + self.delete_hovered_comment = null; + var want_cursor: CursorKind = .arrow; + if (self.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; + } else if (mouse_y >= content_top and scaled_line_h > 0) { const relative_y = mouse_y - content_top + scroll_int; if (relative_y >= 0) { - const hover_row: usize = @intCast(@divFloor(relative_y, scaled_line_h)); - if (hover_row < self.display_rows.items.len) { - const row = self.display_rows.items[hover_row]; - if (row.kind == .file_header) { - self.hovered_file = row.file_index; - } + const target = self.resolveClickTarget(relative_y, scaled_line_h, host.ui_scale, host.now_ms); + switch (target) { + .diff_row => |row_idx| { + if (row_idx < self.display_rows.items.len) { + const row = self.display_rows.items[row_idx]; + if (row.kind == .file_header) { + self.hovered_file = row.file_index; + want_cursor = .pointer; + } else if (row.kind == .diff_line and row.text_byte_offset == 0) { + want_cursor = .pointer; + } + } + }, + .comment_box => |box_row| { + self.delete_hovered_comment = self.findCommentDeleteTarget(host, box_row, mouse_x, mouse_y); + if (self.delete_hovered_comment != null) { + want_cursor = .pointer; + } else if (self.editing) |ed| { + if (ed.target_display_row == box_row) { + want_cursor = .text; + } else { + want_cursor = .pointer; + } + } else { + want_cursor = .pointer; + } + }, + else => {}, } } } + self.setCursor(want_cursor); return true; }, - c.SDL_EVENT_KEY_UP, c.SDL_EVENT_MOUSE_BUTTON_UP, c.SDL_EVENT_TEXT_INPUT, c.SDL_EVENT_TEXT_EDITING => return true, + c.SDL_EVENT_KEY_UP, c.SDL_EVENT_MOUSE_BUTTON_UP, c.SDL_EVENT_TEXT_EDITING => return true, else => return false, } } @@ -1031,6 +1394,38 @@ pub const DiffOverlayComponent = struct { }, .open, .closed => {}, } + + if (self.comment_anim) |anim| { + const comment_elapsed = host.now_ms - self.comment_anim_start_ms; + const duration: i64 = switch (anim) { + .editor_opening => editor_open_duration_ms, + .editor_closing => editor_close_duration_ms, + .submitting => submit_morph_duration_ms, + .submitted_glow => submit_glow_duration_ms, + }; + if (comment_elapsed >= duration) { + switch (anim) { + .editor_opening => { + self.comment_anim = null; + }, + .editor_closing => { + self.finishCancelEditing(); + self.comment_anim = null; + }, + .submitting => { + self.comment_anim = .submitted_glow; + self.comment_anim_start_ms = host.now_ms; + }, + .submitted_glow => { + if (self.submit_anim_text) |txt| { + self.allocator.free(txt); + self.submit_anim_text = null; + } + self.comment_anim = null; + }, + } + } + } } fn hitTestFn(self_ptr: *anyopaque, host: *const types.UiHost, x: c_int, y: c_int) bool { @@ -1042,7 +1437,7 @@ pub const DiffOverlayComponent = struct { 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; + return self.first_frame.wantsFrame() or self.visible or self.animation_state == .closing or self.comment_anim != null; } // --- Rendering --- @@ -1073,7 +1468,8 @@ pub const DiffOverlayComponent = struct { 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 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); @@ -1101,6 +1497,7 @@ pub const DiffOverlayComponent = struct { @floatFromInt(rect.y + scaled_title_h), ); + self.renderSendButton(host, renderer, assets, rect); self.renderCloseButton(host, renderer, assets, rect, scaled_font_size); const content_clip = c.SDL_Rect{ @@ -1111,11 +1508,12 @@ pub const DiffOverlayComponent = struct { }; _ = c.SDL_SetRenderClipRect(renderer, &content_clip); - self.renderDiffContent(host, renderer, rect, scaled_title_h, scaled_padding, cache); + self.renderDiffContent(host, renderer, rect, scaled_title_h, scaled_padding, cache, assets); _ = c.SDL_SetRenderClipRect(renderer, null); self.renderScrollbar(host, renderer, rect, scaled_title_h, content_height, viewport_height); + self.renderAgentDropdown(host, renderer, assets, rect); self.first_frame.markDrawn(); } @@ -1185,6 +1583,7 @@ pub const DiffOverlayComponent = struct { if (new_wrap != self.wrap_cols and new_wrap > 0) { self.wrap_cols = new_wrap; self.rebuildDisplayRows(); + self.resolveCommentPositions(); } } @@ -1529,7 +1928,7 @@ 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) void { + 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 content_top = rect.y + title_h; @@ -1538,8 +1937,8 @@ pub const DiffOverlayComponent = struct { const row_height = cache.line_height; if (row_height <= 0 or content_h <= 0) return; - const first_visible: usize = @intCast(@divFloor(scroll_int, row_height)); - const visible_count: usize = @intCast(@divFloor(content_h, row_height) + 2); + const has_comments = self.comments.items.len > 0 or self.editing != null; + const first_visible: usize = if (has_comments) 0 else @intCast(@divFloor(scroll_int, row_height)); const scaled_gutter_w = dpi.scale(gutter_width, host.ui_scale); const scaled_chevron_sz = dpi.scale(chevron_size, host.ui_scale); @@ -1549,11 +1948,27 @@ pub const DiffOverlayComponent = struct { const fg = host.theme.foreground; const accent = host.theme.accent; - const end_row = @min(self.display_rows.items.len, first_visible + visible_count); + // Compute y_pos incrementally to avoid O(n²) from per-row computeRowY calls + var cumulative_y: c_int = if (has_comments) + content_top + self.computeRowY(first_visible, row_height, host.ui_scale, host.now_ms) - scroll_int + else + content_top + @as(c_int, @intCast(first_visible)) * row_height - scroll_int; + var row_index: usize = first_visible; - while (row_index < end_row) : (row_index += 1) { + while (row_index < self.display_rows.items.len) : (row_index += 1) { const row = self.display_rows.items[row_index]; - const y_pos = content_top + @as(c_int, @intCast(row_index)) * row_height - scroll_int; + const y_pos = cumulative_y; + + // Advance cumulative_y for the next iteration (row height + any comment height) + cumulative_y += row_height + self.commentHeightAtRow(row_index, host.ui_scale, host.now_ms); + + // Skip rows above the viewport, but render their attached comments + if (y_pos + row_height < content_top) { + self.renderRowComments(host, renderer, assets, rect, y_pos + row_height, row_index); + continue; + } + // Stop when below the viewport + if (y_pos > content_top + content_h) break; switch (row.kind) { .file_header => { @@ -1694,6 +2109,51 @@ pub const DiffOverlayComponent = struct { .h = @floatFromInt(render_h), }); } + + self.renderRowComments(host, renderer, assets, rect, y_pos + row_height, row_index); + } + } + + fn renderRowComments(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets, rect: geom.Rect, base_y: c_int, row_index: usize) void { + var comment_y = base_y; + const is_anim_row = self.comment_anim != null and self.comment_anim_row == row_index; + const anim_p = if (is_anim_row) self.commentAnimProgress(host.now_ms) else @as(f32, 1.0); + + for (self.comments.items, 0..) |comment, ci| { + if (comment.sent) continue; + if (comment.display_row_index) |dri| { + if (dri == row_index) { + if (is_anim_row and self.comment_anim.? == .submitting) { + self.renderSubmitMorph(host, renderer, assets, rect, comment_y, comment, anim_p); + const full_edit_h: f32 = @floatFromInt(dpi.scale(editing_comment_height, host.ui_scale)); + const full_saved_h: f32 = @floatFromInt(dpi.scale(saved_comment_height, host.ui_scale)); + comment_y += @intFromFloat(full_edit_h + (full_saved_h - full_edit_h) * anim_p); + } else if (is_anim_row and self.comment_anim.? == .submitted_glow) { + self.renderSavedCommentWithGlow(host, renderer, assets, rect, comment_y, comment, anim_p, ci); + comment_y += dpi.scale(saved_comment_height, host.ui_scale); + } else { + self.renderSavedComment(host, renderer, assets, rect, comment_y, comment, ci); + comment_y += dpi.scale(saved_comment_height, host.ui_scale); + } + } + } + } + if (self.editing) |ed| { + if (ed.target_display_row == row_index) { + if (is_anim_row) { + if (self.comment_anim) |anim| { + if (anim == .editor_opening or anim == .editor_closing) { + self.renderEditingCommentAnimated(host, renderer, assets, rect, comment_y, anim_p, anim == .editor_closing); + } else { + self.renderEditingComment(host, renderer, assets, rect, comment_y); + } + } else { + self.renderEditingComment(host, renderer, assets, rect, comment_y); + } + } else { + self.renderEditingComment(host, renderer, assets, rect, comment_y); + } + } } } @@ -1755,10 +2215,1246 @@ pub const DiffOverlayComponent = struct { }); } + // --- Comment management --- + + fn freeComments(self: *DiffOverlayComponent) void { + for (self.comments.items) |comment| { + self.allocator.free(comment.key.file_path); + self.allocator.free(comment.text); + } + self.comments.deinit(self.allocator); + self.comments = .{}; + } + + fn cancelEditing(self: *DiffOverlayComponent, now_ms: i64) void { + if (self.editing) |ed| { + if (self.comment_anim) |anim| { + if (anim == .editor_closing) return; + if (anim == .editor_opening) { + // Interrupt opening with close + self.comment_anim = .editor_closing; + self.comment_anim_start_ms = now_ms; + self.comment_anim_row = ed.target_display_row; + return; + } + } + self.comment_anim = .editor_closing; + self.comment_anim_start_ms = now_ms; + self.comment_anim_row = ed.target_display_row; + } + } + + fn cancelEditingImmediate(self: *DiffOverlayComponent) void { + self.finishCommentAnim(); + self.finishCancelEditing(); + } + + fn commentKeyForRow(self: *DiffOverlayComponent, row_index: usize) ?CommentKey { + if (row_index >= self.display_rows.items.len) return null; + const row = self.display_rows.items[row_index]; + if (row.kind != .diff_line) return null; + if (row.text_byte_offset != 0) return null; + + const file_idx = row.file_index orelse return null; + const hunk_idx = row.hunk_index orelse return null; + const line_idx = row.line_index orelse return null; + + if (file_idx >= self.files.items.len) return null; + const file = &self.files.items[file_idx]; + if (hunk_idx >= file.hunks.items.len) return null; + const hunk = &file.hunks.items[hunk_idx]; + if (line_idx >= hunk.lines.items.len) return null; + const hunk_line = &hunk.lines.items[line_idx]; + + const line_num = switch (hunk_line.kind) { + .add, .context => hunk_line.new_line orelse return null, + .remove => hunk_line.old_line orelse return null, + }; + + const file_path = self.allocator.dupe(u8, file.path) catch return null; + return CommentKey{ + .file_path = file_path, + .line_number = line_num, + }; + } + + fn findCommentIndex(self: *DiffOverlayComponent, key: CommentKey) ?usize { + for (self.comments.items, 0..) |comment, i| { + if (comment.sent) continue; + if (comment.key.line_number == key.line_number and std.mem.eql(u8, comment.key.file_path, key.file_path)) { + return i; + } + } + return null; + } + + fn lastWrappedRowForLine(self: *DiffOverlayComponent, row_index: usize) usize { + const row = self.display_rows.items[row_index]; + if (row.kind != .diff_line) return row_index; + const fi = row.file_index orelse return row_index; + const hi = row.hunk_index orelse return row_index; + const li = row.line_index orelse return row_index; + + var last = row_index; + var i = row_index + 1; + while (i < self.display_rows.items.len) : (i += 1) { + const r = self.display_rows.items[i]; + if (r.kind != .diff_line) break; + if (r.file_index != fi or r.hunk_index != hi or r.line_index != li) break; + last = i; + } + return last; + } + + fn openCommentForRow(self: *DiffOverlayComponent, row_index: usize, now_ms: i64) void { + const key = self.commentKeyForRow(row_index) orelse return; + const attach_row = self.lastWrappedRowForLine(row_index); + + if (self.editing) |*existing| { + if (existing.target_display_row == attach_row) { + self.allocator.free(key.file_path); + return; + } + self.cancelEditingImmediate(); + } + + const existing_idx = self.findCommentIndex(key); + var input_buf = std.ArrayList(u8){}; + if (existing_idx) |idx| { + input_buf.appendSlice(self.allocator, self.comments.items[idx].text) catch |err| { + log.warn("failed to copy comment text: {}", .{err}); + }; + } + + self.editing = EditingComment{ + .target_display_row = attach_row, + .key = key, + .input_buf = input_buf, + .cursor_blink_start_ms = now_ms, + .existing_index = existing_idx, + }; + self.comment_anim = .editor_opening; + self.comment_anim_start_ms = now_ms; + self.comment_anim_row = attach_row; + self.first_frame.markTransition(); + } + + fn submitComment(self: *DiffOverlayComponent, now_ms: i64) void { + const ed = &(self.editing orelse return); + if (ed.input_buf.items.len == 0) { + if (ed.existing_index) |idx| { + self.removeComment(idx); + } + self.cancelEditing(now_ms); + self.saveCommentsToFile(); + return; + } + + const anim_row = ed.target_display_row; + + const anim_text = self.allocator.dupe(u8, ed.input_buf.items) catch |err| { + log.warn("failed to dupe anim text: {}", .{err}); + return; + }; + + const text = self.allocator.dupe(u8, ed.input_buf.items) catch |err| { + log.warn("failed to dupe comment text: {}", .{err}); + self.allocator.free(anim_text); + return; + }; + const file_path = self.allocator.dupe(u8, ed.key.file_path) catch |err| { + log.warn("failed to dupe comment path: {}", .{err}); + self.allocator.free(text); + self.allocator.free(anim_text); + return; + }; + + if (ed.existing_index) |idx| { + self.allocator.free(self.comments.items[idx].text); + self.comments.items[idx].text = text; + self.allocator.free(file_path); + } else { + self.comments.append(self.allocator, DiffComment{ + .key = .{ .file_path = file_path, .line_number = ed.key.line_number }, + .text = text, + .sent = false, + .display_row_index = anim_row, + }) catch |err| { + log.warn("failed to append comment: {}", .{err}); + self.allocator.free(text); + self.allocator.free(file_path); + self.allocator.free(anim_text); + return; + }; + } + + self.finishCancelEditing(); + if (self.submit_anim_text) |old| self.allocator.free(old); + self.submit_anim_text = anim_text; + self.comment_anim = .submitting; + self.comment_anim_start_ms = now_ms; + self.comment_anim_row = anim_row; + self.destroyCache(); + self.saveCommentsToFile(); + } + + fn removeComment(self: *DiffOverlayComponent, idx: usize) void { + const comment = self.comments.items[idx]; + self.allocator.free(comment.key.file_path); + self.allocator.free(comment.text); + _ = self.comments.orderedRemove(idx); + } + + fn setCursor(self: *DiffOverlayComponent, kind: CursorKind) void { + if (self.current_cursor == kind) return; + self.current_cursor = kind; + const cursor = switch (kind) { + .arrow => self.arrow_cursor, + .pointer => self.pointer_cursor, + .text => self.text_cursor, + }; + if (cursor) |cur| _ = c.SDL_SetCursor(cur); + } + + fn hasUnsentComments(self: *DiffOverlayComponent) bool { + for (self.comments.items) |comment| { + if (!comment.sent) return true; + } + return false; + } + + fn resolveCommentPositions(self: *DiffOverlayComponent) void { + for (self.comments.items) |*comment| { + comment.display_row_index = null; + if (comment.sent) continue; + + for (self.display_rows.items, 0..) |row, i| { + if (row.kind != .diff_line) continue; + if (row.text_byte_offset != 0) continue; + const fi = row.file_index orelse continue; + const hi = row.hunk_index orelse continue; + const li = row.line_index orelse continue; + + if (fi >= self.files.items.len) continue; + const file = &self.files.items[fi]; + if (hi >= file.hunks.items.len) continue; + const hunk = &file.hunks.items[hi]; + if (li >= hunk.lines.items.len) continue; + const hunk_line = &hunk.lines.items[li]; + + const line_num = switch (hunk_line.kind) { + .add, .context => hunk_line.new_line orelse continue, + .remove => hunk_line.old_line orelse continue, + }; + + if (line_num == comment.key.line_number and std.mem.eql(u8, file.path, comment.key.file_path)) { + comment.display_row_index = self.lastWrappedRowForLine(i); + break; + } + } + } + } + + fn commentHeightAtRow(self: *DiffOverlayComponent, row_index: usize, ui_scale: f32, now_ms: i64) c_int { + var h: c_int = 0; + const full_saved_h = dpi.scale(saved_comment_height, ui_scale); + const full_edit_h = dpi.scale(editing_comment_height, ui_scale); + const is_anim_row = self.comment_anim != null and self.comment_anim_row == row_index; + + for (self.comments.items) |comment| { + if (comment.sent) continue; + if (comment.display_row_index) |dri| { + if (dri == row_index) { + if (is_anim_row and self.comment_anim.? == .submitting) { + // During morph: height interpolates from editing to saved + const p = self.commentAnimProgress(now_ms); + const edit_f: f32 = @floatFromInt(full_edit_h); + const saved_f: f32 = @floatFromInt(full_saved_h); + h += @intFromFloat(edit_f + (saved_f - edit_f) * p); + } else { + h += full_saved_h; + } + } + } + } + if (self.editing) |ed| { + if (ed.target_display_row == row_index) { + if (is_anim_row) { + const p = self.commentAnimProgress(now_ms); + const edit_f: f32 = @floatFromInt(full_edit_h); + if (self.comment_anim.? == .editor_opening) { + h += @intFromFloat(edit_f * p); + } else if (self.comment_anim.? == .editor_closing) { + h += @intFromFloat(edit_f * (1.0 - p)); + } else { + h += full_edit_h; + } + } else { + h += full_edit_h; + } + } + } + return h; + } + + fn totalCommentPixelHeight(self: *DiffOverlayComponent, host: *const types.UiHost) c_int { + var total: c_int = 0; + for (self.display_rows.items, 0..) |_, i| { + total += self.commentHeightAtRow(i, host.ui_scale, host.now_ms); + } + return total; + } + + fn computeRowY(self: *DiffOverlayComponent, row_index: usize, row_height: c_int, ui_scale: f32, now_ms: i64) c_int { + var y: c_int = @as(c_int, @intCast(row_index)) * row_height; + var i: usize = 0; + while (i < row_index) : (i += 1) { + y += self.commentHeightAtRow(i, ui_scale, now_ms); + } + return y; + } + + fn resolveClickTarget(self: *DiffOverlayComponent, relative_y: c_int, row_height: c_int, ui_scale: f32, now_ms: i64) ClickTarget { + if (row_height <= 0) return .{ .other = {} }; + var cumulative_y: c_int = 0; + for (self.display_rows.items, 0..) |_, i| { + const row_start = cumulative_y; + const row_end = cumulative_y + row_height; + if (relative_y >= row_start and relative_y < row_end) { + return .{ .diff_row = i }; + } + cumulative_y = row_end; + const comment_h = self.commentHeightAtRow(i, ui_scale, now_ms); + if (comment_h > 0 and relative_y >= cumulative_y and relative_y < cumulative_y + comment_h) { + return .{ .comment_box = i }; + } + cumulative_y += comment_h; + } + return .{ .other = {} }; + } + + fn formatCommentsForAgent(self: *DiffOverlayComponent) ?[]const u8 { + var buf = std.ArrayList(u8){}; + var first = true; + for (self.comments.items) |comment| { + if (comment.sent) continue; + if (!first) { + buf.appendSlice(self.allocator, "\n\n") catch return null; + } + buf.appendSlice(self.allocator, comment.key.file_path) catch return null; + buf.append(self.allocator, ':') catch return null; + var num_buf: [20]u8 = undefined; + const num_str = std.fmt.bufPrint(&num_buf, "{d}", .{comment.key.line_number}) catch return null; + buf.appendSlice(self.allocator, num_str) catch return null; + buf.appendSlice(self.allocator, ": ") catch return null; + buf.appendSlice(self.allocator, comment.text) catch return null; + first = false; + } + buf.append(self.allocator, '\n') catch return null; + return buf.toOwnedSlice(self.allocator) catch |err| { + log.warn("failed to format comments: {}", .{err}); + return null; + }; + } + + fn sendCommentsToAgent(self: *DiffOverlayComponent, host: *const types.UiHost, actions: *types.UiActionQueue, agent_name: ?[]const u8) void { + const comments_text = self.formatCommentsForAgent() orelse return; + var agent_cmd: ?[]const u8 = null; + if (agent_name) |name| { + agent_cmd = std.fmt.allocPrint(self.allocator, "{s}\n", .{name}) catch { + self.allocator.free(comments_text); + return; + }; + } + actions.append(.{ .SendDiffComments = .{ + .session = host.focused_session, + .comments_text = comments_text, + .agent_command = agent_cmd, + } }) catch |err| { + log.warn("failed to queue SendDiffComments action: {}", .{err}); + self.allocator.free(comments_text); + if (agent_cmd) |cmd| self.allocator.free(cmd); + return; + }; + self.markCommentsSent(); + // Close the diff overlay after sending + actions.append(.ToggleDiffOverlay) catch |err| { + log.warn("failed to queue ToggleDiffOverlay action: {}", .{err}); + }; + } + + fn markCommentsSent(self: *DiffOverlayComponent) void { + for (self.comments.items) |*comment| { + comment.sent = true; + comment.display_row_index = null; + } + self.destroyCache(); + } + + fn commentDeleteBtnRect(host: *const types.UiHost, overlay_rect: geom.Rect, comment_y: c_int) geom.Rect { + const btn_size = dpi.scale(comment_delete_btn_size, host.ui_scale); + const comment_h = dpi.scale(saved_comment_height, host.ui_scale); + const margin_r = dpi.scale(8, host.ui_scale); + return .{ + .x = overlay_rect.x + overlay_rect.w - btn_size - margin_r, + .y = comment_y + @divFloor(comment_h - btn_size, 2), + .w = btn_size, + .h = btn_size, + }; + } + + 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 scaled_line_h = self.lineHeight(host); + const content_top = rect.y + scaled_title_h; + const scroll_int: c_int = @intFromFloat(self.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); + + for (self.comments.items, 0..) |comment, ci| { + if (comment.sent) continue; + if (comment.display_row_index) |dri| { + if (dri == row_idx) { + const del_btn = commentDeleteBtnRect(host, rect, comment_y); + if (geom.containsPoint(del_btn, mouse_x, mouse_y)) { + return ci; + } + comment_y += saved_h; + } + } + } + return null; + } + + 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); + return geom.Rect{ + .x = overlay_rect.x + overlay_rect.w - close_w - btn_margin * 2 - btn_w, + .y = overlay_rect.y + btn_margin, + .w = btn_w, + .h = btn_h, + }; + } + + fn agentDropdownRect(host: *const types.UiHost, overlay_rect: geom.Rect) geom.Rect { + const sb = sendButtonRect(host, overlay_rect); + const item_h = dpi.scale(agent_dropdown_item_height, host.ui_scale); + const dd_w = dpi.scale(agent_dropdown_width, host.ui_scale); + return geom.Rect{ + .x = sb.x + sb.w - dd_w, + .y = sb.y + sb.h + dpi.scale(2, host.ui_scale), + .w = dd_w, + .h = item_h * @as(c_int, @intCast(dropdown_items.len)), + }; + } + + // --- Persistence --- + + fn saveCommentsToFile(self: *DiffOverlayComponent) void { + const repo_root = self.last_repo_root orelse return; + + const dir_path = std.fs.path.join(self.allocator, &.{ repo_root, ".architect" }) catch return; + defer self.allocator.free(dir_path); + std.fs.makeDirAbsolute(dir_path) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => { + log.warn("failed to create .architect dir: {}", .{err}); + return; + }, + }; + + const file_path = std.fs.path.join(self.allocator, &.{ dir_path, "diff_comments.json" }) catch return; + defer self.allocator.free(file_path); + + var buf = std.ArrayList(u8){}; + defer buf.deinit(self.allocator); + buf.appendSlice(self.allocator, "[\n") catch return; + var first = true; + for (self.comments.items) |comment| { + if (comment.sent) continue; + if (!first) buf.appendSlice(self.allocator, ",\n") catch return; + buf.appendSlice(self.allocator, " {\"file\":\"") catch return; + self.appendJsonEscaped(&buf, comment.key.file_path); + buf.appendSlice(self.allocator, "\",\"line\":") catch return; + var num_buf: [20]u8 = undefined; + const num_str = std.fmt.bufPrint(&num_buf, "{d}", .{comment.key.line_number}) catch return; + buf.appendSlice(self.allocator, num_str) catch return; + buf.appendSlice(self.allocator, ",\"text\":\"") catch return; + self.appendJsonEscaped(&buf, comment.text); + buf.appendSlice(self.allocator, "\"}") catch return; + first = false; + } + buf.appendSlice(self.allocator, "\n]\n") catch return; + + const file = std.fs.createFileAbsolute(file_path, .{ .truncate = true }) catch |err| { + log.warn("failed to create comments file: {}", .{err}); + return; + }; + defer file.close(); + file.writeAll(buf.items) catch |err| { + log.warn("failed to write comments file: {}", .{err}); + }; + } + + fn appendJsonEscaped(self: *DiffOverlayComponent, buf: *std.ArrayList(u8), text: []const u8) void { + for (text) |ch| { + switch (ch) { + '"' => buf.appendSlice(self.allocator, "\\\"") catch return, + '\\' => buf.appendSlice(self.allocator, "\\\\") catch return, + '\n' => buf.appendSlice(self.allocator, "\\n") catch return, + '\r' => buf.appendSlice(self.allocator, "\\r") catch return, + '\t' => buf.appendSlice(self.allocator, "\\t") catch return, + else => { + if (ch < 0x20) { + var esc_buf: [6]u8 = undefined; + const esc = std.fmt.bufPrint(&esc_buf, "\\u{X:0>4}", .{ch}) catch return; + buf.appendSlice(self.allocator, esc) catch return; + } else { + buf.append(self.allocator, ch) catch return; + } + }, + } + } + } + + fn loadCommentsFromFile(self: *DiffOverlayComponent) void { + const repo_root = self.last_repo_root orelse return; + const file_path = std.fs.path.join(self.allocator, &.{ repo_root, ".architect", "diff_comments.json" }) catch return; + defer self.allocator.free(file_path); + + const file = std.fs.openFileAbsolute(file_path, .{}) catch return; + defer file.close(); + const content = file.readToEndAlloc(self.allocator, 1024 * 1024) catch return; + defer self.allocator.free(content); + + self.parseCommentsJson(content); + } + + fn parseCommentsJson(self: *DiffOverlayComponent, content: []const u8) void { + var pos: usize = 0; + // Skip to '[' + while (pos < content.len and content[pos] != '[') pos += 1; + if (pos >= content.len) return; + pos += 1; + + while (pos < content.len) { + // Skip whitespace and commas + while (pos < content.len and (content[pos] == ' ' or content[pos] == '\n' or content[pos] == '\r' or content[pos] == '\t' or content[pos] == ',')) pos += 1; + if (pos >= content.len or content[pos] == ']') break; + if (content[pos] != '{') break; + pos += 1; + + var file_str: ?[]const u8 = null; + var line_num: usize = 0; + var text_str: ?[]const u8 = null; + + // Parse object fields + while (pos < content.len and content[pos] != '}') { + while (pos < content.len and content[pos] != '"' and content[pos] != '}') pos += 1; + if (pos >= content.len or content[pos] == '}') break; + pos += 1; // skip opening quote + const key_start = pos; + while (pos < content.len and content[pos] != '"') pos += 1; + const key = content[key_start..pos]; + if (pos < content.len) pos += 1; // skip closing quote + // skip colon + while (pos < content.len and content[pos] != ':') pos += 1; + if (pos < content.len) pos += 1; + // skip whitespace + while (pos < content.len and (content[pos] == ' ' or content[pos] == '\t')) pos += 1; + + if (std.mem.eql(u8, key, "file") or std.mem.eql(u8, key, "text")) { + if (pos < content.len and content[pos] == '"') { + pos += 1; + const val = self.parseJsonString(content, &pos) orelse continue; + if (std.mem.eql(u8, key, "file")) { + if (file_str) |old| self.allocator.free(old); + file_str = val; + } else { + if (text_str) |old| self.allocator.free(old); + text_str = val; + } + } + } else if (std.mem.eql(u8, key, "line")) { + var n: usize = 0; + while (pos < content.len and content[pos] >= '0' and content[pos] <= '9') { + n = n * 10 + @as(usize, content[pos] - '0'); + pos += 1; + } + line_num = n; + } + } + if (pos < content.len and content[pos] == '}') pos += 1; + + if (file_str) |fp| { + if (text_str) |txt| { + self.comments.append(self.allocator, DiffComment{ + .key = .{ .file_path = fp, .line_number = line_num }, + .text = txt, + .sent = false, + .display_row_index = null, + }) catch |err| { + log.warn("failed to load comment: {}", .{err}); + self.allocator.free(fp); + self.allocator.free(txt); + }; + continue; + } + } + if (file_str) |fp| self.allocator.free(fp); + if (text_str) |txt| self.allocator.free(txt); + } + } + + fn parseJsonString(self: *DiffOverlayComponent, content: []const u8, pos: *usize) ?[]const u8 { + var buf = std.ArrayList(u8){}; + while (pos.* < content.len and content[pos.*] != '"') { + if (content[pos.*] == '\\' and pos.* + 1 < content.len) { + pos.* += 1; + switch (content[pos.*]) { + 'n' => buf.append(self.allocator, '\n') catch return null, + 'r' => buf.append(self.allocator, '\r') catch return null, + 't' => buf.append(self.allocator, '\t') catch return null, + '"' => buf.append(self.allocator, '"') catch return null, + '\\' => buf.append(self.allocator, '\\') catch return null, + else => buf.append(self.allocator, content[pos.*]) catch return null, + } + } else { + buf.append(self.allocator, content[pos.*]) catch return null; + } + pos.* += 1; + } + if (pos.* < content.len) pos.* += 1; // skip closing quote + return buf.toOwnedSlice(self.allocator) catch |err| { + log.warn("failed to parse JSON string: {}", .{err}); + return null; + }; + } + + // --- Comment rendering --- + + 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 accent_w = dpi.scale(4, host.ui_scale); + const alpha = self.render_alpha; + const del_btn = commentDeleteBtnRect(host, rect, y_pos); + + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + + // Background — more visible amber/orange tint + _ = c.SDL_SetRenderDrawColor(renderer, 180, 140, 40, @intFromFloat(50.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(comment_h), + }); + + // Left accent bar — thick amber + _ = c.SDL_SetRenderDrawColor(renderer, 220, 170, 50, @intFromFloat(220.0 * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + 1), + .y = @floatFromInt(y_pos), + .w = @floatFromInt(accent_w), + .h = @floatFromInt(comment_h), + }); + + const font_cache = assets.font_cache orelse return; + const scaled_font_size = dpi.scale(font_size, host.ui_scale); + const fonts = font_cache.get(scaled_font_size) catch return; + + // Render comment text in warm yellow/amber color + const comment_color = c.SDL_Color{ .r = 230, .g = 200, .b = 110, .a = 255 }; + const display_text = if (comment.text.len > 200) comment.text[0..200] else comment.text; + const tex = self.makeTextTexture(renderer, fonts.regular, display_text, comment_color) catch return; + defer c.SDL_DestroyTexture(tex.tex); + _ = c.SDL_SetTextureAlphaMod(tex.tex, @intFromFloat(255.0 * alpha)); + const text_x = rect.x + scaled_padding + accent_w + dpi.scale(4, host.ui_scale); + const text_y = y_pos + @divFloor(comment_h - tex.h, 2); + const del_space = dpi.scale(comment_delete_btn_size + 16, host.ui_scale); + const max_w = rect.w - scaled_padding * 2 - accent_w - dpi.scale(8, host.ui_scale) - del_space; + const render_w = @min(tex.w, max_w); + _ = c.SDL_RenderTexture(renderer, tex.tex, null, &c.SDL_FRect{ + .x = @floatFromInt(text_x), + .y = @floatFromInt(text_y), + .w = @floatFromInt(render_w), + .h = @floatFromInt(tex.h), + }); + + // Delete button "x" + self.renderCommentDeleteBtn(host, renderer, del_btn, comment_idx); + } + + 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 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; + + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + + // Background + _ = c.SDL_SetRenderDrawColor(renderer, 40, 44, 52, @intFromFloat(230.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(total_h), + }); + + // Left accent bar + _ = c.SDL_SetRenderDrawColor(renderer, 40, 167, 69, @intFromFloat(200.0 * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + 1), + .y = @floatFromInt(y_pos), + .w = @floatFromInt(dpi.scale(3, host.ui_scale)), + .h = @floatFromInt(total_h), + }); + + // Input area + const input_x = rect.x + scaled_padding + dpi.scale(6, host.ui_scale); + const input_y = y_pos + dpi.scale(4, host.ui_scale); + const input_w = rect.w - scaled_padding * 2 - dpi.scale(12, host.ui_scale); + + _ = c.SDL_SetRenderDrawColor(renderer, 30, 33, 40, @intFromFloat(255.0 * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(input_x), + .y = @floatFromInt(input_y), + .w = @floatFromInt(input_w), + .h = @floatFromInt(input_h), + }); + + // Input border + const accent = host.theme.accent; + _ = c.SDL_SetRenderDrawColor(renderer, accent.r, accent.g, accent.b, @intFromFloat(100.0 * alpha)); + _ = c.SDL_RenderRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(input_x), + .y = @floatFromInt(input_y), + .w = @floatFromInt(input_w), + .h = @floatFromInt(input_h), + }); + + // Render input text + const font_cache = assets.font_cache orelse return; + const scaled_font_size = dpi.scale(font_size, host.ui_scale); + const fonts = font_cache.get(scaled_font_size) catch return; + + if (ed.input_buf.items.len > 0) { + const display_text = if (ed.input_buf.items.len > 500) ed.input_buf.items[0..500] else ed.input_buf.items; + const tex = self.makeTextTexture(renderer, fonts.regular, display_text, host.theme.foreground) catch return; + defer c.SDL_DestroyTexture(tex.tex); + _ = c.SDL_SetTextureAlphaMod(tex.tex, @intFromFloat(255.0 * alpha)); + const text_y = input_y + dpi.scale(4, host.ui_scale); + const max_text_w = input_w - dpi.scale(8, host.ui_scale); + const render_w = @min(tex.w, max_text_w); + _ = c.SDL_RenderTexture(renderer, tex.tex, null, &c.SDL_FRect{ + .x = @floatFromInt(input_x + dpi.scale(4, host.ui_scale)), + .y = @floatFromInt(text_y), + .w = @floatFromInt(render_w), + .h = @floatFromInt(tex.h), + }); + } + + // Blinking cursor + const blink_ms = host.now_ms - ed.cursor_blink_start_ms; + const show_cursor = @mod(@divFloor(blink_ms, 500), 2) == 0; + if (show_cursor) { + var cursor_x = input_x + dpi.scale(4, host.ui_scale); + if (ed.input_buf.items.len > 0) { + const display_text = if (ed.input_buf.items.len > 500) ed.input_buf.items[0..500] else ed.input_buf.items; + if (self.makeTextTexture(renderer, fonts.regular, display_text, host.theme.foreground)) |tex| { + cursor_x += tex.w; + c.SDL_DestroyTexture(tex.tex); + } else |_| {} + } + const cursor_top = input_y + dpi.scale(4, host.ui_scale); + const cursor_h = scaled_font_size + dpi.scale(4, host.ui_scale); + const fg = host.theme.foreground; + _ = c.SDL_SetRenderDrawColor(renderer, fg.r, fg.g, fg.b, @intFromFloat(200.0 * alpha)); + _ = c.SDL_RenderLine( + renderer, + @floatFromInt(cursor_x), + @floatFromInt(cursor_top), + @floatFromInt(cursor_x), + @floatFromInt(cursor_top + cursor_h), + ); + } + + // Submit button + const btn_y = y_pos + total_h - btn_h - dpi.scale(6, host.ui_scale); + const submit_x = rect.x + rect.w - scaled_padding - btn_w * 2 - dpi.scale(12, host.ui_scale); + _ = c.SDL_SetRenderDrawColor(renderer, 40, 167, 69, @intFromFloat(220.0 * alpha)); + primitives.fillRoundedRect(renderer, .{ .x = submit_x, .y = btn_y, .w = btn_w, .h = btn_h }, dpi.scale(4, host.ui_scale)); + const submit_tex = self.makeTextTexture(renderer, fonts.regular, "Submit", .{ .r = 255, .g = 255, .b = 255, .a = 255 }) catch return; + defer c.SDL_DestroyTexture(submit_tex.tex); + _ = c.SDL_SetTextureAlphaMod(submit_tex.tex, @intFromFloat(255.0 * alpha)); + _ = c.SDL_RenderTexture(renderer, submit_tex.tex, null, &c.SDL_FRect{ + .x = @floatFromInt(submit_x + @divFloor(btn_w - submit_tex.w, 2)), + .y = @floatFromInt(btn_y + @divFloor(btn_h - submit_tex.h, 2)), + .w = @floatFromInt(submit_tex.w), + .h = @floatFromInt(submit_tex.h), + }); + + // Cancel button + const cancel_x = submit_x + btn_w + dpi.scale(6, host.ui_scale); + const fg = host.theme.foreground; + _ = c.SDL_SetRenderDrawColor(renderer, fg.r, fg.g, fg.b, @intFromFloat(40.0 * alpha)); + primitives.fillRoundedRect(renderer, .{ .x = cancel_x, .y = btn_y, .w = btn_w, .h = btn_h }, dpi.scale(4, host.ui_scale)); + _ = c.SDL_SetRenderDrawColor(renderer, fg.r, fg.g, fg.b, @intFromFloat(80.0 * alpha)); + primitives.drawRoundedBorder(renderer, .{ .x = cancel_x, .y = btn_y, .w = btn_w, .h = btn_h }, dpi.scale(4, host.ui_scale)); + const cancel_tex = self.makeTextTexture(renderer, fonts.regular, "Cancel", host.theme.foreground) catch return; + defer c.SDL_DestroyTexture(cancel_tex.tex); + _ = c.SDL_SetTextureAlphaMod(cancel_tex.tex, @intFromFloat(255.0 * alpha)); + _ = c.SDL_RenderTexture(renderer, cancel_tex.tex, null, &c.SDL_FRect{ + .x = @floatFromInt(cancel_x + @divFloor(btn_w - cancel_tex.w, 2)), + .y = @floatFromInt(btn_y + @divFloor(btn_h - cancel_tex.h, 2)), + .w = @floatFromInt(cancel_tex.w), + .h = @floatFromInt(cancel_tex.h), + }); + } + + fn renderEditingCommentAnimated(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets, rect: geom.Rect, y_pos: c_int, progress: f32, is_closing: bool) void { + const ed = self.editing orelse return; + const full_h = dpi.scale(editing_comment_height, host.ui_scale); + const anim_alpha = if (is_closing) 1.0 - progress else progress; + const anim_h_f: f32 = @as(f32, @floatFromInt(full_h)) * anim_alpha; + 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 input_h = dpi.scale(comment_input_height, host.ui_scale); + const alpha = self.render_alpha * anim_alpha; + + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + + // Background + _ = c.SDL_SetRenderDrawColor(renderer, 40, 44, 52, @intFromFloat(230.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(anim_h), + }); + + // Accent bar — grows with the height + _ = c.SDL_SetRenderDrawColor(renderer, 40, 167, 69, @intFromFloat(200.0 * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + 1), + .y = @floatFromInt(y_pos), + .w = @floatFromInt(dpi.scale(3, host.ui_scale)), + .h = @floatFromInt(anim_h), + }); + + // Clip interior rendering to the animated height, saving outer clip + var prev_clip: c.SDL_Rect = undefined; + _ = c.SDL_GetRenderClipRect(renderer, &prev_clip); + const anim_clip = c.SDL_Rect{ + .x = rect.x, + .y = y_pos, + .w = rect.w, + .h = anim_h, + }; + _ = c.SDL_SetRenderClipRect(renderer, &anim_clip); + + // Input area + const input_x = rect.x + scaled_padding + dpi.scale(6, host.ui_scale); + const input_y = y_pos + dpi.scale(4, host.ui_scale); + const input_w = rect.w - scaled_padding * 2 - dpi.scale(12, host.ui_scale); + + _ = c.SDL_SetRenderDrawColor(renderer, 30, 33, 40, @intFromFloat(255.0 * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(input_x), + .y = @floatFromInt(input_y), + .w = @floatFromInt(input_w), + .h = @floatFromInt(input_h), + }); + + const accent = host.theme.accent; + _ = c.SDL_SetRenderDrawColor(renderer, accent.r, accent.g, accent.b, @intFromFloat(100.0 * alpha)); + _ = c.SDL_RenderRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(input_x), + .y = @floatFromInt(input_y), + .w = @floatFromInt(input_w), + .h = @floatFromInt(input_h), + }); + + // Input text + const font_cache = assets.font_cache orelse { + _ = c.SDL_SetRenderClipRect(renderer, &prev_clip); + return; + }; + const scaled_font_size = dpi.scale(font_size, host.ui_scale); + const fonts = font_cache.get(scaled_font_size) catch { + _ = c.SDL_SetRenderClipRect(renderer, &prev_clip); + return; + }; + + if (ed.input_buf.items.len > 0) { + const display_text = if (ed.input_buf.items.len > 500) ed.input_buf.items[0..500] else ed.input_buf.items; + if (self.makeTextTexture(renderer, fonts.regular, display_text, host.theme.foreground)) |tex| { + defer c.SDL_DestroyTexture(tex.tex); + _ = c.SDL_SetTextureAlphaMod(tex.tex, @intFromFloat(255.0 * alpha)); + const text_y = input_y + dpi.scale(4, host.ui_scale); + const max_text_w = input_w - dpi.scale(8, host.ui_scale); + const render_w = @min(tex.w, max_text_w); + _ = c.SDL_RenderTexture(renderer, tex.tex, null, &c.SDL_FRect{ + .x = @floatFromInt(input_x + dpi.scale(4, host.ui_scale)), + .y = @floatFromInt(text_y), + .w = @floatFromInt(render_w), + .h = @floatFromInt(tex.h), + }); + } else |_| {} + } + + // Buttons + const btn_h = dpi.scale(comment_button_height, host.ui_scale); + const btn_w = dpi.scale(comment_button_width, host.ui_scale); + const btn_y = y_pos + full_h - btn_h - dpi.scale(6, host.ui_scale); + const submit_x = rect.x + rect.w - scaled_padding - btn_w * 2 - dpi.scale(12, host.ui_scale); + _ = c.SDL_SetRenderDrawColor(renderer, 40, 167, 69, @intFromFloat(220.0 * alpha)); + primitives.fillRoundedRect(renderer, .{ .x = submit_x, .y = btn_y, .w = btn_w, .h = btn_h }, dpi.scale(4, host.ui_scale)); + if (self.makeTextTexture(renderer, fonts.regular, "Submit", .{ .r = 255, .g = 255, .b = 255, .a = 255 })) |submit_tex| { + defer c.SDL_DestroyTexture(submit_tex.tex); + _ = c.SDL_SetTextureAlphaMod(submit_tex.tex, @intFromFloat(255.0 * alpha)); + _ = c.SDL_RenderTexture(renderer, submit_tex.tex, null, &c.SDL_FRect{ + .x = @floatFromInt(submit_x + @divFloor(btn_w - submit_tex.w, 2)), + .y = @floatFromInt(btn_y + @divFloor(btn_h - submit_tex.h, 2)), + .w = @floatFromInt(submit_tex.w), + .h = @floatFromInt(submit_tex.h), + }); + } else |_| {} + + const cancel_x = submit_x + btn_w + dpi.scale(6, host.ui_scale); + const fg = host.theme.foreground; + _ = c.SDL_SetRenderDrawColor(renderer, fg.r, fg.g, fg.b, @intFromFloat(40.0 * alpha)); + primitives.fillRoundedRect(renderer, .{ .x = cancel_x, .y = btn_y, .w = btn_w, .h = btn_h }, dpi.scale(4, host.ui_scale)); + _ = c.SDL_SetRenderDrawColor(renderer, fg.r, fg.g, fg.b, @intFromFloat(80.0 * alpha)); + primitives.drawRoundedBorder(renderer, .{ .x = cancel_x, .y = btn_y, .w = btn_w, .h = btn_h }, dpi.scale(4, host.ui_scale)); + if (self.makeTextTexture(renderer, fonts.regular, "Cancel", host.theme.foreground)) |cancel_tex| { + defer c.SDL_DestroyTexture(cancel_tex.tex); + _ = c.SDL_SetTextureAlphaMod(cancel_tex.tex, @intFromFloat(255.0 * alpha)); + _ = c.SDL_RenderTexture(renderer, cancel_tex.tex, null, &c.SDL_FRect{ + .x = @floatFromInt(cancel_x + @divFloor(btn_w - cancel_tex.w, 2)), + .y = @floatFromInt(btn_y + @divFloor(btn_h - cancel_tex.h, 2)), + .w = @floatFromInt(cancel_tex.w), + .h = @floatFromInt(cancel_tex.h), + }); + } else |_| {} + + _ = c.SDL_SetRenderClipRect(renderer, &prev_clip); + } + + fn renderSubmitMorph(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets, rect: geom.Rect, y_pos: c_int, comment: DiffComment, progress: f32) void { + const full_edit_h = dpi.scale(editing_comment_height, host.ui_scale); + const full_saved_h = dpi.scale(saved_comment_height, host.ui_scale); + const edit_h_f: f32 = @floatFromInt(full_edit_h); + const saved_h_f: f32 = @floatFromInt(full_saved_h); + 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 accent_w = dpi.scale(4, host.ui_scale); + const alpha = self.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); + const bg_g: u8 = @intFromFloat(44.0 + (140.0 - 44.0) * progress); + const bg_b: u8 = @intFromFloat(52.0 + (40.0 - 52.0) * progress); + // Interpolate background alpha: 230 → 50 + const bg_a: u8 = @intFromFloat((230.0 + (50.0 - 230.0) * progress) * alpha); + + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + _ = c.SDL_SetRenderDrawColor(renderer, bg_r, bg_g, bg_b, bg_a); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + 1), + .y = @floatFromInt(y_pos), + .w = @floatFromInt(rect.w - 2), + .h = @floatFromInt(morph_h), + }); + + // Interpolate accent bar: green (40,167,69) → amber (220,170,50) + const ac_r: u8 = @intFromFloat(40.0 + (220.0 - 40.0) * progress); + const ac_g: u8 = @intFromFloat(167.0 + (170.0 - 167.0) * progress); + const ac_b: u8 = @intFromFloat(69.0 + (50.0 - 69.0) * progress); + const ac_a: u8 = @intFromFloat((200.0 + (220.0 - 200.0) * progress) * alpha); + _ = c.SDL_SetRenderDrawColor(renderer, ac_r, ac_g, ac_b, ac_a); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + 1), + .y = @floatFromInt(y_pos), + .w = @floatFromInt(accent_w), + .h = @floatFromInt(morph_h), + }); + + // Clip to morph area, saving outer clip + var morph_prev_clip: c.SDL_Rect = undefined; + _ = c.SDL_GetRenderClipRect(renderer, &morph_prev_clip); + const morph_clip = c.SDL_Rect{ + .x = rect.x, + .y = y_pos, + .w = rect.w, + .h = morph_h, + }; + _ = c.SDL_SetRenderClipRect(renderer, &morph_clip); + + const font_cache = assets.font_cache orelse { + _ = c.SDL_SetRenderClipRect(renderer, &morph_prev_clip); + return; + }; + const scaled_font_size = dpi.scale(font_size, host.ui_scale); + const fonts = font_cache.get(scaled_font_size) catch { + _ = c.SDL_SetRenderClipRect(renderer, &morph_prev_clip); + return; + }; + + // Crossfade text: fade out editing text (from submit_anim_text), fade in saved comment text + const text_x = rect.x + scaled_padding + accent_w + dpi.scale(4, host.ui_scale); + const max_w = rect.w - scaled_padding * 2 - accent_w - dpi.scale(8, host.ui_scale); + + // Fading out: input text (first half fades faster) + const fade_out = @max(0.0, 1.0 - progress * 2.0); + if (fade_out > 0.01) { + if (self.submit_anim_text) |anim_text| { + const display_text = if (anim_text.len > 500) anim_text[0..500] else anim_text; + if (self.makeTextTexture(renderer, fonts.regular, display_text, host.theme.foreground)) |tex| { + defer c.SDL_DestroyTexture(tex.tex); + _ = c.SDL_SetTextureAlphaMod(tex.tex, @intFromFloat(255.0 * alpha * fade_out)); + const text_y = y_pos + @divFloor(morph_h - tex.h, 2); + const render_w = @min(tex.w, max_w); + _ = c.SDL_RenderTexture(renderer, tex.tex, null, &c.SDL_FRect{ + .x = @floatFromInt(text_x), + .y = @floatFromInt(text_y), + .w = @floatFromInt(render_w), + .h = @floatFromInt(tex.h), + }); + } else |_| {} + } + } + + // Fading in: saved comment text (second half fades in) + const fade_in = @max(0.0, progress * 2.0 - 1.0); + if (fade_in > 0.01) { + const comment_color = c.SDL_Color{ .r = 230, .g = 200, .b = 110, .a = 255 }; + const display_text = if (comment.text.len > 200) comment.text[0..200] else comment.text; + if (self.makeTextTexture(renderer, fonts.regular, display_text, comment_color)) |tex| { + defer c.SDL_DestroyTexture(tex.tex); + _ = c.SDL_SetTextureAlphaMod(tex.tex, @intFromFloat(255.0 * alpha * fade_in)); + const text_y = y_pos + @divFloor(morph_h - tex.h, 2); + const render_w = @min(tex.w, max_w); + _ = c.SDL_RenderTexture(renderer, tex.tex, null, &c.SDL_FRect{ + .x = @floatFromInt(text_x), + .y = @floatFromInt(text_y), + .w = @floatFromInt(render_w), + .h = @floatFromInt(tex.h), + }); + } else |_| {} + } + + _ = c.SDL_SetRenderClipRect(renderer, &morph_prev_clip); + } + + 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 accent_w = dpi.scale(4, host.ui_scale); + const alpha = self.render_alpha; + const del_btn = commentDeleteBtnRect(host, rect, y_pos); + + // Glow effect: pulse peaks at the start and fades out + const glow = (1.0 - glow_progress) * (1.0 - glow_progress); + + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + + // Background with glow: amber tint alpha pulses from ~120 down to 50 + const bg_base_alpha: f32 = 50.0; + const bg_glow_alpha: f32 = bg_base_alpha + 80.0 * glow; + _ = c.SDL_SetRenderDrawColor(renderer, 180, 140, 40, @intFromFloat(bg_glow_alpha * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + 1), + .y = @floatFromInt(y_pos), + .w = @floatFromInt(rect.w - 2), + .h = @floatFromInt(comment_h), + }); + + // Accent bar with glow: brighter amber + const accent_base_alpha: f32 = 220.0; + const accent_glow_alpha: f32 = @min(255.0, accent_base_alpha + 35.0 * glow); + _ = c.SDL_SetRenderDrawColor(renderer, 220, 170, 50, @intFromFloat(accent_glow_alpha * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + 1), + .y = @floatFromInt(y_pos), + .w = @floatFromInt(accent_w), + .h = @floatFromInt(comment_h), + }); + + // Outer glow: subtle warm highlight that fades + if (glow > 0.05) { + const outer_glow_alpha: u8 = @intFromFloat(30.0 * glow * alpha); + _ = c.SDL_SetRenderDrawColor(renderer, 220, 180, 60, outer_glow_alpha); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x), + .y = @floatFromInt(y_pos - 1), + .w = @floatFromInt(rect.w), + .h = @floatFromInt(comment_h + 2), + }); + } + + const font_cache = assets.font_cache orelse return; + const scaled_font_size = dpi.scale(font_size, host.ui_scale); + const fonts = font_cache.get(scaled_font_size) catch return; + + const comment_color = c.SDL_Color{ .r = 230, .g = 200, .b = 110, .a = 255 }; + const display_text = if (comment.text.len > 200) comment.text[0..200] else comment.text; + const tex = self.makeTextTexture(renderer, fonts.regular, display_text, comment_color) catch return; + defer c.SDL_DestroyTexture(tex.tex); + _ = c.SDL_SetTextureAlphaMod(tex.tex, @intFromFloat(255.0 * alpha)); + const text_x = rect.x + scaled_padding + accent_w + dpi.scale(4, host.ui_scale); + const text_y = y_pos + @divFloor(comment_h - tex.h, 2); + const del_space = dpi.scale(comment_delete_btn_size + 16, host.ui_scale); + const max_w = rect.w - scaled_padding * 2 - accent_w - dpi.scale(8, host.ui_scale) - del_space; + const render_w = @min(tex.w, max_w); + _ = c.SDL_RenderTexture(renderer, tex.tex, null, &c.SDL_FRect{ + .x = @floatFromInt(text_x), + .y = @floatFromInt(text_y), + .w = @floatFromInt(render_w), + .h = @floatFromInt(tex.h), + }); + + // Delete button "x" + self.renderCommentDeleteBtn(host, renderer, del_btn, comment_idx); + } + + 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 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; + + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + + // Hover background + if (is_hovered) { + _ = c.SDL_SetRenderDrawColor(renderer, 180, 140, 40, @intFromFloat(60.0 * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(btn.x), + .y = @floatFromInt(btn.y), + .w = @floatFromInt(btn.w), + .h = @floatFromInt(btn.h), + }); + } + + // Draw "x" cross + const fg = host.theme.foreground; + _ = c.SDL_SetRenderDrawColor(renderer, fg.r, fg.g, fg.b, @intFromFloat(btn_alpha * alpha)); + + const inset = @divFloor(btn.w, 4); + const x1: f32 = @floatFromInt(btn.x + inset); + const y1: f32 = @floatFromInt(btn.y + inset); + const x2: f32 = @floatFromInt(btn.x + btn.w - inset); + const y2: f32 = @floatFromInt(btn.y + btn.h - inset); + + _ = c.SDL_RenderLine(renderer, x1, y1, x2, y2); + _ = c.SDL_RenderLine(renderer, x2, y1, x1, y2); + + // Thicker lines on hover + if (is_hovered) { + _ = c.SDL_RenderLine(renderer, x1 + 1.0, y1, x2 + 1.0, y2); + _ = c.SDL_RenderLine(renderer, x2 + 1.0, y1, x1 + 1.0, y2); + } + } + + fn renderSendButton(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets, overlay_rect: geom.Rect) void { + if (!self.hasUnsentComments()) return; + + const btn = sendButtonRect(host, overlay_rect); + const alpha = self.render_alpha; + const radius = dpi.scale(4, host.ui_scale); + + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + const green_alpha: u8 = @intFromFloat(if (self.send_button_hovered) 255.0 * alpha else 200.0 * alpha); + _ = c.SDL_SetRenderDrawColor(renderer, 40, 167, 69, green_alpha); + primitives.fillRoundedRect(renderer, btn, radius); + + const font_cache = assets.font_cache orelse return; + const scaled_font_size = dpi.scale(font_size, host.ui_scale); + const fonts = font_cache.get(scaled_font_size) catch return; + const tex = self.makeTextTexture(renderer, fonts.regular, "Send to agent", .{ .r = 255, .g = 255, .b = 255, .a = 255 }) catch return; + 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(btn.x + @divFloor(btn.w - tex.w, 2)), + .y = @floatFromInt(btn.y + @divFloor(btn.h - tex.h, 2)), + .w = @floatFromInt(tex.w), + .h = @floatFromInt(tex.h), + }); + } + + fn renderAgentDropdown(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets, overlay_rect: geom.Rect) void { + if (!self.show_agent_dropdown) return; + + const dd = agentDropdownRect(host, overlay_rect); + const item_h = dpi.scale(agent_dropdown_item_height, host.ui_scale); + const alpha = self.render_alpha; + const radius = dpi.scale(4, host.ui_scale); + + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + const bg = host.theme.background; + _ = c.SDL_SetRenderDrawColor(renderer, bg.r, bg.g, bg.b, @intFromFloat(250.0 * alpha)); + primitives.fillRoundedRect(renderer, dd, radius); + + const accent = host.theme.accent; + _ = c.SDL_SetRenderDrawColor(renderer, accent.r, accent.g, accent.b, @intFromFloat(150.0 * alpha)); + primitives.drawRoundedBorder(renderer, dd, radius); + + const font_cache = assets.font_cache orelse return; + const scaled_font_size = dpi.scale(font_size, host.ui_scale); + const fonts = font_cache.get(scaled_font_size) catch return; + + for (dropdown_items, 0..) |name, i| { + const item_y = dd.y + @as(c_int, @intCast(i)) * item_h; + + if (self.agent_dropdown_hovered) |h| { + if (h == i) { + const sel = host.theme.selection; + _ = c.SDL_SetRenderDrawColor(renderer, sel.r, sel.g, sel.b, @intFromFloat(60.0 * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(dd.x + 1), + .y = @floatFromInt(item_y), + .w = @floatFromInt(dd.w - 2), + .h = @floatFromInt(item_h), + }); + } + } + + const tex = self.makeTextTexture(renderer, fonts.regular, name, host.theme.foreground) catch |err| { + log.warn("failed to render dropdown text: {}", .{err}); + continue; + }; + 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)), + .y = @floatFromInt(item_y + @divFloor(item_h - tex.h, 2)), + .w = @floatFromInt(tex.w), + .h = @floatFromInt(tex.h), + }); + } + } + fn destroy(self: *DiffOverlayComponent, renderer: *c.SDL_Renderer) void { _ = renderer; self.clearContent(); self.display_rows.deinit(self.allocator); + if (self.arrow_cursor) |cur| c.SDL_DestroyCursor(cur); + if (self.pointer_cursor) |cur| c.SDL_DestroyCursor(cur); + if (self.text_cursor) |cur| c.SDL_DestroyCursor(cur); self.allocator.destroy(self); } diff --git a/src/ui/components/escape_hold.zig b/src/ui/components/escape_hold.zig index 9e9ae56..3fb1c10 100644 --- a/src/ui/components/escape_hold.zig +++ b/src/ui/components/escape_hold.zig @@ -48,25 +48,22 @@ pub const EscapeHoldComponent = struct { fn handleEvent(self_ptr: *anyopaque, host: *const types.UiHost, event: *const c.SDL_Event, _: *types.UiActionQueue) bool { const self: *EscapeHoldComponent = @ptrCast(@alignCast(self_ptr)); - if (!input.canHandleEscapePress(host.view_mode)) return false; - switch (event.type) { c.SDL_EVENT_KEY_DOWN => { - if (event.key.key == c.SDLK_ESCAPE and !event.key.repeat) { - self.gesture.start(host.now_ms, esc_hold_total_ms); - self.first_frame.markTransition(); - return true; + if (event.key.key == c.SDLK_ESCAPE) { + if (!input.canHandleEscapePress(host.view_mode)) return false; + if (!event.key.repeat) { + self.gesture.start(host.now_ms, esc_hold_total_ms); + self.first_frame.markTransition(); + } + return self.gesture.active; } }, c.SDL_EVENT_KEY_UP => { - if (event.key.key == c.SDLK_ESCAPE) { - if (self.gesture.isComplete(host.now_ms) or self.gesture.consumed) { - self.gesture.stop(); - return true; - } - // quick tap: let main handle ESC to terminal + if (event.key.key == c.SDLK_ESCAPE and self.gesture.active) { + const was_consumed = self.gesture.consumed; self.gesture.stop(); - return false; + return was_consumed; } }, else => {}, @@ -86,11 +83,7 @@ pub const EscapeHoldComponent = struct { } if (self.gesture.consumed) { - const elapsed_since_complete = host.now_ms - (self.gesture.start_ms + esc_hold_total_ms); - const flash_duration_ms: i64 = 200; - if (elapsed_since_complete >= flash_duration_ms) { - self.gesture.stop(); - } + self.first_frame.markTransition(); } } diff --git a/src/ui/types.zig b/src/ui/types.zig index 501812a..47b2504 100644 --- a/src/ui/types.zig +++ b/src/ui/types.zig @@ -10,6 +10,7 @@ pub const SessionUiInfo = struct { spawned: bool, cwd_path: ?[]const u8 = null, cwd_basename: ?[]const u8 = null, + session_status: app_state.SessionStatus = .idle, }; pub const UiHost = struct { @@ -54,6 +55,7 @@ pub const UiAction = union(enum) { DespawnSession: usize, ToggleMetrics: void, ToggleDiffOverlay: void, + SendDiffComments: SendDiffCommentsAction, }; pub const SwitchWorktreeAction = struct { @@ -77,6 +79,14 @@ pub const ChangeDirAction = struct { path: []const u8, }; +pub const SendDiffCommentsAction = struct { + session: usize, + /// Heap-allocated; ownership transfers to runtime, which frees after send. + comments_text: []const u8, + /// Heap-allocated; ownership transfers to runtime, which frees after send. + agent_command: ?[]const u8, +}; + pub const UiAssets = struct { ui_font: ?*font_mod.Font = null, font_cache: ?*font_cache.FontCache = null,