diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 048e429d..e0f3d2d6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -391,7 +391,9 @@ Status notifications -> SessionStatus updated (idle -> awaiting_approval) Story notifications -> StoryOverlay opens with file content | v -Renderer draws attention border / story overlay +Renderer draws terminal content and borders; the cwd-bar UI component reasserts +the focused border after its label so attention tiles keep the same focus-border +z-order as ordinary tiles. ``` ### External MCP Spawn Path @@ -493,13 +495,13 @@ Rotate: rename active file to architect-.log and continue in new | `session/pty_reader.zig` | Background thread that `poll(2)`s spawned sessions' PTY master fds and drains readable ones into per-session SPSC ring buffers; registry with retire handshake so teardown can safely close fds | `PtyReader`, `PtyOutputBuffer`, `start()`, `register()`, `retire()` | std (poll, thread) | | `wake_pipe.zig` | Non-blocking self-pipe used to wake blocking background-thread polls for PTY registry changes and shutdown | `WakePipe`, `poll_error_backoff_ns` | `posix_util`, std (poll) | | `session/*` (shell, pty, vt_stream, cwd) | Shell spawning, PTY abstraction, VT parsing, working directory detection | `spawn()`, `Pty`, `VtStream.processBytes()`, `getCwd()` | std (posix), ghostty-vt | -| `render/renderer.zig` | Scene rendering: terminals, borders, animations, terminal scrollbar painting, first-launch onboarding hint | `render()`, `RenderCache`, per-session texture management | `font`, `font_cache`, `gfx/*`, `anim/easing`, `app/app_state`, `ui/components/scrollbar`, `c` | +| `render/renderer.zig` | Scene rendering: terminals, borders, animations, terminal scrollbar painting, first-launch onboarding hint. Exposes the shared focused-border primitive used by the cwd-bar UI component to preserve border z-order after the bar label | `render()`, `RenderCache`, `renderFocusBorder()`, per-session texture management | `font`, `font_cache`, `gfx/*`, `anim/easing`, `app/app_state`, `ui/components/scrollbar`, `c` | | `font.zig` + `font_cache.zig` | Font rendering, HarfBuzz shaping, glyph LRU cache, shared font cache | `Font`, `openFont()`, `renderGlyph()`, `FontCache`, `getOrCreate()` | `font_paths`, `c` (SDL3_ttf) | | `gfx/*` (box_drawing, primitives) | Procedural box-drawing characters (U+2500-U+257F), rounded/thick border helpers, bezier arrow rendering | `renderBoxDrawing()`, `drawRoundedRect()`, `drawThickBorder()`, `fillRoundedRect()`, `renderBezierArrow()` | `c` | | `env.zig`, `clock.zig`, `proc.zig` | Process-environment access, I/O-aware timestamps/sleep, and I/O-aware process execution helpers | `get()`, `now*()`, `sleepNanos()`, `run()`, `spawnDetached()` | std | | `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/types.zig` | Shared UI type definitions, including per-session attention state needed by grid chrome | `UiHost`, `UiAction`, `UiActionQueue`, `UiAssets`, `SessionUiInfo` | `app/app_state`, `colors`, `font`, `geom` | | `ui/session_view_state.zig` | Per-session UI interaction state (selection, scroll, hover, agent status, scrollbar fade/drag state) | `SessionViewState` (selection, scroll offset, hover, status, terminal scrollbar state) | `app/app_state` (for `SessionStatus` enum), `ui/components/scrollbar` | | `ui/first_frame_guard.zig` | Idle throttle bypass for visible state transitions | `FirstFrameGuard`, `markTransition()`, `markDrawn()`, `wantsFrame()` | (none) | | `ui/text_edit.zig` | Shared text-field model used by every input (worktree name, recent-folder/reader/story search, diff comments). `TextInput` owns the buffer, caret blink phase and select-all flag, and handles Backspace (⌘ clears / ⌥ word / plain one UTF-8 codepoint), ⌘A, ⌘C and ⌘V. Append-only by design: the caret sits at the end and the only selection is "everything". Components keep layout and rendering, reading `caretVisible()`/`select_all` for the visuals. | `TextInput`, `handleKey()`, `insert()`, `caretVisible()`, `touch()`, `DeleteScope`, `scopeFromMods()`, `backspace()`, `isSingleLineChar()`, `name_separators`, `path_separators`, `prose_separators` | `c` (keycodes, clipboard) | diff --git a/src/app/ui_host.zig b/src/app/ui_host.zig index 41329764..7f560b8c 100644 --- a/src/app/ui_host.zig +++ b/src/app/ui_host.zig @@ -71,6 +71,7 @@ pub fn makeUiHost( buffer[i] = .{ .dead = session.dead, .spawned = session.spawned, + .attention = if (i < views.len) views[i].attention else false, .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/render/renderer.zig b/src/render/renderer.zig index 3647cf0f..6483d5d7 100644 --- a/src/render/renderer.zig +++ b/src/render/renderer.zig @@ -1011,6 +1011,56 @@ test "grid active screen rendering follows cursor row" { try std.testing.expectEqual(@as(usize, 0), activeScreenRowOffset(50, 0, 49, true, false)); } +/// Return the geometry of a focused border, including the attention inset. +fn focusBorderRect(rect: Rect, has_attention: bool, border_thickness: c_int) ?Rect { + const inset: c_int = if (has_attention) border_thickness else 0; + var focus_rect = rect; + focus_rect.x += inset; + focus_rect.y += inset; + focus_rect.w -= inset * 2; + focus_rect.h -= inset * 2; + if (focus_rect.w <= 0 or focus_rect.h <= 0) return null; + return focus_rect; +} + +/// Draw the focused grid border. Attention borders inset the focus border, so +/// the cwd bar redraws this after its label to preserve the intended z-order. +pub fn renderFocusBorder( + renderer: *c.SDL_Renderer, + rect: Rect, + has_attention: bool, + theme: *const colors.Theme, + ui_scale: f32, + draw_fill: bool, +) void { + const focus_blue = theme.palette[12]; + const border_thickness: c_int = dpi.scale(attention_thickness, ui_scale); + const focus_rect = focusBorderRect(rect, has_attention, border_thickness) orelse return; + + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + if (draw_fill and !has_attention) { + _ = c.SDL_SetRenderDrawColor(renderer, focus_blue.r, focus_blue.g, focus_blue.b, 38); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(focus_rect.x), + .y = @floatFromInt(focus_rect.y), + .w = @floatFromInt(focus_rect.w), + .h = @floatFromInt(focus_rect.h), + }); + } + primitives.drawThickBorder(renderer, focus_rect, border_thickness, dpi.scale(6, ui_scale), focus_blue); +} + +test "focused border is inset only for attention tiles" { + const rect = Rect{ .x = 10, .y = 20, .w = 100, .h = 80 }; + + try std.testing.expectEqual(rect, focusBorderRect(rect, false, 6).?); + try std.testing.expectEqual( + Rect{ .x = 16, .y = 26, .w = 88, .h = 68 }, + focusBorderRect(rect, true, 6).?, + ); + try std.testing.expectEqual(@as(?Rect, null), focusBorderRect(Rect{ .x = 0, .y = 0, .w = 12, .h = 12 }, true, 6)); +} + fn renderSessionOverlays( renderer: *c.SDL_Renderer, session: *SessionState, @@ -1038,28 +1088,7 @@ fn renderSessionOverlays( primitives.drawThickBorder(renderer, rect, border_thickness, border_radius, base_border); } - if (is_focused) { - const focus_blue = theme.palette[12]; - const inset: c_int = if (has_attention) border_thickness else 0; - var focus_rect = rect; - focus_rect.x += inset; - focus_rect.y += inset; - focus_rect.w -= inset * 2; - focus_rect.h -= inset * 2; - if (focus_rect.w > 0 and focus_rect.h > 0) { - _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); - if (!has_attention) { - _ = c.SDL_SetRenderDrawColor(renderer, focus_blue.r, focus_blue.g, focus_blue.b, 38); - _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ - .x = @floatFromInt(focus_rect.x), - .y = @floatFromInt(focus_rect.y), - .w = @floatFromInt(focus_rect.w), - .h = @floatFromInt(focus_rect.h), - }); - } - primitives.drawThickBorder(renderer, focus_rect, border_thickness, border_radius, focus_blue); - } - } + if (is_focused) renderFocusBorder(renderer, rect, has_attention, theme, ui_scale, true); } if (has_attention) { diff --git a/src/ui/components/cwd_bar.zig b/src/ui/components/cwd_bar.zig index b11d89ab..e858445e 100644 --- a/src/ui/components/cwd_bar.zig +++ b/src/ui/components/cwd_bar.zig @@ -233,6 +233,9 @@ pub const CwdBarComponent = struct { .h = host.cell_h, }; self.renderCwdBar(renderer, i, info, cell_rect, host, cache, i); + if (i == host.focused_session) { + renderer_mod.renderFocusBorder(renderer, cell_rect, info.attention, host.theme, host.ui_scale, false); + } } } diff --git a/src/ui/types.zig b/src/ui/types.zig index 6010d733..8a3b130d 100644 --- a/src/ui/types.zig +++ b/src/ui/types.zig @@ -8,6 +8,7 @@ const geom = @import("../geom.zig"); pub const SessionUiInfo = struct { dead: bool, spawned: bool, + attention: bool = false, cwd_path: ?[]const u8 = null, cwd_basename: ?[]const u8 = null, session_status: app_state.SessionStatus = .idle,