// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! The status window: every terminal's progress and status records, as a //! tree, in a window of its own that can be moved aside and kept open //! while working in the terminals. //! //! There is one for the whole program. It is a child of the terminal window //! it was opened from, which makes it a dialog to the compositor: most //! float it, and keep it above that window. It shows each window with //! anything to report (named only when there are several), its tabs, their //! panes (named only in a tab of several), and each pane's OSC 9;4 progress //! and OSC 7501 records. A click on a row shows that pane; the window stays. const std = @import("std"); const Allocator = std.mem.Allocator; const vt = @import("ghostty-vt"); const App = @import("App.zig"); const Window = @import("Window.zig"); const Tab = @import("Tab.zig"); const Pane = @import("Pane.zig"); const Config = @import("config/Config.zig"); const req = @import("request.zig"); const platform = @import("aerie").platform; const ev = platform.event; const bindings = @import("input/bindings.zig"); const frame = @import("renderer/frame.zig"); const Renderer = @import("aerie").gpu.Renderer; const TitleBar = @import("decor/TitleBar.zig"); const decor = @import("decor/decor.zig"); const StatusButton = @import("decor/StatusButton.zig"); const prog_status = @import("status.zig"); const l10n = @import("l10n.zig"); const StatusWindow = @This(); const log = std.log.scoped(.status_window); app: *App, gpa: Allocator, pw: *platform.Window, renderer: Renderer, target: Window.Target, builder: frame.Builder, /// The terminal window it is a child of, if that is still open. parent: ?*Window, /// In physical pixels, and how many of those to a logical one. width: u32 = 0, height: u32 = 0, scale: f32 = 1, focused: bool = false, dirty: bool = true, /// To be closed at the end of this turn of the loop. closing: bool = false, /// Our own title bar and frame, when the compositor draws none. title_bar: TitleBar = .{}, pointer_x: f32 = 0, pointer_y: f32 = 0, pointer_shape: ev.Cursor = .default, /// The first line shown, scrolled down to. first: usize = 0, /// The lines as last drawn, and where each is, for the pointer. rows: [max_rows]Row = undefined, row_count: usize = 0, /// The row under the pointer. hover: ?usize = null, /// Scrolling smaller than a line, kept until it adds up to one. scroll_rest: f32 = 0, const max_rows = 256; const Row = struct { y: u32, h: u32, pane: ?u64 }; /// A line of the tree. pub const Line = struct { depth: u8, /// A mark saying the state, in its color. mark: ?u21 = null, mark_color: vt.color.RGB = .{}, label: []const u8, /// Said dimmer, after the label. detail: []const u8 = "", /// The pane a click on it goes to. pane: ?u64 = null, }; /// Opens it, a child of `parent`, sized to what it has to show. pub fn create(app: *App, parent: ?*Window) !*StatusWindow { const gpa = app.gpa; const sw = try gpa.create(StatusWindow); errdefer gpa.destroy(sw); // As wide as its longest line and as tall as its lines, within reason. var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const lines = try statusLines(app, arena_state.allocator()); const m = app.grid.metrics; const cols: u32 = @intCast(std.math.clamp(longest(lines), 40, 100) + 2); const rows: u32 = @intCast(std.math.clamp(lines.len, 4, 30) + 1); const config = app.config(); // Bigger by our own title bar and frame, when we will draw them. var expect: TitleBar = .{ .bar = .{ .enabled = config.window.title_bar == .client or !app.platform.serverTitleBar(), .scale = app.scale, } }; if (expect.bar.enabled) expect.lines(app.titleFamily(), false); const in = expect.bar.insets(); const width_px = cols * m.cell_width + in.left + in.right; const height_px = rows * m.cell_height + in.top + in.bottom; const pw = try platform.Window.create(app.platform, .{ .title = l10n.text("status-window-title"), .app_id = req.app_id, .width = @intFromFloat(@round(@as(f32, @floatFromInt(width_px)) / app.scale)), .height = @intFromFloat(@round(@as(f32, @floatFromInt(height_px)) / app.scale)), .title_bar = switch (config.window.title_bar) { .server, .none => .server, .client => .client, }, .user = sw, }); errdefer pw.destroy(); if (parent) |p| pw.setParent(p.pw); var renderer: Renderer = try .init(app.device); errdefer renderer.deinit(); var target = try Window.makeTarget(app, pw, width_px, height_px); errdefer target.deinit(); sw.* = .{ .app = app, .gpa = gpa, .pw = pw, .renderer = renderer, .target = target, .parent = parent, .builder = try .init(gpa, app.io, .{ .features = config.font.features, .ligatures = config.font.ligatures, .builtin_glyphs = config.font.builtin_glyphs, }), }; return sw; } pub fn destroy(sw: *StatusWindow) void { sw.builder.deinit(); sw.target.deinit(); sw.renderer.deinit(); sw.pw.destroy(); sw.gpa.destroy(sw); } /// Its parent is closing: it becomes a child of `next`, or a window of its /// own. pub fn reparent(sw: *StatusWindow, next: ?*Window) void { sw.parent = next; sw.pw.setParent(if (next) |n| n.pw else null); } pub fn handle(sw: *StatusWindow, data: ev.Data) void { switch (data) { .close => sw.closing = true, .resize => |r| { sw.scale = r.scale; sw.width = @intFromFloat(@round(@as(f32, @floatFromInt(r.width)) * r.scale)); sw.height = @intFromFloat(@round(@as(f32, @floatFromInt(r.height)) * r.scale)); sw.title_bar.bar.resized(sw.width, sw.height, r.scale, r.state, r.draw_title_bar); sw.dirty = true; }, .focus => |f| { sw.focused = f; sw.dirty = true; }, .key => |k| { if (k.action != .press) return; // Escape closes it, and so does the key that opened it. const toggle = if (bindings.find(sw.app.bindings, k)) |a| a == .toggle_status else false; if (k.keysym == 0xff1b or toggle) sw.closing = true; }, .motion => |mo| sw.motion(mo.x * sw.scale, mo.y * sw.scale), .pointer_leave => if (sw.hover != null or !sw.title_bar.bar.hovered.eql(.content)) { sw.hover = null; sw.title_bar.bar.hovered = .content; sw.dirty = true; }, .button => |b| sw.button(b.button, b.pressed), .scroll => |s| sw.scroll(s.dy), else => {}, } } fn setPointerShape(sw: *StatusWindow, shape: ev.Cursor) void { if (shape == sw.pointer_shape) return; sw.pointer_shape = shape; sw.pw.setCursor(shape); } fn motion(sw: *StatusWindow, x: f32, y: f32) void { sw.pointer_x = x; sw.pointer_y = y; const hit = sw.title_bar.bar.hitTest(x, y); if (!hit.eql(sw.title_bar.bar.hovered)) { sw.title_bar.bar.hovered = hit; sw.dirty = true; } const row = if (hit.eql(.content)) sw.rowAt(y) else null; if (row != sw.hover) { sw.hover = row; sw.dirty = true; } sw.setPointerShape(switch (hit) { .content => if (row != null) .pointer else .default, .title, .leading, .trailing => .default, .close, .maximize, .minimize => .pointer, .edge => |e| e.cursor(), }); } /// The row at `y` that shows a pane. fn rowAt(sw: *const StatusWindow, y: f32) ?usize { for (sw.rows[0..sw.row_count], 0..) |row, i| { if (row.pane == null) continue; if (y >= @as(f32, @floatFromInt(row.y)) and y < @as(f32, @floatFromInt(row.y + row.h))) return i; } return null; } fn button(sw: *StatusWindow, b: ev.MouseButton, pressed: bool) void { const bar = &sw.title_bar.bar; const outcome = if (pressed) bar.press(sw.pw, b, sw.pointer_x, sw.pointer_y, std.Io.Clock.awake.now(sw.app.io)) else bar.release(sw.pw, b, sw.pointer_x, sw.pointer_y); switch (outcome) { .close => sw.closing = true, // A press on a row shows its pane. .content => if (pressed and b == .left) { const i = sw.rowAt(sw.pointer_y) orelse return; const id = sw.rows[i].pane orelse return; sw.app.open(&.{ .focus_pane = id }) catch |err| log.err("showing a pane: {t}", .{err}); }, .handled, .leading, .trailing => {}, } } fn scroll(sw: *StatusWindow, dy: f32) void { sw.scroll_rest += dy; const lines = @trunc(sw.scroll_rest); if (lines == 0) return; sw.scroll_rest -= lines; const n: i64 = @intFromFloat(lines); sw.first = @intCast(@max(0, @as(i64, @intCast(sw.first)) + n)); // Clamped to the lines there are when it is next drawn. sw.dirty = true; } pub fn wantsDraw(sw: *const StatusWindow) bool { return sw.dirty and sw.width > 0 and sw.height > 0 and sw.pw.ready(); } pub fn draw(sw: *StatusWindow) !void { sw.dirty = false; sw.build() catch |err| switch (err) { error.AtlasFull => { sw.app.glyphs.clear(); try sw.build(); }, else => return err, }; const bg = sw.app.theme.background; const clear: [4]f32 = .{ @as(f32, @floatFromInt(bg.r)) / 255, @as(f32, @floatFromInt(bg.g)) / 255, @as(f32, @floatFromInt(bg.b)) / 255, 1, }; try sw.renderer.draw(sw.width, sw.height, clear, sw.builder.instances.items, sw.builder.runs.items, &.{}, &sw.app.glyphs.alpha, &sw.app.glyphs.color, sw.target.target()); } fn build(sw: *StatusWindow) !void { try sw.builder.reset(); var arena_state: std.heap.ArenaAllocator = .init(sw.gpa); defer arena_state.deinit(); const a = arena_state.allocator(); const lines = try statusLines(sw.app, a); const depths = try a.alloc(u8, lines.len); for (lines, depths) |l, *d| d.* = l.depth; const theme = &sw.app.theme; const m = sw.app.grid.metrics; if (sw.title_bar.bar.enabled) sw.title_bar.lines(sw.app.titleFamily(), false); const cw = m.cell_width; const ch = m.cell_height; const fg = theme.foreground; const bg = theme.background; const dim = decor.mix(bg, fg, 0.55); const edge = decor.mix(bg, fg, 0.35); const hover = decor.mix(bg, fg, 0.14); const in = sw.title_bar.bar.insets(); const pad = cw; const x0 = in.left + pad; const top = in.top + ch / 2; const avail_w = sw.width -| in.left -| in.right -| 2 * pad; const avail_h = sw.height -| top -| in.bottom -| ch / 2; const cols: u32 = avail_w / @max(cw, 1); const fits: usize = avail_h / @max(ch, 1); sw.row_count = 0; if (lines.len == 0) { _ = try sw.text(x0, top, l10n.text("status-window-empty"), cols, dim); } else { sw.first = @min(sw.first, lines.len -| fits); const end = @min(lines.len, sw.first + fits); var y = top; for (lines[sw.first..end], sw.first..) |l, i| { if (sw.row_count < max_rows) { sw.rows[sw.row_count] = .{ .y = y, .h = ch, .pane = l.pane }; if (sw.hover == sw.row_count and l.pane != null) try sw.builder.rect(@floatFromInt(in.left), @floatFromInt(y), @floatFromInt(sw.width -| in.left -| in.right), @floatFromInt(ch), .{ hover.r, hover.g, hover.b, 255 }); sw.row_count += 1; } var col: u32 = 0; var pbuf: [64]u21 = undefined; const prefix = prog_status.treePrefix(depths, i, &pbuf); if (prefix.len > 0) { const n: u32 = @intCast(@min(prefix.len, cols)); try sw.builder.text(&sw.app.grid, &sw.app.glyphs, @floatFromInt(x0), @floatFromInt(y), prefix[0..n], .{ edge.r, edge.g, edge.b, 255 }); col += n; } if (l.mark) |mk| if (col + 2 <= cols) { try sw.builder.text(&sw.app.grid, &sw.app.glyphs, @floatFromInt(x0 + col * cw), @floatFromInt(y), &.{mk}, .{ l.mark_color.r, l.mark_color.g, l.mark_color.b, 255 }); col += 2; }; col += try sw.text(x0 + col * cw, y, l.label, cols -| col, if (l.depth == 0) fg else decor.mix(bg, fg, 0.9)); if (l.detail.len > 0 and col + 3 < cols) { col += 2; _ = try sw.text(x0 + col * cw, y, l.detail, cols - col, dim); } y += ch; } } try sw.title_bar.build(&sw.builder, sw.app.titleText(), l10n.text("status-window-title"), null, .{ .foreground = fg, .background = bg }, sw.focused); } /// `s` at `x`, `y`, cut to `max` cells; the cells it took. fn text(sw: *StatusWindow, x: u32, y: u32, s: []const u8, max: u32, color: vt.color.RGB) !u32 { var buf: [256]u21 = undefined; const cps = decor.fitTitle(s, max, &buf); try sw.builder.text(&sw.app.grid, &sw.app.glyphs, @floatFromInt(x), @floatFromInt(y), cps, .{ color.r, color.g, color.b, 255 }); return @intCast(decor.cells(cps)); } /// The longest line, in cells, with its tree prefix and mark. fn longest(lines: []const Line) usize { var want: usize = decor.widthOf(l10n.text("status-window-empty")); for (lines) |l| { const label = decor.widthOf(l.label); const detail = if (l.detail.len > 0) 2 + decor.widthOf(l.detail) else 0; want = @max(want, 3 * @as(usize, l.depth) + 2 + label + detail); } return want; } // --------------------------------------------------------------------------- // What there is to show // --------------------------------------------------------------------------- /// The color a state is shown in: the configuration's, or else the /// palette's — blue going, yellow paused or waiting, green done, red /// failed. pub fn stateColor(theme: *const Config.Theme, state: Pane.Progress.State, palette: *const vt.color.Palette) vt.color.RGB { return switch (state) { .@"error" => theme.progress.@"error" orelse palette[1], .paused, .blocked => theme.progress.paused orelse palette[3], .done => theme.progress.done orelse palette[2], .normal => theme.progress.normal orelse palette[4], }; } /// The mark a state is shown with: a dot going, a diamond waiting, a tick /// done, a cross failed. pub fn markOf(state: Pane.Progress.State) u21 { return switch (state) { .normal => 0x25cf, .paused, .blocked => 0x25c6, .done => 0x2713, .@"error" => 0x2717, }; } fn recordState(state: prog_status.State) ?Pane.Progress.State { return switch (state) { .working => .normal, .blocked => .blocked, .done => .done, .@"error" => .@"error", .idle, .clear => null, }; } fn progressState(state: anytype) Pane.Progress.State { return switch (state) { .pause => .paused, .@"error" => .@"error", else => .normal, }; } /// How pressing a state is, for the indicator: a failure first, then /// something waiting on the user, then work going on, then work done. fn urgency(state: Pane.Progress.State) u8 { return switch (state) { .done => 0, .normal => 1, .paused => 2, .blocked => 3, .@"error" => 4, }; } /// What the status button shows: how many reports there are in every /// terminal, and the most pressing of them. With none — records that only /// say a program is idle do not count — an empty circle, greyed out. pub fn indicator(app: *const App) StatusButton.Indicator { var count: u32 = 0; var worst: ?Pane.Progress.State = null; const note = struct { fn f(w: *?Pane.Progress.State, s: Pane.Progress.State) void { if (w.* == null or urgency(s) > urgency(w.*.?)) w.* = s; } }.f; var buf: [Tab.max_panes]*Pane = undefined; for (app.windows.items) |w| for (w.tabs.items) |t| for (t.panes(&buf)) |p| { if (p.progress94) |pr| { count += 1; note(&worst, progressState(pr.state)); } for (p.status) |rec| if (recordState(rec.state)) |s| { count += 1; note(&worst, s); }; }; const s = worst orelse return .{ .mark = 0x25cb, .color = decor.mix(app.theme.background, app.theme.foreground, 0.4), .count = 0, }; return .{ .mark = markOf(s), .color = stateColor(&app.theme, s, &app.theme.palette), .count = count }; } /// Whether `p` has anything to show. fn hasStatus(p: *const Pane) bool { return p.status.len > 0 or p.progress94 != null; } /// The lines of the tree, in `a`. pub fn statusLines(app: *const App, a: Allocator) ![]Line { var lines: std.ArrayList(Line) = .empty; const windows = app.windows.items; const theme = &app.theme; const palette = &theme.palette; var buf: [Tab.max_panes]*Pane = undefined; const dim = decor.mix(theme.background, theme.foreground, 0.55); for (windows, 0..) |win, wi| { var any = false; for (win.tabs.items) |t| for (t.panes(&buf)) |p| { if (hasStatus(p)) any = true; }; if (!any) continue; var base: u8 = 0; if (windows.len > 1) { try lines.append(a, .{ .depth = 0, .label = try l10n.format(a, "status-window-window", &.{l10n.num("number", wi + 1)}), .pane = win.pane().id }); base = 1; } for (win.tabs.items) |t| { const ps = t.panes(&buf); var shown = false; for (ps) |p| if (hasStatus(p)) { shown = true; }; if (!shown) continue; try lines.append(a, .{ .depth = base, .label = t.title(), .pane = t.focused.id }); const several = ps.len > 1; for (ps) |p| { if (!hasStatus(p)) continue; var pd = base + 1; if (several) { try lines.append(a, .{ .depth = pd, .label = p.title.items, .pane = p.id }); pd += 1; } if (p.progress94) |pr| { const state = progressState(pr.state); const word = l10n.choice("status-state", "state", switch (pr.state) { .pause => "paused", .@"error" => "failed", else => "working", }); try lines.append(a, .{ .depth = pd, .mark = markOf(state), .mark_color = stateColor(theme, state, palette), .label = l10n.text("status-progress-label"), .detail = if (pr.percent) |pct| try l10n.format(a, "status-state-percent", &.{ l10n.str("state", word), l10n.num("percent", pct) }) else word, .pane = p.id, }); } for (p.status) |rec| { const app_name = prog_status.appOf(p.status, rec); const label = rec.title orelse if (rec.id.len == 0) (app_name orelse l10n.text("status-unnamed")) else rec.name(); var detail: std.Io.Writer.Allocating = .init(a); if (app_name) |an| if (!std.mem.eql(u8, an, label)) try detail.writer.print("{s} · ", .{an}); const said = prog_status.describe(rec.state, rec.kind); if (rec.progress) |pct| { try l10n.write(&detail.writer, "status-state-percent", &.{ l10n.str("state", said), l10n.num("percent", pct) }); } else try detail.writer.writeAll(said); if (rec.msg) |msg| try detail.writer.print(" — {s}", .{msg}); const state = recordState(rec.state); try lines.append(a, .{ .depth = pd + @as(u8, @intCast(@min(prog_status.shownDepth(p.status, rec), 16))), .mark = if (state) |s| markOf(s) else 0x25cb, .mark_color = if (state) |s| stateColor(theme, s, palette) else dim, .label = label, .detail = detail.written(), .pane = p.id, }); } } } } return lines.items; } test "the most pressing state wins" { try std.testing.expect(urgency(.@"error") > urgency(.blocked)); try std.testing.expect(urgency(.blocked) > urgency(.normal)); try std.testing.expect(urgency(.normal) > urgency(.done)); }