From cec7ef1a0ded8b1b009a196900dd25e0dc67e895 Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Thu, 3 Sep 2026 17:12:51 +0200 Subject: [PATCH] fix(ui): keep focused border above cwd bar Issue: Fix #259, where a focused terminal with an active notification highlight could draw its blue border underneath the cwd bar label. Solution: Share the focused-border rendering between the scene renderer and cwd-bar component while preserving the existing attention inset and normal focus fill behavior. Propagate the exact attention state to the UI host so the cwd bar can reassert the border after its label, and cover the inset geometry with a regression test. --- docs/ARCHITECTURE.md | 8 ++-- src/app/ui_host.zig | 1 + src/render/renderer.zig | 73 ++++++++++++++++++++++++----------- src/ui/components/cwd_bar.zig | 3 ++ src/ui/types.zig | 1 + 5 files changed, 61 insertions(+), 25 deletions(-) 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,