diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bba1ca5..2bb6041 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,7 +2,7 @@ ## System Overview -Architect is a **single-process, layered desktop application** built in Zig that functions as a grid-based terminal multiplexer optimized for multi-agent AI coding workflows. It follows a five-layer architecture: a thin entrypoint delegates to an application runtime that owns the frame loop, platform abstraction (SDL3), session management (PTY + ghostty-vt terminal emulation), scene rendering, and a component-based UI overlay system. The UI and terminal loop run on the main thread; background threads are used only for bounded auxiliary work (notification socket listener, local control socket listener, and quit-time agent-teardown worker). The frame loop uses a wakeable wait model: active work continues at the normal frame cadence, while idle frames block in SDL until either a wake-worthy event arrives or the next idle deadline expires. The application uses an action-queue pattern for UI-to-app mutations, request queues for external socket inputs, epoch-based cache invalidation for efficient rendering, and a vtable-based component registry for extensible UI overlays. Renderer cache entries are reused for both grid tiles and the steady-state full-screen terminal view; overlays remain live unless an effect needs them baked into the cached texture. +Architect is a **single-process, layered desktop application** built in Zig that functions as a grid-based terminal multiplexer optimized for multi-agent AI coding workflows. It follows a five-layer architecture: a thin entrypoint delegates to an application runtime that owns the frame loop, platform abstraction (SDL3), session management (PTY + ghostty-vt terminal emulation), scene rendering, and a component-based UI overlay system. The UI and terminal loop run on the main thread; background threads are used only for bounded auxiliary work (notification socket listener, local control socket listener, a PTY-readability watcher, and quit-time agent-teardown worker). The frame loop uses a wakeable wait model: both idle and active-frame pacing block in SDL until either a wake-worthy event arrives or the relevant deadline expires, so PTY output, keystrokes, notification/control socket activity, and window events all interrupt the wait immediately instead of waiting out a fixed timeout. The application uses an action-queue pattern for UI-to-app mutations, request queues for external socket inputs, epoch-based cache invalidation for efficient rendering, and a vtable-based component registry for extensible UI overlays. Renderer cache entries are reused for both grid tiles and the steady-state full-screen terminal view; overlays remain live unless an effect needs them baked into the cached texture. ## Component Diagram @@ -28,6 +28,7 @@ graph TD subgraph Session Layer SS["session/state.zig
PTY, ghostty-vt, process watcher"] SN["session/notify.zig
Background socket thread"] + PW["session/pty_watcher.zig
Background PTY-readability poll thread"] SESSION_MODS["shell, pty, vt_stream, cwd"] end @@ -58,6 +59,7 @@ graph TD SS --> SESSION_MODS SN -.->|"thread-safe queue"| RT CTRL -.->|"thread-safe queue"| RT + PW -.->|"wake event + fd list"| RT REN --> FONT REN --> GFX UR --> UI_CORE @@ -89,10 +91,10 @@ Platform Session Rendering UI Overlay **Invariants:** - Session, Rendering, and UI Overlay layers never import from each other directly. All cross-layer communication flows through the Application layer or shared types. - UI components communicate with the application exclusively via the `UiAction` queue (never direct state mutation). -- Background threads are intentionally limited to three cases: the notification socket listener (`session/notify.zig`), the local control socket listener (`app/control.zig`), and a quit-time agent-teardown worker in `app/runtime.zig`. They communicate completion/state back to the main thread through thread-safe primitives. The notification and control listeners also post a custom SDL wake event after queueing work so the idle frame loop breaks out of `SDL_WaitEventTimeout(...)` promptly. +- Background threads are intentionally limited to four cases: the notification socket listener (`session/notify.zig`), the local control socket listener (`app/control.zig`), the PTY-readability watcher (`session/pty_watcher.zig`), and a quit-time agent-teardown worker in `app/runtime.zig`. They communicate completion/state back to the main thread through thread-safe primitives. The notification listener, control listener, and PTY watcher also post a custom SDL wake event after queueing work (or observing readable/hung-up PTY fds) so the frame loop breaks out of `SDL_WaitEventTimeout(...)` promptly during both idle and active-frame pacing. The PTY watcher polls the master fds of all spawned sessions with a ~100ms timeout; the runtime refreshes its fd list once per frame (cheap `ptyMasterFd()` scan over all slots) and clears a shared `wake_pending` flag right before draining session output. While `wake_pending` is set the watcher backs off with short sleeps instead of re-polling and re-pushing wakes, so a burst of PTY output cannot spin the watcher thread ahead of the main thread's drain. - Shutdown order is UI-first for teardown dependencies: `UiRoot.deinit()` runs before session teardown so components that reference sessions are released while session memory is still valid. - Runtime uses a one-shot teardown guard around UI cleanup so mixed `errdefer`/`defer` error unwind paths cannot deinitialize `UiRoot` twice. -- Runtime persistence is updated during the frame loop when runtime state changes (cwd changes, terminal spawn/despawn, window move/resize, font size changes), and finalization is explicit at the end of `app/runtime.zig`: final save and deinit `Persistence` before deferred subsystem teardown begins. +- Runtime persistence is updated during the frame loop when runtime state changes (cwd changes, terminal spawn/despawn, window move/resize, font size changes), and finalization is explicit at the end of `app/runtime.zig`: final save and deinit `Persistence` before deferred subsystem teardown begins. Every change site only marks a dirty flag and records the time it first became dirty (`markPersistenceDirty`); the actual TOML write happens at most once per frame and only once the dirty state is at least 500ms old (`shouldSavePersistenceNow`), so a window drag or resize does not trigger a synchronous file write per mouse tick. The dirty timestamp is set once per dirty period (not refreshed by later changes), which caps the deferral at the debounce window even under continuous events. - Font reload paths are transactional: acquire both replacement fonts first, then swap and destroy old fonts, so a partial reload failure cannot leave deinit hooks pointing at already-freed font resources. - Window-resize scale handling follows a single ordered path (`reload-if-needed`, then `resize`) to keep behavior consistent between changed-scale and unchanged-scale events. - Terminal resizes use Ghostty's minimal flow: a single `ioctl(master, TIOCSWINSZ)` on the PTY master, which the kernel pairs with a SIGWINCH to the foreground process group of the slave's controlling terminal. Each session is sized independently. The focused session in `.Full`/`.Expanding`/`.Collapsing` mode (and additionally the previous session during a panning transition) is sized to the full-window cell count; every other session stays at grid-cell size. Grid↔full view toggle therefore reflows exactly one session — the one the user actually zoomed — instead of every session in the workspace. Window resize, font size change, and grid layout change all flow through the same per-session dispatch (`fullSetForMode` in `app/runtime.zig`, `applyTerminalResize` with `Sizes`/`FullSet` in `app/layout.zig`). Grid sizing accounts for the user's grid font scale and the reserved CWD-bar space when computing tile cell count. While DEC mode 2026 (`\e[?2026h`) is active for a session, the renderer reuses the last cached texture for that session via `synchronizedOutputHoldsCache` in `render/renderer.zig` instead of refreshing from the in-progress vt model, so reflows from agents like Codex appear as one atomic frame change rather than a top-to-bottom rescroll. The hold is dropped when the cached composition mismatches the requested one (an overlay or wave needs to bake into the next frame) or when the app sends the closing `\e[?2026l`; the next frame after the close refreshes once and snaps to the final state. A timeout-based safety net in `session/state.zig` force-clears the mode if a session leaves `\e[?2026h` set without ever closing it. @@ -135,8 +137,9 @@ These patterns are mandatory for all new code. They are derived from the archite ``` +--------------------------------------+ - | Optional SDL_WaitEventTimeout() | - | (idle only; returns early on wake) | + | SDL_WaitEventTimeout() | + | (idle: ~50ms budget, active: ~16ms; | + | returns early on any wake event) | +------------------+-------------------+ | event or timeout v @@ -176,9 +179,12 @@ These patterns are mandatory for all new code. They are derived from the archite | v +--------------------------------------+ + | Refresh PTY watcher fd list, | + | clear wake_pending flag | | Drain session output -> ghostty-vt | | Drain notification queue | - | (socket thread can post wake event) | + | (socket/PTY-watcher threads can | + | post a wake event) | +------------------+-------------------+ | v @@ -405,6 +411,7 @@ Rotate: rename active file to architect-.log and continue in new | `c.zig` | C FFI re-exports (SDL3, SDL3_ttf constants) | `SDLK_*`, `SDL_*`, `TTF_*` re-exports | SDL3 system libs (via `@cImport`) | | `session/state.zig` | Terminal session lifecycle: PTY, ghostty-vt, process watcher, foreground agent detection, graceful agent teardown at quit | `SessionState`, `AgentKind`, `init()`, `despawn()`, `deinit()`, `ensureSpawnedWithDir()`, `render_epoch`, `pending_write`, `detectForegroundAgent()`, `sendTermToForegroundPgrp()`, `drainOutputForMs()` | `shell`, `pty`, `vt_stream`, `cwd`, `font`, xev | | `session/notify.zig` | Background notification socket thread and queue; handles status and story notifications | `NotificationQueue`, `Notification` (union: status/story), `startThread()`, `push()`, `drain()` | std (socket, thread) | +| `session/pty_watcher.zig` | Background thread that `poll(2)`s spawned sessions' PTY master fds and posts a wake event when one becomes readable (or hangs up/errors); mutex-guarded fd list refreshed once per frame by the runtime | `PtyWatcher`, `start()`, `updateFds()` | std (poll, thread) | | `session/*` (shell, pty, vt_stream, cwd) | Shell spawning, PTY abstraction, VT parsing, working directory detection | `spawn()`, `Pty`, `VtStream.processBytes()`, `getCwd()` | std (posix), ghostty-vt | | `render/renderer.zig` | Scene rendering: terminals, borders, animations, terminal scrollbar painting | `render()`, `RenderCache`, 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) | diff --git a/src/app/control.zig b/src/app/control.zig index 578c7a9..2e76b1b 100644 --- a/src/app/control.zig +++ b/src/app/control.zig @@ -897,8 +897,9 @@ test "parseSpawnRequestFromValue rejects invalid shapes" { for (cases) |case| { var parsed = try std.json.parseFromSlice(std.json.Value, allocator, case, .{}); defer parsed.deinit(); - if (parseSpawnRequestFromValue(allocator, parsed.value)) |*request| { - request.deinit(allocator); + if (parseSpawnRequestFromValue(allocator, parsed.value)) |request| { + var owned = request; + owned.deinit(allocator); try std.testing.expect(false); } else |_| {} } @@ -960,7 +961,9 @@ test "fallback control runtime directory does not use TMPDIR" { const path = try fallbackControlRuntimeDirAlloc(allocator); defer allocator.free(path); - try std.testing.expect(std.mem.indexOf(u8, path, "architect") != null); + // The macOS fallback is ~/Library/Caches/Architect/runtime (capitalized), + // the Linux one ~/.cache/architect/runtime, so match case-insensitively. + try std.testing.expect(std.ascii.indexOfIgnoreCase(path, "architect") != null); try std.testing.expect(std.mem.indexOf(u8, path, "nix-shell.") == null); } diff --git a/src/app/runtime.zig b/src/app/runtime.zig index e3adb12..b953cce 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -15,6 +15,7 @@ const ui_host = @import("ui_host.zig"); const worktree = @import("worktree.zig"); const control = @import("control.zig"); const notify = @import("../session/notify.zig"); +const pty_watcher = @import("../session/pty_watcher.zig"); const session_state = @import("../session/state.zig"); const view_state = @import("../ui/session_view_state.zig"); const platform = @import("../platform/sdl.zig"); @@ -60,8 +61,7 @@ const SessionMove = grid_layout.SessionMove; const FrameWaitDecision = union(enum) { none, - idle_wait_ms: c_int, - active_sleep_ns: u64, + wait_ms: c_int, }; const ForegroundProcessCache = struct { @@ -119,22 +119,22 @@ fn waitTimeoutMsFromNs(remaining_ns: u64) c_int { fn computeFrameWaitDecision(is_idle: bool, vsync_enabled: bool, frame_ns: i128) FrameWaitDecision { if (is_idle) { const timeout_ms = waitTimeoutMsFromNs(remainingFrameBudgetNs(idle_frame_ns, frame_ns)); - return if (timeout_ms > 0) .{ .idle_wait_ms = timeout_ms } else .none; + return if (timeout_ms > 0) .{ .wait_ms = timeout_ms } else .none; } if (vsync_enabled) return .none; - const sleep_ns = remainingFrameBudgetNs(active_frame_ns, frame_ns); - return if (sleep_ns > 0) .{ .active_sleep_ns = sleep_ns } else .none; + const timeout_ms = waitTimeoutMsFromNs(remainingFrameBudgetNs(active_frame_ns, frame_ns)); + return if (timeout_ms > 0) .{ .wait_ms = timeout_ms } else .none; } +/// Waits for the next frame using the same interruptible SDL wait mechanism +/// for both idle and active pacing, so a key press or PTY-watcher wake event +/// during active-frame pacing is observed immediately instead of waiting out +/// a fixed, uninterruptible sleep. fn waitForNextFrame(wait_decision: FrameWaitDecision) ?c.SDL_Event { return switch (wait_decision) { .none => null, - .idle_wait_ms => |timeout_ms| platform.waitEventTimeout(timeout_ms), - .active_sleep_ns => |sleep_ns| blk: { - std.Thread.sleep(sleep_ns); - break :blk null; - }, + .wait_ms => |timeout_ms| platform.waitEventTimeout(timeout_ms), }; } @@ -287,17 +287,43 @@ fn syncPersistenceTerminalEntriesFromSessions( return true; } +const persistence_save_debounce_ms: i64 = 500; + +/// Marks persistence dirty and records when it first became dirty. +/// `dirty_since_ms` is only set on the false->true transition, so repeated +/// calls during a burst of events (e.g. every tick of a window drag) keep the +/// oldest timestamp rather than pushing the debounce window forward. This +/// guarantees a save happens within `persistence_save_debounce_ms` of the +/// first change even under continuous activity. +fn markPersistenceDirty(dirty: *bool, dirty_since_ms: *i64, now_ms: i64) void { + if (dirty.*) return; + dirty.* = true; + dirty_since_ms.* = now_ms; +} + +/// Pure debounce decision: a dirty persistence state is due for saving once +/// it has been dirty for at least `persistence_save_debounce_ms`. Guards +/// against a backwards clock jump stalling the save indefinitely. +fn shouldSavePersistenceNow(dirty: bool, dirty_since_ms: i64, now_ms: i64) bool { + if (!dirty) return false; + if (now_ms < dirty_since_ms) return true; + return now_ms - dirty_since_ms >= persistence_save_debounce_ms; +} + fn savePersistenceIfDirty( persistence: *config_mod.Persistence, allocator: std.mem.Allocator, dirty: *bool, + dirty_since_ms: *i64, + now_ms: i64, ) void { - if (!dirty.*) return; + if (!shouldSavePersistenceNow(dirty.*, dirty_since_ms.*, now_ms)) return; persistence.save(allocator) catch |err| { std.debug.print("Failed to save persistence: {}\n", .{err}); return; }; dirty.* = false; + dirty_since_ms.* = 0; } fn highestSpawnedIndex(sessions: []const *SessionState) ?usize { @@ -1175,6 +1201,9 @@ pub fn run() !void { var notify_stop = std.atomic.Value(bool).init(false); var control_stop = std.atomic.Value(bool).init(false); + var pty_watcher_stop = std.atomic.Value(bool).init(false); + var pty_wake_pending = std.atomic.Value(bool).init(false); + var pty_watcher_state = pty_watcher.PtyWatcher{}; var config = config_mod.Config.load(allocator) catch |err| blk: { if (err == error.ConfigNotFound) { @@ -1300,6 +1329,19 @@ pub fn run() !void { control_thread.join(); control.cleanupControlFiles(control_sock, control_discovery_path); } + const pty_watcher_thread = try pty_watcher.start( + &pty_watcher_state, + &pty_watcher_stop, + &pty_wake_pending, + .{ + .context = &sdl, + .callback = platform.pushWakeEventFromOpaque, + }, + ); + defer { + pty_watcher_stop.store(true, .seq_cst); + pty_watcher_thread.join(); + } var text_input_active = true; var input_source_tracker = macos_input.InputSourceTracker.init(); defer input_source_tracker.deinit(); @@ -1472,6 +1514,7 @@ pub fn run() !void { var running = true; var persistence_dirty = false; + var persistence_dirty_since_ms: i64 = 0; var quit_teardown = QuitTeardownState{}; defer quit_teardown.join(); @@ -1664,8 +1707,7 @@ pub fn run() !void { persistence.window.x = window_x; persistence.window.y = window_y; - persistence_dirty = true; - savePersistenceIfDirty(&persistence, allocator, &persistence_dirty); + markPersistenceDirty(&persistence_dirty, &persistence_dirty_since_ms, now); }, c.SDL_EVENT_WINDOW_RESIZED => { layout.updateRenderSizes(sdl.window, &window_width_points, &window_height_points, &render_width, &render_height, &scale_x, &scale_y); @@ -1710,8 +1752,7 @@ pub fn run() !void { persistence.window.height = window_height_points; persistence.window.x = window_x; persistence.window.y = window_y; - persistence_dirty = true; - savePersistenceIfDirty(&persistence, allocator, &persistence_dirty); + markPersistenceDirty(&persistence_dirty, &persistence_dirty_since_ms, now); }, c.SDL_EVENT_WINDOW_FOCUS_LOST => { if (builtin.os.tag == .macos) { @@ -2052,8 +2093,7 @@ pub fn run() !void { std.debug.print("Font size -> {d}px, terminal size: {d}x{d}\n", .{ font_size, full_cols, full_rows }); persistence.font_size = font_size; - persistence_dirty = true; - savePersistenceIfDirty(&persistence, allocator, &persistence_dirty); + markPersistenceDirty(&persistence_dirty, &persistence_dirty_since_ms, now); } var notification_buf: [64]u8 = undefined; @@ -2315,6 +2355,16 @@ pub fn run() !void { log.info("frame trace after xev run", .{}); } + pty_wake_pending.store(false, .seq_cst); + var pty_fds: [grid_layout.max_terminals]posix.fd_t = undefined; + var pty_fd_count: usize = 0; + for (sessions) |session| { + const pty_fd = session.ptyMasterFd() orelse continue; + pty_fds[pty_fd_count] = pty_fd; + pty_fd_count += 1; + } + pty_watcher_state.updateFds(pty_fds[0..pty_fd_count]); + for (sessions) |session| { if (relaunch_trace_frames > 0 and session.spawned) { log.info("frame trace before process session idx={d} id={d}", .{ session.slot_index, session.id }); @@ -2341,7 +2391,7 @@ pub fn run() !void { log.warn("failed to update recent folders: {}", .{err}); }; recent_folders_comp_ptr.setFolders(persistence.getRecentFolders()); - persistence_dirty = true; + markPersistenceDirty(&persistence_dirty, &persistence_dirty_since_ms, now); } } if (relaunch_trace_frames > 0 and session.spawned) { @@ -2354,9 +2404,9 @@ pub fn run() !void { break :blk false; }; if (terminal_entries_changed) { - persistence_dirty = true; + markPersistenceDirty(&persistence_dirty, &persistence_dirty_since_ms, now); } - savePersistenceIfDirty(&persistence, allocator, &persistence_dirty); + savePersistenceIfDirty(&persistence, allocator, &persistence_dirty, &persistence_dirty_since_ms, now); if (quit_teardown.isFinished()) { running = false; @@ -3205,18 +3255,44 @@ test "waitTimeoutMsFromNs rounds up to whole milliseconds" { try std.testing.expectEqual(@as(c_int, 50), waitTimeoutMsFromNs((49 * std.time.ns_per_ms) + 999_999)); } +test "markPersistenceDirty keeps the oldest dirty timestamp" { + var dirty = false; + var dirty_since_ms: i64 = 0; + + markPersistenceDirty(&dirty, &dirty_since_ms, 1_000); + try std.testing.expect(dirty); + try std.testing.expectEqual(@as(i64, 1_000), dirty_since_ms); + + // A later mark while still dirty must not push the timestamp forward, + // otherwise continuous events (e.g. a window drag) would starve the save. + markPersistenceDirty(&dirty, &dirty_since_ms, 1_400); + try std.testing.expectEqual(@as(i64, 1_000), dirty_since_ms); +} + +test "shouldSavePersistenceNow waits for the debounce window then fires" { + try std.testing.expect(!shouldSavePersistenceNow(false, 0, 10_000)); + try std.testing.expect(!shouldSavePersistenceNow(true, 1_000, 1_000 + persistence_save_debounce_ms - 1)); + try std.testing.expect(shouldSavePersistenceNow(true, 1_000, 1_000 + persistence_save_debounce_ms)); + try std.testing.expect(shouldSavePersistenceNow(true, 1_000, 1_000 + persistence_save_debounce_ms + 500)); +} + +test "shouldSavePersistenceNow does not stall forever if the clock moves backwards" { + try std.testing.expect(shouldSavePersistenceNow(true, 5_000, 4_000)); +} + test "computeFrameWaitDecision returns idle wait while idle" { const decision = computeFrameWaitDecision(true, false, 10 * std.time.ns_per_ms); switch (decision) { - .idle_wait_ms => |timeout_ms| try std.testing.expectEqual(@as(c_int, 40), timeout_ms), + .wait_ms => |timeout_ms| try std.testing.expectEqual(@as(c_int, 40), timeout_ms), else => try std.testing.expect(false), } } -test "computeFrameWaitDecision keeps active pacing without vsync" { +test "computeFrameWaitDecision keeps active pacing without vsync, rounded up to whole ms" { const decision = computeFrameWaitDecision(false, false, 5 * std.time.ns_per_ms); + const expected_ms = waitTimeoutMsFromNs(@intCast(active_frame_ns - (5 * std.time.ns_per_ms))); switch (decision) { - .active_sleep_ns => |sleep_ns| try std.testing.expectEqual(@as(u64, active_frame_ns - (5 * std.time.ns_per_ms)), sleep_ns), + .wait_ms => |timeout_ms| try std.testing.expectEqual(expected_ms, timeout_ms), else => try std.testing.expect(false), } } diff --git a/src/c.zig b/src/c.zig index c206582..f393393 100644 --- a/src/c.zig +++ b/src/c.zig @@ -111,6 +111,7 @@ pub const SDL_SCANCODE_AC_END = c_import.SDL_SCANCODE_AC_END; pub const SDL_SetTextureScaleMode = c_import.SDL_SetTextureScaleMode; pub const SDL_SCALEMODE_LINEAR = c_import.SDL_SCALEMODE_LINEAR; +pub const SDL_SetTextureColorMod = c_import.SDL_SetTextureColorMod; pub const SDLK_ESCAPE = c_import.SDLK_ESCAPE; pub const SDLK_RETURN = c_import.SDLK_RETURN; diff --git a/src/config.zig b/src/config.zig index 2acdf28..bc7cb12 100644 --- a/src/config.zig +++ b/src/config.zig @@ -1163,7 +1163,7 @@ test "Persistence.appendLegacyTerminalEntries migrates row-major order" { test "Persistence save/load round-trip preserves all fields" { const allocator = std.testing.allocator; - const tmp_dir = std.testing.tmpDir(.{}); + var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); const tmp_path = try tmp_dir.dir.realpathAlloc(allocator, "."); diff --git a/src/font.zig b/src/font.zig index 01bf1bd..cffad83 100644 --- a/src/font.zig +++ b/src/font.zig @@ -34,7 +34,6 @@ pub const Faces = struct { const GlyphKey = struct { hash: u64, - color: u32, fallback: Fallback, variant: Variant, // Cluster length in codepoints. Using u16 provides generous headroom @@ -44,6 +43,19 @@ const GlyphKey = struct { const white: c.SDL_Color = .{ .r = 255, .g = 255, .b = 255, .a = 255 }; +/// Bitmask of which font faces contain a given codepoint's glyph, memoized +/// per codepoint in `Font.face_mask_cache` so repeated lookups (which happen +/// every frame for every visible cell) skip the `TTF_FontHasGlyph` FFI call. +const FaceMask = struct { + const primary: u8 = 1 << 0; + const bold: u8 = 1 << 1; + const italic: u8 = 1 << 2; + const bold_italic: u8 = 1 << 3; + const symbol_embedded: u8 = 1 << 4; + const symbol: u8 = 1 << 5; + const symbol_secondary: u8 = 1 << 6; +}; + const FontMetrics = struct { ascent: f32, descent: f32, @@ -74,6 +86,7 @@ pub const Font = struct { symbol_secondary_metrics: ?FontMetrics, renderer: *c.SDL_Renderer, glyph_cache: std.AutoHashMap(GlyphKey, CacheEntry), + face_mask_cache: std.AutoHashMap(u21, u8), cache_tick: u64 = 0, allocator: std.mem.Allocator, cell_width: c_int, @@ -84,6 +97,19 @@ pub const Font = struct { /// Limit cached glyph textures to avoid unbounded GPU/heap growth. const max_glyph_cache_entries: usize = 4096; + /// Number of entries evicted in one batch once the glyph cache is full. + /// Selecting a whole batch per O(n) hashmap pass (see + /// `selectEvictionVictims`) amortizes eviction cost across many inserts + /// instead of paying a full scan on every single insert. + const eviction_batch_size: usize = 256; + + /// Bound on the per-codepoint face mask memo. The memo is keyed by + /// codepoint, so its size is bounded by the distinct codepoints actually + /// seen rather than by rendered text volume. 64k comfortably covers real + /// terminal usage (BMP plus common emoji); if ever exceeded, the memo is + /// cleared and rebuilt lazily rather than growing unbounded. + const max_face_mask_entries: usize = 65536; + /// Maximum byte length for a single glyph string to prevent abuse from /// malicious or malformed terminal output. 256 bytes allows for reasonable /// grapheme clusters including emoji sequences and combining characters @@ -289,6 +315,7 @@ pub const Font = struct { .symbol_secondary_metrics = if (symbol_fallback_secondary) |f| fontMetrics(f) else null, .renderer = renderer, .glyph_cache = std.AutoHashMap(GlyphKey, CacheEntry).init(allocator), + .face_mask_cache = std.AutoHashMap(u21, u8).init(allocator), .allocator = allocator, .cell_width = cell_width, .cell_height = cell_height, @@ -302,6 +329,7 @@ pub const Font = struct { c.SDL_DestroyTexture(entry.texture); } self.glyph_cache.deinit(); + self.face_mask_cache.deinit(); if (self.owns_fonts) { c.TTF_CloseFont(self.font); if (self.bold_font) |f| c.TTF_CloseFont(f); @@ -345,6 +373,7 @@ pub const Font = struct { .symbol_secondary_metrics = if (faces.symbol_secondary) |f| fontMetrics(f) else null, .renderer = renderer, .glyph_cache = std.AutoHashMap(GlyphKey, CacheEntry).init(allocator), + .face_mask_cache = std.AutoHashMap(u21, u8).init(allocator), .allocator = allocator, .cell_width = cell_width, .cell_height = cell_height, @@ -423,10 +452,11 @@ pub const Font = struct { } const fallback_choice = self.classifyFallback(cluster); - const texture = self.getGlyphTexture(utf8_slice[0..utf8_len], fg_color, fallback_choice, effective_variant) catch |err| { + const texture = self.getGlyphTexture(utf8_slice[0..utf8_len], fallback_choice, effective_variant) catch |err| { if (err == error.GlyphRenderFailed) return; return err; }; + applyColorMod(texture, fallback_choice, fg_color); var tex_w: f32 = 0; var tex_h: f32 = 0; @@ -540,10 +570,11 @@ pub const Font = struct { } const fallback_choice = self.classifyFallback(cluster); - const texture = self.getGlyphTexture(utf8_slice[0..utf8_len], fg_color, fallback_choice, effective_variant) catch |err| { + const texture = self.getGlyphTexture(utf8_slice[0..utf8_len], fallback_choice, effective_variant) catch |err| { if (err == error.GlyphRenderFailed) return; return err; }; + applyColorMod(texture, fallback_choice, fg_color); var tex_w: f32 = 0; var tex_h: f32 = 0; @@ -562,69 +593,112 @@ pub const Font = struct { } pub fn classifyFallback(self: *Font, codepoints: []const u21) Fallback { - const has_all = blk: { - for (codepoints) |cp| { - if (!c.TTF_FontHasGlyph(self.font, @intCast(cp))) { - break :blk false; - } - } - break :blk true; - }; - if (has_all) return .primary; + var mask_buf: [max_cluster_size]u8 = undefined; + const n = @min(codepoints.len, max_cluster_size); + for (codepoints[0..n], 0..) |cp, i| { + mask_buf[i] = self.faceMask(cp); + } - const has_emoji = blk: { - for (codepoints) |cp| { - if (cp >= 0x1F000) break :blk true; + var has_emoji_range = false; + for (codepoints) |cp| { + if (cp >= 0x1F000) { + has_emoji_range = true; + break; } - break :blk false; - }; - - if (has_emoji and self.emoji_fallback != null) { - return .emoji; } - if (self.symbol_fallback_embedded) |fallback_font| { - const has_in_symbol = blk: { - for (codepoints) |cp| { - if (!c.TTF_FontHasGlyph(fallback_font, @intCast(cp))) { - break :blk false; - } - } - break :blk true; - }; - if (has_in_symbol) return .symbol_embedded; - } + return classifyFromMasks( + mask_buf[0..n], + has_emoji_range, + self.symbol_fallback_embedded != null, + self.symbol_fallback != null, + self.symbol_fallback_secondary != null, + self.emoji_fallback != null, + ); + } - if (self.symbol_fallback) |fallback_font| { - const has_in_symbol = blk: { - for (codepoints) |cp| { - if (!c.TTF_FontHasGlyph(fallback_font, @intCast(cp))) { - break :blk false; - } - } - break :blk true; - }; - if (has_in_symbol) return .symbol; + /// Pure decision logic shared by `classifyFallback`: given the per-codepoint + /// face masks for a cluster and which fallback fonts are configured, picks + /// the fallback tier. Kept free of FFI/self so it can be unit tested directly. + fn classifyFromMasks( + masks: []const u8, + has_emoji_range: bool, + has_symbol_embedded_font: bool, + has_symbol_font: bool, + has_symbol_secondary_font: bool, + has_emoji_font: bool, + ) Fallback { + var all_primary = true; + var all_symbol_embedded = true; + var all_symbol = true; + var all_symbol_secondary = true; + for (masks) |mask| { + if (mask & FaceMask.primary == 0) all_primary = false; + if (mask & FaceMask.symbol_embedded == 0) all_symbol_embedded = false; + if (mask & FaceMask.symbol == 0) all_symbol = false; + if (mask & FaceMask.symbol_secondary == 0) all_symbol_secondary = false; } - if (self.symbol_fallback_secondary) |fallback_font| { - const has_in_symbol = blk: { - for (codepoints) |cp| { - if (!c.TTF_FontHasGlyph(fallback_font, @intCast(cp))) { - break :blk false; - } - } - break :blk true; - }; - if (has_in_symbol) return .symbol_secondary; + if (all_primary) return .primary; + if (has_emoji_range and has_emoji_font) return .emoji; + if (has_symbol_embedded_font and all_symbol_embedded) return .symbol_embedded; + if (has_symbol_font and all_symbol) return .symbol; + if (has_symbol_secondary_font and all_symbol_secondary) return .symbol_secondary; + if (has_emoji_font) return .emoji; + return .primary; + } + + /// Returns the bitmask of faces containing `cp`'s glyph, consulting and + /// filling `face_mask_cache` so the `TTF_FontHasGlyph` FFI calls happen at + /// most once per codepoint per `Font` instance instead of once per cell + /// per frame. + fn faceMask(self: *Font, cp: u21) u8 { + if (self.face_mask_cache.get(cp)) |cached| return cached; + + var mask: u8 = 0; + if (c.TTF_FontHasGlyph(self.font, @intCast(cp))) mask |= FaceMask.primary; + if (self.bold_font) |f| { + if (c.TTF_FontHasGlyph(f, @intCast(cp))) mask |= FaceMask.bold; + } + if (self.italic_font) |f| { + if (c.TTF_FontHasGlyph(f, @intCast(cp))) mask |= FaceMask.italic; + } + if (self.bold_italic_font) |f| { + if (c.TTF_FontHasGlyph(f, @intCast(cp))) mask |= FaceMask.bold_italic; + } + if (self.symbol_fallback_embedded) |f| { + if (c.TTF_FontHasGlyph(f, @intCast(cp))) mask |= FaceMask.symbol_embedded; + } + if (self.symbol_fallback) |f| { + if (c.TTF_FontHasGlyph(f, @intCast(cp))) mask |= FaceMask.symbol; + } + if (self.symbol_fallback_secondary) |f| { + if (c.TTF_FontHasGlyph(f, @intCast(cp))) mask |= FaceMask.symbol_secondary; } - if (self.emoji_fallback != null) return .emoji; + if (self.face_mask_cache.count() >= max_face_mask_entries) { + self.face_mask_cache.clearRetainingCapacity(); + } + self.face_mask_cache.put(cp, mask) catch |err| { + log.warn("failed to cache glyph face mask for U+{X}: {}", .{ cp, err }); + }; + return mask; + } - return .primary; + /// Sets the texture color modulation used to tint the shared white glyph + /// texture at draw time. Non-emoji glyphs are rasterized in white once and + /// tinted per draw via color mod; emoji glyphs are already full-color, so + /// their mod is explicitly reset to white to avoid inheriting a stale tint + /// left on the texture object from a prior draw. + fn applyColorMod(texture: *c.SDL_Texture, fallback: Fallback, fg_color: c.SDL_Color) void { + if (fallback == .emoji) { + _ = c.SDL_SetTextureColorMod(texture, white.r, white.g, white.b); + } else { + _ = c.SDL_SetTextureColorMod(texture, fg_color.r, fg_color.g, fg_color.b); + } } - fn getGlyphTexture(self: *Font, utf8: []const u8, fg_color: c.SDL_Color, fallback: Fallback, variant: Variant) RenderGlyphError!*c.SDL_Texture { + fn getGlyphTexture(self: *Font, utf8: []const u8, fallback: Fallback, variant: Variant) RenderGlyphError!*c.SDL_Texture { if (utf8.len > max_glyph_byte_length) { log.warn("Refusing to render excessively long glyph string: {d} bytes", .{utf8.len}); return error.GlyphRenderFailed; @@ -632,7 +706,6 @@ pub const Font = struct { const key = GlyphKey{ .hash = std.hash.Wyhash.hash(0, utf8), - .color = packColor(if (fallback == .emoji) white else fg_color), .fallback = fallback, .variant = variant, .len = @intCast(utf8.len), @@ -651,9 +724,11 @@ pub const Font = struct { .symbol_secondary => self.symbol_fallback_secondary orelse self.symbol_fallback orelse self.font, .emoji => self.emoji_fallback orelse self.font, }; - const render_color = if (fallback == .emoji) white else fg_color; - const surface = c.TTF_RenderText_Blended(render_font, @ptrCast(utf8.ptr), @intCast(utf8.len), render_color) orelse { + // Every glyph texture is rasterized in white regardless of fallback tier + // (the cache key no longer includes color) so the same texture can be + // reused across differently-colored draws via SDL_SetTextureColorMod. + const surface = c.TTF_RenderText_Blended(render_font, @ptrCast(utf8.ptr), @intCast(utf8.len), white) orelse { log.debug("TTF_RenderText_Blended failed: {s}", .{c.SDL_GetError()}); return error.GlyphRenderFailed; }; @@ -689,37 +764,74 @@ pub const Font = struct { return self.cache_tick; } + const KeySeq = struct { + key: GlyphKey, + seq: u64, + }; + + // Batch eviction: once the cache exceeds its limit, evict a whole batch of + // the oldest entries in a single O(n log batch_size) pass rather than + // rescanning all n entries on every single insert. Key selection + // (`selectEvictionVictims`) is a pure function over seq numbers so it can + // be unit tested without touching SDL textures; only this function + // performs the actual (SDL-destroying) removal. fn evictIfNeeded(self: *Font) void { if (self.glyph_cache.count() <= max_glyph_cache_entries) return; - if (findOldestKey(&self.glyph_cache)) |victim| { - if (self.glyph_cache.fetchRemove(victim)) |removed| { + var victim_buf: [eviction_batch_size]KeySeq = undefined; + const victims = selectEvictionVictims(&self.glyph_cache, &victim_buf); + for (victims) |victim| { + if (self.glyph_cache.fetchRemove(victim.key)) |removed| { c.SDL_DestroyTexture(removed.value.texture); if (self.metrics) |m| m.increment(.glyph_cache_evictions); } } } - // O(n) linear scan to find the oldest entry. A proper LRU list would give O(1) eviction, - // but in practice the 4096-entry cache is large enough that evictions are rare during - // normal terminal usage—even with diverse Unicode, emoji, and multiple font sizes. - // The simplicity of a flat hashmap outweighs the cost of occasional linear scans. - fn findOldestKey(map: *std.AutoHashMap(GlyphKey, CacheEntry)) ?GlyphKey { + /// Selects up to `buf.len` entries with the lowest `seq` (i.e. the oldest) + /// in one O(n log buf.len) pass over the map, using `buf` as a bounded + /// max-heap: the root always holds the largest seq among the current + /// candidates, so a full heap can reject or evict its root in O(log + /// buf.len) as better (older) candidates are found. + fn selectEvictionVictims(map: *std.AutoHashMap(GlyphKey, CacheEntry), buf: []KeySeq) []KeySeq { + var filled: usize = 0; var it = map.iterator(); - var oldest_key: ?GlyphKey = null; - var oldest_seq: u64 = std.math.maxInt(u64); while (it.next()) |entry| { const seq = entry.value_ptr.seq; - if (oldest_key == null or seq < oldest_seq) { - oldest_key = entry.key_ptr.*; - oldest_seq = seq; + if (filled < buf.len) { + buf[filled] = .{ .key = entry.key_ptr.*, .seq = seq }; + heapSiftUp(buf[0 .. filled + 1], filled); + filled += 1; + } else if (seq < buf[0].seq) { + buf[0] = .{ .key = entry.key_ptr.*, .seq = seq }; + heapSiftDown(buf[0..filled], 0); } } - return oldest_key; + return buf[0..filled]; } - fn packColor(color: c.SDL_Color) u32 { - return (@as(u32, color.r)) | (@as(u32, color.g) << 8) | (@as(u32, color.b) << 16) | (@as(u32, color.a) << 24); + fn heapSiftUp(heap: []KeySeq, start: usize) void { + var i = start; + while (i > 0) { + const parent = (i - 1) / 2; + if (heap[parent].seq >= heap[i].seq) break; + std.mem.swap(KeySeq, &heap[i], &heap[parent]); + i = parent; + } + } + + fn heapSiftDown(heap: []KeySeq, start: usize) void { + var i = start; + while (true) { + const left = 2 * i + 1; + const right = 2 * i + 2; + var largest = i; + if (left < heap.len and heap[left].seq > heap[largest].seq) largest = left; + if (right < heap.len and heap[right].seq > heap[largest].seq) largest = right; + if (largest == i) break; + std.mem.swap(KeySeq, &heap[i], &heap[largest]); + i = largest; + } } fn variantFont(self: *Font, variant: Variant) *c.TTF_Font { @@ -738,31 +850,109 @@ pub const Font = struct { } fn variantHasGlyphs(self: *Font, variant: Variant, codepoints: []const u21) bool { - const font_ptr = switch (variant) { + // Bit is only ever set by `faceMask` when the corresponding font + // pointer is non-null, so a missing font naturally yields `false` + // here without a separate null check. + const bit: u8 = switch (variant) { .regular => return true, - .bold => self.bold_font, - .italic => self.italic_font, - .bold_italic => self.bold_italic_font, - } orelse return false; + .bold => FaceMask.bold, + .italic => FaceMask.italic, + .bold_italic => FaceMask.bold_italic, + }; for (codepoints) |cp| { - if (!c.TTF_FontHasGlyph(font_ptr, @intCast(cp))) { - return false; - } + if (self.faceMask(cp) & bit == 0) return false; } return true; } }; -test "findOldestKey picks lowest seq" { +test "selectEvictionVictims picks the lowest seq when under batch size" { const allocator = std.testing.allocator; var map = std.AutoHashMap(GlyphKey, Font.CacheEntry).init(allocator); defer map.deinit(); - const k1 = GlyphKey{ .hash = 1, .color = 0, .fallback = .primary, .variant = .regular, .len = 1 }; - const k2 = GlyphKey{ .hash = 2, .color = 0, .fallback = .primary, .variant = .regular, .len = 1 }; + const k1 = GlyphKey{ .hash = 1, .fallback = .primary, .variant = .regular, .len = 1 }; + const k2 = GlyphKey{ .hash = 2, .fallback = .primary, .variant = .regular, .len = 1 }; + // Textures are left undefined because selection never reads them: it only + // inspects `seq`. Only `evictIfNeeded` touches (and destroys) textures. try map.put(k1, .{ .texture = undefined, .seq = 10 }); try map.put(k2, .{ .texture = undefined, .seq = 5 }); - const oldest = Font.findOldestKey(&map) orelse return error.TestExpectedResult; - try std.testing.expect(std.meta.eql(oldest, k2)); + var buf: [4]Font.KeySeq = undefined; + const victims = Font.selectEvictionVictims(&map, &buf); + + try std.testing.expectEqual(@as(usize, 2), victims.len); + var saw_k2_first = false; + for (victims) |v| { + if (std.meta.eql(v.key, k2)) saw_k2_first = true; + } + try std.testing.expect(saw_k2_first); +} + +test "selectEvictionVictims returns exactly the batch-size oldest entries" { + const allocator = std.testing.allocator; + var map = std.AutoHashMap(GlyphKey, Font.CacheEntry).init(allocator); + defer map.deinit(); + + // Seeded, shuffled-looking sequence so the heap has to evict its root + // (i.e. discard newer candidates) multiple times during the scan. + const seqs = [_]u64{ 50, 10, 90, 5, 70, 1, 60, 30, 2, 80, 20, 3 }; + for (seqs, 0..) |seq, i| { + const key = GlyphKey{ .hash = @intCast(i), .fallback = .primary, .variant = .regular, .len = 1 }; + try map.put(key, .{ .texture = undefined, .seq = seq }); + } + + const batch_size = 4; + var buf: [batch_size]Font.KeySeq = undefined; + const victims = Font.selectEvictionVictims(&map, &buf); + try std.testing.expectEqual(@as(usize, batch_size), victims.len); + + var got: [batch_size]u64 = undefined; + for (victims, 0..) |v, i| got[i] = v.seq; + std.mem.sort(u64, &got, {}, std.sort.asc(u64)); + try std.testing.expectEqualSlices(u64, &[_]u64{ 1, 2, 3, 5 }, &got); +} + +test "classifyFromMasks prefers primary when all codepoints resolve there" { + const masks = [_]u8{ FaceMask.primary, FaceMask.primary | FaceMask.symbol }; + const choice = Font.classifyFromMasks(&masks, false, true, true, true, true); + try std.testing.expectEqual(Fallback.primary, choice); +} + +test "classifyFromMasks falls back to emoji for emoji-range codepoints" { + const masks = [_]u8{FaceMask.symbol_secondary}; + const choice = Font.classifyFromMasks(&masks, true, true, true, true, true); + try std.testing.expectEqual(Fallback.emoji, choice); +} + +test "classifyFromMasks walks symbol tiers in order" { + const embedded_masks = [_]u8{FaceMask.symbol_embedded}; + try std.testing.expectEqual( + Fallback.symbol_embedded, + Font.classifyFromMasks(&embedded_masks, false, true, true, true, true), + ); + + const symbol_masks = [_]u8{FaceMask.symbol}; + try std.testing.expectEqual( + Fallback.symbol, + Font.classifyFromMasks(&symbol_masks, false, false, true, true, true), + ); + + const secondary_masks = [_]u8{FaceMask.symbol_secondary}; + try std.testing.expectEqual( + Fallback.symbol_secondary, + Font.classifyFromMasks(&secondary_masks, false, false, false, true, true), + ); +} + +test "classifyFromMasks falls back to emoji font, then primary, when no tier matches" { + const masks = [_]u8{0}; + try std.testing.expectEqual( + Fallback.emoji, + Font.classifyFromMasks(&masks, false, false, false, false, true), + ); + try std.testing.expectEqual( + Fallback.primary, + Font.classifyFromMasks(&masks, false, false, false, false, false), + ); } diff --git a/src/input/mapper.zig b/src/input/mapper.zig index 390c283..b99178c 100644 --- a/src/input/mapper.zig +++ b/src/input/mapper.zig @@ -9,7 +9,10 @@ pub fn fontSizeShortcut(key: c.SDL_Keycode, mod: c.SDL_Keymod) ?FontSizeDirectio if ((mod & c.SDL_KMOD_GUI) == 0) return null; return switch (key) { - c.SDLK_EQUALS, c.SDLK_KP_PLUS => if ((mod & c.SDL_KMOD_SHIFT) != 0) .increase else null, + // The keypad plus key produces a plus on its own; only the main-row + // equals key needs Shift to become a plus. + c.SDLK_KP_PLUS => .increase, + c.SDLK_EQUALS => if ((mod & c.SDL_KMOD_SHIFT) != 0) .increase else null, c.SDLK_MINUS, c.SDLK_KP_MINUS => .decrease, else => null, }; @@ -106,6 +109,25 @@ pub fn encodeKeyWithMod(key: c.SDL_Keycode, mod: c.SDL_Keymod, cursor_keys: bool if (ctrl_result > 0) return ctrl_result; } + // Modified Tab/Enter/Backspace: when the app enabled the kitty keyboard + // protocol, CSI-u encoding takes priority over the legacy Cmd/Alt + // mappings below, which would otherwise swallow these key combinations. + const special_keycode: ?u8 = switch (key) { + c.SDLK_TAB => 9, + c.SDLK_RETURN => 13, + c.SDLK_BACKSPACE => 127, + else => null, + }; + const has_special_modifier = (mod & (c.SDL_KMOD_SHIFT | c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT | c.SDL_KMOD_GUI)) != 0; + if (kitty_enabled and has_special_modifier) { + if (special_keycode) |kc| { + // Full CSI-u encoding with all modifier bits: ESC [ keycode ; modifier+1 u + const csi_mod = computeCsiModifier(mod); + const result = std.fmt.bufPrint(buf, "\x1b[{d};{d}u", .{ kc, csi_mod }) catch return 0; + return result.len; + } + } + if (mod & c.SDL_KMOD_GUI != 0) { return switch (key) { c.SDLK_LEFT => blk: { @@ -142,40 +164,23 @@ pub fn encodeKeyWithMod(key: c.SDL_Keycode, mod: c.SDL_Keymod, cursor_keys: bool }; } - // Modified special keys: Tab, Enter, Backspace - // When kitty enabled: any modifier combo emits CSI-u - // When kitty disabled: only Shift+Tab has special encoding, others fall through to legacy - const special_keycode: ?u8 = switch (key) { - c.SDLK_TAB => 9, - c.SDLK_RETURN => 13, - c.SDLK_BACKSPACE => 127, - else => null, - }; - if (special_keycode) |kc| { - const has_modifier = (mod & (c.SDL_KMOD_SHIFT | c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT | c.SDL_KMOD_GUI)) != 0; - if (kitty_enabled and has_modifier) { - // Full CSI-u encoding with all modifier bits: ESC [ keycode ; modifier+1 u - const csi_mod = computeCsiModifier(mod); - const result = std.fmt.bufPrint(buf, "\x1b[{d};{d}u", .{ kc, csi_mod }) catch return 0; - return result.len; - } else if (!kitty_enabled and (mod & c.SDL_KMOD_SHIFT) != 0) { - // Legacy encoding for Shift-modified keys - return switch (key) { - c.SDLK_TAB => blk: { - @memcpy(buf[0..3], "\x1b[Z"); - break :blk 3; - }, - c.SDLK_RETURN => blk: { - buf[0] = '\r'; - break :blk 1; - }, - c.SDLK_BACKSPACE => blk: { - buf[0] = 127; - break :blk 1; - }, - else => 0, - }; - } + // Legacy encoding for Shift-modified special keys when kitty is disabled. + if (special_keycode != null and !kitty_enabled and (mod & c.SDL_KMOD_SHIFT) != 0) { + return switch (key) { + c.SDLK_TAB => blk: { + @memcpy(buf[0..3], "\x1b[Z"); + break :blk 3; + }, + c.SDLK_RETURN => blk: { + buf[0] = '\r'; + break :blk 1; + }, + c.SDLK_BACKSPACE => blk: { + buf[0] = 127; + break :blk 1; + }, + else => 0, + }; } return switch (key) { diff --git a/src/main.zig b/src/main.zig index 1d469e8..490566d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); const runtime = @import("app/runtime.zig"); const logging = @import("logging.zig"); @@ -13,8 +14,41 @@ pub fn main() !void { try runtime.run(); } +// Zig only collects tests from files reachable through this block, so every +// file that declares tests must be referenced here or its tests silently +// never run. mcp/main.zig and app/control.zig are covered by the separate +// mcp test binary in build.zig. test { + _ = @import("app/app_state.zig"); + _ = @import("app/grid_layout.zig"); _ = @import("app/layout.zig"); - _ = @import("ui/components/diff_comment_layout.zig"); + _ = @import("app/runtime.zig"); + _ = @import("app/terminal_history.zig"); + _ = @import("colors.zig"); + _ = @import("config.zig"); + _ = @import("font.zig"); + _ = @import("input/mapper.zig"); + _ = @import("logging.zig"); + _ = @import("metrics.zig"); + _ = @import("pty.zig"); + _ = @import("render/renderer.zig"); + _ = @import("session/notify.zig"); + _ = @import("session/pty_watcher.zig"); + _ = @import("session/state.zig"); _ = @import("shell.zig"); + _ = @import("ui/components/diff_comment_layout.zig"); + _ = @import("ui/components/markdown_parser.zig"); + _ = @import("ui/components/markdown_renderer.zig"); + _ = @import("ui/components/quit_blocking_overlay.zig"); + _ = @import("ui/components/scrollbar.zig"); + _ = @import("ui/components/search_utils.zig"); + _ = @import("ui/components/session_interaction.zig"); + _ = @import("ui/components/worktree_overlay.zig"); + _ = @import("url_matcher.zig"); + + // cwd.zig guards itself with a top-level @compileError on non-macOS + // platforms, so it can only be referenced where it can compile. + if (builtin.os.tag == .macos) { + _ = @import("cwd.zig"); + } } diff --git a/src/render/renderer.zig b/src/render/renderer.zig index 0d0f938..6f480c8 100644 --- a/src/render/renderer.zig +++ b/src/render/renderer.zig @@ -418,6 +418,19 @@ fn renderSessionContent( const active_selection = screen.selection; + // Resolve the pin for the first visible row once, then step it down a + // row at a time. A fresh `pages.pin(...)` call walks from the top-left + // anchor every time it's invoked (O(rows) per call), so calling it once + // per cell made a full repaint quadratic in the row count. `Pin.down(1)` + // advances to the next row (crossing page-node boundaries as needed) in + // amortized O(1), and a `Pin` copy with an updated `.x` is free, so it + // replaces the extra per-cell `pages.pin(...)` calls used for selection + // and hovered-link highlighting below. + var row_pin = pages.pin(if (view.is_viewing_scrollback) + ghostty_vt.point.Point{ .viewport = .{ .x = 0, .y = 0 } } + else + ghostty_vt.point.Point{ .active = .{ .x = 0, .y = @intCast(active_row_offset) } }); + var row: usize = 0; while (row < visible_rows) : (row += 1) { const eff_cw = cell_width_actual; @@ -438,15 +451,16 @@ fn renderSessionContent( var underline_count: usize = 0; var underline_segments: [256]struct { x_start: f32, x_end: f32, y_pos: f32, color: c.SDL_Color } = undefined; + const current_row_pin = row_pin orelse continue; + row_pin = current_row_pin.down(1); + const row_cells = current_row_pin.node.data.getCells(current_row_pin.rowAndCell().row); + var col: usize = 0; while (col < visible_cols) : (col += 1) { const source_row = row + active_row_offset; - const list_cell = pages.getCell(if (view.is_viewing_scrollback) - .{ .viewport = .{ .x = @intCast(col), .y = @intCast(row) } } - else - .{ .active = .{ .x = @intCast(col), .y = @intCast(source_row) } }) orelse continue; - - const cell = list_cell.cell; + const cell = &row_cells[col]; + var cell_pin = current_row_pin; + cell_pin.x = @intCast(col); const cp: u21 = if (cell.content_tag == .codepoint or cell.content_tag == .codepoint_grapheme) cell.content.codepoint else 0; const glyph_width_cells: c_int = switch (cell.wide) { .wide => 2, @@ -461,9 +475,9 @@ fn renderSessionContent( const on_cursor = should_render_cursor and cursor_col == col and cursor_row == source_row; - const style = list_cell.style(); + const style = current_row_pin.style(cell); var fg_color = getCellColor(style.fg_color, session_fg_color, &terminal.colors.palette.current); - var bg_color = if (style.bg(list_cell.cell, &terminal.colors.palette.current)) |rgb| + var bg_color = if (style.bg(cell, &terminal.colors.palette.current)) |rgb| c.SDL_Color{ .r = rgb.r, .g = rgb.g, .b = rgb.b, .a = 255 } else session_bg_color; @@ -496,35 +510,24 @@ fn renderSessionContent( } if (active_selection) |sel| { - const point_tag = if (view.is_viewing_scrollback) - ghostty_vt.point.Point{ .viewport = .{ .x = @intCast(col), .y = @intCast(row) } } - else - ghostty_vt.point.Point{ .active = .{ .x = @intCast(col), .y = @intCast(source_row) } }; - if (pages.pin(point_tag)) |pin| { - if (sel.contains(screen, pin)) { - _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); - _ = c.SDL_SetRenderDrawColor(renderer, theme.selection.r, theme.selection.g, theme.selection.b, theme.selection.a); - const sel_rect = c.SDL_FRect{ - .x = @floatFromInt(x), - .y = @floatFromInt(y), - .w = @floatFromInt(eff_cw * glyph_width_cells), - .h = @floatFromInt(eff_ch), - }; - _ = c.SDL_RenderFillRect(renderer, &sel_rect); - } + if (sel.contains(screen, cell_pin)) { + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + _ = c.SDL_SetRenderDrawColor(renderer, theme.selection.r, theme.selection.g, theme.selection.b, theme.selection.a); + const sel_rect = c.SDL_FRect{ + .x = @floatFromInt(x), + .y = @floatFromInt(y), + .w = @floatFromInt(eff_cw * glyph_width_cells), + .h = @floatFromInt(eff_ch), + }; + _ = c.SDL_RenderFillRect(renderer, &sel_rect); } } const has_hover_underline = blk: { const link_start = view.hovered_link_start orelse break :blk false; const link_end = view.hovered_link_end orelse break :blk false; - const point_for_link = if (view.is_viewing_scrollback) - ghostty_vt.point.Point{ .viewport = .{ .x = @intCast(col), .y = @intCast(row) } } - else - ghostty_vt.point.Point{ .active = .{ .x = @intCast(col), .y = @intCast(source_row) } }; - const link_pin = pages.pin(point_for_link) orelse break :blk false; const link_sel = ghostty_vt.Selection.init(link_start, link_end, false); - break :blk link_sel.contains(screen, link_pin); + break :blk link_sel.contains(screen, cell_pin); }; if ((style.flags.underline != .none or has_hover_underline) and underline_count < underline_segments.len) { @@ -566,7 +569,7 @@ fn renderSessionContent( cluster_len += 1; if (cell.hasGrapheme()) { - if (list_cell.node.data.lookupGrapheme(list_cell.cell)) |extra| { + if (current_row_pin.node.data.lookupGrapheme(cell)) |extra| { for (extra) |gcp| { if (cluster_len >= cluster_buf.len) break; cluster_buf[cluster_len] = gcp; @@ -1182,6 +1185,50 @@ test "getCellColor uses the live terminal palette for indexed colors" { try std.testing.expectEqual(@as(u8, 255), color.a); } +test "row pin stepping matches per-cell getCell for content, wide flag, and style" { + const allocator = std.testing.allocator; + + var terminal = try ghostty_vt.Terminal.init(allocator, .{ + .cols = 10, + .rows = 4, + .max_scrollback = 0, + }); + defer terminal.deinit(allocator); + + try terminal.printString("Hi\n\xe4\xbd\xa0\xe5\xa5\xbd\nABCDEFGHIJ\nZ"); + + const pages = terminal.screens.active.pages; + + var row_pin = pages.pin(.{ .active = .{ .x = 0, .y = 0 } }) orelse return error.TestUnexpectedResult; + + var row: usize = 0; + while (row < @as(usize, terminal.rows)) : (row += 1) { + const row_rac = row_pin.rowAndCell(); + const row_cells = row_pin.node.data.getCells(row_rac.row); + + var col: usize = 0; + while (col < @as(usize, terminal.cols)) : (col += 1) { + const walked_cell = &row_cells[col]; + const reference = pages.getCell(.{ .active = .{ .x = @intCast(col), .y = @intCast(row) } }) orelse + return error.TestUnexpectedResult; + + try std.testing.expectEqual(reference.cell.content_tag, walked_cell.content_tag); + try std.testing.expectEqual(reference.cell.wide, walked_cell.wide); + if (reference.cell.content_tag == .codepoint or reference.cell.content_tag == .codepoint_grapheme) { + try std.testing.expectEqual(reference.cell.content.codepoint, walked_cell.content.codepoint); + } + + const reference_style = reference.style(); + const walked_style = row_pin.style(walked_cell); + try std.testing.expectEqual(reference_style.flags, walked_style.flags); + } + + if (row + 1 < @as(usize, terminal.rows)) { + row_pin = row_pin.down(1) orelse return error.TestUnexpectedResult; + } + } +} + test "cache refresh predicate stays clean for an unchanged content-only texture" { const entry = RenderCache.Entry{ .cache_epoch = 42, diff --git a/src/session/pty_watcher.zig b/src/session/pty_watcher.zig new file mode 100644 index 0000000..57d5ae6 --- /dev/null +++ b/src/session/pty_watcher.zig @@ -0,0 +1,170 @@ +const std = @import("std"); +const posix = std.posix; +const atomic = std.atomic; +const grid_layout = @import("../app/grid_layout.zig"); + +const log = std.log.scoped(.pty_watcher); + +const poll_timeout_ms: i32 = 100; +const busy_wake_backoff_ns: u64 = 2 * std.time.ns_per_ms; +const poll_error_backoff_ns: u64 = 10 * std.time.ns_per_ms; + +/// Wake-worthy poll conditions: readable data, and the terminal states that +/// still need one final drain (hang-up, error, invalid fd). The main thread's +/// per-frame fd-set refresh drops fds once their session goes away, so this +/// watcher does not need to distinguish them itself. +const wake_worthy_events: i16 = posix.POLL.IN | posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL; + +fn isWakeWorthy(revents: i16) bool { + return (revents & wake_worthy_events) != 0; +} + +pub const RuntimeWake = struct { + context: ?*anyopaque, + callback: *const fn (?*anyopaque) void, + + pub fn notify(self: RuntimeWake) void { + self.callback(self.context); + } +}; + +/// Mutex-guarded set of PTY master fds the watcher thread polls. Owned by the +/// caller (the runtime) and refreshed once per frame via `updateFds`; the +/// watcher thread copies the list under the mutex at the top of each poll +/// iteration so it never observes a torn read. +pub const PtyWatcher = struct { + mutex: std.Thread.Mutex = .{}, + fds: [grid_layout.max_terminals]posix.fd_t = undefined, + fd_count: usize = 0, + + pub fn updateFds(self: *PtyWatcher, fds: []const posix.fd_t) void { + self.mutex.lock(); + defer self.mutex.unlock(); + const count = @min(fds.len, grid_layout.max_terminals); + @memcpy(self.fds[0..count], fds[0..count]); + self.fd_count = count; + } + + fn snapshotPollfds(self: *PtyWatcher, out: *[grid_layout.max_terminals]posix.pollfd) usize { + self.mutex.lock(); + defer self.mutex.unlock(); + for (self.fds[0..self.fd_count], 0..) |fd, i| { + out[i] = .{ .fd = fd, .events = posix.POLL.IN, .revents = 0 }; + } + return self.fd_count; + } +}; + +const WatcherContext = struct { + watcher: *PtyWatcher, + stop: *atomic.Value(bool), + wake_pending: *atomic.Value(bool), + runtime_wake: ?RuntimeWake, +}; + +pub const StartError = std.Thread.SpawnError; + +/// Starts the PTY-readability watcher thread. Mirrors `notify.startNotifyThread`'s +/// shape: the caller owns `watcher`/`stop`/`wake_pending` and joins the returned +/// thread after setting `stop`. +pub fn start( + watcher: *PtyWatcher, + stop: *atomic.Value(bool), + wake_pending: *atomic.Value(bool), + runtime_wake: ?RuntimeWake, +) StartError!std.Thread { + const ctx = WatcherContext{ + .watcher = watcher, + .stop = stop, + .wake_pending = wake_pending, + .runtime_wake = runtime_wake, + }; + return try std.Thread.spawn(.{}, run, .{ctx}); +} + +fn run(ctx: WatcherContext) void { + var pollfds: [grid_layout.max_terminals]posix.pollfd = undefined; + + while (!ctx.stop.load(.seq_cst)) { + // A wake was pushed but the main thread has not drained it yet. + // Avoid spinning and re-pushing wakes while data sits unread. + if (ctx.wake_pending.load(.seq_cst)) { + std.Thread.sleep(busy_wake_backoff_ns); + continue; + } + + const count = ctx.watcher.snapshotPollfds(&pollfds); + if (count == 0) { + std.Thread.sleep(@as(u64, @intCast(poll_timeout_ms)) * std.time.ns_per_ms); + continue; + } + + const ready = posix.poll(pollfds[0..count], poll_timeout_ms) catch |err| { + log.debug("poll failed: {}", .{err}); + std.Thread.sleep(poll_error_backoff_ns); + continue; + }; + if (ready == 0) continue; + + var should_wake = false; + for (pollfds[0..count]) |pfd| { + if (isWakeWorthy(pfd.revents)) { + should_wake = true; + break; + } + } + + if (should_wake) { + ctx.wake_pending.store(true, .seq_cst); + if (ctx.runtime_wake) |waker| waker.notify(); + } + } +} + +test "isWakeWorthy fires for readable, hangup, error, and invalid fds" { + try std.testing.expect(isWakeWorthy(posix.POLL.IN)); + try std.testing.expect(isWakeWorthy(posix.POLL.HUP)); + try std.testing.expect(isWakeWorthy(posix.POLL.ERR)); + try std.testing.expect(isWakeWorthy(posix.POLL.NVAL)); + try std.testing.expect(!isWakeWorthy(posix.POLL.OUT)); + try std.testing.expect(!isWakeWorthy(0)); +} + +test "PtyWatcher.updateFds copies up to the capacity and snapshotPollfds reflects it" { + var watcher = PtyWatcher{}; + + watcher.updateFds(&[_]posix.fd_t{ 3, 4, 5 }); + + var pollfds: [grid_layout.max_terminals]posix.pollfd = undefined; + const count = watcher.snapshotPollfds(&pollfds); + + try std.testing.expectEqual(@as(usize, 3), count); + try std.testing.expectEqual(@as(posix.fd_t, 3), pollfds[0].fd); + try std.testing.expectEqual(@as(posix.fd_t, 4), pollfds[1].fd); + try std.testing.expectEqual(@as(posix.fd_t, 5), pollfds[2].fd); + try std.testing.expectEqual(@as(i16, posix.POLL.IN), pollfds[0].events); +} + +test "PtyWatcher.updateFds replaces the previous set, shrinking fd_count" { + var watcher = PtyWatcher{}; + watcher.updateFds(&[_]posix.fd_t{ 1, 2, 3, 4 }); + + watcher.updateFds(&[_]posix.fd_t{7}); + + var pollfds: [grid_layout.max_terminals]posix.pollfd = undefined; + const count = watcher.snapshotPollfds(&pollfds); + try std.testing.expectEqual(@as(usize, 1), count); + try std.testing.expectEqual(@as(posix.fd_t, 7), pollfds[0].fd); +} + +test "PtyWatcher.updateFds truncates to the fixed capacity without allocating" { + var watcher = PtyWatcher{}; + var many: [grid_layout.max_terminals + 5]posix.fd_t = undefined; + for (&many, 0..) |*fd, i| fd.* = @intCast(i); + + watcher.updateFds(&many); + + var pollfds: [grid_layout.max_terminals]posix.pollfd = undefined; + const count = watcher.snapshotPollfds(&pollfds); + try std.testing.expectEqual(@as(usize, grid_layout.max_terminals), count); +} diff --git a/src/session/state.zig b/src/session/state.zig index 59462c7..e41ae7d 100644 --- a/src/session/state.zig +++ b/src/session/state.zig @@ -85,7 +85,7 @@ pub const SessionState = struct { shell: ?shell_mod.Shell, terminal: ?ghostty_vt.Terminal, stream: ?vt_stream.StreamType, - output_buf: [4096]u8, + output_buf: [65536]u8, render_epoch: u64 = 1, spawned: bool = false, dead: bool = false, @@ -357,9 +357,6 @@ pub const SessionState = struct { DivisionByZero, GraphemeAllocOutOfMemory, GraphemeMapOutOfMemory, - HyperlinkMapOutOfMemory, - HyperlinkSetNeedsRehash, - HyperlinkSetOutOfMemory, NeedsRehash, OutOfMemory, OutOfSpace, @@ -410,8 +407,14 @@ pub const SessionState = struct { return .disarm; } + /// Polling fallback for exit detection. Sessions spawned with an xev loop + /// (the common case) get event-driven exit detection from `processExitCallback` + /// via `process_watcher`, so this is a no-op for them. Sessions spawned without + /// a loop (e.g. `restart()`, which calls `ensureSpawned()` with no loop) have no + /// watcher and rely on this per-frame `waitpid(WNOHANG)` poll instead. pub fn checkAlive(self: *SessionState) void { if (!self.spawned or self.dead) return; + if (self.process_watcher != null) return; if (self.shell) |shell| { var status: c_int = 0; @@ -568,14 +571,25 @@ pub const SessionState = struct { } } + /// Per-call cap on bytes read from the PTY. Without a cap, a fast producer + /// (e.g. `cat`-ing a huge file) can keep the drain loop below going until + /// the PTY would block, holding the frame loop hostage and starving + /// keystroke/input processing for the duration. Once the cap is hit, + /// remaining data stays buffered in the kernel and is picked up on the + /// next call (the runtime calls processOutput once per frame; the + /// quit-time drain path in runtime.zig calls it in a loop instead). + const max_process_output_bytes_per_call: usize = 1 << 20; + pub fn processOutput(self: *SessionState) ProcessOutputError!void { if (!shouldProcessOutput(self.spawned, self.dead, self.quit_capture_active)) return; const shell = &(self.shell orelse return); const stream = &(self.stream orelse return); - while (true) { - const n = shell.read(&self.output_buf) catch |err| switch (err) { + var bytes_consumed: usize = 0; + while (shouldContinueDraining(bytes_consumed, max_process_output_bytes_per_call)) { + const read_len = cappedReadLen(self.output_buf.len, bytes_consumed, max_process_output_bytes_per_call); + const n = shell.read(self.output_buf[0..read_len]) catch |err| switch (err) { error.WouldBlock => return, // Linux PTYs can report EIO after the slave side closes. // Treat it as terminal EOF so normal dead sessions don't fail the runtime loop. @@ -584,6 +598,7 @@ pub const SessionState = struct { }; if (n == 0) return; + bytes_consumed += n; if (scanOsc1Agent(self.output_buf[0..n])) |kind| { self.agent_icon = kind; @@ -594,24 +609,14 @@ pub const SessionState = struct { }; } const was_synchronized_output = self.synchronizedOutputActive(); - // Process byte-by-byte so a hyperlink capacity error on byte N doesn't - // silently discard bytes N+1..end of the PTY read. Hyperlink errors drop - // only that byte's side effect; normal output/control sequences continue. - for (self.output_buf[0..n]) |byte| { - stream.next(byte) catch |err| switch (err) { - error.HyperlinkSetOutOfMemory, - error.HyperlinkSetNeedsRehash, - error.HyperlinkMapOutOfMemory, - => log.warn("session {d}: OSC 8 hyperlink capacity exhausted, hyperlink dropped: {}", .{ self.id, err }), - else => return err, - }; - } + try stream.nextSlice(self.output_buf[0..n]); const processed_at_ms = std.time.milliTimestamp(); self.updateSynchronizedOutputState(was_synchronized_output, processed_at_ms); self.markDirty(); - // Keep draining until the PTY would block to avoid frame-bounded - // throttling of bursty output (e.g. startup logos). + // Keep draining until the PTY would block (or the byte budget is + // hit) to avoid frame-bounded throttling of bursty output (e.g. + // startup logos). } } @@ -621,8 +626,23 @@ pub const SessionState = struct { return quit_capture_active; } + /// True when the drain loop in processOutput should attempt another read. + /// Extracted for testability; see max_process_output_bytes_per_call for why + /// the budget exists. + fn shouldContinueDraining(bytes_consumed: usize, budget: usize) bool { + return bytes_consumed < budget; + } + + /// Length for the next drain read: the full buffer, or the remaining byte + /// budget when that is smaller, so the final read of a processOutput call + /// cannot overshoot the per-call cap. Callers must ensure + /// `bytes_consumed < budget` (the drain loop condition guarantees it). + fn cappedReadLen(buf_len: usize, bytes_consumed: usize, budget: usize) usize { + return @min(buf_len, budget - bytes_consumed); + } + /// Mark the session as failed after an unrecoverable output-processing error - /// (e.g. ghostty-vt resource exhaustion like `HyperlinkSetOutOfMemory`). Sends + /// (e.g. ghostty-vt resource exhaustion like `StyleSetOutOfMemory`). Sends /// SIGTERM to the still-running child so it stops consuming resources behind a /// "[Process completed]" UI, drops queued stdin so `flushPendingWrites` does /// not retry every frame, then flips `dead` and bumps the render epoch. The @@ -1097,6 +1117,58 @@ test "SessionState assigns incrementing ids" { try std.testing.expectEqualStrings("1", std.mem.sliceTo(second.session_id_z[0..], 0)); } +test "checkAlive skips waitpid polling when a process watcher owns exit detection" { + const allocator = std.testing.allocator; + const theme = colors_mod.Theme.default(); + const size = pty_mod.winsize{ + .ws_row = 24, + .ws_col = 80, + .ws_xpixel = 0, + .ws_ypixel = 0, + }; + const notify_sock: [:0]const u8 = "sock"; + + const pid = try posix.fork(); + if (pid == 0) { + std.c._exit(0); + } + defer _ = std.c.waitpid(pid, null, 0); + // Give the forked child a moment to exit and become reapable. + std.Thread.sleep(50 * std.time.ns_per_ms); + + const pipe_fds = try posix.pipe(); + // pty.deinit() only closes the master fd; close the slave ourselves. + defer posix.close(pipe_fds[0]); + + var session = try SessionState.init(allocator, 0, "/bin/zsh", size, notify_sock, theme); + // Closes pipe_fds[1] (master) via shell.deinit() -> pty.deinit(). + defer session.deinit(allocator); + + session.spawned = true; + session.dead = false; + session.render_epoch = 1; + session.shell = shell_mod.Shell{ + .pty = .{ .master = pipe_fds[1], .slave = pipe_fds[0] }, + .child_pid = pid, + }; + + session.process_watcher = try xev.Process.init(pid); + session.checkAlive(); + try std.testing.expect(!session.dead); + try std.testing.expectEqual(@as(u64, 1), session.render_epoch); + + session.process_watcher.?.deinit(); + session.process_watcher = null; + + session.checkAlive(); + try std.testing.expect(session.dead); + try std.testing.expectEqual(@as(u64, 2), session.render_epoch); + + // Prevent teardown from re-killing/re-reaping the already-exited child. + session.spawned = false; + if (session.shell) |*shell| shell.child_pid = -1; +} + test "despawn keeps active wait context alive until callback reclaims it" { const allocator = std.testing.allocator; const theme = colors_mod.Theme.default(); @@ -1193,6 +1265,21 @@ test "shouldProcessOutput drains dead sessions only during quit capture" { try std.testing.expect(SessionState.shouldProcessOutput(true, true, true)); } +test "shouldContinueDraining stops once the byte budget is reached" { + try std.testing.expect(SessionState.shouldContinueDraining(0, 1024)); + try std.testing.expect(SessionState.shouldContinueDraining(1023, 1024)); + try std.testing.expect(!SessionState.shouldContinueDraining(1024, 1024)); + try std.testing.expect(!SessionState.shouldContinueDraining(2048, 1024)); + try std.testing.expect(!SessionState.shouldContinueDraining(0, 0)); +} + +test "cappedReadLen limits the final read to the remaining byte budget" { + try std.testing.expectEqual(@as(usize, 512), SessionState.cappedReadLen(512, 0, 1024)); + try std.testing.expectEqual(@as(usize, 512), SessionState.cappedReadLen(512, 512, 1024)); + try std.testing.expectEqual(@as(usize, 100), SessionState.cappedReadLen(512, 924, 1024)); + try std.testing.expectEqual(@as(usize, 1), SessionState.cappedReadLen(512, 1023, 1024)); +} + test "failAndTerminate marks dead, bumps render epoch, and drops pending writes" { const allocator = std.testing.allocator; diff --git a/src/ui/components/scrollbar.zig b/src/ui/components/scrollbar.zig index dc0b495..f2f338f 100644 --- a/src/ui/components/scrollbar.zig +++ b/src/ui/components/scrollbar.zig @@ -727,19 +727,25 @@ test "state fades in, waits, and fades out with auto-hide timing" { state.noteActivity(t0); try std.testing.expect(state.wantsFrame(t0)); + // Each transition arms the FirstFrameGuard; in the app every render calls + // markDrawn(), so the test does the same after each frame it "draws". + state.markDrawn(); state.update(t0 + fade_in_duration_ms); try std.testing.expectApproxEqAbs(@as(f32, 1.0), state.alpha, 0.001); try std.testing.expect(state.wantsFrame(t0 + fade_in_duration_ms)); + state.markDrawn(); const before_hide = t0 + idle_hide_delay_ms - 1; state.update(before_hide); try std.testing.expect(state.alpha > 0.9); try std.testing.expect(state.wantsFrame(before_hide)); + state.markDrawn(); const fade_start = t0 + idle_hide_delay_ms + 1; state.update(fade_start); try std.testing.expect(state.phase == .fading_out); + state.markDrawn(); const hidden_at = fade_start + fade_out_duration_ms + 1; state.update(hidden_at); diff --git a/src/url_matcher.zig b/src/url_matcher.zig index 09fa99a..a63ec2a 100644 --- a/src/url_matcher.zig +++ b/src/url_matcher.zig @@ -104,7 +104,10 @@ fn trimUrlEnd(url: []const u8) []const u8 { end -= 1; }, ')' => { - if (!hasUnmatchedOpenParen(url[0..end])) { + // Balance is checked against the text before this closing + // paren: an unmatched '(' earlier in the URL means this ')' + // closes it and belongs to the URL (e.g. Wikipedia paths). + if (!hasUnmatchedOpenParen(url[0 .. end - 1])) { end -= 1; } else { break; diff --git a/src/vt_stream.zig b/src/vt_stream.zig index ae15325..3d6ae02 100644 --- a/src/vt_stream.zig +++ b/src/vt_stream.zig @@ -41,7 +41,7 @@ pub const Handler = struct { .request_mode => try self.handleRequestMode(value.mode), .request_mode_unknown => try self.handleRequestModeUnknown(value.mode, value.ansi), .set_mode => { - try self.readonly.vt(action, value); + try self.delegateVt(action, value); // DEC mode 2048 (in-band size reports): apps that enable this // expect a size report whenever the terminal is resized AND an // initial report when the mode is first enabled. Matches @@ -52,33 +52,80 @@ pub const Handler = struct { }, .kitty_keyboard_push => { log.debug("kitty_keyboard_push: flags={d}", .{value.flags.int()}); - try self.readonly.vt(action, value); + try self.delegateVt(action, value); log.debug("kitty_keyboard: current={d}", .{self.terminal.screens.active.kitty_keyboard.current().int()}); }, .kitty_keyboard_pop => { log.debug("kitty_keyboard_pop: n={d}", .{value}); - try self.readonly.vt(action, value); + try self.delegateVt(action, value); log.debug("kitty_keyboard: current={d}", .{self.terminal.screens.active.kitty_keyboard.current().int()}); }, .kitty_keyboard_set => { log.debug("kitty_keyboard_set: flags={d}", .{value.flags.int()}); - try self.readonly.vt(action, value); + try self.delegateVt(action, value); log.debug("kitty_keyboard: current={d}", .{self.terminal.screens.active.kitty_keyboard.current().int()}); }, .kitty_keyboard_set_or => { log.debug("kitty_keyboard_set_or: flags={d}", .{value.flags.int()}); - try self.readonly.vt(action, value); + try self.delegateVt(action, value); log.debug("kitty_keyboard: current={d}", .{self.terminal.screens.active.kitty_keyboard.current().int()}); }, .kitty_keyboard_set_not => { log.debug("kitty_keyboard_set_not: flags={d}", .{value.flags.int()}); - try self.readonly.vt(action, value); + try self.delegateVt(action, value); log.debug("kitty_keyboard: current={d}", .{self.terminal.screens.active.kitty_keyboard.current().int()}); }, - else => try self.readonly.vt(action, value), + else => try self.delegateVt(action, value), } } + /// Errors the built-in readonly handler can surface, other than the OSC 8 + /// hyperlink capacity errors that delegateVt always contains. Declared + /// explicitly (rather than inferred) because `vt` is comptime-generic over + /// the action tag: each action instantiates a differently-shaped inferred + /// error union, and only some of them actually include the hyperlink + /// errors. An inferred return type here would force every switch arm in + /// `vt` below to reference literals that are members of the local error + /// union, which fails to compile for actions that can't produce them. + const DelegateVtError = error{ + DivisionByZero, + GraphemeAllocOutOfMemory, + GraphemeMapOutOfMemory, + NeedsRehash, + OutOfMemory, + OutOfSpace, + StringAllocOutOfMemory, + StyleSetNeedsRehash, + StyleSetOutOfMemory, + }; + + /// Delegates to the built-in readonly handler, containing OSC 8 hyperlink + /// capacity errors so a single exhausted hyperlink table doesn't abort + /// parsing of the rest of the PTY output. All other errors propagate. + fn delegateVt( + self: *Handler, + comptime action: ghostty_vt.StreamAction.Tag, + value: ghostty_vt.StreamAction.Value(action), + ) DelegateVtError!void { + self.readonly.vt(action, value) catch |err| { + // Match against anyerror since the hyperlink error names aren't + // members of every action's own inferred error set. + switch (@as(anyerror, err)) { + error.HyperlinkSetOutOfMemory, + error.HyperlinkSetNeedsRehash, + error.HyperlinkMapOutOfMemory, + => { + log.warn("OSC 8 hyperlink capacity exhausted, hyperlink dropped: {}", .{err}); + return; + }, + else => {}, + } + // Safe: the hyperlink errors were already handled above, so err + // is guaranteed to be a member of DelegateVtError here. + return @errorCast(err); + }; + } + fn handleDeviceAttributes(self: *Handler, req: ghostty_vt.DeviceAttributeReq) !void { switch (req) { .primary => { @@ -448,9 +495,16 @@ test "stream answers unknown mode report queries" { const private_len = try std.posix.read(pipe_fds[0], &buf); try std.testing.expectEqualSlices(u8, "\x1b[?9999;0$y", buf[0..private_len]); + // ghostty 1.3.1 only dispatches DECRQM for the DEC form (`?$p`); the ANSI + // form (`$p`) is dropped by the parser before reaching the handler, so no + // response is written. Verify via a non-blocking read instead of hanging + // on a reply that can never arrive. + const flags = try std.posix.fcntl(pipe_fds[0], std.posix.F.GETFL, 0); + var o_flags: std.posix.O = @bitCast(@as(u32, @intCast(flags))); + o_flags.NONBLOCK = true; + _ = try std.posix.fcntl(pipe_fds[0], std.posix.F.SETFL, @as(u32, @bitCast(o_flags))); try stream.nextSlice("\x1b[9999$p"); - const ansi_len = try std.posix.read(pipe_fds[0], &buf); - try std.testing.expectEqualSlices(u8, "\x1b[9999;0$y", buf[0..ansi_len]); + try std.testing.expectError(error.WouldBlock, std.posix.read(pipe_fds[0], &buf)); } test "stream answers OSC 4 palette queries with the current terminal palette" { @@ -493,6 +547,38 @@ test "stream answers OSC 4 palette queries with the current terminal palette" { try std.testing.expectEqualSlices(u8, "\x1b]4;17;rgb:1212/3434/5656\x07", buf[0..len]); } +test "stream processes OSC 8 hyperlinks via nextSlice without error" { + const allocator = std.testing.allocator; + + var terminal = try ghostty_vt.Terminal.init(allocator, .{ + .cols = 80, + .rows = 24, + }); + defer terminal.deinit(allocator); + + const pipe_fds = try std.posix.pipe(); + defer std.posix.close(pipe_fds[0]); + defer std.posix.close(pipe_fds[1]); + + var shell = shell_mod.Shell{ + .pty = .{ + .master = pipe_fds[1], + .slave = pipe_fds[0], + }, + .child_pid = 0, + }; + + var stream = initStream(allocator, &terminal, &shell); + defer stream.deinit(); + + // Delegated (non-explicit) VT actions such as hyperlink start/end and + // print route through Handler.delegateVt; this exercises that path end + // to end via the SIMD-capable nextSlice entry point. + try stream.nextSlice("\x1b]8;;http://example.com\x1b\\link text\x1b]8;;\x1b\\"); + + try std.testing.expectEqual(@as(usize, 9), terminal.screens.active.cursor.x); +} + pub const StreamType = ghostty_vt.Stream(Handler); pub fn initStream(