Something went wrong. Try again.
A GPU terminal emulator for Wayland and Windows on libghostty-vt, drawing with Vulkan, running a program on a pty or a serial port.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// 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));}