diff --git a/README.md b/README.md index 476b13ea..8027561c 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,8 @@ Architect solves this with a grid view that keeps all your agents visible, with - **Dynamic grid** — starts with a single terminal in full view; press ⌘N to add a terminal after the current one, and closing terminals compacts the grid forward - **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; new worktrees are created outside the repo tree (configurable via `[worktree]` in `config.toml`) with automatic post-create initialization -- **Recent folders** (⌘O) — quickly `cd` into recently visited directories with instant search filtering (start typing to narrow the list — the overlay takes the keyboard the instant it starts opening, so nothing you type is lost to the animation), substring highlighting, arrow key navigation, and ⌘1–⌘9 quick selection -- **Pull requests** (⌘P) — when the focused terminal is inside a GitHub-cloned repo, the ⌘P pill loads and shows the current branch's PR number (e.g. `#123`), scaled to fit the pill; expand it to list open PRs (via `gh pr list`) with search filtering and check out a branch with `gh pr checkout` +- **Recent folders** (⌘O) — quickly `cd` into recently visited directories with instant search filtering (start typing to narrow the list — the overlay takes the keyboard the instant it starts opening, so nothing you type is lost to the animation), substring highlighting, arrow key navigation, and ⌘1–⌘9 quick selection; its pill is hidden while the focused terminal runs a foreground process +- **Pull requests** (⌘P) — when the focused terminal is inside a GitHub-cloned repo, the ⌘P pill loads and shows the current branch's PR number (e.g. `#123`), scaled to fit the pill; expand it to list open PRs (via `gh pr list`) with search filtering and check out a branch with `gh pr checkout`; the pill is hidden while the focused terminal runs a foreground process - **Diff review comments** — click diff lines in the ⌘D overlay to leave inline comments with multiline wrapping, then send them all to a running agent (or start one) with the "Send to agent" button - **Selection-to-agent context** — release after selecting terminal text to reveal a narrow robot button anchored just outside the selection; the button follows the selection while scrolling; choose Claude, Codex, or Gemini in a centered modal with highlighted dropdown navigation, review the fully wrapped context in a scrollable field, enter wrapped multiline instructions, cancel if needed, or launch a new agent in the selected terminal's working directory with the selection submitted as its initial prompt without embedding large contexts in the shell command line - **Story viewer** — run `architect story ` to open a scrollable overlay that renders PR story files with prose text and diff-colored code blocks @@ -43,6 +43,7 @@ Architect solves this with a grid view that keeps all your agents visible, with ### Terminal Essentials - Smooth animated transitions for grid expansion, contraction, and reflow (cells and borders move/resize together) +- Context-aware shortcut pills stay right-aligned with fixed spacing, compact when a pill is unavailable, and animate into their new positions; newly available pills enter after the existing row reflows to avoid overlapping hit targets and remain non-interactive until they reach a clear slot; the worktree, recent-folder, and pull-request pills are hidden while the focused terminal runs a foreground process and any open picker closes immediately - Wakeable idle input handling keeps typing responsive after short idle periods instead of waiting on a fixed sleep window - Keyboard navigation: ⌘+Return to expand, ⌘1–⌘0 to switch grid slots, ⌘Arrow to move focus in grid view (plays a brief wave animation on the destination terminal), ⌘N to add, ⌘W to close a terminal (restarts if it's the only terminal), ⌘T for worktrees, ⌘O for recent folders, ⌘D for repo-wide git diff (staged + unstaged + untracked), ⌘R for reader mode, ⌘/ for shortcuts; quit with ⌘Q or the window close button - Every text field (recent-folder and reader/story search, worktree name, diff comments, selection-agent instructions) behaves like a macOS text field: a blinking caret, Backspace deletes one character, ⌥Backspace the previous word, ⌘Backspace the whole field, ⌘A selects everything (the next keystroke or paste replaces it), and ⌘C/⌘V copy and paste. The selection-agent field accepts multiline instructions with ⇧↩; plain ↩ launches, while Escape or Cancel dismisses it. Pasting multi-line text into a one-line field drops the newlines, and text longer than the field fades out at its edge instead of spilling past it. Emoji are scaled to the surrounding line height wherever they appear (search fields, comments, reader mode, stories, selection-agent instructions) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5c83f366..048e429d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -345,6 +345,35 @@ On Enter / click: runtime.zig dispatch: send `gh pr checkout \n` to the focused shell ``` +### Top-Right Pill Layout + +`PillGroupComponent` owns the top-right shortcut row for help, recent folders, +worktrees, and pull requests. Each overlay reports whether its pill is +currently available; the group packs available pills from right to left with a +fixed gap and applies the resulting x-coordinate to the shared +`ExpandingOverlay` geometry. When availability or the window geometry changes, +the existing pills interpolate to their new positions with the shared cubic +easing function. Newly available pills remain staged outside the row while +existing pills reflow, then enter in a second eased phase once the occupied +slots are clear. +If another availability or geometry change arrives during an entrance, the +group keeps the entrant's current interpolated position instead of restarting +it offscreen. +The group also requests frames for the short layout animation, so the +transition is visible under idle throttling; newly entering pill hit targets are +disabled until they reach their clear slots. The worktree, recent-folder, and +pull-request pills are unavailable while the focused terminal has a foreground +process, because their actions send commands to that shell. Their event +handlers reject input at the same boundary, before the scheduled update +collapses an already-open picker, so foreground-process input cannot be +consumed by stale UI. A busy or otherwise unavailable transition closes the +recent-folder, PR, and worktree pickers immediately, resetting their +transient state instead of exposing a partial collapse. It also clears any +pending worktree removal confirmation before the modal is hidden. +The group consumes pointer events over an entering pill so an overlapping +interactive pill underneath cannot receive the click. Pending Escape releases +remain consumed even if a busy transition closes the picker before key-up. + ### External Notification Path ``` @@ -482,7 +511,8 @@ Rotate: rename active file to architect-.log and continue in new | `ui/components/modal_frame.zig` | Shared chrome for centered modal dialogs: full-window darkening scrim + rounded filled/bordered panel, and the Escape/⌘W dismiss-key check. Used by `confirm_dialog.zig` and `selection_agent_overlay.zig` so their scrim/panel rendering and dismissal keys can't drift independently | `renderScrimAndPanel()`, `isDismissKey()` | `gfx/primitives`, `geom`, `c` | | `ui/components/dropdown_menu.zig` | Reusable vertical list menu: owns open/hover/keyboard-nav state and the committed `selected` index, renders its own cached item-label textures, and reports a `.selected`/`.closed` event on click or Enter/Escape so the owning component reacts (persist the pick, or act on it immediately) instead of tracking hit-testing and highlight rendering itself. Used by `selection_agent_overlay.zig`'s agent selector and `diff_overlay.zig`'s "Send to agent" menu | `DropdownMenu`, `openMenu()`, `close()`, `handleKey()`, `handleClick()`, `handleMotion()`, `itemAt()`, `itemRect()`, `render()` | `gfx/primitives`, `font_cache`, `ui/text_render`, `geom`, `c` | | `ui/components/selection_agent_overlay.zig` | Selection action form with highlighted agent selector, multiline prompt field, fully wrapped and scrollable selected-context preview, viewport-bounded context textures, cached UI text, and launch action containing the selected terminal context | `SelectionAgentOverlayComponent`, `open()`, `formatAgentPrompt()` | `ui/text_edit`, `ui/text_render`, `ui/first_frame_guard`, `ui/components/modal_frame`, `ui/components/dropdown_menu`, `ui/components/scrollbar`, `gfx/primitives`, `font_cache`, `geom`, `c` | -| `ui/components/*` | Individual overlay and widget implementations conforming to `UiComponent` vtable. Includes: help overlay, worktree picker, recent folders picker (with instant search filtering), PR dropdown, diff viewer (with inline review comments), story viewer (PR story file visualization with rich markdown, anchor badges, bezier arrows, clickable links, and Cmd+F search — uses shared markdown parser/renderer pipeline and shared search utilities), reader mode overlay (uses shared search utilities), fullscreen overlay helper (shared animation/scroll/close logic embedded by story, diff, and reader overlays), reusable aqua-style scrollbar widget, session interaction, toast, quit confirm, quit-blocking overlay, restart buttons, escape hold indicator, metrics overlay, global shortcuts, pill group, cwd bar (its clipped parent-path marquee runs only in a focused Grid view), expanding overlay helper (badge-to-panel animation; `State.isOpenOrOpening()` is the canonical "this overlay owns the keyboard and is visible" test, so input is never dropped during the expand), button, confirm dialog (shares its scrim/panel chrome and dismiss-key check with the selection-agent overlay via `ui/components/modal_frame`), marquee label, hotkey indicator, flowing line, hold gesture detector. | Each component implements the `VTable` interface; overlays toggle via keyboard shortcuts or external commands and emit `UiAction` values. | `ui/component`, `ui/types`, `anim/easing`, `font`, `metrics`, `url_matcher`, `ui/session_view_state` | +| `ui/components/*` | Individual overlay and widget implementations conforming to `UiComponent` vtable. Includes: help overlay, worktree picker, recent folders picker (with instant search filtering), PR dropdown, diff viewer (with inline review comments), story viewer (PR story file visualization with rich markdown, anchor badges, bezier arrows, clickable links, and Cmd+F search — uses shared markdown parser/renderer pipeline and shared search utilities), reader mode overlay (uses shared search utilities), fullscreen overlay helper (shared animation/scroll/close logic embedded by story, diff, and reader overlays), reusable aqua-style scrollbar widget, session interaction, toast, quit confirm, quit-blocking overlay, restart buttons, escape hold indicator, metrics overlay, global shortcuts, dynamic pill group, cwd bar (its clipped parent-path marquee runs only in a focused Grid view), expanding overlay helper (badge-to-panel animation; `State.isOpenOrOpening()` is the canonical "this overlay owns the keyboard and is visible" test, so input is never dropped during the expand), button, confirm dialog (shares its scrim/panel chrome and dismiss-key check with the selection-agent overlay via `ui/components/modal_frame`), marquee label, hotkey indicator, flowing line, hold gesture detector. | Each component implements the `VTable` interface; overlays toggle via keyboard shortcuts or external commands and emit `UiAction` values. The pill group additionally owns right-aligned placement and eased membership transitions for the top-right shortcut row. | `ui/component`, `ui/types`, `anim/easing`, `font`, `metrics`, `url_matcher`, `ui/session_view_state` | +| `ui/components/pill_group.zig` | Dynamic top-right shortcut-pill layout and overlay coordination. Packs currently available pills with fixed spacing, animates existing pills when membership or window geometry changes, stages newly available pills during reflow, eases their entrance afterward, and synchronizes positions with overlay rendering and hit-testing. | `PillGroupComponent`, `PillLayout` | `ui/components/expanding_overlay`, `ui/components/help_overlay`, `ui/components/recent_folders_overlay`, `ui/components/worktree_overlay`, `ui/components/pr_dropdown`, `ui/first_frame_guard`, `anim/easing`, `dpi` | | `ui/components/pr_dropdown.zig` | GitHub pull request picker orchestration: owns focused-repository state, input/lifecycle handling, repository-keyed worker jobs, stale-result filtering, branch badges, and checkout actions | `PRDropdownComponent` | `ui/components/pr_dropdown_model`, `ui/components/pr_dropdown_repo`, `ui/components/pr_dropdown_fetch`, `ui/components/pr_dropdown_view`, `ui/components/expanding_overlay`, `ui/components/search_utils`, `ui/text_edit`, `ui/types`, `geom`, `c` | | `ui/components/pr_dropdown_model.zig` | Pull request and fetch result types plus pure repository/result matching predicates | `PullRequest`, `FetchStatus`, `FetchResult`, `freeFetchResult()`, `prNumberForBranch()` | std | | `ui/components/pr_dropdown_repo.zig` | Synchronous repository discovery: `.git` and worktree config/HEAD resolution and GitHub origin detection | `findRepoRoot()`, `detectGithubOrigin()`, `readCurrentBranch()`, `originUrlIsGithub()` | std | diff --git a/src/main.zig b/src/main.zig index 1251c32a..9fb0fa02 100644 --- a/src/main.zig +++ b/src/main.zig @@ -72,6 +72,7 @@ test { _ = @import("ui/components/markdown_parser.zig"); _ = @import("ui/components/markdown_renderer.zig"); _ = @import("ui/components/metrics_overlay.zig"); + _ = @import("ui/components/pill_group.zig"); _ = @import("ui/components/quit_blocking_overlay.zig"); _ = @import("ui/components/recent_folders_overlay.zig"); _ = @import("ui/components/pr_dropdown.zig"); diff --git a/src/ui/components/expanding_overlay.zig b/src/ui/components/expanding_overlay.zig index 49ac186d..4d258205 100644 --- a/src/ui/components/expanding_overlay.zig +++ b/src/ui/components/expanding_overlay.zig @@ -9,6 +9,7 @@ pub const ExpandingOverlay = struct { start_size: c_int, target_size: c_int, slot: usize, + layout_x: ?c_int = null, margin: c_int, small_size: c_int, large_size: c_int, @@ -46,6 +47,12 @@ pub const ExpandingOverlay = struct { self.content_height = height; } + /// Sets the left edge of the collapsed pill. Expanded rectangles keep the + /// same right edge, so the panel grows to the left from this position. + pub fn setLayoutX(self: *ExpandingOverlay, x: c_int) void { + self.layout_x = x; + } + pub fn startExpanding(self: *ExpandingOverlay, now: i64) void { self.state = .Expanding; self.start_time = now; @@ -64,6 +71,12 @@ pub const ExpandingOverlay = struct { self.target_size = self.small_size; } + pub fn closeImmediately(self: *ExpandingOverlay) void { + self.state = .Closed; + self.start_size = self.small_size; + self.target_size = self.small_size; + } + pub fn isAnimating(self: *const ExpandingOverlay) bool { return self.state == .Expanding or self.state == .Collapsing; } @@ -91,8 +104,13 @@ pub const ExpandingOverlay = struct { _ = window_height; const margin = dpi.scale(self.margin, ui_scale); const size = self.currentSize(now, ui_scale); + const small = dpi.scale(self.small_size, ui_scale); + const large = dpi.scale(self.large_size, ui_scale); const spacing = dpi.scale(self.small_size + self.margin, ui_scale); - const x = window_width - margin - size - @as(c_int, @intCast(self.slot)) * spacing; + const x = if (self.layout_x) |collapsed_x| + collapsed_x - (size - small) + else + window_width - margin - size - @as(c_int, @intCast(self.slot)) * spacing; const y = margin; const height = blk: { @@ -100,8 +118,6 @@ pub const ExpandingOverlay = struct { break :blk size; } - const small = dpi.scale(self.small_size, ui_scale); - const large = dpi.scale(self.large_size, ui_scale); // content_height is already scaled by the overlay component const target_height = self.content_height; @@ -150,3 +166,23 @@ test "collapsing from the open state starts at full size" { try std.testing.expectEqual(@as(c_int, 400), overlay.currentSize(1000, 1.0)); try std.testing.expectEqual(@as(c_int, 40), overlay.currentSize(1200, 1.0)); } + +test "closeImmediately resets the overlay to its collapsed state" { + var overlay = ExpandingOverlay.init(0, 20, 40, 400, 200); + overlay.startExpanding(0); + overlay.closeImmediately(); + + try std.testing.expectEqual(ExpandingOverlay.State.Closed, overlay.state); + try std.testing.expectEqual(@as(c_int, 40), overlay.currentSize(100, 1.0)); + try std.testing.expect(!overlay.isAnimating()); +} + +test "layout position keeps the expanded overlay right edge anchored" { + var overlay = ExpandingOverlay.init(0, 20, 40, 400, 200); + overlay.setLayoutX(600); + overlay.startExpanding(0); + + const rect = overlay.rect(100, 800, 800, 1.0); + try std.testing.expectEqual(@as(c_int, 420), rect.x); + try std.testing.expectEqual(@as(c_int, 640), rect.x + rect.w); +} diff --git a/src/ui/components/pill_group.zig b/src/ui/components/pill_group.zig index 02b3b5bf..1da63f69 100644 --- a/src/ui/components/pill_group.zig +++ b/src/ui/components/pill_group.zig @@ -1,5 +1,6 @@ const std = @import("std"); const c = @import("../../c.zig"); +const geom = @import("../../geom.zig"); const types = @import("../types.zig"); const UiComponent = @import("../component.zig").UiComponent; const HelpOverlayComponent = @import("help_overlay.zig").HelpOverlayComponent; @@ -7,7 +8,179 @@ const WorktreeOverlayComponent = @import("worktree_overlay.zig").WorktreeOverlay const RecentFoldersOverlayComponent = @import("recent_folders_overlay.zig").RecentFoldersOverlayComponent; const PRDropdownComponent = @import("pr_dropdown.zig").PRDropdownComponent; +const dpi = @import("../../dpi.zig"); +const easing = @import("../../anim/easing.zig"); const ExpandingOverlay = @import("expanding_overlay.zig").ExpandingOverlay; +const FirstFrameGuard = @import("../first_frame_guard.zig").FirstFrameGuard; + +const pill_count: usize = 4; +const pill_size: c_int = 40; +const pill_margin: c_int = 20; +const pill_spacing: c_int = 20; +const pill_animation_duration_ms: i64 = 200; + +const PillKind = enum(usize) { + pull_request, + worktree, + recent_folders, + help, +}; + +const layout_order = [_]PillKind{ .help, .recent_folders, .worktree, .pull_request }; + +const AnimationPhase = enum { + idle, + reflow, + entrance, +}; + +const PillLayout = struct { + current_x: [pill_count]c_int = [_]c_int{0} ** pill_count, + start_x: [pill_count]c_int = [_]c_int{0} ** pill_count, + target_x: [pill_count]c_int = [_]c_int{0} ** pill_count, + visible: [pill_count]bool = [_]bool{false} ** pill_count, + entering: [pill_count]bool = [_]bool{false} ** pill_count, + start_time: i64 = 0, + initialized: bool = false, + animating: bool = false, + phase: AnimationPhase = .idle, + + fn update(self: *PillLayout, now_ms: i64, window_w: c_int, ui_scale: f32, visible: [pill_count]bool) bool { + const animation_completed = if (self.initialized) self.advance(now_ms) else false; + + const targets = self.calculateTargets(window_w, ui_scale, visible); + if (!self.initialized) { + self.current_x = targets; + self.start_x = targets; + self.target_x = targets; + self.visible = visible; + self.initialized = true; + return false; + } + + const membership_changed = !std.mem.eql(bool, self.visible[0..], visible[0..]); + var target_changed = false; + for (0..pill_count) |idx| { + if (visible[idx] and self.target_x[idx] != targets[idx]) { + target_changed = true; + break; + } + } + + if (!membership_changed and !target_changed) return animation_completed; + + self.start_x = self.current_x; + self.target_x = targets; + const entrance_x = window_w + dpi.scale(pill_size, ui_scale); + for (0..pill_count) |idx| { + const was_entering = self.entering[idx]; + const stage = visible[idx] and (was_entering or !self.visible[idx]); + self.entering[idx] = stage; + if (stage and !was_entering) { + self.start_x[idx] = entrance_x; + self.current_x[idx] = entrance_x; + } + } + self.visible = visible; + self.start_time = now_ms; + self.animating = true; + self.phase = .reflow; + return membership_changed; + } + + fn calculateTargets(self: *const PillLayout, window_w: c_int, ui_scale: f32, visible: [pill_count]bool) [pill_count]c_int { + var targets = self.current_x; + const margin = dpi.scale(pill_margin, ui_scale); + const size = dpi.scale(pill_size, ui_scale); + const spacing = dpi.scale(pill_spacing, ui_scale); + + var right_edge = window_w - margin; + + for (layout_order) |pill| { + const idx: usize = @intFromEnum(pill); + if (!visible[idx]) continue; + targets[idx] = right_edge - size; + right_edge = targets[idx] - spacing; + } + + return targets; + } + + fn advance(self: *PillLayout, now_ms: i64) bool { + if (!self.animating) return false; + + const elapsed = now_ms - self.start_time; + switch (self.phase) { + .idle => return false, + .reflow => { + if (elapsed >= pill_animation_duration_ms) { + for (0..pill_count) |idx| { + if (!self.entering[idx]) self.current_x[idx] = self.target_x[idx]; + } + + if (self.hasEnteringPills()) { + self.phase = .entrance; + self.start_time += pill_animation_duration_ms; + return self.advance(now_ms); + } + + return self.finishAnimation(); + } + + self.applyEasedPositions(now_ms, false); + }, + .entrance => { + if (elapsed >= pill_animation_duration_ms) return self.finishAnimation(); + self.applyEasedPositions(now_ms, true); + }, + } + return false; + } + + fn hasEnteringPills(self: *const PillLayout) bool { + for (self.entering) |entering| { + if (entering) return true; + } + return false; + } + + fn applyEasedPositions(self: *PillLayout, now_ms: i64, entering_only: bool) void { + const elapsed = now_ms - self.start_time; + const clamped_elapsed: i64 = @max(@as(i64, 0), elapsed); + const progress: f32 = @min( + @as(f32, 1.0), + @as(f32, @floatFromInt(clamped_elapsed)) / @as(f32, @floatFromInt(pill_animation_duration_ms)), + ); + const eased = easing.easeInOutCubic(progress); + + for (0..pill_count) |idx| { + if (self.entering[idx] != entering_only) continue; + const distance = self.target_x[idx] - self.start_x[idx]; + self.current_x[idx] = self.start_x[idx] + @as(c_int, @intFromFloat(@as(f32, @floatFromInt(distance)) * eased)); + } + } + + fn finishAnimation(self: *PillLayout) bool { + self.current_x = self.target_x; + self.entering = [_]bool{false} ** pill_count; + self.animating = false; + self.phase = .idle; + return true; + } + + fn currentX(self: *const PillLayout, pill: PillKind) c_int { + return self.current_x[@intFromEnum(pill)]; + } + + fn pillInteractive(self: *const PillLayout, pill: PillKind) bool { + const idx = @intFromEnum(pill); + return self.visible[idx] and !self.entering[idx]; + } + + fn isEntering(self: *const PillLayout, pill: PillKind) bool { + return self.entering[@intFromEnum(pill)]; + } +}; pub const PillGroupComponent = struct { allocator: std.mem.Allocator, @@ -15,10 +188,15 @@ pub const PillGroupComponent = struct { recent_folders: *RecentFoldersOverlayComponent, worktree: *WorktreeOverlayComponent, pr_dropdown: *PRDropdownComponent, + layout: PillLayout = .{}, + first_frame: FirstFrameGuard = .{}, last_help_state: ExpandingOverlay.State = .Closed, last_recent_folders_state: ExpandingOverlay.State = .Closed, last_worktree_state: ExpandingOverlay.State = .Closed, last_pr_state: ExpandingOverlay.State = .Closed, + pointer_blocked: bool = false, + + pub const component_z_index: i32 = 1001; pub fn create( allocator: std.mem.Allocator, @@ -39,7 +217,7 @@ pub const PillGroupComponent = struct { return UiComponent{ .ptr = comp, .vtable = &vtable, - .z_index = 999, + .z_index = component_z_index, }; } @@ -48,11 +226,56 @@ pub const PillGroupComponent = struct { self.allocator.destroy(self); } - fn handleEvent(_: *anyopaque, _: *const types.UiHost, _: *const c.SDL_Event, _: *types.UiActionQueue) bool { + fn handleEvent(self_ptr: *anyopaque, host: *const types.UiHost, event: *const c.SDL_Event, _: *types.UiActionQueue) bool { + const self: *PillGroupComponent = @ptrCast(@alignCast(self_ptr)); + + switch (event.type) { + c.SDL_EVENT_MOUSE_BUTTON_DOWN => { + const mouse_x: c_int = @intFromFloat(event.button.x); + const mouse_y: c_int = @intFromFloat(event.button.y); + if (self.containsEnteringPill(host, mouse_x, mouse_y)) { + self.pointer_blocked = true; + return true; + } + }, + c.SDL_EVENT_MOUSE_BUTTON_UP => { + if (self.pointer_blocked) { + self.pointer_blocked = false; + return true; + } + }, + c.SDL_EVENT_MOUSE_MOTION => { + if (self.pointer_blocked) return true; + const mouse_x: c_int = @intFromFloat(event.motion.x); + const mouse_y: c_int = @intFromFloat(event.motion.y); + return self.containsEnteringPill(host, mouse_x, mouse_y); + }, + else => {}, + } + return false; } - fn hitTest(_: *anyopaque, _: *const types.UiHost, _: c_int, _: c_int) bool { + fn hitTest(self_ptr: *anyopaque, host: *const types.UiHost, x: c_int, y: c_int) bool { + const self: *PillGroupComponent = @ptrCast(@alignCast(self_ptr)); + return self.containsEnteringPill(host, x, y); + } + + fn containsEnteringPill(self: *const PillGroupComponent, host: *const types.UiHost, x: c_int, y: c_int) bool { + const scaled_pill_size = dpi.scale(pill_size, host.ui_scale); + const scaled_pill_margin = dpi.scale(pill_margin, host.ui_scale); + + for (layout_order) |pill| { + if (!self.layout.isEntering(pill)) continue; + const rect = geom.Rect{ + .x = self.layout.currentX(pill), + .y = scaled_pill_margin, + .w = scaled_pill_size, + .h = scaled_pill_size, + }; + if (geom.containsPoint(rect, x, y)) return true; + } + return false; } @@ -118,13 +341,42 @@ pub const PillGroupComponent = struct { } } + const visible = [pill_count]bool{ + self.pr_dropdown.pillVisible(host), + self.worktree.pillVisible(host), + self.recent_folders.pillVisible(host), + true, + }; + if (self.layout.update(host.now_ms, host.window_w, host.ui_scale, visible)) { + self.first_frame.markTransition(); + } + self.applyLayout(); + self.last_help_state = help_state; self.last_recent_folders_state = recent_folders_state; self.last_worktree_state = worktree_state; self.last_pr_state = pr_state; } - fn render(_: *anyopaque, _: *const types.UiHost, _: *c.SDL_Renderer, _: *types.UiAssets) void {} + fn applyLayout(self: *PillGroupComponent) void { + self.pr_dropdown.overlay.setLayoutX(self.layout.currentX(.pull_request)); + self.pr_dropdown.setPillInteractive(self.layout.pillInteractive(.pull_request)); + self.worktree.overlay.setLayoutX(self.layout.currentX(.worktree)); + self.worktree.setPillInteractive(self.layout.pillInteractive(.worktree)); + self.recent_folders.overlay.setLayoutX(self.layout.currentX(.recent_folders)); + self.recent_folders.setPillInteractive(self.layout.pillInteractive(.recent_folders)); + self.help.overlay.setLayoutX(self.layout.currentX(.help)); + } + + fn render(self_ptr: *anyopaque, _: *const types.UiHost, _: *c.SDL_Renderer, _: *types.UiAssets) void { + const self: *PillGroupComponent = @ptrCast(@alignCast(self_ptr)); + self.first_frame.markDrawn(); + } + + fn wantsFrame(self_ptr: *anyopaque, _: *const types.UiHost) bool { + const self: *PillGroupComponent = @ptrCast(@alignCast(self_ptr)); + return self.layout.animating or self.first_frame.wantsFrame(); + } fn deinitComp(self_ptr: *anyopaque, renderer: *c.SDL_Renderer) void { deinit(self_ptr, renderer); @@ -136,5 +388,176 @@ pub const PillGroupComponent = struct { .update = update, .render = render, .deinit = deinitComp, + .wantsFrame = wantsFrame, }; }; + +test "pill layout packs visible pills against the right edge" { + var layout: PillLayout = .{}; + const all_visible = [pill_count]bool{ true, true, true, true }; + _ = layout.update(0, 800, 1.0, all_visible); + + try std.testing.expectEqual(@as(c_int, 560), layout.currentX(.pull_request)); + try std.testing.expectEqual(@as(c_int, 620), layout.currentX(.worktree)); + try std.testing.expectEqual(@as(c_int, 680), layout.currentX(.recent_folders)); + try std.testing.expectEqual(@as(c_int, 740), layout.currentX(.help)); + + const without_worktree = [pill_count]bool{ true, false, true, true }; + try std.testing.expect(layout.update(1, 800, 1.0, without_worktree)); + try std.testing.expectEqual(@as(c_int, 620), layout.target_x[@intFromEnum(PillKind.pull_request)]); + try std.testing.expectEqual(@as(c_int, 680), layout.target_x[@intFromEnum(PillKind.recent_folders)]); +} + +test "pill layout eases remaining pills into new positions" { + var layout: PillLayout = .{}; + const all_visible = [pill_count]bool{ true, true, true, true }; + _ = layout.update(0, 800, 1.0, all_visible); + + const without_worktree = [pill_count]bool{ true, false, true, true }; + _ = layout.update(0, 800, 1.0, without_worktree); + _ = layout.update(100, 800, 1.0, without_worktree); + try std.testing.expectEqual(@as(c_int, 590), layout.currentX(.pull_request)); + try std.testing.expect(layout.animating); + + _ = layout.update(200, 800, 1.0, without_worktree); + try std.testing.expectEqual(@as(c_int, 620), layout.currentX(.pull_request)); + try std.testing.expect(!layout.animating); +} + +test "pill layout reports when easing reaches its final position" { + var layout: PillLayout = .{}; + const all_visible = [pill_count]bool{ true, true, true, true }; + _ = layout.update(0, 800, 1.0, all_visible); + + const without_worktree = [pill_count]bool{ true, false, true, true }; + _ = layout.update(0, 800, 1.0, without_worktree); + try std.testing.expect(layout.animating); + + try std.testing.expect(layout.update(pill_animation_duration_ms, 800, 1.0, without_worktree)); + try std.testing.expectEqual(@as(c_int, 620), layout.currentX(.pull_request)); + try std.testing.expect(!layout.animating); +} + +test "pill layout eases a newly available pill into the compact row" { + var layout: PillLayout = .{}; + const only_help = [pill_count]bool{ false, false, false, true }; + _ = layout.update(0, 800, 1.0, only_help); + + const pull_request_and_help = [pill_count]bool{ true, false, false, true }; + _ = layout.update(0, 800, 1.0, pull_request_and_help); + _ = layout.update(100, 800, 1.0, pull_request_and_help); + + try std.testing.expectEqual(@as(c_int, 840), layout.currentX(.pull_request)); + try std.testing.expectEqual(@as(c_int, 740), layout.currentX(.help)); + + _ = layout.update(200, 800, 1.0, pull_request_and_help); + try std.testing.expectEqual(@as(c_int, 840), layout.currentX(.pull_request)); + + _ = layout.update(300, 800, 1.0, pull_request_and_help); + try std.testing.expectEqual(@as(c_int, 760), layout.currentX(.pull_request)); + + _ = layout.update(400, 800, 1.0, pull_request_and_help); + try std.testing.expectEqual(@as(c_int, 680), layout.currentX(.pull_request)); +} + +test "pill layout stages a newly available pill outside the occupied row" { + var layout: PillLayout = .{}; + const pull_request_and_help = [pill_count]bool{ true, false, false, true }; + _ = layout.update(0, 800, 1.0, pull_request_and_help); + + const with_recent_folders = [pill_count]bool{ true, false, true, true }; + _ = layout.update(0, 800, 1.0, with_recent_folders); + _ = layout.update(100, 800, 1.0, with_recent_folders); + + try std.testing.expectEqual(@as(c_int, 840), layout.currentX(.recent_folders)); + try std.testing.expectEqual(@as(c_int, 650), layout.currentX(.pull_request)); + + _ = layout.update(200, 800, 1.0, with_recent_folders); + try std.testing.expectEqual(@as(c_int, 840), layout.currentX(.recent_folders)); + try std.testing.expectEqual(@as(c_int, 620), layout.currentX(.pull_request)); + + _ = layout.update(300, 800, 1.0, with_recent_folders); + try std.testing.expectEqual(@as(c_int, 760), layout.currentX(.recent_folders)); + + _ = layout.update(400, 800, 1.0, with_recent_folders); + try std.testing.expectEqual(@as(c_int, 680), layout.currentX(.recent_folders)); + try std.testing.expectEqual(@as(c_int, 620), layout.currentX(.pull_request)); +} + +test "pill layout disables hit targets while positions are moving" { + var layout: PillLayout = .{}; + const all_visible = [pill_count]bool{ true, true, true, true }; + _ = layout.update(0, 800, 1.0, all_visible); + + const without_worktree = [pill_count]bool{ true, false, true, true }; + _ = layout.update(0, 800, 1.0, without_worktree); + try std.testing.expect(layout.pillInteractive(.pull_request)); + try std.testing.expect(layout.pillInteractive(.recent_folders)); + try std.testing.expect(layout.pillInteractive(.help)); + + _ = layout.update(pill_animation_duration_ms, 800, 1.0, without_worktree); + try std.testing.expect(layout.pillInteractive(.pull_request)); + try std.testing.expect(layout.pillInteractive(.recent_folders)); + try std.testing.expect(layout.pillInteractive(.help)); + + const with_worktree = [pill_count]bool{ true, true, true, true }; + _ = layout.update(pill_animation_duration_ms, 800, 1.0, with_worktree); + try std.testing.expect(!layout.pillInteractive(.worktree)); + try std.testing.expect(layout.pillInteractive(.pull_request)); + try std.testing.expect(layout.pillInteractive(.recent_folders)); + + _ = layout.update(pill_animation_duration_ms + 50, 800, 1.0, with_worktree); + try std.testing.expect(!layout.pillInteractive(.worktree)); + _ = layout.update(pill_animation_duration_ms * 3, 800, 1.0, with_worktree); + try std.testing.expect(layout.pillInteractive(.worktree)); +} + +test "pill layout preserves an entrance position across a second layout change" { + var layout: PillLayout = .{}; + const only_help = [pill_count]bool{ false, false, false, true }; + _ = layout.update(0, 800, 1.0, only_help); + + const with_pull_request = [pill_count]bool{ true, false, false, true }; + _ = layout.update(0, 800, 1.0, with_pull_request); + _ = layout.update(250, 800, 1.0, with_pull_request); + const position_before_second_change = layout.currentX(.pull_request); + try std.testing.expect(position_before_second_change < 840); + + const with_recent_folders = [pill_count]bool{ true, false, true, true }; + _ = layout.update(250, 800, 1.0, with_recent_folders); + try std.testing.expectEqual(position_before_second_change, layout.currentX(.pull_request)); + try std.testing.expect(!layout.pillInteractive(.pull_request)); +} + +test "pill group blocks clicks on entering pills" { + var group = PillGroupComponent{ + .allocator = std.testing.allocator, + .help = undefined, + .recent_folders = undefined, + .worktree = undefined, + .pr_dropdown = undefined, + }; + group.layout.initialized = true; + group.layout.visible = [pill_count]bool{ true, false, false, true }; + group.layout.entering = [pill_count]bool{ true, false, false, false }; + group.layout.current_x = [pill_count]c_int{ 760, 0, 0, 740 }; + + var host: types.UiHost = undefined; + host.ui_scale = 1.0; + var actions = types.UiActionQueue.init(std.testing.allocator); + defer actions.deinit(); + + var event: c.SDL_Event = undefined; + event.type = c.SDL_EVENT_MOUSE_BUTTON_DOWN; + event.button.x = 770; + event.button.y = 30; + try std.testing.expect(PillGroupComponent.handleEvent(&group, &host, &event, &actions)); + try std.testing.expect(group.pointer_blocked); + + group.layout.entering = [_]bool{false} ** pill_count; + event.type = c.SDL_EVENT_MOUSE_BUTTON_UP; + event.button.x = 500; + event.button.y = 500; + try std.testing.expect(PillGroupComponent.handleEvent(&group, &host, &event, &actions)); + try std.testing.expect(!group.pointer_blocked); +} diff --git a/src/ui/components/pr_dropdown.zig b/src/ui/components/pr_dropdown.zig index 8519f6fa..bf320eb3 100644 --- a/src/ui/components/pr_dropdown.zig +++ b/src/ui/components/pr_dropdown.zig @@ -1,5 +1,6 @@ const std = @import("std"); const c = @import("../../c.zig"); +const colors = @import("../../colors.zig"); const geom = @import("../../geom.zig"); const primitives = @import("../../gfx/primitives.zig"); const types = @import("../types.zig"); @@ -83,6 +84,7 @@ pub const PRDropdownComponent = struct { cache: ?*view.Cache = null, escape_pressed: bool = false, focused_busy: bool = false, + pill_interactive: bool = true, flow_animation_start_ms: i64 = 0, pub const button_size_small: c_int = 40; @@ -150,6 +152,8 @@ pub const PRDropdownComponent = struct { } } + if (!self.pillVisible(host) or !self.pill_interactive) return false; + switch (event.type) { c.SDL_EVENT_KEY_DOWN => { const key = event.key.key; @@ -159,7 +163,6 @@ pub const PRDropdownComponent = struct { // Cmd+P toggles overlay (only meaningful inside a GitHub repo) if (has_gui and !has_blocking_mod and key == c.SDLK_P) { - if (!self.is_github_repo) return false; if (self.overlay.state == .Open) { self.closeOverlay(host.now_ms); } else { @@ -232,7 +235,6 @@ pub const PRDropdownComponent = struct { } }, c.SDL_EVENT_MOUSE_BUTTON_DOWN => { - if (!self.is_github_repo) return false; const mouse_x: c_int = @intFromFloat(event.button.x); const mouse_y: c_int = @intFromFloat(event.button.y); const rect = self.overlay.rect(host.now_ms, host.window_w, host.window_h, host.ui_scale); @@ -281,11 +283,23 @@ pub const PRDropdownComponent = struct { fn hitTest(self_ptr: *anyopaque, host: *const types.UiHost, x: c_int, y: c_int) bool { const self: *PRDropdownComponent = @ptrCast(@alignCast(self_ptr)); - if (!self.is_github_repo) return false; + if (!self.pillVisible(host) or !self.pill_interactive) return false; const rect = self.overlay.rect(host.now_ms, host.window_w, host.window_h, host.ui_scale); return geom.containsPoint(rect, x, y); } + pub fn shouldShowPill(is_github_repo: bool, focused_busy: bool) bool { + return is_github_repo and !focused_busy; + } + + pub fn pillVisible(self: *const PRDropdownComponent, host: *const types.UiHost) bool { + return shouldShowPill(self.is_github_repo, host.focused_has_foreground_process); + } + + pub fn setPillInteractive(self: *PRDropdownComponent, interactive: bool) void { + self.pill_interactive = interactive; + } + fn update(self_ptr: *anyopaque, host: *const types.UiHost, _: *types.UiActionQueue) void { const self: *PRDropdownComponent = @ptrCast(@alignCast(self_ptr)); @@ -308,7 +322,7 @@ pub const PRDropdownComponent = struct { // Close overlay if no longer applicable. if (!self.is_github_repo and self.overlay.state != .Closed) { - self.closeOverlay(host.now_ms); + self.closeOverlayImmediately(); } // Block while focused shell is busy with a foreground process. @@ -317,11 +331,13 @@ pub const PRDropdownComponent = struct { self.focused_busy = busy; if (busy) { self.destroyCache(); - self.hovered_entry = null; - self.escape_pressed = false; } } + if (!self.pillVisible(host) and self.overlay.state != .Closed) { + self.closeOverlayImmediately(); + } + // Pick up completed background fetch results, including results that // belong to a repository that is no longer focused. self.collectFetchResults(); @@ -347,7 +363,7 @@ pub const PRDropdownComponent = struct { fn render(self_ptr: *anyopaque, ui_host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets) void { const self: *PRDropdownComponent = @ptrCast(@alignCast(self_ptr)); self.first_frame.markDrawn(); - if (!self.is_github_repo) return; + if (!self.pillVisible(ui_host)) return; const rect = self.overlay.rect(ui_host.now_ms, ui_host.window_w, ui_host.window_h, ui_host.ui_scale); const radius: c_int = 8; @@ -420,6 +436,14 @@ pub const PRDropdownComponent = struct { self.refilter(); } + fn closeOverlayImmediately(self: *PRDropdownComponent) void { + self.overlay.closeImmediately(); + self.search_query.clear(); + self.refilter(); + self.hovered_entry = null; + self.flow_animation_start_ms = 0; + } + fn fetchIsStale(self: *PRDropdownComponent, now_ms: i64) bool { if (self.fetch_status != .ok) return true; if (self.last_fetched_repo == null) return true; @@ -714,3 +738,121 @@ pub const PRDropdownComponent = struct { test "PR dropdown renders below sibling pill overlays" { try std.testing.expect(PRDropdownComponent.component_z_index < 1000); } + +test "pull request pill is hidden while the focused shell is busy" { + try std.testing.expect(PRDropdownComponent.shouldShowPill(true, false)); + try std.testing.expect(!PRDropdownComponent.shouldShowPill(true, true)); + try std.testing.expect(!PRDropdownComponent.shouldShowPill(false, false)); +} + +const testing = std.testing; + +fn testTheme() colors.Theme { + const base = c.SDL_Color{ .r = 0, .g = 0, .b = 0, .a = 255 }; + return .{ + .background = base, + .foreground = base, + .selection = base, + .accent = base, + .palette = [_]c.SDL_Color{base} ** 16, + }; +} + +fn testHost(now_ms: i64, focused_busy: bool, theme: *const colors.Theme) types.UiHost { + return .{ + .now_ms = now_ms, + .window_w = 1200, + .window_h = 800, + .window_focused = true, + .ui_scale = 1.0, + .grid_cols = 2, + .grid_rows = 2, + .cell_w = 8, + .cell_h = 16, + .term_cols = 80, + .term_rows = 24, + .view_mode = .Grid, + .focused_session = 0, + .focused_cwd = null, + .focused_has_foreground_process = focused_busy, + .sessions = &.{}, + .theme = theme, + }; +} + +fn keyEvent(key: c.SDL_Keycode, mod: c.SDL_Keymod) c.SDL_Event { + var event: c.SDL_Event = undefined; + event.type = c.SDL_EVENT_KEY_DOWN; + event.key.key = key; + event.key.mod = mod; + return event; +} + +fn keyUpEvent(key: c.SDL_Keycode) c.SDL_Event { + var event: c.SDL_Event = undefined; + event.type = c.SDL_EVENT_KEY_UP; + event.key.key = key; + return event; +} + +fn textEvent(text: [*c]const u8) c.SDL_Event { + var event: c.SDL_Event = undefined; + event.type = c.SDL_EVENT_TEXT_INPUT; + event.text.text = text; + return event; +} + +test "open PR picker does not consume input while the focused shell is busy" { + var component: PRDropdownComponent = .{ .allocator = testing.allocator, .io = undefined }; + defer component.search_query.deinit(testing.allocator); + component.is_github_repo = true; + component.overlay.state = .Open; + try component.search_query.buf.appendSlice(testing.allocator, "before"); + + var actions = types.UiActionQueue.init(testing.allocator); + defer actions.deinit(); + var theme = testTheme(); + const host = testHost(0, true, &theme); + + var typed = textEvent("x"); + try testing.expect(!PRDropdownComponent.handleEvent(&component, &host, &typed, &actions)); + try testing.expectEqualStrings("before", component.search_query.text()); + + var erased = keyEvent(c.SDLK_BACKSPACE, 0); + try testing.expect(!PRDropdownComponent.handleEvent(&component, &host, &erased, &actions)); + try testing.expectEqualStrings("before", component.search_query.text()); +} + +test "busy update immediately closes the PR picker" { + var component: PRDropdownComponent = .{ .allocator = testing.allocator, .io = undefined }; + defer component.search_query.deinit(testing.allocator); + + component.is_github_repo = true; + component.overlay.state = .Open; + component.escape_pressed = true; + component.hovered_entry = 0; + component.flow_animation_start_ms = 123; + try component.search_query.buf.appendSlice(testing.allocator, "before"); + + var actions = types.UiActionQueue.init(testing.allocator); + defer actions.deinit(); + var theme = testTheme(); + var host = testHost(200, true, &theme); + PRDropdownComponent.update(&component, &host, &actions); + + try testing.expectEqual(ExpandingOverlay.State.Closed, component.overlay.state); + try testing.expect(!component.overlay.isAnimating()); + try testing.expectEqualStrings("", component.search_query.text()); + try testing.expectEqual(@as(?usize, null), component.hovered_entry); + try testing.expect(component.escape_pressed); + try testing.expectEqual(@as(i64, 0), component.flow_animation_start_ms); + + var escape_release = keyUpEvent(c.SDLK_ESCAPE); + try testing.expect(PRDropdownComponent.handleEvent(&component, &host, &escape_release, &actions)); + try testing.expect(!component.escape_pressed); + + host.now_ms = 250; + host.focused_has_foreground_process = false; + PRDropdownComponent.update(&component, &host, &actions); + try testing.expectEqual(ExpandingOverlay.State.Closed, component.overlay.state); +} diff --git a/src/ui/components/recent_folders_overlay.zig b/src/ui/components/recent_folders_overlay.zig index f916c72e..42240caa 100644 --- a/src/ui/components/recent_folders_overlay.zig +++ b/src/ui/components/recent_folders_overlay.zig @@ -31,6 +31,7 @@ pub const RecentFoldersOverlayComponent = struct { hovered_entry: ?usize = null, escape_pressed: bool = false, focused_busy: bool = false, + pill_interactive: bool = true, cache: ?*Cache = null, flow_animation_start_ms: i64 = 0, @@ -160,6 +161,8 @@ pub const RecentFoldersOverlayComponent = struct { } } + if (!self.pillVisible(host) or !self.pill_interactive) return false; + switch (event.type) { c.SDL_EVENT_MOUSE_BUTTON_DOWN => { const mouse_x: c_int = @intFromFloat(event.button.x); @@ -301,12 +304,33 @@ pub const RecentFoldersOverlayComponent = struct { self.refilter(); } + fn closeOverlayImmediately(self: *RecentFoldersOverlayComponent) void { + self.overlay.closeImmediately(); + self.search.clear(); + self.refilter(); + self.hovered_entry = null; + self.flow_animation_start_ms = 0; + } + fn hitTest(self_ptr: *anyopaque, host: *const types.UiHost, x: c_int, y: c_int) bool { const self: *RecentFoldersOverlayComponent = @ptrCast(@alignCast(self_ptr)); + if (!self.pillVisible(host) or !self.pill_interactive) return false; const rect = self.overlay.rect(host.now_ms, host.window_w, host.window_h, host.ui_scale); return geom.containsPoint(rect, x, y); } + pub fn shouldShowPill(folder_count: usize, focused_busy: bool) bool { + return folder_count > 0 and !focused_busy; + } + + pub fn pillVisible(self: *const RecentFoldersOverlayComponent, host: *const types.UiHost) bool { + return shouldShowPill(self.all_folders.items.len, host.focused_has_foreground_process); + } + + pub fn setPillInteractive(self: *RecentFoldersOverlayComponent, interactive: bool) void { + self.pill_interactive = interactive; + } + fn update(self_ptr: *anyopaque, host: *const types.UiHost, _: *types.UiActionQueue) void { const self: *RecentFoldersOverlayComponent = @ptrCast(@alignCast(self_ptr)); @@ -315,14 +339,13 @@ pub const RecentFoldersOverlayComponent = struct { self.focused_busy = busy; if (busy) { self.destroyCache(); - self.hovered_entry = null; - self.escape_pressed = false; - if (self.overlay.state == .Open or self.overlay.state == .Expanding) { - self.closeOverlay(host.now_ms); - } } } + if (!self.pillVisible(host) and self.overlay.state != .Closed) { + self.closeOverlayImmediately(); + } + if (self.overlay.isAnimating() and self.overlay.isComplete(host.now_ms)) { self.overlay.state = switch (self.overlay.state) { .Expanding => .Open, @@ -348,7 +371,7 @@ pub const RecentFoldersOverlayComponent = struct { fn render(self_ptr: *anyopaque, ui_host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets) void { const self: *RecentFoldersOverlayComponent = @ptrCast(@alignCast(self_ptr)); self.first_frame.markDrawn(); - if (self.all_folders.items.len == 0) return; + if (!self.pillVisible(ui_host)) return; const rect = self.overlay.rect(ui_host.now_ms, ui_host.window_w, ui_host.window_h, ui_host.ui_scale); const radius: c_int = 8; @@ -867,6 +890,13 @@ fn keyEvent(key: c.SDL_Keycode, mod: c.SDL_Keymod) c.SDL_Event { return event; } +fn keyUpEvent(key: c.SDL_Keycode) c.SDL_Event { + var event: c.SDL_Event = undefined; + event.type = c.SDL_EVENT_KEY_UP; + event.key.key = key; + return event; +} + fn textEvent(text: [*c]const u8) c.SDL_Event { var event: c.SDL_Event = undefined; event.type = c.SDL_EVENT_TEXT_INPUT; @@ -895,17 +925,30 @@ const TestComponent = struct { self.actions.deinit(); } - fn send(self: *TestComponent, event: c.SDL_Event, now_ms: i64) bool { - const host = testHost(now_ms, &self.theme); + fn sendWithBusy(self: *TestComponent, event: c.SDL_Event, now_ms: i64, focused_busy: bool) bool { + var host = testHost(now_ms, &self.theme); + host.focused_has_foreground_process = focused_busy; var ev = event; return RecentFoldersOverlayComponent.handleEvent(&self.comp, &host, &ev, &self.actions); } + + fn send(self: *TestComponent, event: c.SDL_Event, now_ms: i64) bool { + return self.sendWithBusy(event, now_ms, false); + } }; +fn addTestFolder(t: *TestComponent) void { + const folders = [_]config.Persistence.RecentFolder{ + .{ .path = "/tmp/architect-test-folder", .count = 1 }, + }; + t.comp.setFolders(&folders); +} + test "Cmd+Backspace clears the search query and Alt+Backspace drops one segment" { var t = TestComponent.init(); defer t.deinit(); + addTestFolder(&t); t.comp.overlay.state = .Open; try t.comp.search.buf.appendSlice(testing.allocator, "dev/github/architect"); @@ -925,6 +968,7 @@ test "plain Backspace still deletes a single character" { var t = TestComponent.init(); defer t.deinit(); + addTestFolder(&t); t.comp.overlay.state = .Open; try t.comp.search.buf.appendSlice(testing.allocator, "arch"); @@ -938,6 +982,7 @@ test "keys typed during the expand animation reach the search query" { // Cmd+O starts the expand; the overlay is .Expanding, not .Open, for the // whole animation, and must already own the keyboard. + addTestFolder(&t); try testing.expect(t.send(keyEvent(c.SDLK_O, c.SDL_KMOD_GUI), 0)); try testing.expectEqual(ExpandingOverlay.State.Expanding, t.comp.overlay.state); @@ -953,6 +998,7 @@ test "Cmd+O during the expand animation closes the overlay" { var t = TestComponent.init(); defer t.deinit(); + addTestFolder(&t); try testing.expect(t.send(keyEvent(c.SDLK_O, c.SDL_KMOD_GUI), 0)); try testing.expect(t.send(keyEvent(c.SDLK_O, c.SDL_KMOD_GUI), 50)); try testing.expectEqual(ExpandingOverlay.State.Collapsing, t.comp.overlay.state); @@ -971,6 +1017,7 @@ test "Cmd+A selects the query and the next keystroke replaces it" { var t = TestComponent.init(); defer t.deinit(); + addTestFolder(&t); t.comp.overlay.state = .Open; try t.comp.search.buf.appendSlice(testing.allocator, "arch"); @@ -1006,8 +1053,61 @@ test "the caret blinks while the picker is open" { defer t.deinit(); // Opening resets the blink so the caret is solid on the first frame. + addTestFolder(&t); try testing.expect(t.send(keyEvent(c.SDLK_O, c.SDL_KMOD_GUI), 1000)); try testing.expect(t.comp.search.caretVisible(1000)); try testing.expect(!t.comp.search.caretVisible(1600)); try testing.expect(t.comp.search.caretVisible(2100)); } + +test "recent folders pill is hidden while the focused shell is busy" { + try testing.expect(RecentFoldersOverlayComponent.shouldShowPill(1, false)); + try testing.expect(!RecentFoldersOverlayComponent.shouldShowPill(1, true)); + try testing.expect(!RecentFoldersOverlayComponent.shouldShowPill(0, false)); +} + +test "open recent folders picker does not consume input while the focused shell is busy" { + var t = TestComponent.init(); + defer t.deinit(); + + addTestFolder(&t); + t.comp.overlay.state = .Open; + try t.comp.search.buf.appendSlice(testing.allocator, "before"); + + try testing.expect(!t.sendWithBusy(textEvent("x"), 0, true)); + try testing.expectEqualStrings("before", t.comp.search.text()); + + try testing.expect(!t.sendWithBusy(keyEvent(c.SDLK_BACKSPACE, 0), 0, true)); + try testing.expectEqualStrings("before", t.comp.search.text()); +} + +test "busy update immediately closes the recent folders picker" { + var t = TestComponent.init(); + defer t.deinit(); + + addTestFolder(&t); + t.comp.overlay.state = .Open; + t.comp.escape_pressed = true; + t.comp.hovered_entry = 0; + t.comp.flow_animation_start_ms = 123; + try t.comp.search.buf.appendSlice(testing.allocator, "before"); + + var host = testHost(200, &t.theme); + host.focused_has_foreground_process = true; + RecentFoldersOverlayComponent.update(&t.comp, &host, &t.actions); + + try testing.expectEqual(ExpandingOverlay.State.Closed, t.comp.overlay.state); + try testing.expect(!t.comp.overlay.isAnimating()); + try testing.expectEqualStrings("", t.comp.search.text()); + try testing.expectEqual(@as(?usize, null), t.comp.hovered_entry); + try testing.expect(t.comp.escape_pressed); + try testing.expectEqual(@as(i64, 0), t.comp.flow_animation_start_ms); + + try testing.expect(t.sendWithBusy(keyUpEvent(c.SDLK_ESCAPE), 200, true)); + try testing.expect(!t.comp.escape_pressed); + + host.now_ms = 250; + host.focused_has_foreground_process = false; + RecentFoldersOverlayComponent.update(&t.comp, &host, &t.actions); + try testing.expectEqual(ExpandingOverlay.State.Closed, t.comp.overlay.state); +} diff --git a/src/ui/components/worktree_overlay.zig b/src/ui/components/worktree_overlay.zig index b92ea9b9..a9c19d2c 100644 --- a/src/ui/components/worktree_overlay.zig +++ b/src/ui/components/worktree_overlay.zig @@ -37,6 +37,7 @@ pub const WorktreeOverlayComponent = struct { pending_removal_path: ?[]const u8 = null, pending_refresh_ms: i64 = 0, escape_pressed: bool = false, + pill_interactive: bool = true, create_input: text_edit.TextInput = .{ .separators = text_edit.name_separators, .max_len = create_name_max_len, @@ -141,7 +142,7 @@ pub const WorktreeOverlayComponent = struct { } } - if (!self.available) return false; + if (!self.pillVisible(host) or !self.pill_interactive) return false; switch (event.type) { c.SDL_EVENT_MOUSE_BUTTON_DOWN => { @@ -268,11 +269,23 @@ pub const WorktreeOverlayComponent = struct { fn hitTest(self_ptr: *anyopaque, host: *const types.UiHost, x: c_int, y: c_int) bool { const self: *WorktreeOverlayComponent = @ptrCast(@alignCast(self_ptr)); - if (!self.available) return false; + if (!self.pillVisible(host) or !self.pill_interactive) return false; const rect = self.overlay.rect(host.now_ms, host.window_w, host.window_h, host.ui_scale); return geom.containsPoint(rect, x, y); } + pub fn shouldShowPill(available: bool, focused_busy: bool) bool { + return available and !focused_busy; + } + + pub fn pillVisible(self: *const WorktreeOverlayComponent, host: *const types.UiHost) bool { + return shouldShowPill(self.available, host.focused_has_foreground_process); + } + + pub fn setPillInteractive(self: *WorktreeOverlayComponent, interactive: bool) void { + self.pill_interactive = interactive; + } + fn update(self_ptr: *anyopaque, host: *const types.UiHost, _: *types.UiActionQueue) void { const self: *WorktreeOverlayComponent = @ptrCast(@alignCast(self_ptr)); @@ -281,14 +294,7 @@ pub const WorktreeOverlayComponent = struct { self.focused_busy = busy; if (busy) { self.available = false; - self.destroyCache(); - self.hovered_entry = null; - self.creating = false; - self.escape_pressed = false; - self.clearCreateInput(); - if (self.overlay.state == .Open or self.overlay.state == .Expanding) { - self.overlay.startCollapsing(host.now_ms); - } + self.closeOverlayImmediately(); } else { self.needs_refresh = true; } @@ -338,15 +344,15 @@ pub const WorktreeOverlayComponent = struct { self.needs_refresh = false; } - if (!self.available and self.overlay.state == .Open) { - self.overlay.startCollapsing(host.now_ms); + if (!self.pillVisible(host) and self.overlay.state != .Closed) { + self.closeOverlayImmediately(); } } fn render(self_ptr: *anyopaque, ui_host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets) void { const self: *WorktreeOverlayComponent = @ptrCast(@alignCast(self_ptr)); self.first_frame.markDrawn(); - if (!self.available and !self.creating and !self.confirming_removal) return; + if (!self.pillVisible(ui_host) and !self.creating and !self.confirming_removal) return; if (self.creating) { _ = self.ensureCache(renderer, ui_host.ui_scale, assets, ui_host.theme); @@ -575,7 +581,6 @@ pub const WorktreeOverlayComponent = struct { self.hovered_entry = null; self.clearCreateInput(); self.creating = false; - self.escape_pressed = false; self.setDisplayBase(cwd); @@ -1035,6 +1040,20 @@ pub const WorktreeOverlayComponent = struct { } } + fn closeOverlayImmediately(self: *WorktreeOverlayComponent) void { + self.overlay.closeImmediately(); + self.destroyCache(); + self.hovered_entry = null; + self.hovered_remove_btn = null; + self.creating = false; + self.confirming_removal = false; + self.clearCreateInput(); + self.clearPendingRemoval(); + self.flow_animation_start_ms = 0; + self.modal_confirm_hovered = false; + self.modal_cancel_hovered = false; + } + fn clearPendingRemoval(self: *WorktreeOverlayComponent) void { self.confirming_removal = false; self.pending_removal_index = null; @@ -1662,3 +1681,48 @@ test "createModalInputStyle uses the active theme colors" { try std.testing.expectEqual(@as(u8, 6), style.placeholder.b); try std.testing.expectEqual(@as(u8, 150), style.placeholder.a); } + +test "worktree pill is hidden while the focused shell is busy" { + try std.testing.expect(WorktreeOverlayComponent.shouldShowPill(true, false)); + try std.testing.expect(!WorktreeOverlayComponent.shouldShowPill(true, true)); + try std.testing.expect(!WorktreeOverlayComponent.shouldShowPill(false, false)); +} + +test "busy transition clears the removal confirmation modal" { + var component: WorktreeOverlayComponent = .{ .allocator = std.testing.allocator, .io = undefined }; + component.available = true; + component.overlay.state = .Open; + component.confirming_removal = true; + component.escape_pressed = true; + component.pending_removal_index = 0; + component.pending_removal_path = try std.testing.allocator.dupe(u8, "/tmp/architect-worktree"); + component.flow_animation_start_ms = 123; + + var host: types.UiHost = undefined; + host.now_ms = 100; + host.focused_has_foreground_process = true; + var actions = types.UiActionQueue.init(std.testing.allocator); + defer actions.deinit(); + + WorktreeOverlayComponent.update(&component, &host, &actions); + + try std.testing.expect(!component.confirming_removal); + try std.testing.expectEqual(@as(?usize, null), component.pending_removal_index); + try std.testing.expect(component.pending_removal_path == null); + try std.testing.expectEqual(ExpandingOverlay.State.Closed, component.overlay.state); + try std.testing.expect(!component.overlay.isAnimating()); + try std.testing.expectEqual(@as(i64, 0), component.flow_animation_start_ms); + try std.testing.expect(component.escape_pressed); + + var escape_release: c.SDL_Event = undefined; + escape_release.type = c.SDL_EVENT_KEY_UP; + escape_release.key.key = c.SDLK_ESCAPE; + try std.testing.expect(WorktreeOverlayComponent.handleEvent(&component, &host, &escape_release, &actions)); + try std.testing.expect(!component.escape_pressed); + + host.now_ms = 150; + host.focused_cwd = null; + host.focused_has_foreground_process = false; + WorktreeOverlayComponent.update(&component, &host, &actions); + try std.testing.expectEqual(ExpandingOverlay.State.Closed, component.overlay.state); +}