Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
948 changes: 148 additions & 800 deletions src/main.zig

Large diffs are not rendered by default.

55 changes: 31 additions & 24 deletions src/render/renderer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -126,15 +130,15 @@ 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
else
-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;
Expand All @@ -146,15 +150,15 @@ 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
else
-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);
Expand All @@ -179,20 +183,21 @@ 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);
},
}
}

fn renderSession(
renderer: *c.SDL_Renderer,
session: *SessionState,
view: *const SessionViewState,
cache_entry: *RenderCache.Entry,
rect: Rect,
scale: f32,
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) } };
Expand All @@ -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) } };
Expand Down Expand Up @@ -497,15 +503,15 @@ fn renderSessionContent(

fn renderSessionOverlays(
renderer: *c.SDL_Renderer,
session: *SessionState,
view: *const SessionViewState,
rect: Rect,
is_focused: bool,
apply_effects: bool,
current_time_ms: i64,
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) {
Expand Down Expand Up @@ -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{
Expand All @@ -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);
Expand All @@ -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 };
Expand Down Expand Up @@ -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,
Expand All @@ -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);
}
Expand All @@ -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 {
Expand Down
27 changes: 2 additions & 25 deletions src/session/state.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down
Loading