Skip to content

Commit 8b69516

Browse files
authored
Merge pull request #146 from forketyfork/feat/session-view-state
move session UI state to UiRoot
2 parents 1f3795c + 1c063c5 commit 8b69516

8 files changed

Lines changed: 1116 additions & 850 deletions

File tree

docs/architecture.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,13 @@ Architect is a terminal multiplexer displaying interactive sessions in a grid wi
5454
- CWD bar with marquee scrolling for long paths
5555
- Scrollback indicator strip
5656

57-
**UiRoot (src/ui/)** is the registry for UI overlay components:
57+
**UiRoot (src/ui/)** is the registry for UI overlay components and session interaction state:
5858
- Dispatches events topmost-first (by z-index)
5959
- Runs per-frame `update()` on all components
6060
- Drains `UiAction` queue for UI→app mutations
6161
- Renders all components in z-order after the scene
6262
- Reports `needsFrame()` when any component requires animation
63+
- Owns per-session `SessionViewState` via `SessionInteractionComponent` (selection, hover, scrollback state)
6364

6465
**UiAssets** provides shared rendering resources:
6566
- `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/
119120
├── root.zig # UiRoot: component registry, dispatch
120121
├── component.zig # UiComponent vtable interface
121122
├── types.zig # UiHost, UiAction, UiAssets, SessionUiInfo
123+
├── session_view_state.zig # Per-session UI interaction state
122124
├── scale.zig # DPI scaling helper
123125
├── first_frame_guard.zig # Idle throttling transition helper
124126
@@ -135,6 +137,7 @@ src/
135137
│ ├── pill_group.zig # Pill overlay coordinator (collapses others)
136138
│ ├── quit_confirm.zig # Quit confirmation dialog
137139
│ ├── restart_buttons.zig # Dead session restart buttons
140+
│ ├── session_interaction.zig # Terminal mouse/scroll interaction handling
138141
│ ├── toast.zig # Toast notification display
139142
│ └── worktree_overlay.zig # Git worktree picker (T pill)
140143

src/main.zig

Lines changed: 148 additions & 800 deletions
Large diffs are not rendered by default.

src/render/renderer.zig

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ const easing = @import("../anim/easing.zig");
88
const font_mod = @import("../font.zig");
99
const FontVariant = font_mod.Variant;
1010
const session_state = @import("../session/state.zig");
11+
const view_state = @import("../ui/session_view_state.zig");
1112
const primitives = @import("../gfx/primitives.zig");
1213

1314
const log = std.log.scoped(.render);
1415

1516
const SessionState = session_state.SessionState;
17+
const SessionViewState = view_state.SessionViewState;
1618
const Rect = geom.Rect;
1719
const AnimationState = app_state.AnimationState;
1820

@@ -71,6 +73,7 @@ pub fn render(
7173
renderer: *c.SDL_Renderer,
7274
render_cache: *RenderCache,
7375
sessions: []SessionState,
76+
views: []const SessionViewState,
7477
cell_width_pixels: c_int,
7578
cell_height_pixels: c_int,
7679
grid_cols: usize,
@@ -87,6 +90,7 @@ pub fn render(
8790
) RenderError!void {
8891
_ = c.SDL_SetRenderDrawColor(renderer, theme.background.r, theme.background.g, theme.background.b, 255);
8992
_ = c.SDL_RenderClear(renderer);
93+
std.debug.assert(sessions.len == views.len);
9094

9195
// Use the larger dimension for grid scale to ensure proper scaling
9296
// Multiply by grid_font_scale to allow proportionally larger font in grid view
@@ -108,13 +112,13 @@ pub fn render(
108112
};
109113

110114
const entry = render_cache.entry(i);
111-
try renderGridSessionCached(renderer, session, entry, cell_rect, grid_scale, i == anim_state.focused_session, true, font, term_cols, term_rows, current_time, theme);
115+
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);
112116
}
113117
},
114118
.Full => {
115119
const full_rect = Rect{ .x = 0, .y = 0, .w = window_width, .h = window_height };
116120
const entry = render_cache.entry(anim_state.focused_session);
117-
try renderSession(renderer, &sessions[anim_state.focused_session], entry, full_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme);
121+
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);
118122
},
119123
.PanningLeft, .PanningRight => {
120124
const elapsed = current_time - anim_state.start_time;
@@ -126,15 +130,15 @@ pub fn render(
126130

127131
const prev_rect = Rect{ .x = pan_offset, .y = 0, .w = window_width, .h = window_height };
128132
const prev_entry = render_cache.entry(anim_state.previous_session);
129-
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);
133+
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);
130134

131135
const new_offset = if (anim_state.mode == .PanningLeft)
132136
window_width - offset
133137
else
134138
-window_width + offset;
135139
const new_rect = Rect{ .x = new_offset, .y = 0, .w = window_width, .h = window_height };
136140
const new_entry = render_cache.entry(anim_state.focused_session);
137-
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);
141+
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);
138142
},
139143
.PanningUp, .PanningDown => {
140144
const elapsed = current_time - anim_state.start_time;
@@ -146,15 +150,15 @@ pub fn render(
146150

147151
const prev_rect = Rect{ .x = 0, .y = pan_offset, .w = window_width, .h = window_height };
148152
const prev_entry = render_cache.entry(anim_state.previous_session);
149-
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);
153+
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);
150154

151155
const new_offset = if (anim_state.mode == .PanningUp)
152156
window_height - offset
153157
else
154158
-window_height + offset;
155159
const new_rect = Rect{ .x = 0, .y = new_offset, .w = window_width, .h = window_height };
156160
const new_entry = render_cache.entry(anim_state.focused_session);
157-
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);
161+
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);
158162
},
159163
.Expanding, .Collapsing => {
160164
const animating_rect = anim_state.getCurrentRect(current_time);
@@ -179,20 +183,21 @@ pub fn render(
179183
};
180184

181185
const entry = render_cache.entry(i);
182-
try renderGridSessionCached(renderer, session, entry, cell_rect, grid_scale, false, true, font, term_cols, term_rows, current_time, theme);
186+
try renderGridSessionCached(renderer, session, &views[i], entry, cell_rect, grid_scale, false, true, font, term_cols, term_rows, current_time, theme);
183187
}
184188
}
185189

186190
const apply_effects = anim_scale < 0.999;
187191
const entry = render_cache.entry(anim_state.focused_session);
188-
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);
192+
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);
189193
},
190194
}
191195
}
192196

193197
fn renderSession(
194198
renderer: *c.SDL_Renderer,
195199
session: *SessionState,
200+
view: *const SessionViewState,
196201
cache_entry: *RenderCache.Entry,
197202
rect: Rect,
198203
scale: f32,
@@ -205,14 +210,15 @@ fn renderSession(
205210
is_grid_view: bool,
206211
theme: *const colors.Theme,
207212
) RenderError!void {
208-
try renderSessionContent(renderer, session, rect, scale, is_focused, font, term_cols, term_rows, theme);
209-
renderSessionOverlays(renderer, session, rect, is_focused, apply_effects, current_time_ms, is_grid_view, theme);
213+
try renderSessionContent(renderer, session, view, rect, scale, is_focused, font, term_cols, term_rows, theme);
214+
renderSessionOverlays(renderer, view, rect, is_focused, apply_effects, current_time_ms, is_grid_view, theme);
210215
cache_entry.presented_epoch = session.render_epoch;
211216
}
212217

213218
fn renderSessionContent(
214219
renderer: *c.SDL_Renderer,
215220
session: *SessionState,
221+
view: *const SessionViewState,
216222
rect: Rect,
217223
scale: f32,
218224
is_focused: bool,
@@ -247,7 +253,7 @@ fn renderSessionContent(
247253
const cursor = screen.cursor;
248254
const cursor_col: usize = cursor.x;
249255
const cursor_row: usize = cursor.y;
250-
const should_render_cursor = !session.is_viewing_scrollback and is_focused and !session.dead and cursor_visible;
256+
const should_render_cursor = !view.is_viewing_scrollback and is_focused and !session.dead and cursor_visible;
251257
const pages = screen.pages;
252258

253259
const base_cell_width = font.cell_width;
@@ -288,7 +294,7 @@ fn renderSessionContent(
288294

289295
var col: usize = 0;
290296
while (col < visible_cols) : (col += 1) {
291-
const list_cell = pages.getCell(if (session.is_viewing_scrollback)
297+
const list_cell = pages.getCell(if (view.is_viewing_scrollback)
292298
.{ .viewport = .{ .x = @intCast(col), .y = @intCast(row) } }
293299
else
294300
.{ .active = .{ .x = @intCast(col), .y = @intCast(row) } }) orelse continue;
@@ -343,7 +349,7 @@ fn renderSessionContent(
343349
}
344350

345351
if (active_selection) |sel| {
346-
const point_tag = if (session.is_viewing_scrollback)
352+
const point_tag = if (view.is_viewing_scrollback)
347353
ghostty_vt.point.Point{ .viewport = .{ .x = @intCast(col), .y = @intCast(row) } }
348354
else
349355
ghostty_vt.point.Point{ .active = .{ .x = @intCast(col), .y = @intCast(row) } };
@@ -362,9 +368,9 @@ fn renderSessionContent(
362368
}
363369
}
364370

365-
if (session.hovered_link_start) |link_start| {
366-
if (session.hovered_link_end) |link_end| {
367-
const point_for_link = if (session.is_viewing_scrollback)
371+
if (view.hovered_link_start) |link_start| {
372+
if (view.hovered_link_end) |link_end| {
373+
const point_for_link = if (view.is_viewing_scrollback)
368374
ghostty_vt.point.Point{ .viewport = .{ .x = @intCast(col), .y = @intCast(row) } }
369375
else
370376
ghostty_vt.point.Point{ .active = .{ .x = @intCast(col), .y = @intCast(row) } };
@@ -497,15 +503,15 @@ fn renderSessionContent(
497503

498504
fn renderSessionOverlays(
499505
renderer: *c.SDL_Renderer,
500-
session: *SessionState,
506+
view: *const SessionViewState,
501507
rect: Rect,
502508
is_focused: bool,
503509
apply_effects: bool,
504510
current_time_ms: i64,
505511
is_grid_view: bool,
506512
theme: *const colors.Theme,
507513
) void {
508-
const has_attention = is_grid_view and session.attention;
514+
const has_attention = is_grid_view and view.attention;
509515
const border_thickness: c_int = ATTENTION_THICKNESS;
510516

511517
if (apply_effects) {
@@ -542,7 +548,7 @@ fn renderSessionOverlays(
542548
}
543549
}
544550

545-
if (is_grid_view and session.is_viewing_scrollback) {
551+
if (is_grid_view and view.is_viewing_scrollback) {
546552
const yellow = theme.palette[3];
547553
_ = c.SDL_SetRenderDrawColor(renderer, yellow.r, yellow.g, yellow.b, 220);
548554
const indicator_rect = c.SDL_FRect{
@@ -563,7 +569,7 @@ fn renderSessionOverlays(
563569
.b = @intCast(std.math.clamp(@as(i32, base_green.b) - 20, 0, 255)),
564570
.a = 255,
565571
};
566-
const color = switch (session.status) {
572+
const color = switch (view.status) {
567573
.awaiting_approval => blk: {
568574
const phase_ms: f32 = @floatFromInt(@mod(current_time_ms, @as(i64, 1000)));
569575
const pulse = 0.5 + 0.5 * std.math.sin(phase_ms / 1000.0 * 2.0 * std.math.pi);
@@ -577,7 +583,7 @@ fn renderSessionOverlays(
577583
};
578584
primitives.drawThickBorder(renderer, rect, ATTENTION_THICKNESS, color);
579585

580-
const tint_color = switch (session.status) {
586+
const tint_color = switch (view.status) {
581587
.awaiting_approval => c.SDL_Color{ .r = yellow.r, .g = yellow.g, .b = yellow.b, .a = 25 },
582588
.done => blk: {
583589
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
625631
fn renderGridSessionCached(
626632
renderer: *c.SDL_Renderer,
627633
session: *SessionState,
634+
view: *const SessionViewState,
628635
cache_entry: *RenderCache.Entry,
629636
rect: Rect,
630637
scale: f32,
@@ -647,7 +654,7 @@ fn renderGridSessionCached(
647654
_ = c.SDL_SetRenderDrawColor(renderer, theme.background.r, theme.background.g, theme.background.b, 255);
648655
_ = c.SDL_RenderClear(renderer);
649656
const local_rect = Rect{ .x = 0, .y = 0, .w = rect.w, .h = rect.h };
650-
try renderSessionContent(renderer, session, local_rect, scale, is_focused, font, term_cols, term_rows, theme);
657+
try renderSessionContent(renderer, session, view, local_rect, scale, is_focused, font, term_cols, term_rows, theme);
651658
cache_entry.cache_epoch = session.render_epoch;
652659
_ = c.SDL_SetRenderTarget(renderer, null);
653660
}
@@ -659,13 +666,13 @@ fn renderGridSessionCached(
659666
.h = @floatFromInt(rect.h),
660667
};
661668
_ = c.SDL_RenderTexture(renderer, tex, null, &dest_rect);
662-
renderSessionOverlays(renderer, session, rect, is_focused, apply_effects, current_time_ms, true, theme);
669+
renderSessionOverlays(renderer, view, rect, is_focused, apply_effects, current_time_ms, true, theme);
663670
cache_entry.presented_epoch = session.render_epoch;
664671
return;
665672
}
666673
}
667674

668-
try renderSession(renderer, session, cache_entry, rect, scale, is_focused, apply_effects, font, term_cols, term_rows, current_time_ms, true, theme);
675+
try renderSession(renderer, session, view, cache_entry, rect, scale, is_focused, apply_effects, font, term_cols, term_rows, current_time_ms, true, theme);
669676
}
670677

671678
fn applyTvOverlay(renderer: *c.SDL_Renderer, rect: Rect, is_focused: bool, theme: *const colors.Theme) void {

src/session/state.zig

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ const xev = @import("xev");
55
const ghostty_vt = @import("ghostty-vt");
66
const shell_mod = @import("../shell.zig");
77
const pty_mod = @import("../pty.zig");
8-
const app_state = @import("../app/app_state.zig");
98
const fs = std.fs;
109
const cwd_mod = if (builtin.os.tag == .macos) @import("../cwd.zig") else struct {};
1110
const vt_stream = @import("../vt_stream.zig");
@@ -31,9 +30,6 @@ pub const SessionState = struct {
3130
terminal: ?ghostty_vt.Terminal,
3231
stream: ?vt_stream.StreamType,
3332
output_buf: [4096]u8,
34-
status: app_state.SessionStatus = .running,
35-
attention: bool = false,
36-
is_viewing_scrollback: bool = false,
3733
render_epoch: u64 = 1,
3834
spawned: bool = false,
3935
dead: bool = false,
@@ -47,19 +43,6 @@ pub const SessionState = struct {
4743
/// When cwd_path is freed, this becomes invalid and must not be used.
4844
cwd_basename: ?[]const u8 = null,
4945
cwd_last_check: i64 = 0,
50-
scroll_velocity: f32 = 0.0,
51-
scroll_remainder: f32 = 0.0,
52-
last_scroll_time: i64 = 0,
53-
/// Whether custom inertia should be applied after the most recent scroll event.
54-
scroll_inertia_allowed: bool = true,
55-
/// Selection anchor for in-progress drags.
56-
selection_anchor: ?ghostty_vt.Pin = null,
57-
selection_dragging: bool = false,
58-
/// True while the primary button is held down and we're waiting to see if it turns into a drag.
59-
selection_pending: bool = false,
60-
/// Hovered link range (for underlining).
61-
hovered_link_start: ?ghostty_vt.Pin = null,
62-
hovered_link_end: ?ghostty_vt.Pin = null,
6346
pending_write: std.ArrayListUnmanaged(u8) = .empty,
6447
/// Process watcher for event-driven exit detection.
6548
process_watcher: ?xev.Process = null,
@@ -337,7 +320,7 @@ pub const SessionState = struct {
337320
}
338321

339322
fn resetForRespawn(self: *SessionState) void {
340-
self.clearSelection();
323+
self.clearTerminalSelection();
341324
self.pending_write.clearAndFree(self.allocator);
342325
// Clean up process watcher. The orphaned flag ensures the callback (which will fire
343326
// when the old process exits) frees the context without affecting the new session state.
@@ -368,19 +351,13 @@ pub const SessionState = struct {
368351

369352
self.spawned = false;
370353
self.dead = false;
371-
self.scroll_velocity = 0.0;
372-
self.scroll_remainder = 0.0;
373-
self.last_scroll_time = 0;
374354
}
375355

376356
pub fn markDirty(self: *SessionState) void {
377357
self.render_epoch +%= 1;
378358
}
379359

380-
pub fn clearSelection(self: *SessionState) void {
381-
self.selection_anchor = null;
382-
self.selection_dragging = false;
383-
self.selection_pending = false;
360+
fn clearTerminalSelection(self: *SessionState) void {
384361
if (!self.spawned) return;
385362
if (self.terminal) |*terminal| {
386363
terminal.screens.active.clearSelection();

0 commit comments

Comments
 (0)