diff --git a/docs/architecture.md b/docs/architecture.md index 6c86b34..8d20879 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,12 +54,13 @@ Architect is a terminal multiplexer displaying interactive sessions in a grid wi - CWD bar with marquee scrolling for long paths - Scrollback indicator strip -**UiRoot (src/ui/)** is the registry for UI overlay components: +**UiRoot (src/ui/)** is the registry for UI overlay components and session interaction state: - Dispatches events topmost-first (by z-index) - Runs per-frame `update()` on all components - Drains `UiAction` queue for UI→app mutations - Renders all components in z-order after the scene - Reports `needsFrame()` when any component requires animation +- Owns per-session `SessionViewState` via `SessionInteractionComponent` (selection, hover, scrollback state) **UiAssets** provides shared rendering resources: - `FontCache` stores configured fonts keyed by pixel size, so terminal rendering and UI components reuse a single loaded font set instead of opening per-component instances. @@ -119,6 +120,7 @@ src/ ├── root.zig # UiRoot: component registry, dispatch ├── component.zig # UiComponent vtable interface ├── types.zig # UiHost, UiAction, UiAssets, SessionUiInfo + ├── session_view_state.zig # Per-session UI interaction state ├── scale.zig # DPI scaling helper ├── first_frame_guard.zig # Idle throttling transition helper │ @@ -135,6 +137,7 @@ src/ │ ├── pill_group.zig # Pill overlay coordinator (collapses others) │ ├── quit_confirm.zig # Quit confirmation dialog │ ├── restart_buttons.zig # Dead session restart buttons + │ ├── session_interaction.zig # Terminal mouse/scroll interaction handling │ ├── toast.zig # Toast notification display │ └── worktree_overlay.zig # Git worktree picker (T pill) │ diff --git a/src/main.zig b/src/main.zig index 8a26e8f..6d930ca 100644 --- a/src/main.zig +++ b/src/main.zig @@ -24,14 +24,11 @@ const ghostty_vt = @import("ghostty-vt"); const c = @import("c.zig"); const metrics_mod = @import("metrics.zig"); const open_url = @import("os/open.zig"); -const url_matcher = @import("url_matcher.zig"); const log = std.log.scoped(.main); const INITIAL_WINDOW_WIDTH = 1200; const INITIAL_WINDOW_HEIGHT = 900; -const SCROLL_LINES_PER_TICK: isize = 1; -const MAX_SCROLL_VELOCITY: f32 = 30.0; const DEFAULT_FONT_SIZE: c_int = 14; const MIN_FONT_SIZE: c_int = 8; const MAX_FONT_SIZE: c_int = 96; @@ -41,16 +38,11 @@ const ACTIVE_FRAME_NS: i128 = 16_666_667; const IDLE_FRAME_NS: i128 = 50_000_000; const MAX_IDLE_RENDER_GAP_NS: i128 = 250_000_000; const FOREGROUND_PROCESS_CACHE_MS: i64 = 150; -const SessionStatus = app_state.SessionStatus; const ViewMode = app_state.ViewMode; const Rect = app_state.Rect; const AnimationState = app_state.AnimationState; const NotificationQueue = notify.NotificationQueue; -const Notification = notify.Notification; const SessionState = session_state.SessionState; -const FontSizeDirection = input.FontSizeDirection; -const GridNavDirection = input.GridNavDirection; -const CursorKind = enum { arrow, ibeam, pointer }; const ForegroundProcessCache = struct { session_idx: ?usize = null, @@ -241,17 +233,6 @@ pub fn main() !void { }; } - const arrow_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_DEFAULT); - defer if (arrow_cursor) |cursor| c.SDL_DestroyCursor(cursor); - const ibeam_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_TEXT); - defer if (ibeam_cursor) |cursor| c.SDL_DestroyCursor(cursor); - const pointer_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_POINTER); - defer if (pointer_cursor) |cursor| c.SDL_DestroyCursor(cursor); - var current_cursor: CursorKind = .arrow; - if (arrow_cursor) |cursor| { - _ = c.SDL_SetCursor(cursor); - } - const renderer = sdl.renderer; var font_size: c_int = persistence.font_size; @@ -378,6 +359,9 @@ pub fn main() !void { var ime_composition = ImeComposition{}; var last_focused_session: usize = anim_state.focused_session; + const session_interaction_component = try ui_mod.SessionInteractionComponent.init(allocator, sessions, &font); + try ui.register(session_interaction_component.asComponent()); + const worktree_comp_ptr = try allocator.create(ui_mod.worktree_overlay.WorktreeOverlayComponent); worktree_comp_ptr.* = .{ .allocator = allocator }; const worktree_component = ui_mod.UiComponent{ @@ -421,19 +405,10 @@ pub fn main() !void { // Main loop: handle SDL input, feed PTY output into terminals, apply async // notifications, drive animations, and render at ~60 FPS. - var previous_frame_ns: i128 = undefined; - var first_frame: bool = true; var last_render_ns: i128 = 0; while (running) { const frame_start_ns: i128 = std.time.nanoTimestamp(); const now = std.time.milliTimestamp(); - var delta_time_s: f32 = 0.0; - if (first_frame) { - first_frame = false; - } else { - delta_time_s = @as(f32, @floatFromInt(frame_start_ns - previous_frame_ns)) / 1_000_000_000.0; - } - previous_frame_ns = frame_start_ns; var event: c.SDL_Event = undefined; var processed_event = false; @@ -458,14 +433,18 @@ pub fn main() !void { cell_height_pixels, grid_cols, grid_rows, + full_cols, + full_rows, &anim_state, sessions, session_ui_info, focused_has_foreground_process, &theme, ); + var event_ui_host = ui_host; + applyMouseContext(&ui, &event_ui_host, &scaled_event); - const ui_consumed = ui.handleEvent(&ui_host, &scaled_event); + const ui_consumed = ui.handleEvent(&event_ui_host, &scaled_event); if (ui_consumed) continue; switch (scaled_event.type) { @@ -549,7 +528,7 @@ pub fn main() !void { }, c.SDL_EVENT_TEXT_INPUT => { const focused = &sessions[anim_state.focused_session]; - handleTextInput(focused, &ime_composition, scaled_event.text.text) catch |err| { + handleTextInput(focused, &ime_composition, scaled_event.text.text, session_interaction_component) catch |err| { std.debug.print("Text input failed: {}\n", .{err}); }; }, @@ -561,6 +540,7 @@ pub fn main() !void { scaled_event.edit.text, scaled_event.edit.start, scaled_event.edit.length, + session_interaction_component, ) catch |err| { std.debug.print("Edit input failed: {}\n", .{err}); }; @@ -595,7 +575,7 @@ pub fn main() !void { }; defer allocator.free(escaped); - pasteText(session, allocator, escaped) catch |err| switch (err) { + pasteText(session, allocator, escaped, session_interaction_component) catch |err| switch (err) { error.NoTerminal => ui.showToast("No terminal to paste into", now), error.NoShell => ui.showToast("Shell not available", now), else => std.debug.print("Failed to paste dropped path: {}\n", .{err}), @@ -647,6 +627,7 @@ pub fn main() !void { } } session.deinit(allocator); + session_interaction_component.resetView(session_idx); session.markDirty(); } continue; @@ -663,7 +644,7 @@ pub fn main() !void { }; } else if (key == c.SDLK_V and has_gui and !has_blocking_mod) { if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘V", now); - pasteClipboardIntoSession(focused, allocator, &ui, now) catch |err| { + pasteClipboardIntoSession(focused, allocator, &ui, now, session_interaction_component) catch |err| { std.debug.print("Paste failed: {}\n", .{err}); }; } else if (input.fontSizeShortcut(key, mod)) |direction| { @@ -715,11 +696,11 @@ pub fn main() !void { defer if (cwd_buf) |buf| allocator.free(buf); try sessions[next_free_idx].ensureSpawnedWithDir(cwd_z, &loop); - sessions[next_free_idx].status = .running; - sessions[next_free_idx].attention = false; + session_interaction_component.setStatus(next_free_idx, .running); + session_interaction_component.setAttention(next_free_idx, false); - sessions[anim_state.focused_session].clearSelection(); - sessions[next_free_idx].clearSelection(); + session_interaction_component.clearSelection(anim_state.focused_session); + session_interaction_component.clearSelection(next_free_idx); anim_state.previous_session = anim_state.focused_session; anim_state.focused_session = next_free_idx; @@ -738,8 +719,8 @@ pub fn main() !void { if (anim_state.mode == .Grid) { try sessions[idx].ensureSpawnedWithLoop(&loop); - sessions[idx].status = .running; - sessions[idx].attention = false; + session_interaction_component.setStatus(idx, .running); + session_interaction_component.setAttention(idx, false); const grid_row: c_int = @intCast(idx / grid_cols); const grid_col: c_int = @intCast(idx % grid_cols); @@ -767,10 +748,10 @@ pub fn main() !void { std.debug.print("Expanding session via hotkey: {d}\n", .{idx}); } else if (anim_state.mode == .Full and idx != anim_state.focused_session) { try sessions[idx].ensureSpawnedWithLoop(&loop); - sessions[anim_state.focused_session].clearSelection(); - sessions[idx].clearSelection(); - sessions[idx].status = .running; - sessions[idx].attention = false; + session_interaction_component.clearSelection(anim_state.focused_session); + session_interaction_component.clearSelection(idx); + session_interaction_component.setStatus(idx, .running); + session_interaction_component.setAttention(idx, false); anim_state.focused_session = idx; const buf_size = gridNotificationBufferSize(grid_cols, grid_rows); @@ -791,7 +772,7 @@ pub fn main() !void { }; ui.showHotkey(arrow, now); } - try navigateGrid(&anim_state, sessions, direction, now, true, false, grid_cols, grid_rows, &loop); + try navigateGrid(&anim_state, sessions, session_interaction_component, direction, now, true, false, grid_cols, grid_rows, &loop); const new_session = anim_state.focused_session; sessions[new_session].markDirty(); std.debug.print("Grid nav to session {d} (with wrapping)\n", .{new_session}); @@ -805,7 +786,7 @@ pub fn main() !void { }; ui.showHotkey(arrow, now); } - try navigateGrid(&anim_state, sessions, direction, now, true, animations_enabled, grid_cols, grid_rows, &loop); + try navigateGrid(&anim_state, sessions, session_interaction_component, direction, now, true, animations_enabled, grid_cols, grid_rows, &loop); const buf_size = gridNotificationBufferSize(grid_cols, grid_rows); const notification_buf = try allocator.alloc(u8, buf_size); @@ -816,6 +797,7 @@ pub fn main() !void { std.debug.print("Full mode grid nav to session {d}\n", .{anim_state.focused_session}); } else { if (focused.spawned and !focused.dead) { + session_interaction_component.resetScrollIfNeeded(anim_state.focused_session); try handleKeyInput(focused, key, mod); } } @@ -824,8 +806,8 @@ pub fn main() !void { const clicked_session = anim_state.focused_session; try sessions[clicked_session].ensureSpawnedWithLoop(&loop); - sessions[clicked_session].status = .running; - sessions[clicked_session].attention = false; + session_interaction_component.setStatus(clicked_session, .running); + session_interaction_component.setAttention(clicked_session, false); const grid_row: c_int = @intCast(clicked_session / grid_cols); const grid_col: c_int = @intCast(clicked_session % grid_cols); @@ -852,6 +834,7 @@ pub fn main() !void { } std.debug.print("Expanding session: {d}\n", .{clicked_session}); } else if (focused.spawned and !focused.dead and !isModifierKey(key)) { + session_interaction_component.resetScrollIfNeeded(anim_state.focused_session); try handleKeyInput(focused, key, mod); } }, @@ -868,255 +851,17 @@ pub fn main() !void { std.debug.print("Escape released, sent to terminal\n", .{}); } }, - c.SDL_EVENT_MOUSE_BUTTON_DOWN => { - const mouse_x: c_int = @intFromFloat(scaled_event.button.x); - const mouse_y: c_int = @intFromFloat(scaled_event.button.y); - - if (anim_state.mode == .Grid) { - sessions[anim_state.focused_session].clearSelection(); - const grid_col_idx: usize = @min(@as(usize, @intCast(@divFloor(mouse_x, cell_width_pixels))), grid_cols - 1); - const grid_row_idx: usize = @min(@as(usize, @intCast(@divFloor(mouse_y, cell_height_pixels))), grid_rows - 1); - const clicked_session: usize = grid_row_idx * grid_cols + grid_col_idx; - - const cell_rect = Rect{ - .x = @as(c_int, @intCast(grid_col_idx)) * cell_width_pixels, - .y = @as(c_int, @intCast(grid_row_idx)) * cell_height_pixels, - .w = cell_width_pixels, - .h = cell_height_pixels, - }; - - try sessions[clicked_session].ensureSpawnedWithLoop(&loop); - - sessions[clicked_session].status = .running; - sessions[clicked_session].attention = false; - - const target_rect = Rect{ .x = 0, .y = 0, .w = render_width, .h = render_height }; - - anim_state.focused_session = clicked_session; - if (animations_enabled) { - anim_state.mode = .Expanding; - anim_state.start_time = now; - anim_state.start_rect = cell_rect; - anim_state.target_rect = target_rect; - } else { - anim_state.mode = .Full; - anim_state.start_time = now; - anim_state.start_rect = target_rect; - anim_state.target_rect = target_rect; - anim_state.previous_session = clicked_session; - } - std.debug.print("Expanding session: {d}\n", .{clicked_session}); - } else if (anim_state.mode == .Full and scaled_event.button.button == c.SDL_BUTTON_LEFT) { - const focused = &sessions[anim_state.focused_session]; - if (focused.spawned and focused.terminal != null) { - if (fullViewPinFromMouse(focused, mouse_x, mouse_y, render_width, render_height, &font, full_cols, full_rows)) |pin| { - const clicks = scaled_event.button.clicks; - - if (clicks >= 3) { - // Triple-click: select entire line - selectLine(focused, pin, focused.is_viewing_scrollback); - } else if (clicks == 2) { - // Double-click: select word - selectWord(focused, pin, focused.is_viewing_scrollback); - } else { - // Single-click: begin drag selection or open link - const mod = c.SDL_GetModState(); - const cmd_held = (mod & c.SDL_KMOD_GUI) != 0; - - if (cmd_held) { - if (getLinkAtPin(allocator, &focused.terminal.?, pin, focused.is_viewing_scrollback)) |uri| { - defer allocator.free(uri); - open_url.openUrl(allocator, uri) catch |err| { - log.err("Failed to open URL: {}", .{err}); - }; - } else { - beginSelection(focused, pin); - } - } else { - beginSelection(focused, pin); - } - } - } - } - } - }, - c.SDL_EVENT_MOUSE_BUTTON_UP => { - if (scaled_event.button.button == c.SDL_BUTTON_LEFT and anim_state.mode == .Full) { - const focused = &sessions[anim_state.focused_session]; - endSelection(focused); - } - }, - c.SDL_EVENT_MOUSE_MOTION => { - const mouse_x: c_int = @intFromFloat(scaled_event.motion.x); - const mouse_y: c_int = @intFromFloat(scaled_event.motion.y); - const over_ui = ui.hitTest(&ui_host, mouse_x, mouse_y); - var desired_cursor: CursorKind = .arrow; - - if (anim_state.mode == .Full) { - const focused = &sessions[anim_state.focused_session]; - const pin = fullViewPinFromMouse(focused, mouse_x, mouse_y, render_width, render_height, &font, full_cols, full_rows); - - if (focused.selection_dragging) { - if (pin) |p| { - updateSelectionDrag(focused, p); - } - - const edge_threshold: c_int = 50; - const scroll_speed: isize = 1; - - if (mouse_y < edge_threshold) { - scrollSession(focused, -scroll_speed, now); - } else if (mouse_y > render_height - edge_threshold) { - scrollSession(focused, scroll_speed, now); - } - } else if (focused.selection_pending) { - if (focused.selection_anchor) |anchor| { - if (pin) |p| { - if (!pinsEqual(anchor, p)) { - startSelectionDrag(focused, p); - } - } - } else { - focused.selection_pending = false; - } - } - - if (!over_ui and pin != null and focused.terminal != null) { - const mod = c.SDL_GetModState(); - const cmd_held = (mod & c.SDL_KMOD_GUI) != 0; - - if (cmd_held) { - if (getLinkMatchAtPin(allocator, &focused.terminal.?, pin.?, focused.is_viewing_scrollback)) |link_match| { - desired_cursor = .pointer; - focused.hovered_link_start = link_match.start_pin; - focused.hovered_link_end = link_match.end_pin; - allocator.free(link_match.url); - focused.markDirty(); - } else { - desired_cursor = .ibeam; - focused.hovered_link_start = null; - focused.hovered_link_end = null; - focused.markDirty(); - } - } else { - desired_cursor = .ibeam; - if (focused.hovered_link_start != null) { - focused.hovered_link_start = null; - focused.hovered_link_end = null; - focused.markDirty(); - } - } - } else { - if (focused.hovered_link_start != null) { - focused.hovered_link_start = null; - focused.hovered_link_end = null; - focused.markDirty(); - } - } - } - - if (desired_cursor != current_cursor) { - const target_cursor = switch (desired_cursor) { - .arrow => arrow_cursor, - .ibeam => ibeam_cursor, - .pointer => pointer_cursor, - }; - if (target_cursor) |cursor| { - _ = c.SDL_SetCursor(cursor); - current_cursor = desired_cursor; - } - } - }, - c.SDL_EVENT_MOUSE_WHEEL => { - const mouse_x: c_int = @intFromFloat(scaled_event.wheel.mouse_x); - const mouse_y: c_int = @intFromFloat(scaled_event.wheel.mouse_y); - - const hovered_session = calculateHoveredSession( - mouse_x, - mouse_y, - &anim_state, - cell_width_pixels, - cell_height_pixels, - render_width, - render_height, - grid_cols, - grid_rows, - ); - - if (hovered_session) |session_idx| { - var session = &sessions[session_idx]; - const ticks_per_notch: isize = SCROLL_LINES_PER_TICK; - const wheel_ticks: isize = if (scaled_event.wheel.integer_y != 0) - @as(isize, @intCast(scaled_event.wheel.integer_y)) * ticks_per_notch - else - @as(isize, @intFromFloat(scaled_event.wheel.y * @as(f32, @floatFromInt(SCROLL_LINES_PER_TICK)))); - const scroll_delta = -wheel_ticks; - if (scroll_delta != 0) { - // Check if terminal has mouse tracking enabled and we should forward scroll to app - const should_forward = blk: { - if (anim_state.mode != .Full) break :blk false; - if (session.is_viewing_scrollback) break :blk false; - const terminal = session.terminal orelse break :blk false; - // Check if any mouse tracking mode is enabled - const mouse_tracking = terminal.modes.get(.mouse_event_normal) or - terminal.modes.get(.mouse_event_button) or - terminal.modes.get(.mouse_event_any) or - terminal.modes.get(.mouse_event_x10); - break :blk mouse_tracking; - }; - - if (should_forward) { - if (fullViewCellFromMouse(mouse_x, mouse_y, render_width, render_height, &font, full_cols, full_rows)) |cell| { - // Forward scroll to terminal as mouse events - const terminal = session.terminal.?; - const sgr_format = terminal.modes.get(.mouse_format_sgr); - const direction: input.MouseScrollDirection = if (scroll_delta < 0) .up else .down; - const count = @abs(scroll_delta); - var buf: [32]u8 = undefined; - var i: usize = 0; - while (i < count) : (i += 1) { - const n = input.encodeMouseScroll(direction, cell.col, cell.row, sgr_format, &buf); - if (n > 0) { - session.sendInput(buf[0..n]) catch |err| { - log.warn("session {d}: failed to send mouse scroll: {}", .{ session_idx, err }); - }; - } - } - } else { - scrollSession(session, scroll_delta, now); - // If the wheel event originates from a touch/trackpad - // contact (SDL_TOUCH_MOUSEID), keep inertia suppressed - // until the contact is released. - if (scaled_event.wheel.which == c.SDL_TOUCH_MOUSEID) { - session.scroll_inertia_allowed = false; - } - } - } else { - scrollSession(session, scroll_delta, now); - // If the wheel event originates from a touch/trackpad - // contact (SDL_TOUCH_MOUSEID), keep inertia suppressed - // until the contact is released. - if (scaled_event.wheel.which == c.SDL_TOUCH_MOUSEID) { - session.scroll_inertia_allowed = false; - } - } - } - } - }, else => {}, } } try loop.run(.no_wait); - var has_scroll_inertia = false; for (sessions) |*session| { session.checkAlive(); try session.processOutput(); try session.flushPendingWrites(); session.updateCwd(now); - updateScrollInertia(session, delta_time_s); - has_scroll_inertia = has_scroll_inertia or (session.scroll_velocity != 0.0); } const any_session_dirty = render_cache.anyDirty(sessions); @@ -1125,14 +870,13 @@ pub fn main() !void { const had_notifications = notifications.items.len > 0; for (notifications.items) |note| { if (note.session < sessions.len) { - var session = &sessions[note.session]; - session.status = note.state; + session_interaction_component.setStatus(note.session, note.state); const wants_attention = switch (note.state) { .awaiting_approval, .done => true, else => false, }; const is_focused_full = anim_state.mode == .Full and anim_state.focused_session == note.session; - session.attention = if (is_focused_full) false else wants_attention; + session_interaction_component.setAttention(note.session, if (is_focused_full) false else wants_attention); std.debug.print("Session {d} status -> {s}\n", .{ note.session, @tagName(note.state) }); } } @@ -1147,6 +891,8 @@ pub fn main() !void { cell_height_pixels, grid_cols, grid_rows, + full_cols, + full_rows, &anim_state, sessions, session_ui_info, @@ -1159,9 +905,44 @@ pub fn main() !void { .RestartSession => |idx| { if (idx < sessions.len) { try sessions[idx].restart(); + session_interaction_component.resetView(idx); std.debug.print("UI requested restart: {d}\n", .{idx}); } }, + .FocusSession => |idx| { + if (anim_state.mode != .Grid) continue; + if (idx >= sessions.len) continue; + + session_interaction_component.clearSelection(anim_state.focused_session); + try sessions[idx].ensureSpawnedWithLoop(&loop); + session_interaction_component.setStatus(idx, .running); + session_interaction_component.setAttention(idx, false); + + const grid_row: c_int = @intCast(idx / grid_cols); + const grid_col: c_int = @intCast(idx % grid_cols); + const cell_rect = Rect{ + .x = grid_col * cell_width_pixels, + .y = grid_row * cell_height_pixels, + .w = cell_width_pixels, + .h = cell_height_pixels, + }; + const target_rect = Rect{ .x = 0, .y = 0, .w = render_width, .h = render_height }; + + anim_state.focused_session = idx; + if (animations_enabled) { + anim_state.mode = .Expanding; + anim_state.start_time = now; + anim_state.start_rect = cell_rect; + anim_state.target_rect = target_rect; + } else { + anim_state.mode = .Full; + anim_state.start_time = now; + anim_state.start_rect = target_rect; + anim_state.target_rect = target_rect; + anim_state.previous_session = idx; + } + std.debug.print("Expanding session: {d}\n", .{idx}); + }, .DespawnSession => |idx| { if (idx < sessions.len) { if (anim_state.mode == .Full and anim_state.focused_session == idx) { @@ -1172,6 +953,7 @@ pub fn main() !void { } } sessions[idx].deinit(allocator); + session_interaction_component.resetView(idx); sessions[idx].markDirty(); std.debug.print("UI requested despawn: {d}\n", .{idx}); } @@ -1240,8 +1022,8 @@ pub fn main() !void { continue; }; - session.status = .running; - session.attention = false; + session_interaction_component.setStatus(switch_action.session, .running); + session_interaction_component.setAttention(switch_action.session, false); ui.showToast("Switched worktree", now); }, .CreateWorktree => |create_action| { @@ -1281,8 +1063,8 @@ pub fn main() !void { allocator.free(abs); } - session.status = .running; - session.attention = false; + session_interaction_component.setStatus(create_action.session, .running); + session_interaction_component.setAttention(create_action.session, false); ui.showToast("Creating worktree…", now); }, .RemoveWorktree => |remove_action| { @@ -1330,8 +1112,8 @@ pub fn main() !void { continue; }; - session.status = .running; - session.attention = false; + session_interaction_component.setStatus(remove_action.session, .running); + session_interaction_component.setAttention(remove_action.session, false); ui.showToast("Removing worktree…", now); }, .ToggleMetrics => { @@ -1388,6 +1170,8 @@ pub fn main() !void { cell_height_pixels, grid_cols, grid_rows, + full_cols, + full_rows, &anim_state, sessions, session_ui_info, @@ -1401,14 +1185,14 @@ pub fn main() !void { const should_render = animating or any_session_dirty or ui_needs_frame or processed_event or had_notifications or last_render_stale; if (should_render) { - try renderer_mod.render(renderer, &render_cache, sessions, cell_width_pixels, cell_height_pixels, grid_cols, grid_rows, &anim_state, now, &font, full_cols, full_rows, render_width, render_height, &theme, config.grid.font_scale); + try renderer_mod.render(renderer, &render_cache, sessions, session_interaction_component.viewSlice(), cell_width_pixels, cell_height_pixels, grid_cols, grid_rows, &anim_state, now, &font, full_cols, full_rows, render_width, render_height, &theme, config.grid.font_scale); ui.render(&ui_render_host, renderer); _ = c.SDL_RenderPresent(renderer); metrics_mod.increment(.frame_count); last_render_ns = std.time.nanoTimestamp(); } - const is_idle = !animating and !any_session_dirty and !ui_needs_frame and !processed_event and !had_notifications and !has_scroll_inertia; + const is_idle = !animating and !any_session_dirty and !ui_needs_frame and !processed_event and !had_notifications; // When vsync is enabled and we're active, let vsync handle frame pacing. // When idle, always throttle to save power regardless of vsync. const needs_throttle = is_idle or !sdl.vsync_enabled; @@ -1513,6 +1297,7 @@ fn formatGridNotification(buf: []u8, focused_session: usize, grid_cols: usize, g fn navigateGrid( anim_state: *AnimationState, sessions: []SessionState, + session_interaction: *ui_mod.SessionInteractionComponent, direction: input.GridNavDirection, now: i64, enable_wrapping: bool, @@ -1582,8 +1367,8 @@ fn navigateGrid( } else if (show_animation) { try sessions[new_session].ensureSpawnedWithLoop(loop); } - sessions[anim_state.focused_session].clearSelection(); - sessions[new_session].clearSelection(); + session_interaction.clearSelection(anim_state.focused_session); + session_interaction.clearSelection(new_session); if (animation_mode) |mode| { anim_state.mode = mode; @@ -1639,6 +1424,44 @@ fn scaleEventToRender(event: *const c.SDL_Event, scale_x: f32, scale_y: f32) c.S return e; } +fn applyMouseContext(ui: *ui_mod.UiRoot, host: *ui_mod.UiHost, event: *const c.SDL_Event) void { + switch (event.type) { + c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP => { + const mouse_x: c_int = @intFromFloat(event.button.x); + const mouse_y: c_int = @intFromFloat(event.button.y); + host.mouse_x = mouse_x; + host.mouse_y = mouse_y; + host.mouse_has_position = true; + host.mouse_over_ui = ui.hitTest(host, mouse_x, mouse_y); + }, + c.SDL_EVENT_MOUSE_MOTION => { + const mouse_x: c_int = @intFromFloat(event.motion.x); + const mouse_y: c_int = @intFromFloat(event.motion.y); + host.mouse_x = mouse_x; + host.mouse_y = mouse_y; + host.mouse_has_position = true; + host.mouse_over_ui = ui.hitTest(host, mouse_x, mouse_y); + }, + c.SDL_EVENT_MOUSE_WHEEL => { + const mouse_x: c_int = @intFromFloat(event.wheel.mouse_x); + const mouse_y: c_int = @intFromFloat(event.wheel.mouse_y); + host.mouse_x = mouse_x; + host.mouse_y = mouse_y; + host.mouse_has_position = true; + host.mouse_over_ui = ui.hitTest(host, mouse_x, mouse_y); + }, + c.SDL_EVENT_DROP_POSITION => { + const mouse_x: c_int = @intFromFloat(event.drop.x); + const mouse_y: c_int = @intFromFloat(event.drop.y); + host.mouse_x = mouse_x; + host.mouse_y = mouse_y; + host.mouse_has_position = true; + host.mouse_over_ui = ui.hitTest(host, mouse_x, mouse_y); + }, + else => {}, + } +} + fn makeUiHost( now: i64, render_width: c_int, @@ -1648,6 +1471,8 @@ fn makeUiHost( cell_height_pixels: c_int, grid_cols: usize, grid_rows: usize, + term_cols: u16, + term_rows: u16, anim_state: *const AnimationState, sessions: []const SessionState, buffer: []ui_mod.SessionUiInfo, @@ -1665,6 +1490,10 @@ fn makeUiHost( const focused_session = &sessions[anim_state.focused_session]; const focused_cwd = focused_session.cwd_path; + const animating_rect: ?Rect = switch (anim_state.mode) { + .Expanding, .Collapsing => anim_state.getCurrentRect(now), + else => null, + }; return .{ .now_ms = now, @@ -1675,10 +1504,13 @@ fn makeUiHost( .grid_rows = grid_rows, .cell_w = cell_width_pixels, .cell_h = cell_height_pixels, + .term_cols = term_cols, + .term_rows = term_rows, .view_mode = anim_state.mode, .focused_session = anim_state.focused_session, .focused_cwd = focused_cwd, .focused_has_foreground_process = focused_has_foreground_process, + .animating_rect = animating_rect, .sessions = buffer[0..sessions.len], .theme = theme, }; @@ -1717,60 +1549,6 @@ fn calculateHoveredSession( }; } -fn scrollSession(session: *SessionState, delta: isize, now: i64) void { - if (!session.spawned) return; - - session.last_scroll_time = now; - session.scroll_remainder = 0.0; - session.scroll_inertia_allowed = true; - - if (session.terminal) |*terminal| { - var pages = &terminal.screens.active.pages; - pages.scroll(.{ .delta_row = delta }); - session.is_viewing_scrollback = (pages.viewport != .active); - session.markDirty(); - } - - const sensitivity: f32 = 0.08; - session.scroll_velocity += @as(f32, @floatFromInt(delta)) * sensitivity; - session.scroll_velocity = std.math.clamp(session.scroll_velocity, -MAX_SCROLL_VELOCITY, MAX_SCROLL_VELOCITY); -} - -fn updateScrollInertia(session: *SessionState, delta_time_s: f32) void { - if (!session.spawned) return; - if (!session.scroll_inertia_allowed) return; - if (session.scroll_velocity == 0.0) return; - if (session.last_scroll_time == 0) return; - - const decay_constant: f32 = 7.5; - const decay_factor = std.math.exp(-decay_constant * delta_time_s); - const velocity_threshold: f32 = 0.12; - - if (@abs(session.scroll_velocity) < velocity_threshold) { - session.scroll_velocity = 0.0; - session.scroll_remainder = 0.0; - return; - } - - const reference_fps: f32 = 60.0; - - if (session.terminal) |*terminal| { - const scroll_amount = session.scroll_velocity * delta_time_s * reference_fps + session.scroll_remainder; - const scroll_lines: isize = @intFromFloat(scroll_amount); - - if (scroll_lines != 0) { - var pages = &terminal.screens.active.pages; - pages.scroll(.{ .delta_row = scroll_lines }); - session.is_viewing_scrollback = (pages.viewport != .active); - session.markDirty(); - } - - session.scroll_remainder = scroll_amount - @as(f32, @floatFromInt(scroll_lines)); - } - - session.scroll_velocity *= decay_factor; -} - const TerminalSize = struct { cols: u16, rows: u16, @@ -1875,15 +1653,6 @@ fn isModifierKey(key: c.SDL_Keycode) bool { fn handleKeyInput(focused: *SessionState, key: c.SDL_Keycode, mod: c.SDL_Keymod) !void { if (key == c.SDLK_ESCAPE) return; - if (focused.is_viewing_scrollback) { - if (focused.terminal) |*terminal| { - terminal.screens.active.pages.scroll(.{ .active = {} }); - focused.is_viewing_scrollback = false; - focused.scroll_velocity = 0.0; - focused.scroll_remainder = 0.0; - } - } - // Check if kitty keyboard protocol is enabled (any non-zero flags value) const kitty_enabled = if (focused.terminal) |*terminal| terminal.screens.active.kitty_keyboard.current().int() != 0 @@ -1897,439 +1666,6 @@ fn handleKeyInput(focused: *SessionState, key: c.SDL_Keycode, mod: c.SDL_Keymod) } } -const CellPosition = struct { col: u16, row: u16 }; - -/// Convert mouse coordinates to terminal cell position for full-screen view. -/// Returns null if the mouse is outside the terminal area. -fn fullViewCellFromMouse( - mouse_x: c_int, - mouse_y: c_int, - render_width: c_int, - render_height: c_int, - font: *const font_mod.Font, - term_cols: u16, - term_rows: u16, -) ?CellPosition { - const padding = renderer_mod.TERMINAL_PADDING; - const origin_x: c_int = padding; - const origin_y: c_int = padding; - const drawable_w: c_int = render_width - padding * 2; - const drawable_h: c_int = render_height - padding * 2; - if (drawable_w <= 0 or drawable_h <= 0) return null; - - const cell_w: c_int = font.cell_width; - const cell_h: c_int = font.cell_height; - if (cell_w == 0 or cell_h == 0) return null; - - if (mouse_x < origin_x or mouse_y < origin_y) return null; - if (mouse_x >= origin_x + drawable_w or mouse_y >= origin_y + drawable_h) return null; - - const col = @as(u16, @intCast(@divFloor(mouse_x - origin_x, cell_w))); - const row = @as(u16, @intCast(@divFloor(mouse_y - origin_y, cell_h))); - if (col >= term_cols or row >= term_rows) return null; - - return .{ .col = col, .row = row }; -} - -fn fullViewPinFromMouse( - session: *SessionState, - mouse_x: c_int, - mouse_y: c_int, - render_width: c_int, - render_height: c_int, - font: *const font_mod.Font, - term_cols: u16, - term_rows: u16, -) ?ghostty_vt.Pin { - if (!session.spawned or session.terminal == null) return null; - - const padding = renderer_mod.TERMINAL_PADDING; - const origin_x: c_int = padding; - const origin_y: c_int = padding; - const drawable_w: c_int = render_width - padding * 2; - const drawable_h: c_int = render_height - padding * 2; - if (drawable_w <= 0 or drawable_h <= 0) return null; - - const cell_w: c_int = font.cell_width; - const cell_h: c_int = font.cell_height; - if (cell_w == 0 or cell_h == 0) return null; - - if (mouse_x < origin_x or mouse_y < origin_y) return null; - if (mouse_x >= origin_x + drawable_w or mouse_y >= origin_y + drawable_h) return null; - - const col = @as(u16, @intCast(@divFloor(mouse_x - origin_x, cell_w))); - const row = @as(u16, @intCast(@divFloor(mouse_y - origin_y, cell_h))); - if (col >= term_cols or row >= term_rows) return null; - - const point = if (session.is_viewing_scrollback) - ghostty_vt.point.Point{ .viewport = .{ .x = col, .y = row } } - else - ghostty_vt.point.Point{ .active = .{ .x = col, .y = row } }; - - const terminal = session.terminal orelse return null; - return terminal.screens.active.pages.pin(point); -} - -fn beginSelection(session: *SessionState, pin: ghostty_vt.Pin) void { - const terminal = session.terminal orelse return; - terminal.screens.active.clearSelection(); - session.selection_anchor = pin; - session.selection_pending = true; - session.selection_dragging = false; - session.markDirty(); -} - -fn startSelectionDrag(session: *SessionState, pin: ghostty_vt.Pin) void { - const terminal = session.terminal orelse return; - const anchor = session.selection_anchor orelse return; - - session.selection_dragging = true; - session.selection_pending = false; - - terminal.screens.active.clearSelection(); - terminal.screens.active.select(ghostty_vt.Selection.init(anchor, pin, false)) catch |err| { - log.warn("session {d}: failed to start selection: {}", .{ session.id, err }); - }; - session.markDirty(); -} - -fn updateSelectionDrag(session: *SessionState, pin: ghostty_vt.Pin) void { - if (!session.selection_dragging) return; - const anchor = session.selection_anchor orelse return; - const terminal = session.terminal orelse return; - terminal.screens.active.select(ghostty_vt.Selection.init(anchor, pin, false)) catch |err| { - log.warn("session {d}: failed to update selection: {}", .{ session.id, err }); - }; - session.markDirty(); -} - -fn endSelection(session: *SessionState) void { - session.selection_dragging = false; - session.selection_pending = false; - session.selection_anchor = null; -} - -fn pinsEqual(a: ghostty_vt.Pin, b: ghostty_vt.Pin) bool { - return a.node == b.node and a.x == b.x and a.y == b.y; -} - -/// Returns true if the codepoint is considered part of a word (alphanumeric or underscore). -/// Only ASCII characters are considered; non-ASCII codepoints return false. -fn isWordCharacter(codepoint: u21) bool { - if (codepoint > 127) return false; - const ch: u8 = @intCast(codepoint); - return std.ascii.isAlphanumeric(ch) or ch == '_'; -} - -/// Select the word at the given pin position. A word is a contiguous sequence of -/// word characters (alphanumeric and underscore). -fn selectWord(session: *SessionState, pin: ghostty_vt.Pin, is_viewing_scrollback: bool) void { - const terminal = &(session.terminal orelse return); - const page = &pin.node.data; - const max_col: u16 = @intCast(page.size.cols - 1); - - // Get the point from the pin - const pin_point = if (is_viewing_scrollback) - terminal.screens.active.pages.pointFromPin(.viewport, pin) - else - terminal.screens.active.pages.pointFromPin(.active, pin); - const point = pin_point orelse return; - const click_x = if (is_viewing_scrollback) point.viewport.x else point.active.x; - const click_y = if (is_viewing_scrollback) point.viewport.y else point.active.y; - - // Check if clicked cell is a word character - const clicked_cell = terminal.screens.active.pages.getCell( - if (is_viewing_scrollback) - ghostty_vt.point.Point{ .viewport = .{ .x = click_x, .y = click_y } } - else - ghostty_vt.point.Point{ .active = .{ .x = click_x, .y = click_y } }, - ) orelse return; - const clicked_cp = clicked_cell.cell.content.codepoint; - if (!isWordCharacter(clicked_cp)) return; - - // Find word start by scanning left - var start_x = click_x; - while (start_x > 0) { - const prev_x = start_x - 1; - const prev_cell = terminal.screens.active.pages.getCell( - if (is_viewing_scrollback) - ghostty_vt.point.Point{ .viewport = .{ .x = prev_x, .y = click_y } } - else - ghostty_vt.point.Point{ .active = .{ .x = prev_x, .y = click_y } }, - ) orelse break; - if (!isWordCharacter(prev_cell.cell.content.codepoint)) break; - start_x = prev_x; - } - - // Find word end by scanning right - var end_x = click_x; - while (end_x < max_col) { - const next_x = end_x + 1; - const next_cell = terminal.screens.active.pages.getCell( - if (is_viewing_scrollback) - ghostty_vt.point.Point{ .viewport = .{ .x = next_x, .y = click_y } } - else - ghostty_vt.point.Point{ .active = .{ .x = next_x, .y = click_y } }, - ) orelse break; - if (!isWordCharacter(next_cell.cell.content.codepoint)) break; - end_x = next_x; - } - - // Create pins for the word boundaries - const start_point = if (is_viewing_scrollback) - ghostty_vt.point.Point{ .viewport = .{ .x = start_x, .y = click_y } } - else - ghostty_vt.point.Point{ .active = .{ .x = start_x, .y = click_y } }; - const end_point = if (is_viewing_scrollback) - ghostty_vt.point.Point{ .viewport = .{ .x = end_x, .y = click_y } } - else - ghostty_vt.point.Point{ .active = .{ .x = end_x, .y = click_y } }; - - const start_pin = terminal.screens.active.pages.pin(start_point) orelse return; - const end_pin = terminal.screens.active.pages.pin(end_point) orelse return; - - // Apply the selection - terminal.screens.active.clearSelection(); - terminal.screens.active.select(ghostty_vt.Selection.init(start_pin, end_pin, false)) catch |err| { - log.err("failed to select word: {}", .{err}); - return; - }; - session.markDirty(); -} - -/// Select the entire line at the given pin position. -fn selectLine(session: *SessionState, pin: ghostty_vt.Pin, is_viewing_scrollback: bool) void { - const terminal = &(session.terminal orelse return); - const page = &pin.node.data; - const max_col: u16 = @intCast(page.size.cols - 1); - - // Get the point from the pin - const pin_point = if (is_viewing_scrollback) - terminal.screens.active.pages.pointFromPin(.viewport, pin) - else - terminal.screens.active.pages.pointFromPin(.active, pin); - const point = pin_point orelse return; - const click_y = if (is_viewing_scrollback) point.viewport.y else point.active.y; - - // Create pins for line start (x=0) and line end (x=max_col) - const start_point = if (is_viewing_scrollback) - ghostty_vt.point.Point{ .viewport = .{ .x = 0, .y = click_y } } - else - ghostty_vt.point.Point{ .active = .{ .x = 0, .y = click_y } }; - const end_point = if (is_viewing_scrollback) - ghostty_vt.point.Point{ .viewport = .{ .x = max_col, .y = click_y } } - else - ghostty_vt.point.Point{ .active = .{ .x = max_col, .y = click_y } }; - - const start_pin = terminal.screens.active.pages.pin(start_point) orelse return; - const end_pin = terminal.screens.active.pages.pin(end_point) orelse return; - - // Apply the selection - terminal.screens.active.clearSelection(); - terminal.screens.active.select(ghostty_vt.Selection.init(start_pin, end_pin, false)) catch |err| { - log.err("failed to select line: {}", .{err}); - return; - }; - session.markDirty(); -} - -const LinkMatch = struct { - url: []u8, - start_pin: ghostty_vt.Pin, - end_pin: ghostty_vt.Pin, -}; - -fn getLinkMatchAtPin(allocator: std.mem.Allocator, terminal: *ghostty_vt.Terminal, pin: ghostty_vt.Pin, is_viewing_scrollback: bool) ?LinkMatch { - const page = &pin.node.data; - const row_and_cell = pin.rowAndCell(); - const cell = row_and_cell.cell; - - if (page.lookupHyperlink(cell)) |hyperlink_id| { - const entry = page.hyperlink_set.get(page.memory, hyperlink_id); - const url = allocator.dupe(u8, entry.uri.slice(page.memory)) catch return null; - return LinkMatch{ - .url = url, - .start_pin = pin, - .end_pin = pin, - }; - } - - const pin_point = if (is_viewing_scrollback) - terminal.screens.active.pages.pointFromPin(.viewport, pin) - else - terminal.screens.active.pages.pointFromPin(.active, pin); - const point_or_null = pin_point orelse return null; - const start_y_orig = if (is_viewing_scrollback) point_or_null.viewport.y else point_or_null.active.y; - - var start_y = start_y_orig; - var current_row = row_and_cell.row; - - while (current_row.wrap_continuation and start_y > 0) { - start_y -= 1; - const prev_point = if (is_viewing_scrollback) - ghostty_vt.point.Point{ .viewport = .{ .x = 0, .y = start_y } } - else - ghostty_vt.point.Point{ .active = .{ .x = 0, .y = start_y } }; - const prev_pin = terminal.screens.active.pages.pin(prev_point) orelse break; - current_row = prev_pin.rowAndCell().row; - } - - var end_y = start_y_orig; - current_row = row_and_cell.row; - const max_y: u16 = @intCast(page.size.rows - 1); - - while (current_row.wrap and end_y < max_y) { - end_y += 1; - const next_point = if (is_viewing_scrollback) - ghostty_vt.point.Point{ .viewport = .{ .x = 0, .y = end_y } } - else - ghostty_vt.point.Point{ .active = .{ .x = 0, .y = end_y } }; - const next_pin = terminal.screens.active.pages.pin(next_point) orelse break; - current_row = next_pin.rowAndCell().row; - } - - const max_x: u16 = @intCast(page.size.cols - 1); - const row_start_point = if (is_viewing_scrollback) - ghostty_vt.point.Point{ .viewport = .{ .x = 0, .y = start_y } } - else - ghostty_vt.point.Point{ .active = .{ .x = 0, .y = start_y } }; - const row_end_point = if (is_viewing_scrollback) - ghostty_vt.point.Point{ .viewport = .{ .x = max_x, .y = end_y } } - else - ghostty_vt.point.Point{ .active = .{ .x = max_x, .y = end_y } }; - const row_start_pin = terminal.screens.active.pages.pin(row_start_point) orelse return null; - const row_end_pin = terminal.screens.active.pages.pin(row_end_point) orelse return null; - - const selection = ghostty_vt.Selection.init(row_start_pin, row_end_pin, false); - const row_text = terminal.screens.active.selectionString(allocator, .{ - .sel = selection, - .trim = false, - }) catch return null; - defer allocator.free(row_text); - - var cell_to_byte: std.ArrayList(usize) = .empty; - defer cell_to_byte.deinit(allocator); - - var byte_pos: usize = 0; - var cell_idx: usize = 0; - var y = start_y; - while (y <= end_y) : (y += 1) { - var x: u16 = 0; - while (x < page.size.cols) : (x += 1) { - const point = if (is_viewing_scrollback) - ghostty_vt.point.Point{ .viewport = .{ .x = x, .y = y } } - else - ghostty_vt.point.Point{ .active = .{ .x = x, .y = y } }; - const list_cell = terminal.screens.active.pages.getCell(point) orelse { - cell_to_byte.append(allocator, byte_pos) catch return null; - cell_idx += 1; - continue; - }; - - cell_to_byte.append(allocator, byte_pos) catch return null; - - const list_cell_cell = list_cell.cell; - const cp = list_cell_cell.content.codepoint; - const encoded_len: usize = blk: { - if (cp != 0 and cp != ' ') { - var utf8_buf: [4]u8 = undefined; - break :blk std.unicode.utf8Encode(cp, &utf8_buf) catch 1; - } - break :blk 1; - }; - - if (list_cell_cell.wide == .wide) { - // Wide character (takes 2 cells, but emitted as one sequence in text). - byte_pos += encoded_len; - - // If possible, handle the second cell of the wide character now - // so we map it to the same byte position (start of char). - if (x + 1 < page.size.cols) { - x += 1; - // Map the second half to the START of the character. - // The previous append was for the start of the character. - // We need to retrieve that value. - const char_start_pos = cell_to_byte.items[cell_to_byte.items.len - 1]; - cell_to_byte.append(allocator, char_start_pos) catch return null; - cell_idx += 1; - } - } else { - // Narrow character - byte_pos += encoded_len; - } - cell_idx += 1; - } - if (y < end_y) { - byte_pos += 1; - } - } - - const pin_x = if (is_viewing_scrollback) point_or_null.viewport.x else point_or_null.active.x; - const click_cell_idx = (start_y_orig - start_y) * page.size.cols + pin_x; - if (click_cell_idx >= cell_to_byte.items.len) return null; - const click_byte_pos = cell_to_byte.items[click_cell_idx]; - - const url_match = url_matcher.findUrlMatchAtPosition(row_text, click_byte_pos) orelse return null; - - var start_cell_idx: usize = 0; - for (cell_to_byte.items, 0..) |byte, idx| { - if (byte >= url_match.start) { - start_cell_idx = idx; - break; - } - } - - var end_cell_idx: usize = cell_to_byte.items.len - 1; - for (cell_to_byte.items, 0..) |byte, idx| { - if (byte >= url_match.end) { - end_cell_idx = if (idx > 0) idx - 1 else 0; - break; - } - } - - const start_row = start_y + @as(u16, @intCast(start_cell_idx / page.size.cols)); - const start_col: u16 = @intCast(start_cell_idx % page.size.cols); - const end_row = start_y + @as(u16, @intCast(end_cell_idx / page.size.cols)); - const end_col: u16 = @intCast(end_cell_idx % page.size.cols); - - const link_start_point = if (is_viewing_scrollback) - ghostty_vt.point.Point{ .viewport = .{ .x = start_col, .y = start_row } } - else - ghostty_vt.point.Point{ .active = .{ .x = start_col, .y = start_row } }; - const link_end_point = if (is_viewing_scrollback) - ghostty_vt.point.Point{ .viewport = .{ .x = end_col, .y = end_row } } - else - ghostty_vt.point.Point{ .active = .{ .x = end_col, .y = end_row } }; - const link_start_pin = terminal.screens.active.pages.pin(link_start_point) orelse return null; - const link_end_pin = terminal.screens.active.pages.pin(link_end_point) orelse return null; - - const url = allocator.dupe(u8, url_match.url) catch return null; - - return LinkMatch{ - .url = url, - .start_pin = link_start_pin, - .end_pin = link_end_pin, - }; -} - -fn getLinkAtPin(allocator: std.mem.Allocator, terminal: *ghostty_vt.Terminal, pin: ghostty_vt.Pin, is_viewing_scrollback: bool) ?[]u8 { - if (getLinkMatchAtPin(allocator, terminal, pin, is_viewing_scrollback)) |match| { - return match.url; - } - return null; -} - -fn resetScrollIfNeeded(session: *SessionState) void { - if (!session.is_viewing_scrollback) return; - - if (session.terminal) |*terminal| { - terminal.screens.active.pages.scroll(.{ .active = {} }); - session.is_viewing_scrollback = false; - session.scroll_velocity = 0.0; - session.scroll_remainder = 0.0; - } -} - fn appendQuotedPath(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, path: []const u8) !void { try buf.append(allocator, '\''); for (path) |ch| switch (ch) { @@ -2393,10 +1729,15 @@ fn buildRemoveWorktreeCommand(allocator: std.mem.Allocator, path: []const u8) ![ return cmd.toOwnedSlice(allocator); } -fn pasteText(session: *SessionState, allocator: std.mem.Allocator, text: []const u8) !void { +fn pasteText( + session: *SessionState, + allocator: std.mem.Allocator, + text: []const u8, + session_interaction: *ui_mod.SessionInteractionComponent, +) !void { if (text.len == 0) return; - resetScrollIfNeeded(session); + session_interaction.resetScrollIfNeeded(session.id); const terminal = session.terminal orelse return error.NoTerminal; if (session.shell == null) return error.NoShell; @@ -2452,6 +1793,7 @@ fn handleTextEditing( text_ptr: [*c]const u8, start: c_int, length: c_int, + session_interaction: *ui_mod.SessionInteractionComponent, ) !void { if (!session.spawned or session.dead) return; if (text_ptr == null) return; @@ -2459,12 +1801,12 @@ fn handleTextEditing( const text = std.mem.sliceTo(text_ptr, 0); if (text.len == 0) { if (ime.codepoints == 0) return; - resetScrollIfNeeded(session); + session_interaction.resetScrollIfNeeded(session.id); try clearImeComposition(session, ime); return; } - resetScrollIfNeeded(session); + session_interaction.resetScrollIfNeeded(session.id); const is_committed_text = length == 0 and start == 0; if (is_committed_text) { try clearImeComposition(session, ime); @@ -2477,14 +1819,19 @@ fn handleTextEditing( ime.codepoints = countImeCodepoints(text); } -fn handleTextInput(session: *SessionState, ime: *ImeComposition, text_ptr: [*c]const u8) !void { +fn handleTextInput( + session: *SessionState, + ime: *ImeComposition, + text_ptr: [*c]const u8, + session_interaction: *ui_mod.SessionInteractionComponent, +) !void { if (!session.spawned or session.dead) return; if (text_ptr == null) return; const text = std.mem.sliceTo(text_ptr, 0); if (text.len == 0) return; - resetScrollIfNeeded(session); + session_interaction.resetScrollIfNeeded(session.id); try clearImeComposition(session, ime); try session.sendInput(text); } @@ -2543,6 +1890,7 @@ fn pasteClipboardIntoSession( allocator: std.mem.Allocator, ui: *ui_mod.UiRoot, now: i64, + session_interaction: *ui_mod.SessionInteractionComponent, ) !void { const clip_ptr = c.SDL_GetClipboardText(); defer c.SDL_free(clip_ptr); @@ -2556,7 +1904,7 @@ fn pasteClipboardIntoSession( return; } - pasteText(session, allocator, clip) catch |err| switch (err) { + pasteText(session, allocator, clip, session_interaction) catch |err| switch (err) { error.NoTerminal => { ui.showToast("No terminal to paste into", now); return; diff --git a/src/render/renderer.zig b/src/render/renderer.zig index 96cc346..4be0d66 100644 --- a/src/render/renderer.zig +++ b/src/render/renderer.zig @@ -8,11 +8,13 @@ const easing = @import("../anim/easing.zig"); const font_mod = @import("../font.zig"); const FontVariant = font_mod.Variant; const session_state = @import("../session/state.zig"); +const view_state = @import("../ui/session_view_state.zig"); const primitives = @import("../gfx/primitives.zig"); const log = std.log.scoped(.render); const SessionState = session_state.SessionState; +const SessionViewState = view_state.SessionViewState; const Rect = geom.Rect; const AnimationState = app_state.AnimationState; @@ -71,6 +73,7 @@ pub fn render( renderer: *c.SDL_Renderer, render_cache: *RenderCache, sessions: []SessionState, + views: []const SessionViewState, cell_width_pixels: c_int, cell_height_pixels: c_int, grid_cols: usize, @@ -87,6 +90,7 @@ pub fn render( ) RenderError!void { _ = c.SDL_SetRenderDrawColor(renderer, theme.background.r, theme.background.g, theme.background.b, 255); _ = c.SDL_RenderClear(renderer); + std.debug.assert(sessions.len == views.len); // Use the larger dimension for grid scale to ensure proper scaling // Multiply by grid_font_scale to allow proportionally larger font in grid view @@ -108,13 +112,13 @@ pub fn render( }; const entry = render_cache.entry(i); - try renderGridSessionCached(renderer, session, entry, cell_rect, grid_scale, i == anim_state.focused_session, true, font, term_cols, term_rows, current_time, theme); + try renderGridSessionCached(renderer, session, &views[i], entry, cell_rect, grid_scale, i == anim_state.focused_session, true, font, term_cols, term_rows, current_time, theme); } }, .Full => { const full_rect = Rect{ .x = 0, .y = 0, .w = window_width, .h = window_height }; const entry = render_cache.entry(anim_state.focused_session); - try renderSession(renderer, &sessions[anim_state.focused_session], entry, full_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme); + try renderSession(renderer, &sessions[anim_state.focused_session], &views[anim_state.focused_session], entry, full_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme); }, .PanningLeft, .PanningRight => { const elapsed = current_time - anim_state.start_time; @@ -126,7 +130,7 @@ pub fn render( const prev_rect = Rect{ .x = pan_offset, .y = 0, .w = window_width, .h = window_height }; const prev_entry = render_cache.entry(anim_state.previous_session); - try renderSession(renderer, &sessions[anim_state.previous_session], prev_entry, prev_rect, 1.0, false, false, font, term_cols, term_rows, current_time, false, theme); + try renderSession(renderer, &sessions[anim_state.previous_session], &views[anim_state.previous_session], prev_entry, prev_rect, 1.0, false, false, font, term_cols, term_rows, current_time, false, theme); const new_offset = if (anim_state.mode == .PanningLeft) window_width - offset @@ -134,7 +138,7 @@ pub fn render( -window_width + offset; const new_rect = Rect{ .x = new_offset, .y = 0, .w = window_width, .h = window_height }; const new_entry = render_cache.entry(anim_state.focused_session); - try renderSession(renderer, &sessions[anim_state.focused_session], new_entry, new_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme); + try renderSession(renderer, &sessions[anim_state.focused_session], &views[anim_state.focused_session], new_entry, new_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme); }, .PanningUp, .PanningDown => { const elapsed = current_time - anim_state.start_time; @@ -146,7 +150,7 @@ pub fn render( const prev_rect = Rect{ .x = 0, .y = pan_offset, .w = window_width, .h = window_height }; const prev_entry = render_cache.entry(anim_state.previous_session); - try renderSession(renderer, &sessions[anim_state.previous_session], prev_entry, prev_rect, 1.0, false, false, font, term_cols, term_rows, current_time, false, theme); + try renderSession(renderer, &sessions[anim_state.previous_session], &views[anim_state.previous_session], prev_entry, prev_rect, 1.0, false, false, font, term_cols, term_rows, current_time, false, theme); const new_offset = if (anim_state.mode == .PanningUp) window_height - offset @@ -154,7 +158,7 @@ pub fn render( -window_height + offset; const new_rect = Rect{ .x = 0, .y = new_offset, .w = window_width, .h = window_height }; const new_entry = render_cache.entry(anim_state.focused_session); - try renderSession(renderer, &sessions[anim_state.focused_session], new_entry, new_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme); + try renderSession(renderer, &sessions[anim_state.focused_session], &views[anim_state.focused_session], new_entry, new_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme); }, .Expanding, .Collapsing => { const animating_rect = anim_state.getCurrentRect(current_time); @@ -179,13 +183,13 @@ pub fn render( }; const entry = render_cache.entry(i); - try renderGridSessionCached(renderer, session, entry, cell_rect, grid_scale, false, true, font, term_cols, term_rows, current_time, theme); + try renderGridSessionCached(renderer, session, &views[i], entry, cell_rect, grid_scale, false, true, font, term_cols, term_rows, current_time, theme); } } const apply_effects = anim_scale < 0.999; const entry = render_cache.entry(anim_state.focused_session); - try renderSession(renderer, &sessions[anim_state.focused_session], entry, animating_rect, anim_scale, true, apply_effects, font, term_cols, term_rows, current_time, false, theme); + try renderSession(renderer, &sessions[anim_state.focused_session], &views[anim_state.focused_session], entry, animating_rect, anim_scale, true, apply_effects, font, term_cols, term_rows, current_time, false, theme); }, } } @@ -193,6 +197,7 @@ pub fn render( fn renderSession( renderer: *c.SDL_Renderer, session: *SessionState, + view: *const SessionViewState, cache_entry: *RenderCache.Entry, rect: Rect, scale: f32, @@ -205,14 +210,15 @@ fn renderSession( is_grid_view: bool, theme: *const colors.Theme, ) RenderError!void { - try renderSessionContent(renderer, session, rect, scale, is_focused, font, term_cols, term_rows, theme); - renderSessionOverlays(renderer, session, rect, is_focused, apply_effects, current_time_ms, is_grid_view, theme); + try renderSessionContent(renderer, session, view, rect, scale, is_focused, font, term_cols, term_rows, theme); + renderSessionOverlays(renderer, view, rect, is_focused, apply_effects, current_time_ms, is_grid_view, theme); cache_entry.presented_epoch = session.render_epoch; } fn renderSessionContent( renderer: *c.SDL_Renderer, session: *SessionState, + view: *const SessionViewState, rect: Rect, scale: f32, is_focused: bool, @@ -247,7 +253,7 @@ fn renderSessionContent( const cursor = screen.cursor; const cursor_col: usize = cursor.x; const cursor_row: usize = cursor.y; - const should_render_cursor = !session.is_viewing_scrollback and is_focused and !session.dead and cursor_visible; + const should_render_cursor = !view.is_viewing_scrollback and is_focused and !session.dead and cursor_visible; const pages = screen.pages; const base_cell_width = font.cell_width; @@ -288,7 +294,7 @@ fn renderSessionContent( var col: usize = 0; while (col < visible_cols) : (col += 1) { - const list_cell = pages.getCell(if (session.is_viewing_scrollback) + const list_cell = pages.getCell(if (view.is_viewing_scrollback) .{ .viewport = .{ .x = @intCast(col), .y = @intCast(row) } } else .{ .active = .{ .x = @intCast(col), .y = @intCast(row) } }) orelse continue; @@ -343,7 +349,7 @@ fn renderSessionContent( } if (active_selection) |sel| { - const point_tag = if (session.is_viewing_scrollback) + 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(row) } }; @@ -362,9 +368,9 @@ fn renderSessionContent( } } - if (session.hovered_link_start) |link_start| { - if (session.hovered_link_end) |link_end| { - const point_for_link = if (session.is_viewing_scrollback) + if (view.hovered_link_start) |link_start| { + if (view.hovered_link_end) |link_end| { + 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(row) } }; @@ -497,7 +503,7 @@ fn renderSessionContent( fn renderSessionOverlays( renderer: *c.SDL_Renderer, - session: *SessionState, + view: *const SessionViewState, rect: Rect, is_focused: bool, apply_effects: bool, @@ -505,7 +511,7 @@ fn renderSessionOverlays( is_grid_view: bool, theme: *const colors.Theme, ) void { - const has_attention = is_grid_view and session.attention; + const has_attention = is_grid_view and view.attention; const border_thickness: c_int = ATTENTION_THICKNESS; if (apply_effects) { @@ -542,7 +548,7 @@ fn renderSessionOverlays( } } - if (is_grid_view and session.is_viewing_scrollback) { + if (is_grid_view and view.is_viewing_scrollback) { const yellow = theme.palette[3]; _ = c.SDL_SetRenderDrawColor(renderer, yellow.r, yellow.g, yellow.b, 220); const indicator_rect = c.SDL_FRect{ @@ -563,7 +569,7 @@ fn renderSessionOverlays( .b = @intCast(std.math.clamp(@as(i32, base_green.b) - 20, 0, 255)), .a = 255, }; - const color = switch (session.status) { + const color = switch (view.status) { .awaiting_approval => blk: { const phase_ms: f32 = @floatFromInt(@mod(current_time_ms, @as(i64, 1000))); const pulse = 0.5 + 0.5 * std.math.sin(phase_ms / 1000.0 * 2.0 * std.math.pi); @@ -577,7 +583,7 @@ fn renderSessionOverlays( }; primitives.drawThickBorder(renderer, rect, ATTENTION_THICKNESS, color); - const tint_color = switch (session.status) { + const tint_color = switch (view.status) { .awaiting_approval => c.SDL_Color{ .r = yellow.r, .g = yellow.g, .b = yellow.b, .a = 25 }, .done => blk: { break :blk c.SDL_Color{ .r = done_green.r, .g = done_green.g, .b = done_green.b, .a = 35 }; @@ -625,6 +631,7 @@ fn ensureCacheTexture(renderer: *c.SDL_Renderer, cache_entry: *RenderCache.Entry fn renderGridSessionCached( renderer: *c.SDL_Renderer, session: *SessionState, + view: *const SessionViewState, cache_entry: *RenderCache.Entry, rect: Rect, scale: f32, @@ -647,7 +654,7 @@ fn renderGridSessionCached( _ = c.SDL_SetRenderDrawColor(renderer, theme.background.r, theme.background.g, theme.background.b, 255); _ = c.SDL_RenderClear(renderer); const local_rect = Rect{ .x = 0, .y = 0, .w = rect.w, .h = rect.h }; - try renderSessionContent(renderer, session, local_rect, scale, is_focused, font, term_cols, term_rows, theme); + try renderSessionContent(renderer, session, view, local_rect, scale, is_focused, font, term_cols, term_rows, theme); cache_entry.cache_epoch = session.render_epoch; _ = c.SDL_SetRenderTarget(renderer, null); } @@ -659,13 +666,13 @@ fn renderGridSessionCached( .h = @floatFromInt(rect.h), }; _ = c.SDL_RenderTexture(renderer, tex, null, &dest_rect); - renderSessionOverlays(renderer, session, rect, is_focused, apply_effects, current_time_ms, true, theme); + renderSessionOverlays(renderer, view, rect, is_focused, apply_effects, current_time_ms, true, theme); cache_entry.presented_epoch = session.render_epoch; return; } } - try renderSession(renderer, session, cache_entry, rect, scale, is_focused, apply_effects, font, term_cols, term_rows, current_time_ms, true, theme); + try renderSession(renderer, session, view, cache_entry, rect, scale, is_focused, apply_effects, font, term_cols, term_rows, current_time_ms, true, theme); } fn applyTvOverlay(renderer: *c.SDL_Renderer, rect: Rect, is_focused: bool, theme: *const colors.Theme) void { diff --git a/src/session/state.zig b/src/session/state.zig index b26a5f7..05974d7 100644 --- a/src/session/state.zig +++ b/src/session/state.zig @@ -5,7 +5,6 @@ const xev = @import("xev"); const ghostty_vt = @import("ghostty-vt"); const shell_mod = @import("../shell.zig"); const pty_mod = @import("../pty.zig"); -const app_state = @import("../app/app_state.zig"); const fs = std.fs; const cwd_mod = if (builtin.os.tag == .macos) @import("../cwd.zig") else struct {}; const vt_stream = @import("../vt_stream.zig"); @@ -31,9 +30,6 @@ pub const SessionState = struct { terminal: ?ghostty_vt.Terminal, stream: ?vt_stream.StreamType, output_buf: [4096]u8, - status: app_state.SessionStatus = .running, - attention: bool = false, - is_viewing_scrollback: bool = false, render_epoch: u64 = 1, spawned: bool = false, dead: bool = false, @@ -47,19 +43,6 @@ pub const SessionState = struct { /// When cwd_path is freed, this becomes invalid and must not be used. cwd_basename: ?[]const u8 = null, cwd_last_check: i64 = 0, - scroll_velocity: f32 = 0.0, - scroll_remainder: f32 = 0.0, - last_scroll_time: i64 = 0, - /// Whether custom inertia should be applied after the most recent scroll event. - scroll_inertia_allowed: bool = true, - /// Selection anchor for in-progress drags. - selection_anchor: ?ghostty_vt.Pin = null, - selection_dragging: bool = false, - /// True while the primary button is held down and we're waiting to see if it turns into a drag. - selection_pending: bool = false, - /// Hovered link range (for underlining). - hovered_link_start: ?ghostty_vt.Pin = null, - hovered_link_end: ?ghostty_vt.Pin = null, pending_write: std.ArrayListUnmanaged(u8) = .empty, /// Process watcher for event-driven exit detection. process_watcher: ?xev.Process = null, @@ -337,7 +320,7 @@ pub const SessionState = struct { } fn resetForRespawn(self: *SessionState) void { - self.clearSelection(); + self.clearTerminalSelection(); self.pending_write.clearAndFree(self.allocator); // Clean up process watcher. The orphaned flag ensures the callback (which will fire // when the old process exits) frees the context without affecting the new session state. @@ -368,19 +351,13 @@ pub const SessionState = struct { self.spawned = false; self.dead = false; - self.scroll_velocity = 0.0; - self.scroll_remainder = 0.0; - self.last_scroll_time = 0; } pub fn markDirty(self: *SessionState) void { self.render_epoch +%= 1; } - pub fn clearSelection(self: *SessionState) void { - self.selection_anchor = null; - self.selection_dragging = false; - self.selection_pending = false; + fn clearTerminalSelection(self: *SessionState) void { if (!self.spawned) return; if (self.terminal) |*terminal| { terminal.screens.active.clearSelection(); diff --git a/src/ui/components/session_interaction.zig b/src/ui/components/session_interaction.zig new file mode 100644 index 0000000..56587da --- /dev/null +++ b/src/ui/components/session_interaction.zig @@ -0,0 +1,879 @@ +const std = @import("std"); +const c = @import("../../c.zig"); +const ghostty_vt = @import("ghostty-vt"); +const input = @import("../../input/mapper.zig"); +const open_url = @import("../../os/open.zig"); +const renderer_mod = @import("../../render/renderer.zig"); +const session_state = @import("../../session/state.zig"); +const url_matcher = @import("../../url_matcher.zig"); +const font_mod = @import("../../font.zig"); +const app_state = @import("../../app/app_state.zig"); +const types = @import("../types.zig"); +const view_state = @import("../session_view_state.zig"); +const UiComponent = @import("../component.zig").UiComponent; + +const log = std.log.scoped(.session_interaction); + +const SessionState = session_state.SessionState; +const SessionViewState = view_state.SessionViewState; + +const SCROLL_LINES_PER_TICK: isize = 1; +const MAX_SCROLL_VELOCITY: f32 = 30.0; + +const CursorKind = enum { arrow, ibeam, pointer }; + +pub const SessionInteractionComponent = struct { + allocator: std.mem.Allocator, + sessions: []SessionState, + views: []SessionViewState, + font: *font_mod.Font, + arrow_cursor: ?*c.SDL_Cursor = null, + ibeam_cursor: ?*c.SDL_Cursor = null, + pointer_cursor: ?*c.SDL_Cursor = null, + current_cursor: CursorKind = .arrow, + last_update_ms: i64 = 0, + + pub fn init( + allocator: std.mem.Allocator, + sessions: []SessionState, + font: *font_mod.Font, + ) !*SessionInteractionComponent { + const self = try allocator.create(SessionInteractionComponent); + errdefer allocator.destroy(self); + + const views = try allocator.alloc(SessionViewState, sessions.len); + for (views) |*view| { + view.* = .{}; + } + errdefer allocator.free(views); + + self.* = .{ + .allocator = allocator, + .sessions = sessions, + .views = views, + .font = font, + }; + + self.arrow_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_DEFAULT); + self.ibeam_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_TEXT); + self.pointer_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_POINTER); + if (self.arrow_cursor) |cursor| { + _ = c.SDL_SetCursor(cursor); + self.current_cursor = .arrow; + } + + return self; + } + + pub fn asComponent(self: *SessionInteractionComponent) UiComponent { + return .{ + .ptr = self, + .vtable = &vtable, + .z_index = -100, + }; + } + + pub fn destroy(self: *SessionInteractionComponent, renderer: *c.SDL_Renderer) void { + _ = renderer; + if (self.arrow_cursor) |cursor| { + c.SDL_DestroyCursor(cursor); + } + if (self.ibeam_cursor) |cursor| { + c.SDL_DestroyCursor(cursor); + } + if (self.pointer_cursor) |cursor| { + c.SDL_DestroyCursor(cursor); + } + self.allocator.free(self.views); + self.allocator.destroy(self); + } + + pub fn viewSlice(self: *SessionInteractionComponent) []SessionViewState { + return self.views; + } + + pub fn resetView(self: *SessionInteractionComponent, idx: usize) void { + if (idx >= self.views.len or idx >= self.sessions.len) return; + self.views[idx].reset(); + self.sessions[idx].markDirty(); + } + + pub fn clearSelection(self: *SessionInteractionComponent, idx: usize) void { + if (idx >= self.views.len or idx >= self.sessions.len) return; + const view = &self.views[idx]; + view.clearSelection(); + view.clearHover(); + if (self.sessions[idx].terminal) |*terminal| { + terminal.screens.active.clearSelection(); + } + self.sessions[idx].markDirty(); + } + + pub fn setStatus(self: *SessionInteractionComponent, idx: usize, status: app_state.SessionStatus) void { + if (idx >= self.views.len or idx >= self.sessions.len) return; + const view = &self.views[idx]; + if (view.status == status) return; + view.status = status; + self.sessions[idx].markDirty(); + } + + pub fn setAttention(self: *SessionInteractionComponent, idx: usize, attention: bool) void { + if (idx >= self.views.len or idx >= self.sessions.len) return; + const view = &self.views[idx]; + if (view.attention == attention) return; + view.attention = attention; + self.sessions[idx].markDirty(); + } + + pub fn resetScrollIfNeeded(self: *SessionInteractionComponent, idx: usize) void { + if (idx >= self.views.len or idx >= self.sessions.len) return; + const view = &self.views[idx]; + if (!view.is_viewing_scrollback) return; + + if (self.sessions[idx].terminal) |*terminal| { + terminal.screens.active.pages.scroll(.{ .active = {} }); + view.clearScroll(); + self.sessions[idx].markDirty(); + } + } + + fn handleEvent(self_ptr: *anyopaque, host: *const types.UiHost, event: *const c.SDL_Event, actions: *types.UiActionQueue) bool { + const self: *SessionInteractionComponent = @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 (host.view_mode == .Grid) { + const grid_col_idx: usize = @min(@as(usize, @intCast(@divFloor(mouse_x, host.cell_w))), host.grid_cols - 1); + const grid_row_idx: usize = @min(@as(usize, @intCast(@divFloor(mouse_y, host.cell_h))), host.grid_rows - 1); + const clicked_session: usize = grid_row_idx * host.grid_cols + grid_col_idx; + if (clicked_session >= self.sessions.len) return false; + + actions.append(.{ .FocusSession = clicked_session }) catch |err| { + log.warn("failed to queue focus action for session {d}: {}", .{ clicked_session, err }); + }; + return true; + } + + if (host.view_mode == .Full and event.button.button == c.SDL_BUTTON_LEFT) { + const focused_idx = host.focused_session; + if (focused_idx >= self.sessions.len) return false; + const focused = &self.sessions[focused_idx]; + const view = &self.views[focused_idx]; + + if (focused.spawned and focused.terminal != null) { + if (fullViewPinFromMouse(focused, view, mouse_x, mouse_y, host.window_w, host.window_h, self.font, host.term_cols, host.term_rows)) |pin| { + const clicks = event.button.clicks; + if (clicks >= 3) { + selectLine(focused, view, pin); + } else if (clicks == 2) { + selectWord(focused, view, pin); + } else { + const mod = c.SDL_GetModState(); + const cmd_held = (mod & c.SDL_KMOD_GUI) != 0; + if (cmd_held) { + if (getLinkAtPin(self.allocator, &focused.terminal.?, pin, view.is_viewing_scrollback)) |uri| { + defer self.allocator.free(uri); + open_url.openUrl(self.allocator, uri) catch |err| { + log.err("failed to open URL: {}", .{err}); + }; + } else { + beginSelection(focused, view, pin); + } + } else { + beginSelection(focused, view, pin); + } + } + return true; + } + } + } + }, + c.SDL_EVENT_MOUSE_BUTTON_UP => { + if (host.view_mode == .Full and event.button.button == c.SDL_BUTTON_LEFT) { + const focused_idx = host.focused_session; + if (focused_idx >= self.views.len) return false; + endSelection(&self.views[focused_idx]); + return true; + } + }, + c.SDL_EVENT_MOUSE_MOTION => { + const mouse_x: c_int = @intFromFloat(event.motion.x); + const mouse_y: c_int = @intFromFloat(event.motion.y); + + var desired_cursor: CursorKind = .arrow; + + if (host.view_mode == .Full) { + const focused_idx = host.focused_session; + if (focused_idx < self.sessions.len) { + var focused = &self.sessions[focused_idx]; + const view = &self.views[focused_idx]; + const pin = fullViewPinFromMouse(focused, view, mouse_x, mouse_y, host.window_w, host.window_h, self.font, host.term_cols, host.term_rows); + + if (view.selection_dragging) { + if (pin) |p| { + updateSelectionDrag(focused, view, p); + } + + const edge_threshold: c_int = 50; + const scroll_speed: isize = 1; + if (mouse_y < edge_threshold) { + scrollSession(focused, view, -scroll_speed, host.now_ms); + } else if (mouse_y > host.window_h - edge_threshold) { + scrollSession(focused, view, scroll_speed, host.now_ms); + } + } else if (view.selection_pending) { + if (view.selection_anchor) |anchor| { + if (pin) |p| { + if (!pinsEqual(anchor, p)) { + startSelectionDrag(focused, view, p); + } + } + } else { + view.selection_pending = false; + } + } + + if (!host.mouse_over_ui and pin != null and focused.terminal != null) { + const mod = c.SDL_GetModState(); + const cmd_held = (mod & c.SDL_KMOD_GUI) != 0; + + if (cmd_held) { + if (getLinkMatchAtPin(self.allocator, &focused.terminal.?, pin.?, view.is_viewing_scrollback)) |link_match| { + desired_cursor = .pointer; + view.hovered_link_start = link_match.start_pin; + view.hovered_link_end = link_match.end_pin; + self.allocator.free(link_match.url); + focused.markDirty(); + } else { + desired_cursor = .ibeam; + if (view.hovered_link_start != null) { + view.clearHover(); + focused.markDirty(); + } + } + } else { + desired_cursor = .ibeam; + if (view.hovered_link_start != null) { + view.clearHover(); + focused.markDirty(); + } + } + } else { + if (view.hovered_link_start != null) { + view.clearHover(); + focused.markDirty(); + } + } + } + } + + self.updateCursor(desired_cursor); + return true; + }, + c.SDL_EVENT_MOUSE_WHEEL => { + const mouse_x: c_int = @intFromFloat(event.wheel.mouse_x); + const mouse_y: c_int = @intFromFloat(event.wheel.mouse_y); + + const hovered_session = calculateHoveredSession( + mouse_x, + mouse_y, + host, + ); + if (hovered_session) |session_idx| { + if (session_idx >= self.sessions.len) return false; + var session = &self.sessions[session_idx]; + const view = &self.views[session_idx]; + const ticks_per_notch: isize = SCROLL_LINES_PER_TICK; + const wheel_ticks: isize = if (event.wheel.integer_y != 0) + @as(isize, @intCast(event.wheel.integer_y)) * ticks_per_notch + else + @as(isize, @intFromFloat(event.wheel.y * @as(f32, @floatFromInt(SCROLL_LINES_PER_TICK)))); + const scroll_delta = -wheel_ticks; + if (scroll_delta != 0) { + const should_forward = blk: { + if (host.view_mode != .Full) break :blk false; + if (view.is_viewing_scrollback) break :blk false; + const terminal = session.terminal orelse break :blk false; + const mouse_tracking = terminal.modes.get(.mouse_event_normal) or + terminal.modes.get(.mouse_event_button) or + terminal.modes.get(.mouse_event_any) or + terminal.modes.get(.mouse_event_x10); + break :blk mouse_tracking; + }; + + if (should_forward) { + if (fullViewCellFromMouse(mouse_x, mouse_y, host.window_w, host.window_h, self.font, host.term_cols, host.term_rows)) |cell| { + const terminal = session.terminal.?; + const sgr_format = terminal.modes.get(.mouse_format_sgr); + const direction: input.MouseScrollDirection = if (scroll_delta < 0) .up else .down; + const count = @abs(scroll_delta); + var buf: [32]u8 = undefined; + var i: usize = 0; + while (i < count) : (i += 1) { + const n = input.encodeMouseScroll(direction, cell.col, cell.row, sgr_format, &buf); + if (n > 0) { + session.sendInput(buf[0..n]) catch |err| { + log.warn("session {d}: failed to send mouse scroll: {}", .{ session_idx, err }); + }; + } + } + } else { + scrollSession(session, view, scroll_delta, host.now_ms); + if (event.wheel.which == c.SDL_TOUCH_MOUSEID) { + view.scroll_inertia_allowed = false; + } + } + } else { + scrollSession(session, view, scroll_delta, host.now_ms); + if (event.wheel.which == c.SDL_TOUCH_MOUSEID) { + view.scroll_inertia_allowed = false; + } + } + } + } + return true; + }, + else => {}, + } + + return false; + } + + fn hitTest(_: *anyopaque, _: *const types.UiHost, _: c_int, _: c_int) bool { + return false; + } + + fn update(self_ptr: *anyopaque, host: *const types.UiHost, _: *types.UiActionQueue) void { + const self: *SessionInteractionComponent = @ptrCast(@alignCast(self_ptr)); + if (self.last_update_ms == 0) { + self.last_update_ms = host.now_ms; + return; + } + const delta_ms = host.now_ms - self.last_update_ms; + self.last_update_ms = host.now_ms; + if (delta_ms <= 0) return; + + const delta_time_s: f32 = @as(f32, @floatFromInt(delta_ms)) / 1000.0; + for (self.sessions, 0..) |*session, idx| { + updateScrollInertia(session, &self.views[idx], delta_time_s); + } + } + + fn wantsFrame(self_ptr: *anyopaque, _: *const types.UiHost) bool { + const self: *SessionInteractionComponent = @ptrCast(@alignCast(self_ptr)); + for (self.views) |view| { + if (view.scroll_velocity != 0.0) return true; + } + return false; + } + + fn updateCursor(self: *SessionInteractionComponent, desired: CursorKind) void { + if (desired == self.current_cursor) return; + const target_cursor = switch (desired) { + .arrow => self.arrow_cursor, + .ibeam => self.ibeam_cursor, + .pointer => self.pointer_cursor, + }; + if (target_cursor) |cursor| { + _ = c.SDL_SetCursor(cursor); + self.current_cursor = desired; + } + } + + fn deinitComp(self_ptr: *anyopaque, renderer: *c.SDL_Renderer) void { + const self: *SessionInteractionComponent = @ptrCast(@alignCast(self_ptr)); + self.destroy(renderer); + } + + const vtable = UiComponent.VTable{ + .handleEvent = handleEvent, + .update = update, + .render = null, + .hitTest = hitTest, + .deinit = deinitComp, + .wantsFrame = wantsFrame, + }; +}; + +const CellPosition = struct { col: u16, row: u16 }; + +fn fullViewCellFromMouse( + mouse_x: c_int, + mouse_y: c_int, + render_width: c_int, + render_height: c_int, + font: *const font_mod.Font, + term_cols: u16, + term_rows: u16, +) ?CellPosition { + const padding = renderer_mod.TERMINAL_PADDING; + const origin_x: c_int = padding; + const origin_y: c_int = padding; + const drawable_w: c_int = render_width - padding * 2; + const drawable_h: c_int = render_height - padding * 2; + if (drawable_w <= 0 or drawable_h <= 0) return null; + + const cell_w: c_int = font.cell_width; + const cell_h: c_int = font.cell_height; + if (cell_w == 0 or cell_h == 0) return null; + + if (mouse_x < origin_x or mouse_y < origin_y) return null; + if (mouse_x >= origin_x + drawable_w or mouse_y >= origin_y + drawable_h) return null; + + const col = @as(u16, @intCast(@divFloor(mouse_x - origin_x, cell_w))); + const row = @as(u16, @intCast(@divFloor(mouse_y - origin_y, cell_h))); + if (col >= term_cols or row >= term_rows) return null; + + return .{ .col = col, .row = row }; +} + +fn fullViewPinFromMouse( + session: *SessionState, + view: *SessionViewState, + mouse_x: c_int, + mouse_y: c_int, + render_width: c_int, + render_height: c_int, + font: *const font_mod.Font, + term_cols: u16, + term_rows: u16, +) ?ghostty_vt.Pin { + if (!session.spawned or session.terminal == null) return null; + + const padding = renderer_mod.TERMINAL_PADDING; + const origin_x: c_int = padding; + const origin_y: c_int = padding; + const drawable_w: c_int = render_width - padding * 2; + const drawable_h: c_int = render_height - padding * 2; + if (drawable_w <= 0 or drawable_h <= 0) return null; + + const cell_w: c_int = font.cell_width; + const cell_h: c_int = font.cell_height; + if (cell_w == 0 or cell_h == 0) return null; + + if (mouse_x < origin_x or mouse_y < origin_y) return null; + if (mouse_x >= origin_x + drawable_w or mouse_y >= origin_y + drawable_h) return null; + + const col = @as(u16, @intCast(@divFloor(mouse_x - origin_x, cell_w))); + const row = @as(u16, @intCast(@divFloor(mouse_y - origin_y, cell_h))); + if (col >= term_cols or row >= term_rows) return null; + + const point = if (view.is_viewing_scrollback) + ghostty_vt.point.Point{ .viewport = .{ .x = col, .y = row } } + else + ghostty_vt.point.Point{ .active = .{ .x = col, .y = row } }; + + const terminal = session.terminal orelse return null; + return terminal.screens.active.pages.pin(point); +} + +fn beginSelection(session: *SessionState, view: *SessionViewState, pin: ghostty_vt.Pin) void { + const terminal = session.terminal orelse return; + terminal.screens.active.clearSelection(); + view.selection_anchor = pin; + view.selection_pending = true; + view.selection_dragging = false; + session.markDirty(); +} + +fn startSelectionDrag(session: *SessionState, view: *SessionViewState, pin: ghostty_vt.Pin) void { + const terminal = session.terminal orelse return; + const anchor = view.selection_anchor orelse return; + + view.selection_dragging = true; + view.selection_pending = false; + + terminal.screens.active.clearSelection(); + terminal.screens.active.select(ghostty_vt.Selection.init(anchor, pin, false)) catch |err| { + log.warn("session {d}: failed to start selection: {}", .{ session.id, err }); + }; + session.markDirty(); +} + +fn updateSelectionDrag(session: *SessionState, view: *SessionViewState, pin: ghostty_vt.Pin) void { + if (!view.selection_dragging) return; + const anchor = view.selection_anchor orelse return; + const terminal = session.terminal orelse return; + terminal.screens.active.select(ghostty_vt.Selection.init(anchor, pin, false)) catch |err| { + log.warn("session {d}: failed to update selection: {}", .{ session.id, err }); + }; + session.markDirty(); +} + +fn endSelection(view: *SessionViewState) void { + view.selection_dragging = false; + view.selection_pending = false; + view.selection_anchor = null; +} + +fn pinsEqual(a: ghostty_vt.Pin, b: ghostty_vt.Pin) bool { + return a.node == b.node and a.x == b.x and a.y == b.y; +} + +fn isWordCharacter(codepoint: u21) bool { + if (codepoint > 127) return false; + const ch: u8 = @intCast(codepoint); + return std.ascii.isAlphanumeric(ch) or ch == '_'; +} + +fn selectWord(session: *SessionState, view: *SessionViewState, pin: ghostty_vt.Pin) void { + const terminal = &(session.terminal orelse return); + const page = &pin.node.data; + const max_col: u16 = @intCast(page.size.cols - 1); + + const pin_point = if (view.is_viewing_scrollback) + terminal.screens.active.pages.pointFromPin(.viewport, pin) + else + terminal.screens.active.pages.pointFromPin(.active, pin); + const point = pin_point orelse return; + const click_x = if (view.is_viewing_scrollback) point.viewport.x else point.active.x; + const click_y = if (view.is_viewing_scrollback) point.viewport.y else point.active.y; + + const clicked_cell = terminal.screens.active.pages.getCell( + if (view.is_viewing_scrollback) + ghostty_vt.point.Point{ .viewport = .{ .x = click_x, .y = click_y } } + else + ghostty_vt.point.Point{ .active = .{ .x = click_x, .y = click_y } }, + ) orelse return; + const clicked_cp = clicked_cell.cell.content.codepoint; + if (!isWordCharacter(clicked_cp)) return; + + var start_x = click_x; + while (start_x > 0) { + const prev_x = start_x - 1; + const prev_cell = terminal.screens.active.pages.getCell( + if (view.is_viewing_scrollback) + ghostty_vt.point.Point{ .viewport = .{ .x = prev_x, .y = click_y } } + else + ghostty_vt.point.Point{ .active = .{ .x = prev_x, .y = click_y } }, + ) orelse break; + if (!isWordCharacter(prev_cell.cell.content.codepoint)) break; + start_x = prev_x; + } + + var end_x = click_x; + while (end_x < max_col) { + const next_x = end_x + 1; + const next_cell = terminal.screens.active.pages.getCell( + if (view.is_viewing_scrollback) + ghostty_vt.point.Point{ .viewport = .{ .x = next_x, .y = click_y } } + else + ghostty_vt.point.Point{ .active = .{ .x = next_x, .y = click_y } }, + ) orelse break; + if (!isWordCharacter(next_cell.cell.content.codepoint)) break; + end_x = next_x; + } + + const start_point = if (view.is_viewing_scrollback) + ghostty_vt.point.Point{ .viewport = .{ .x = start_x, .y = click_y } } + else + ghostty_vt.point.Point{ .active = .{ .x = start_x, .y = click_y } }; + const end_point = if (view.is_viewing_scrollback) + ghostty_vt.point.Point{ .viewport = .{ .x = end_x, .y = click_y } } + else + ghostty_vt.point.Point{ .active = .{ .x = end_x, .y = click_y } }; + + const start_pin = terminal.screens.active.pages.pin(start_point) orelse return; + const end_pin = terminal.screens.active.pages.pin(end_point) orelse return; + + terminal.screens.active.clearSelection(); + terminal.screens.active.select(ghostty_vt.Selection.init(start_pin, end_pin, false)) catch |err| { + log.err("failed to select word: {}", .{err}); + return; + }; + session.markDirty(); +} + +fn selectLine(session: *SessionState, view: *SessionViewState, pin: ghostty_vt.Pin) void { + const terminal = &(session.terminal orelse return); + const page = &pin.node.data; + const max_col: u16 = @intCast(page.size.cols - 1); + + const pin_point = if (view.is_viewing_scrollback) + terminal.screens.active.pages.pointFromPin(.viewport, pin) + else + terminal.screens.active.pages.pointFromPin(.active, pin); + const point = pin_point orelse return; + const click_y = if (view.is_viewing_scrollback) point.viewport.y else point.active.y; + + const start_point = if (view.is_viewing_scrollback) + ghostty_vt.point.Point{ .viewport = .{ .x = 0, .y = click_y } } + else + ghostty_vt.point.Point{ .active = .{ .x = 0, .y = click_y } }; + const end_point = if (view.is_viewing_scrollback) + ghostty_vt.point.Point{ .viewport = .{ .x = max_col, .y = click_y } } + else + ghostty_vt.point.Point{ .active = .{ .x = max_col, .y = click_y } }; + + const start_pin = terminal.screens.active.pages.pin(start_point) orelse return; + const end_pin = terminal.screens.active.pages.pin(end_point) orelse return; + + terminal.screens.active.clearSelection(); + terminal.screens.active.select(ghostty_vt.Selection.init(start_pin, end_pin, false)) catch |err| { + log.err("failed to select line: {}", .{err}); + return; + }; + session.markDirty(); +} + +const LinkMatch = struct { + url: []u8, + start_pin: ghostty_vt.Pin, + end_pin: ghostty_vt.Pin, +}; + +fn getLinkMatchAtPin(allocator: std.mem.Allocator, terminal: *ghostty_vt.Terminal, pin: ghostty_vt.Pin, is_viewing_scrollback: bool) ?LinkMatch { + const page = &pin.node.data; + const row_and_cell = pin.rowAndCell(); + const cell = row_and_cell.cell; + + if (page.lookupHyperlink(cell)) |hyperlink_id| { + const entry = page.hyperlink_set.get(page.memory, hyperlink_id); + const url = allocator.dupe(u8, entry.uri.slice(page.memory)) catch return null; + return LinkMatch{ + .url = url, + .start_pin = pin, + .end_pin = pin, + }; + } + + const pin_point = if (is_viewing_scrollback) + terminal.screens.active.pages.pointFromPin(.viewport, pin) + else + terminal.screens.active.pages.pointFromPin(.active, pin); + const point_or_null = pin_point orelse return null; + const start_y_orig = if (is_viewing_scrollback) point_or_null.viewport.y else point_or_null.active.y; + + var start_y = start_y_orig; + var current_row = row_and_cell.row; + + while (current_row.wrap_continuation and start_y > 0) { + start_y -= 1; + const prev_point = if (is_viewing_scrollback) + ghostty_vt.point.Point{ .viewport = .{ .x = 0, .y = start_y } } + else + ghostty_vt.point.Point{ .active = .{ .x = 0, .y = start_y } }; + const prev_pin = terminal.screens.active.pages.pin(prev_point) orelse break; + current_row = prev_pin.rowAndCell().row; + } + + var end_y = start_y_orig; + current_row = row_and_cell.row; + const max_y: u16 = @intCast(page.size.rows - 1); + + while (current_row.wrap and end_y < max_y) { + end_y += 1; + const next_point = if (is_viewing_scrollback) + ghostty_vt.point.Point{ .viewport = .{ .x = 0, .y = end_y } } + else + ghostty_vt.point.Point{ .active = .{ .x = 0, .y = end_y } }; + const next_pin = terminal.screens.active.pages.pin(next_point) orelse break; + current_row = next_pin.rowAndCell().row; + } + + const max_x: u16 = @intCast(page.size.cols - 1); + const row_start_point = if (is_viewing_scrollback) + ghostty_vt.point.Point{ .viewport = .{ .x = 0, .y = start_y } } + else + ghostty_vt.point.Point{ .active = .{ .x = 0, .y = start_y } }; + const row_end_point = if (is_viewing_scrollback) + ghostty_vt.point.Point{ .viewport = .{ .x = max_x, .y = end_y } } + else + ghostty_vt.point.Point{ .active = .{ .x = max_x, .y = end_y } }; + const row_start_pin = terminal.screens.active.pages.pin(row_start_point) orelse return null; + const row_end_pin = terminal.screens.active.pages.pin(row_end_point) orelse return null; + + const selection = ghostty_vt.Selection.init(row_start_pin, row_end_pin, false); + const row_text = terminal.screens.active.selectionString(allocator, .{ + .sel = selection, + .trim = false, + }) catch return null; + defer allocator.free(row_text); + + var cell_to_byte: std.ArrayList(usize) = .empty; + defer cell_to_byte.deinit(allocator); + + var byte_pos: usize = 0; + var cell_idx: usize = 0; + var y = start_y; + while (y <= end_y) : (y += 1) { + var x: u16 = 0; + while (x < page.size.cols) : (x += 1) { + const point = if (is_viewing_scrollback) + ghostty_vt.point.Point{ .viewport = .{ .x = x, .y = y } } + else + ghostty_vt.point.Point{ .active = .{ .x = x, .y = y } }; + const list_cell = terminal.screens.active.pages.getCell(point) orelse { + cell_to_byte.append(allocator, byte_pos) catch return null; + cell_idx += 1; + continue; + }; + + cell_to_byte.append(allocator, byte_pos) catch return null; + + const list_cell_cell = list_cell.cell; + const cp = list_cell_cell.content.codepoint; + const encoded_len: usize = blk: { + if (cp != 0 and cp != ' ') { + var utf8_buf: [4]u8 = undefined; + break :blk std.unicode.utf8Encode(cp, &utf8_buf) catch 1; + } + break :blk 1; + }; + + if (list_cell_cell.wide == .wide) { + byte_pos += encoded_len; + if (x + 1 < page.size.cols) { + x += 1; + const char_start_pos = cell_to_byte.items[cell_to_byte.items.len - 1]; + cell_to_byte.append(allocator, char_start_pos) catch return null; + cell_idx += 1; + } + } else { + byte_pos += encoded_len; + } + cell_idx += 1; + } + if (y < end_y) { + byte_pos += 1; + } + } + + const pin_x = if (is_viewing_scrollback) point_or_null.viewport.x else point_or_null.active.x; + const click_cell_idx = (start_y_orig - start_y) * page.size.cols + pin_x; + if (click_cell_idx >= cell_to_byte.items.len) return null; + const click_byte_pos = cell_to_byte.items[click_cell_idx]; + + const url_match = url_matcher.findUrlMatchAtPosition(row_text, click_byte_pos) orelse return null; + + var start_cell_idx: usize = 0; + for (cell_to_byte.items, 0..) |byte, idx| { + if (byte >= url_match.start) { + start_cell_idx = idx; + break; + } + } + + var end_cell_idx: usize = cell_to_byte.items.len - 1; + for (cell_to_byte.items, 0..) |byte, idx| { + if (byte >= url_match.end) { + end_cell_idx = if (idx > 0) idx - 1 else 0; + break; + } + } + + const start_row = start_y + @as(u16, @intCast(start_cell_idx / page.size.cols)); + const start_col: u16 = @intCast(start_cell_idx % page.size.cols); + const end_row = start_y + @as(u16, @intCast(end_cell_idx / page.size.cols)); + const end_col: u16 = @intCast(end_cell_idx % page.size.cols); + + const link_start_point = if (is_viewing_scrollback) + ghostty_vt.point.Point{ .viewport = .{ .x = start_col, .y = start_row } } + else + ghostty_vt.point.Point{ .active = .{ .x = start_col, .y = start_row } }; + const link_end_point = if (is_viewing_scrollback) + ghostty_vt.point.Point{ .viewport = .{ .x = end_col, .y = end_row } } + else + ghostty_vt.point.Point{ .active = .{ .x = end_col, .y = end_row } }; + const link_start_pin = terminal.screens.active.pages.pin(link_start_point) orelse return null; + const link_end_pin = terminal.screens.active.pages.pin(link_end_point) orelse return null; + + const url = allocator.dupe(u8, url_match.url) catch return null; + + return LinkMatch{ + .url = url, + .start_pin = link_start_pin, + .end_pin = link_end_pin, + }; +} + +fn getLinkAtPin(allocator: std.mem.Allocator, terminal: *ghostty_vt.Terminal, pin: ghostty_vt.Pin, is_viewing_scrollback: bool) ?[]u8 { + if (getLinkMatchAtPin(allocator, terminal, pin, is_viewing_scrollback)) |match| { + return match.url; + } + return null; +} + +fn scrollSession(session: *SessionState, view: *SessionViewState, delta: isize, now: i64) void { + if (!session.spawned) return; + + view.last_scroll_time = now; + view.scroll_remainder = 0.0; + view.scroll_inertia_allowed = true; + + if (session.terminal) |*terminal| { + var pages = &terminal.screens.active.pages; + pages.scroll(.{ .delta_row = delta }); + view.is_viewing_scrollback = (pages.viewport != .active); + session.markDirty(); + } + + const sensitivity: f32 = 0.08; + view.scroll_velocity += @as(f32, @floatFromInt(delta)) * sensitivity; + view.scroll_velocity = std.math.clamp(view.scroll_velocity, -MAX_SCROLL_VELOCITY, MAX_SCROLL_VELOCITY); +} + +fn updateScrollInertia(session: *SessionState, view: *SessionViewState, delta_time_s: f32) void { + if (!session.spawned) return; + if (!view.scroll_inertia_allowed) return; + if (view.scroll_velocity == 0.0) return; + if (view.last_scroll_time == 0) return; + + const decay_constant: f32 = 7.5; + const decay_factor = std.math.exp(-decay_constant * delta_time_s); + const velocity_threshold: f32 = 0.12; + + if (@abs(view.scroll_velocity) < velocity_threshold) { + view.scroll_velocity = 0.0; + view.scroll_remainder = 0.0; + return; + } + + const reference_fps: f32 = 60.0; + + if (session.terminal) |*terminal| { + const scroll_amount = view.scroll_velocity * delta_time_s * reference_fps + view.scroll_remainder; + const scroll_lines: isize = @intFromFloat(scroll_amount); + + if (scroll_lines != 0) { + var pages = &terminal.screens.active.pages; + pages.scroll(.{ .delta_row = scroll_lines }); + view.is_viewing_scrollback = (pages.viewport != .active); + session.markDirty(); + } + + view.scroll_remainder = scroll_amount - @as(f32, @floatFromInt(scroll_lines)); + } + + view.scroll_velocity *= decay_factor; +} + +fn calculateHoveredSession( + mouse_x: c_int, + mouse_y: c_int, + host: *const types.UiHost, +) ?usize { + return switch (host.view_mode) { + .Grid => { + if (mouse_x < 0 or mouse_x >= host.window_w or + mouse_y < 0 or mouse_y >= host.window_h) return null; + + const grid_col_idx: usize = @min(@as(usize, @intCast(@divFloor(mouse_x, host.cell_w))), host.grid_cols - 1); + const grid_row_idx: usize = @min(@as(usize, @intCast(@divFloor(mouse_y, host.cell_h))), host.grid_rows - 1); + return grid_row_idx * host.grid_cols + grid_col_idx; + }, + .Full, .PanningLeft, .PanningRight, .PanningUp, .PanningDown => host.focused_session, + .Expanding, .Collapsing => { + const rect = host.animating_rect orelse return host.focused_session; + if (mouse_x >= rect.x and mouse_x < rect.x + rect.w and + mouse_y >= rect.y and mouse_y < rect.y + rect.h) + { + return host.focused_session; + } + return null; + }, + }; +} diff --git a/src/ui/mod.zig b/src/ui/mod.zig index 83fd178..48bdb80 100644 --- a/src/ui/mod.zig +++ b/src/ui/mod.zig @@ -4,6 +4,8 @@ pub const UiHost = @import("types.zig").UiHost; pub const UiAction = @import("types.zig").UiAction; pub const UiAssets = @import("types.zig").UiAssets; pub const SessionUiInfo = @import("types.zig").SessionUiInfo; +pub const SessionViewState = @import("session_view_state.zig").SessionViewState; +pub const SessionInteractionComponent = @import("components/session_interaction.zig").SessionInteractionComponent; pub const help_overlay = @import("components/help_overlay.zig"); pub const worktree_overlay = @import("components/worktree_overlay.zig"); pub const pill_group = @import("components/pill_group.zig"); diff --git a/src/ui/session_view_state.zig b/src/ui/session_view_state.zig new file mode 100644 index 0000000..ec1ce68 --- /dev/null +++ b/src/ui/session_view_state.zig @@ -0,0 +1,40 @@ +const app_state = @import("../app/app_state.zig"); +const ghostty_vt = @import("ghostty-vt"); + +pub const SessionViewState = struct { + status: app_state.SessionStatus = .running, + attention: bool = false, + is_viewing_scrollback: bool = false, + scroll_velocity: f32 = 0.0, + scroll_remainder: f32 = 0.0, + last_scroll_time: i64 = 0, + scroll_inertia_allowed: bool = true, + selection_anchor: ?ghostty_vt.Pin = null, + selection_dragging: bool = false, + selection_pending: bool = false, + hovered_link_start: ?ghostty_vt.Pin = null, + hovered_link_end: ?ghostty_vt.Pin = null, + + pub fn reset(self: *SessionViewState) void { + self.* = .{}; + } + + pub fn clearSelection(self: *SessionViewState) void { + self.selection_anchor = null; + self.selection_dragging = false; + self.selection_pending = false; + } + + pub fn clearHover(self: *SessionViewState) void { + self.hovered_link_start = null; + self.hovered_link_end = null; + } + + pub fn clearScroll(self: *SessionViewState) void { + self.is_viewing_scrollback = false; + self.scroll_velocity = 0.0; + self.scroll_remainder = 0.0; + self.last_scroll_time = 0; + self.scroll_inertia_allowed = true; + } +}; diff --git a/src/ui/types.zig b/src/ui/types.zig index e4da372..c5dd08f 100644 --- a/src/ui/types.zig +++ b/src/ui/types.zig @@ -4,6 +4,7 @@ const app_state = @import("../app/app_state.zig"); const font_mod = @import("../font.zig"); const colors = @import("../colors.zig"); const font_cache = @import("../font_cache.zig"); +const geom = @import("../geom.zig"); pub const SessionUiInfo = struct { dead: bool, @@ -23,18 +24,27 @@ pub const UiHost = struct { grid_rows: usize, cell_w: c_int, cell_h: c_int, + term_cols: u16, + term_rows: u16, view_mode: app_state.ViewMode, focused_session: usize, focused_cwd: ?[]const u8, focused_has_foreground_process: bool, + animating_rect: ?geom.Rect = null, sessions: []const SessionUiInfo, theme: *const colors.Theme, + + mouse_x: c_int = 0, + mouse_y: c_int = 0, + mouse_has_position: bool = false, + mouse_over_ui: bool = false, }; pub const UiAction = union(enum) { RestartSession: usize, + FocusSession: usize, RequestCollapseFocused: void, ConfirmQuit: void, OpenConfig: void,