diff --git a/README.md b/README.md index debaf05..1e0d978 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ A proof-of-concept workspace built on **saved layouts** that open a whole arrangement — directories, scripts and all — in one go. -The sidebar holds the window controls, a new-tab button, and one row per tab — -the layout Zen Browser uses for vertical tabs — with the content inset to its -right. +The sidebar holds the window controls, a new-tab button, one row per tab, and a +settings gear at its foot — the layout Zen Browser uses for vertical tabs — +with the content inset to its right. ## Quick start @@ -107,7 +107,8 @@ just the VT core. ## Architecture ``` -main.zig AdwApplication, CSS loading +main.zig AdwApplication startup +appearance.zig the colour scheme: preference -> libadwaita, CSS, palette Window.zig sidebar + GtkStack of views, tab management, shortcuts View.zig one tab's content: its panes, their layout, and drag handling Layout.zig the split tree: nodes, rearranging, GtkPaned materialization @@ -121,7 +122,9 @@ SaveLayoutDialog.zig turns the current tab into a saved layout Session.zig libghostty-vt Terminal + parser, fed by the PTY Pty.zig openpt/fork/exec, controlling terminal setup key.zig GDK keyval -> libghostty-vt key mapping -theme.zig colors libghostty-vt has no opinion about +theme.zig colors libghostty-vt has no opinion about, per scheme +Settings.zig preferences: model, JSON on disk +SettingsDialog.zig the settings page ``` A tab is a **view**, and a view holds one or more **panes** arranged in a @@ -286,6 +289,9 @@ in principle, but a terminal grid is small. - **Zooming a pane** to fill its tab and back, from the header button or `Ctrl+Shift+Z`. Nothing closes and nothing moves — hidden panes keep running and the split comes back exactly as it was. See [Zoom](#zoom) +- **Light and dark schemes**, following the desktop by default and pinnable + from the settings page, applying to open tabs immediately — terminal palette + included. See [Theme](#theme) ### Shortcuts @@ -299,6 +305,7 @@ in principle, but a terminal grid is small. | `Ctrl+Shift+V` | paste into a terminal (bracketed-paste aware, refuses unsafe pastes) | | `Ctrl+Shift+R` | rename the current tab (empty name = follow the terminal) | | `Ctrl+Shift+Z` | zoom the focused pane to fill the tab, and back | +| `Ctrl+,` | settings | | `Ctrl+PageUp/PageDown` | previous / next tab | | `Alt+1`..`Alt+8` | jump to tab N, `Alt+9` jumps to the last | @@ -468,6 +475,60 @@ smolvm machine exec --name NAME -- mkdir -p /root/.claude/hooks image that has no `jq`; if the title can't be read the state still reports and the tab simply keeps the name it had. +## Theme + +Deep navy surfaces with Signal's ultramarine (`#3a76f0`) as the accent, in a +light and a dark scheme. `Ctrl+,` or the gear at the foot of the sidebar opens +**Settings**, which currently holds one choice: light, dark, or **system**, +which is the default and follows the desktop. + +The preference lives in `~/.config/playpen/settings.json` (or +`$XDG_CONFIG_HOME`), beside `layouts.json`: + +```json +{ + "version": 1, + "theme": "system" +} +``` + +Switching applies immediately to every open tab — nothing needs restarting, and +a shell that has been running all day repaints along with everything else. + +Three separate colour systems have to agree for that to be true, which is what +`appearance.zig` exists to arrange: + +- **libadwaita's style manager** colours the stock widgets — popovers, entries, + dialog chrome. It is told to force a scheme, or left on `default` to follow + the desktop. +- **`style.css`** colours everything the app draws itself. It is written + entirely against named colours, with one palette file per scheme + (`palette-dark.css`, `palette-light.css`); the matching palette is prepended + and the pair loaded as a single `GtkCssProvider`. No rule in `style.css` may + hardcode a colour — a literal hex is a rule that looks right in whichever + scheme you happened to be testing in. +- **`theme.zig`** holds what Cairo draws the terminal grid from: the default + background, foreground and cursor, plus the 16 ANSI colours. The style tree + is never consulted there, so a CSS reload alone would leave every terminal + painted in the scheme it started in. + +The ANSI palette is the part that is easy to skip and shouldn't be. The +standard xterm yellow is `#cdcd00`, which on a white background is close to +invisible — and prompts and build tools use it constantly, so a light scheme +without a light palette is a light scheme you can't read. The 240 colours above +index 16 are fixed by spec and left alone; only the 16 named ones change. + +They are swapped through libghostty-vt's `DynamicPalette.changeDefault`, which +changes what the palette *defaults* to. Anything a program set for itself with +OSC 4 survives the switch, and a later OSC 104 reset returns to the current +scheme's palette rather than the one the app happened to start in. + +Whether "system" currently means light or dark is libadwaita's answer, not +ours: it already watches the desktop for the setting. So the stylesheet follows +its `dark` property rather than the stored preference, and the preference only +decides what the style manager is told. A desktop that switches at sunset takes +this app with it, with no extra machinery and no second source of truth. + ## Not implemented This is a proof of concept, and the following are deliberately absent: diff --git a/dist/dev.greyson.playpen.svg b/dist/dev.greyson.playpen.svg index baa6a8b..296f6a5 100644 --- a/dist/dev.greyson.playpen.svg +++ b/dist/dev.greyson.playpen.svg @@ -3,16 +3,23 @@ Playpen app icon: a terminal pane with the vertical tab strip down its left edge. Shapes are kept large and high-contrast so the sidebar is still readable when the icon is scaled down to 32px in a launcher. + + Colours track the app's dark palette — the navy surfaces of + `palette-dark.css` and Signal's ultramarine as the accent. An icon is the + one part of the app that is looked at next to a row of other icons rather + than on its own, so it stays on the dark scheme in both: a launcher shelf + where one icon repaints itself with the desktop theme is a worse result than + one that is simply recognisable. --> - - + + - - + + @@ -24,18 +31,18 @@ - - + + - - + + diff --git a/src/Session.zig b/src/Session.zig index 66b5ef4..f9355f9 100644 --- a/src/Session.zig +++ b/src/Session.zig @@ -12,6 +12,7 @@ const glibunix = @import("glibunix"); const vt = @import("ghostty-vt"); const Pty = @import("Pty.zig"); +const theme = @import("theme.zig"); const Session = @This(); @@ -130,6 +131,10 @@ pub fn create( }; errdefer self.term.deinit(alloc); + // Before the child is spawned, so the first thing it prints is already in + // the right palette. + self.refreshPalette(); + self.stream = self.term.vtStream(); errdefer self.stream.deinit(); @@ -298,6 +303,16 @@ fn fromHandler(handler: *vt.TerminalStream.Handler) *Session { return @fieldParentPtr("term", handler.terminal); } +/// Adopt the current scheme's ANSI palette. +/// +/// Safe on a session that has been running for hours: this changes what the +/// palette *defaults* to, so anything the program inside set for itself with +/// OSC 4 survives the switch. The caller still has to queue a redraw — nothing +/// here touches the widget. +pub fn refreshPalette(self: *Session) void { + self.term.colors.palette.changeDefault(theme.ansiPalette()); +} + /// Send user input to the child. pub fn write(self: *Session, bytes: []const u8) void { self.pty.writeAll(bytes); diff --git a/src/Settings.zig b/src/Settings.zig new file mode 100644 index 0000000..e1fa6f7 --- /dev/null +++ b/src/Settings.zig @@ -0,0 +1,170 @@ +//! User preferences: the handful of choices that outlive a session. +//! +//! A small JSON file next to `layouts.json`, rewritten whole every time +//! something changes. There is one setting today — the colour scheme — but the +//! file carries a version from the start, so adding the second one is not also +//! the day we invent a format. +//! +//! File access goes through GLib for the same reasons `Layouts` does: it knows +//! the XDG config directory, and `g_file_set_contents` writes to a temporary +//! and renames, so an interrupted save leaves the previous settings intact +//! rather than a truncated file that won't parse. +//! +//! Unlike `Layouts` this holds no allocated strings — every field is a fixed +//! scalar — so it needs no arena and can be copied freely. Parsing borrows a +//! stack buffer for the duration of the call and gives it back. + +const std = @import("std"); +const glib = @import("glib"); + +const Settings = @This(); + +/// Bumped only if the on-disk shape changes incompatibly. Read but not yet +/// acted on: there is nothing older to migrate from. +pub const format_version = 1; + +const max_path = 4096; + +/// Ample for a file holding a version and a word, with enough headroom that a +/// hand-edited one with comments or extra keys still parses. A settings file +/// larger than this is not one we wrote, and defaults are the better answer +/// than a partial read of it. +const max_file_size = 16 * 1024; + +/// Which palette to paint the window with. +/// +/// `system` is the default and defers to the desktop, which is what someone +/// who never opens the settings page should get. The other two pin the app +/// regardless of what the rest of the session is doing — worth having, since +/// a terminal is often the one window you want dark on a light desktop. +pub const Theme = enum { + system, + light, + dark, + + /// What the settings page shows on the button for this choice. + pub fn label(self: Theme) [:0]const u8 { + return switch (self) { + .system => "System", + .light => "Light", + .dark => "Dark", + }; + } +}; + +theme: Theme = .system, + +/// Read the settings file, falling back to defaults for anything missing. +/// +/// Every failure below lands on the same behaviour — carry on with defaults — +/// because there is no useful alternative: this runs before there is a window +/// to report an error in, and refusing to start over an unreadable preferences +/// file would be a worse outcome than ignoring it. A malformed file is logged +/// rather than silently swallowed, since the next save overwrites it. +pub fn load() Settings { + var self: Settings = .{}; + + var path_buf: [max_path]u8 = undefined; + const path = configPath(&path_buf) orelse return self; + + var contents: [*]u8 = undefined; + var length: usize = 0; + var err: ?*glib.Error = null; + + if (glib.fileGetContents(path.ptr, &contents, &length, &err) == 0) { + defer if (err) |e| e.free(); + // No file yet is the normal state before anything has been changed. + if (err) |e| { + const missing = e.f_domain == glib.fileErrorQuark() and + e.f_code == @intFromEnum(glib.FileError.noent); + if (!missing) { + std.log.warn("could not read settings: {s}", .{e.f_message orelse "unknown"}); + } + } + return self; + } + defer glib.free(contents); + + if (length > max_file_size) { + std.log.warn("settings file is implausibly large; using defaults", .{}); + return self; + } + + self.parse(contents[0..length]) catch { + std.log.warn("could not parse {s}; using defaults", .{path}); + return .{}; + }; + + return self; +} + +fn parse(self: *Settings, text: []const u8) !void { + // The parse tree only has to outlive this function: every value read out + // of it is copied into a scalar field, so a stack arena is enough and + // nothing here needs to reach the caller's allocator. + var buf: [max_file_size * 4]u8 = undefined; + var fba: std.heap.FixedBufferAllocator = .init(&buf); + + const parsed = try std.json.parseFromSliceLeaky( + std.json.Value, + fba.allocator(), + text, + .{}, + ); + + const root = switch (parsed) { + .object => |o| o, + else => return error.Malformed, + }; + + // An unknown value is treated as absent rather than as a failure. A file + // written by a newer version naming a scheme this build has never heard of + // should cost the user the default, not the whole file. + if (root.get("theme")) |value| { + if (value == .string) { + if (std.meta.stringToEnum(Theme, value.string)) |t| self.theme = t; + } + } +} + +pub const SaveError = error{WriteFailed}; + +/// Write the whole file back out, creating the config directory if needed. +pub fn save(self: Settings) SaveError!void { + var dir_buf: [max_path]u8 = undefined; + const dir = configDir(&dir_buf) orelse return error.WriteFailed; + if (glib.mkdirWithParents(dir.ptr, 0o700) != 0) return error.WriteFailed; + + var path_buf: [max_path]u8 = undefined; + const path = configPath(&path_buf) orelse return error.WriteFailed; + + // Written by hand rather than through the JSON emitter: this is a fixed + // two-line document with no user-supplied strings in it, so there is + // nothing here that needs escaping and nothing that needs an allocator. + var text_buf: [256]u8 = undefined; + const text = std.fmt.bufPrint(&text_buf, + \\{{ + \\ "version": {d}, + \\ "theme": "{s}" + \\}} + \\ + , .{ format_version, @tagName(self.theme) }) catch return error.WriteFailed; + + var err: ?*glib.Error = null; + if (glib.fileSetContents(path.ptr, text.ptr, @intCast(text.len), &err) == 0) { + if (err) |e| e.free(); + return error.WriteFailed; + } +} + +/// `$XDG_CONFIG_HOME/playpen/settings.json`, or +/// `~/.config/playpen/settings.json` when that isn't set. +fn configPath(buf: []u8) ?[:0]const u8 { + const dir = std.mem.span(glib.getUserConfigDir()); + return std.fmt.bufPrintZ(buf, "{s}/playpen/settings.json", .{dir}) catch null; +} + +fn configDir(buf: []u8) ?[:0]const u8 { + const dir = std.mem.span(glib.getUserConfigDir()); + return std.fmt.bufPrintZ(buf, "{s}/playpen", .{dir}) catch null; +} diff --git a/src/SettingsDialog.zig b/src/SettingsDialog.zig new file mode 100644 index 0000000..6c129bd --- /dev/null +++ b/src/SettingsDialog.zig @@ -0,0 +1,197 @@ +//! The settings page: preferences that apply to the whole app, grouped into +//! titled sections. +//! +//! Built by hand in the same shape as the layout dialogs rather than out of +//! `AdwPreferencesDialog`, so that it wears the app's own chrome — a stock +//! preferences window in the middle of a window this heavily restyled reads as +//! something that belongs to a different program. +//! +//! Changes apply the moment you make them, with no confirm button. That is the +//! right shape for what is here: picking a scheme repaints the window behind +//! the dialog, so the setting *is* its own preview, and an OK button would +//! only offer to undo something you can see the result of. +//! +//! Only one is open at a time. The window is remembered while it is up and +//! presented again rather than duplicated, since two settings pages +//! disagreeing about which scheme is selected is a bug with no upside. + +const std = @import("std"); +const gtk = @import("gtk"); + +const Settings = @import("Settings.zig"); +const appearance = @import("appearance.zig"); + +const SettingsDialog = @This(); + +alloc: std.mem.Allocator, +window: *gtk.Window, + +/// One per `Settings.Theme`, in declaration order, so the selected one can be +/// re-checked without asking each button what it stands for. +theme_buttons: [std.enums.values(Settings.Theme).len]*gtk.ToggleButton, + +/// Set while a click is being applied, so that the resulting `toggled` signals +/// on the other buttons in the group don't re-enter and undo it. +updating: bool = false, + +/// The open dialog, if there is one. A file-level singleton because "the +/// settings page" is a singular thing from the user's point of view. +var open: ?*SettingsDialog = null; + +pub fn present(alloc: std.mem.Allocator, parent: *gtk.Window) !void { + if (open) |existing| { + existing.window.present(); + return; + } + + const self = try alloc.create(SettingsDialog); + errdefer alloc.destroy(self); + + self.* = .{ + .alloc = alloc, + .window = gtk.Window.new(), + .theme_buttons = undefined, + }; + + self.window.setTitle("Settings"); + self.window.setTransientFor(parent); + self.window.setModal(1); + self.window.setDefaultSize(460, -1); + self.window.as(gtk.Widget).addCssClass("playpen-dialog"); + + const content = gtk.Box.new(.vertical, 12); + content.as(gtk.Widget).addCssClass("playpen-dialog-content"); + + content.append(self.buildAppearance()); + + const buttons = gtk.Box.new(.horizontal, 8); + buttons.as(gtk.Widget).setHalign(.end); + + // "Close" rather than "OK": nothing here is pending, so there is nothing + // for a confirm button to confirm. + const close = gtk.Button.newWithLabel("Close"); + _ = gtk.Button.signals.clicked.connect(close, *SettingsDialog, &onClose, self, .{}); + buttons.append(close.as(gtk.Widget)); + + content.append(buttons.as(gtk.Widget)); + self.window.setChild(content.as(gtk.Widget)); + + _ = gtk.Widget.signals.destroy.connect( + self.window, + *SettingsDialog, + &onDestroy, + self, + .{}, + ); + + open = self; + self.window.present(); +} + +/// The Appearance section. One group today; the box it returns is what a +/// second section would sit next to. +fn buildAppearance(self: *SettingsDialog) *gtk.Widget { + const group = gtk.Box.new(.vertical, 10); + group.as(gtk.Widget).addCssClass("playpen-settings-group"); + + const title = gtk.Label.new("Appearance"); + title.setXalign(0); + title.as(gtk.Widget).addCssClass("playpen-settings-title"); + group.append(title.as(gtk.Widget)); + + const row = gtk.Box.new(.horizontal, 12); + + const labels = gtk.Box.new(.vertical, 2); + labels.as(gtk.Widget).setHexpand(1); + labels.as(gtk.Widget).setValign(.center); + + const name = gtk.Label.new("Theme"); + name.setXalign(0); + name.as(gtk.Widget).addCssClass("playpen-dialog-label"); + labels.append(name.as(gtk.Widget)); + + const hint = gtk.Label.new("System follows the desktop's light or dark setting."); + hint.setXalign(0); + hint.setWrap(1); + hint.as(gtk.Widget).addCssClass("playpen-dialog-sublabel"); + labels.append(hint.as(gtk.Widget)); + + row.append(labels.as(gtk.Widget)); + row.append(self.buildThemeChoice()); + + group.append(row.as(gtk.Widget)); + + return group.as(gtk.Widget); +} + +/// The scheme picker: one toggle per choice, drawn as a single linked control. +/// +/// They are not put in a GTK radio group. Grouped toggles can't be unchecked by +/// clicking the active one, which is the behaviour we want, but the group also +/// emits `toggled` twice per change — once off, once on — and the off half +/// would apply whichever scheme happened to be next in the list. Handling the +/// exclusivity here instead makes each click exactly one decision. +fn buildThemeChoice(self: *SettingsDialog) *gtk.Widget { + const box = gtk.Box.new(.horizontal, 0); + box.as(gtk.Widget).addCssClass("linked"); + box.as(gtk.Widget).addCssClass("playpen-settings-choice"); + box.as(gtk.Widget).setValign(.center); + + const current = appearance.currentTheme(); + + for (std.enums.values(Settings.Theme), 0..) |value, i| { + const button = gtk.ToggleButton.newWithLabel(value.label()); + button.setActive(@intFromBool(value == current)); + + _ = gtk.ToggleButton.signals.toggled.connect( + button, + *SettingsDialog, + &onThemeToggled, + self, + .{}, + ); + + box.append(button.as(gtk.Widget)); + self.theme_buttons[i] = button; + } + + return box.as(gtk.Widget); +} + +fn onThemeToggled(button: *gtk.ToggleButton, self: *SettingsDialog) callconv(.c) void { + if (self.updating) return; + + const values = std.enums.values(Settings.Theme); + + const index = for (self.theme_buttons, 0..) |candidate, i| { + if (candidate == button) break i; + } else return; + + // Clicking the choice you are already on: put it back rather than leaving + // the picker with nothing selected, which would show a scheme is in force + // that no button claims. + if (button.getActive() == 0) { + self.updating = true; + defer self.updating = false; + button.setActive(1); + return; + } + + self.updating = true; + defer self.updating = false; + + for (self.theme_buttons, 0..) |other, i| { + if (i != index) other.setActive(0); + } + + appearance.setTheme(values[index]); +} + +fn onClose(_: *gtk.Button, self: *SettingsDialog) callconv(.c) void { + self.window.destroy(); +} + +fn onDestroy(_: *gtk.Window, self: *SettingsDialog) callconv(.c) void { + if (open == self) open = null; + self.alloc.destroy(self); +} diff --git a/src/Terminal.zig b/src/Terminal.zig index d6c41e2..bbd383c 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -365,7 +365,7 @@ fn render(self: *Terminal, cr: *cairo.Context, _: c_int, _: c_int) !void { const default_bg: theme.Rgb = if (term.colors.background.get()) |c| .from(c) else - theme.bg; + theme.bg(); { const r, const g, const b = default_bg.cairoRgb(); cr.setSourceRgb(r, g, b); @@ -375,7 +375,7 @@ fn render(self: *Terminal, cr: *cairo.Context, _: c_int, _: c_int) !void { const default_fg: theme.Rgb = if (term.colors.foreground.get()) |c| .from(c) else - theme.fg; + theme.fg(); const layout = pangocairo.createLayout(cr); defer layout.unref(); @@ -536,7 +536,7 @@ fn drawCursor( const x = pad + @as(f64, @floatFromInt(cursor.x)) * self.cell_w; const y = pad + @as(f64, @floatFromInt(cursor.y)) * self.cell_h; - const color: theme.Rgb = if (term.colors.cursor.get()) |c| .from(c) else theme.cursor; + const color: theme.Rgb = if (term.colors.cursor.get()) |c| .from(c) else theme.cursor(); const r, const g, const b = color.cairoRgb(); cr.setSourceRgb(r, g, b); diff --git a/src/Window.zig b/src/Window.zig index 8c32ec3..cd8524a 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -18,12 +18,18 @@ const Layouts = @import("Layouts.zig"); const OpenLayoutDialog = @import("OpenLayoutDialog.zig"); const Pane = @import("Pane.zig"); const SaveLayoutDialog = @import("SaveLayoutDialog.zig"); +const SettingsDialog = @import("SettingsDialog.zig"); const Terminal = @import("Terminal.zig"); const View = @import("View.zig"); +const appearance = @import("appearance.zig"); const Window = @This(); -const sidebar_width = 220; +/// Total width of the sidebar column, margins and border included — GTK folds +/// both into a widget's size request. Widened when the sidebar became an inset +/// card: the margin, border and roomier row padding all come out of the label, +/// and at the old 220 a tab name truncated a good deal earlier than it used to. +const sidebar_width = 236; alloc: std.mem.Allocator, window: *adw.ApplicationWindow, @@ -147,6 +153,7 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window { header.as(gtk.Widget).addCssClass("flat"); const new_tab_button = gtk.Button.newFromIconName("tab-new-symbolic"); + new_tab_button.as(gtk.Widget).addCssClass("playpen-header-button"); new_tab_button.as(gtk.Widget).setTooltipText("New tab (Ctrl+Shift+T)"); _ = gtk.Button.signals.clicked.connect( new_tab_button, @@ -155,17 +162,22 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window { self, .{}, ); - header.packEnd(new_tab_button.as(gtk.Widget)); + // Packed at the start, with the window controls left alone at the end. In + // a 220px header the two used to sit right up against the close button, + // which both wasted the empty half of the bar and put "new tab" a few + // pixels from "close window". + header.packStart(new_tab_button.as(gtk.Widget)); // Layouts sit behind their own button rather than replacing the plain // new-tab one: opening an ordinary shell stays a single click. const layout_button = gtk.MenuButton.new(); layout_button.setIconName("view-grid-symbolic"); + layout_button.as(gtk.Widget).addCssClass("playpen-header-button"); layout_button.as(gtk.Widget).setTooltipText("Open a saved layout"); layout_button.setPopover(self.layout_popover); self.layout_popover.as(gtk.Widget).addCssClass("playpen-layout-popover"); self.refreshLayoutMenu(); - header.packEnd(layout_button.as(gtk.Widget)); + header.packStart(layout_button.as(gtk.Widget)); sidebar.append(header.as(gtk.Widget)); self.list.setSelectionMode(.single); @@ -185,6 +197,28 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window { scroller.setChild(self.list.as(gtk.Widget)); sidebar.append(scroller.as(gtk.Widget)); + // Settings sit at the foot of the sidebar rather than in its header. The + // header holds the two things you reach for constantly — a new tab and a + // saved layout — and a preferences button is the opposite of that: opened + // rarely, and never in a hurry. Below the tab list it stays out of the way + // of both, and it is where every other sidebar puts it. + const footer = gtk.Box.new(.horizontal, 0); + footer.as(gtk.Widget).addCssClass("playpen-sidebar-footer"); + + const settings_button = gtk.Button.newFromIconName("emblem-system-symbolic"); + settings_button.as(gtk.Widget).addCssClass("flat"); + settings_button.as(gtk.Widget).addCssClass("playpen-settings-button"); + settings_button.as(gtk.Widget).setTooltipText("Settings (Ctrl+,)"); + _ = gtk.Button.signals.clicked.connect( + settings_button, + *Window, + &onSettingsClicked, + self, + .{}, + ); + footer.append(settings_button.as(gtk.Widget)); + sidebar.append(footer.as(gtk.Widget)); + // ---- content ------------------------------------------------------- self.stack.as(gtk.Widget).setHexpand(1); self.stack.as(gtk.Widget).setVexpand(1); @@ -220,6 +254,8 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window { .{}, ); + appearance.onChanged(&onAppearanceChanged, self); + try self.newTab(); return self; @@ -717,6 +753,40 @@ fn closeTab(self: *Window, tab: *Tab) void { self.select(self.tabs.items[next]); } +// ------------------------------------------------------------------------- +// Settings + +fn openSettings(self: *Window) void { + SettingsDialog.present(self.alloc, self.window.as(gtk.Window)) catch |err| { + std.log.err("failed to open settings: {s}", .{@errorName(err)}); + }; +} + +fn onSettingsClicked(_: *gtk.Button, self: *Window) callconv(.c) void { + self.openSettings(); +} + +/// The colour scheme changed. Everything styled by CSS restyles itself; the +/// terminal grids do not, because Cairo draws them from `theme.zig` and GTK +/// has no idea that widget's contents depend on the palette at all. Without +/// this, switching to light leaves every terminal a dark rectangle until +/// something else happens to dirty it. +/// +/// Sessions already open are re-palletted rather than left on the one they +/// started in: a shell you have had running all day is exactly the one you are +/// looking at when you switch, and leaving it in the old scheme's colours +/// would make the setting look like it only applies to new tabs. +fn onAppearanceChanged(ctx: ?*anyopaque) void { + const self: *Window = @ptrCast(@alignCast(ctx.?)); + for (self.tabs.items) |tab| { + for (tab.view.panes.items) |pane| { + const terminal = pane.terminal() orelse continue; + terminal.session.refreshPalette(); + terminal.area.as(gtk.Widget).queueDraw(); + } + } +} + // ------------------------------------------------------------------------- // Signal handlers @@ -813,6 +883,10 @@ fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void { if (self.closing) return; self.closing = true; + // Before anything is freed: a scheme change arriving mid-teardown would + // otherwise walk a tab list we are about to destroy. + appearance.clearOnChanged(); + // Each terminal owns a session, which owns a PTY and its child process. // Dropping them here reaps the children rather than orphaning them. for (self.tabs.items) |tab| { @@ -943,8 +1017,13 @@ fn onShortcut( } // Ctrl+PageUp/PageDown cycles tabs, matching most tabbed terminals. + // Ctrl+comma opens settings, which is the convention nearly everywhere. if (ctrl and !shift) { switch (keyval) { + gdk.KEY_comma => { + self.openSettings(); + return 1; + }, gdk.KEY_Page_Up => { self.cycle(-1); return 1; diff --git a/src/appearance.zig b/src/appearance.zig new file mode 100644 index 0000000..5ee20b8 --- /dev/null +++ b/src/appearance.zig @@ -0,0 +1,141 @@ +//! Which palette the app is painted with, and the plumbing that keeps three +//! separate colour systems agreeing about it. +//! +//! * **libadwaita's style manager** colours every stock widget — popovers, +//! entries, scrollbars, dialog chrome. It is told to force light or dark, or +//! left on `default` so it follows the desktop. +//! * **`style.css`** colours everything the app draws for itself. It is +//! written entirely against named colours, with one palette file per scheme; +//! the matching palette is prepended and the pair loaded as a single +//! provider. +//! * **`theme.zig`** holds the three fallback colours the terminal renderer +//! reads. Cairo never consults the style tree, so a CSS reload on its own +//! would leave every terminal grid painted in the palette it started with. +//! +//! The ordering that makes this work: the user's preference is only ever +//! *told to libadwaita*, and the repaint is driven by libadwaita's answer. +//! That is what makes "system" work at all — libadwaita already watches the +//! desktop for the setting and reports it as the `dark` property, so following +//! that property means a desktop that changes scheme at sunset repaints this +//! app too, with no extra machinery and no second source of truth. + +const std = @import("std"); +const adw = @import("adw"); +const gdk = @import("gdk"); +const gobject = @import("gobject"); +const gtk = @import("gtk"); + +const Settings = @import("Settings.zig"); +const theme = @import("theme.zig"); + +/// The stylesheet, once per scheme. Concatenated at compile time so that a +/// switch is a single `loadFromString` with no allocation and no file IO at +/// the moment the desktop changes its mind. +const style = @embedFile("style.css"); +const css_dark = @embedFile("palette-dark.css") ++ style; +const css_light = @embedFile("palette-light.css") ++ style; + +/// Called after the scheme changes, so the owner can repaint the things GTK +/// doesn't know are colour-dependent — the Cairo-drawn terminal grids. +pub const Callback = *const fn (ctx: ?*anyopaque) void; + +var settings: Settings = .{}; +var provider: ?*gtk.CssProvider = null; +var on_changed: ?Callback = null; +var on_changed_ctx: ?*anyopaque = null; + +/// Load the saved preference, install the stylesheet, and start following the +/// resolved scheme. Called once, before the first window is built. +pub fn init() void { + settings = .load(); + + if (gdk.Display.getDefault()) |display| { + const css = gtk.CssProvider.new(); + provider = css; + gtk.StyleContext.addProviderForDisplay( + display, + css.as(gtk.StyleProvider), + gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } + + // Held for the life of the process, so there is nothing to disconnect. + _ = gobject.Object.signals.notify.connect( + adw.StyleManager.getDefault(), + ?*anyopaque, + &onDarkChanged, + null, + .{ .detail = "dark" }, + ); + + applyPreference(); +} + +pub fn currentTheme() Settings.Theme { + return settings.theme; +} + +/// Change the preference, persist it, and repaint. +/// +/// The write is best-effort: a settings file we can't write is worth a log +/// line, but it is not a reason to refuse the change for this session. +pub fn setTheme(to: Settings.Theme) void { + if (settings.theme == to) return; + settings.theme = to; + + settings.save() catch { + std.log.err("failed to save settings", .{}); + }; + + applyPreference(); +} + +/// Hand libadwaita the preference. Everything visible follows from its answer, +/// which arrives either synchronously — `set_color_scheme` settles `dark` +/// before it returns — or later, when the desktop changes under a `system` +/// preference. +fn applyPreference() void { + adw.StyleManager.getDefault().setColorScheme(switch (settings.theme) { + .system => .default, + .light => .force_light, + .dark => .force_dark, + }); + + // Called unconditionally rather than left to the notify handler: pinning + // to the scheme the desktop was already on doesn't change `dark` and so + // emits nothing, and the very first call has nothing to change either. + repaint(); +} + +fn onDarkChanged( + _: *adw.StyleManager, + _: *gobject.ParamSpec, + _: ?*anyopaque, +) callconv(.c) void { + repaint(); +} + +/// Bring the stylesheet and the terminal palette to whatever libadwaita has +/// settled on. Idempotent, and cheap enough to call speculatively. +fn repaint() void { + const dark = adw.StyleManager.getDefault().getDark() != 0; + + theme.setScheme(if (dark) .dark else .light); + if (provider) |css| css.loadFromString(if (dark) css_dark else css_light); + + if (on_changed) |cb| cb(on_changed_ctx); +} + +/// Register the repaint hook. There is one window, so there is one hook; a +/// second registration replaces the first rather than stacking. +pub fn onChanged(cb: Callback, ctx: ?*anyopaque) void { + on_changed = cb; + on_changed_ctx = ctx; +} + +/// Drop the hook. Called as the window is torn down, so that a scheme change +/// arriving during shutdown can't reach freed state. +pub fn clearOnChanged() void { + on_changed = null; + on_changed_ctx = null; +} diff --git a/src/main.zig b/src/main.zig index 8944a9d..781ac77 100644 --- a/src/main.zig +++ b/src/main.zig @@ -7,11 +7,10 @@ const std = @import("std"); const adw = @import("adw"); -const gdk = @import("gdk"); const gio = @import("gio"); -const gtk = @import("gtk"); const Window = @import("Window.zig"); +const appearance = @import("appearance.zig"); /// libghostty-vt logs unimplemented sequences at debug level, which is very /// chatty against a real shell. Keep the app's own warnings and errors. @@ -19,8 +18,6 @@ pub const std_options: std.Options = .{ .log_level = .info, }; -const css = @embedFile("style.css"); - var gpa: std.heap.DebugAllocator(.{}) = .init; pub fn main() u8 { @@ -40,8 +37,9 @@ pub fn main() u8 { } fn onActivate(app: *adw.Application, _: ?*anyopaque) callconv(.c) void { - forceDark(); - loadCss(); + // Before the window, so that the first frame is drawn in the scheme the + // user chose rather than repainted into it a moment later. + appearance.init(); const window = Window.create(gpa.allocator(), app) catch |err| { std.log.err("failed to create window: {s}", .{@errorName(err)}); @@ -49,25 +47,3 @@ fn onActivate(app: *adw.Application, _: ?*anyopaque) callconv(.c) void { }; window.present(); } - -/// The window's own chrome is dark by hand in `style.css`, but stock widgets — -/// popovers, dialogs, text entries — follow the desktop's colour scheme and -/// would come up light against it. Layouts brought the first real dialogs into -/// the app, which is where that mismatch became visible. -fn forceDark() void { - const manager = adw.StyleManager.getDefault(); - manager.setColorScheme(.force_dark); -} - -fn loadCss() void { - const display = gdk.Display.getDefault() orelse return; - const provider = gtk.CssProvider.new(); - defer provider.unref(); - - provider.loadFromString(css); - gtk.StyleContext.addProviderForDisplay( - display, - provider.as(gtk.StyleProvider), - gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, - ); -} diff --git a/src/palette-dark.css b/src/palette-dark.css new file mode 100644 index 0000000..32afcef --- /dev/null +++ b/src/palette-dark.css @@ -0,0 +1,47 @@ +/* Dark palette: the colour half of the stylesheet. + * + * `appearance.zig` prepends exactly one of `palette-dark.css` or + * `palette-light.css` to `style.css` and loads the pair as a single provider, + * so every named colour below has a counterpart in the other file. Adding one + * here means adding it there too — a name defined in only one palette is a + * rule that silently stops applying in the other scheme. + * + * Deep navy rather than neutral grey, lifted a little at each step so the + * three surfaces (window, sidebar, pane) separate without any of them + * reading as light. `pp_surface` is duplicated in `theme.zig` as the terminal + * renderer's default background; the two have to stay equal or the frame + * around a terminal stops matching its contents. */ + +/* Surfaces, darkest first. The window colour shows through as the gutter + between panes, so it sits below the panes rather than beside them. */ +@define-color pp_bg #070c15; +@define-color pp_sidebar #0b1320; +@define-color pp_surface #0e1624; +@define-color pp_surface_raised #131f30; +@define-color pp_surface_raised_active #182740; + +@define-color pp_border #1d2b3f; +@define-color pp_border_strong #2a3c55; + +/* Text, brightest first. `faint` is for things that are labelled rather than + read — pane titles of panes you aren't in, dialog hints. */ +@define-color pp_text #dde6f4; +@define-color pp_text_dim #9aabc2; +@define-color pp_text_faint #6a7c93; + +@define-color pp_row_hover #162233; +@define-color pp_row_selected #1c2c45; + +/* Signal's ultramarine. `strong` is the variant that has to stay legible as a + small mark on a surface — icons, dots, the state bar down a row's edge — + which is why it gets brighter here and darker in the light palette. */ +@define-color pp_accent #3a76f0; +@define-color pp_accent_strong #6191f3; +@define-color pp_accent_muted #2f5296; + +/* The three states that are news. Kept away from the accent on purpose: the + accent means "here" and these mean "something happened", and a sidebar + where those are the same colour answers neither question. */ +@define-color pp_ok #3ecf8e; +@define-color pp_warn #f0b849; +@define-color pp_err #f2717b; diff --git a/src/palette-light.css b/src/palette-light.css new file mode 100644 index 0000000..a0172f3 --- /dev/null +++ b/src/palette-light.css @@ -0,0 +1,36 @@ +/* Light palette. See `palette-dark.css` for what each name is for; this file + * answers the same questions with light surfaces and dark ink. + * + * Not an inversion of the dark palette. Two things flip meaning rather than + * value: + * + * * `pp_accent_strong` is *darker* than `pp_accent` here. Its job is to stay + * legible as a small mark against the surface it sits on, and on a white + * row that means going down, not up. + * * The three state colours are pulled well away from their dark-palette + * values. A dot in #3ecf8e reads clearly on navy and disappears on white, + * so each one is darkened until it carries against paper. */ + +@define-color pp_bg #d9e1ee; +@define-color pp_sidebar #edf1f8; +@define-color pp_surface #ffffff; +@define-color pp_surface_raised #f2f6fc; +@define-color pp_surface_raised_active #e3edfd; + +@define-color pp_border #ccd8e8; +@define-color pp_border_strong #adbdd4; + +@define-color pp_text #161f2e; +@define-color pp_text_dim #4c5c73; +@define-color pp_text_faint #77879d; + +@define-color pp_row_hover #e1e9f5; +@define-color pp_row_selected #d2e1fd; + +@define-color pp_accent #2c6bed; +@define-color pp_accent_strong #1851b4; +@define-color pp_accent_muted #a9c4f7; + +@define-color pp_ok #12805a; +@define-color pp_warn #8a5a00; +@define-color pp_err #c23b47; diff --git a/src/style.css b/src/style.css index 8c71362..db8a80a 100644 --- a/src/style.css +++ b/src/style.css @@ -1,41 +1,114 @@ -/* Zen-style vertical tabs: a dark sidebar column with the terminal inset - to its right. */ +/* Zen-style vertical tabs: a sidebar column with the terminal inset to its + right. + + Every colour here is a name defined in `palette-dark.css` or + `palette-light.css`; `appearance.zig` prepends one of them and loads the + pair as a single provider. Nothing below may hardcode a colour — a literal + hex would be a rule that looks right in one scheme and wrong in the other, + and it would look right in whichever one you happened to be testing in. + + `alpha()` is used for every wash rather than a second named colour, so a + state's tint is always derived from the state's own colour and the two + can't drift apart. */ .playpen-window { - background-color: #0f0d14; + background-color: @pp_bg; } +/* The sidebar is a card, not a column: rounded on every corner and inset from + the window edge, so it sits on the backdrop the same way a pane does. It + used to run edge to edge and meet the content at a hairline, which left the + top-left corner reading as three unrelated things sharing one point — the + window edge, the header buttons and the first tab row all at once. */ .playpen-sidebar { - background-color: #1b1823; - border-right: 1px solid #2a2536; + background-color: @pp_sidebar; + border: 1px solid @pp_border; + border-radius: 12px; + margin: 6px 0 6px 6px; } +/* Room above the buttons so they clear the card's rounded top edge rather than + sitting in it. + + The rule underneath is stated explicitly rather than left to the one Adwaita + draws for every header bar: that one is a shadow in the desktop theme's + colour, which on this palette read as a stray line rather than as the pair + it now makes with the footer's. */ .playpen-sidebar headerbar { background: none; box-shadow: none; - min-height: 38px; + min-height: 44px; + padding: 6px 8px 2px 8px; + border-bottom: 1px solid @pp_border; +} + +/* The two header actions. Styled explicitly rather than left to Adwaita, whose + default button is a grey that belongs to a different window than this one. + Scoped by class instead of by `headerbar button` so the window controls + beside them keep their own appearance — those belong to the desktop, not to + the app, and making them match would be the wrong kind of consistent. + + Two selectors because a MenuButton wraps its button in a `menubutton` node, + so the class lands one level above the thing that draws. */ +button.playpen-header-button, +.playpen-header-button > button { + min-width: 30px; + min-height: 30px; + padding: 0; + margin: 0 1px; + border-radius: 8px; + color: @pp_text_dim; + background-color: @pp_surface_raised; + background-image: none; + border: 1px solid @pp_border; + box-shadow: none; +} + +button.playpen-header-button:hover, +.playpen-header-button > button:hover { + background-color: @pp_row_hover; + background-image: none; + color: @pp_text; +} + +/* The strip below the tab list, holding the settings button. Separated by a + rule so the button doesn't read as a row of the list it sits under. */ +.playpen-sidebar-footer { + padding: 6px 8px; + border-top: 1px solid @pp_border; +} + +.playpen-settings-button { + min-width: 26px; + min-height: 26px; + padding: 0; + color: @pp_text_faint; +} + +.playpen-settings-button:hover { + color: @pp_text; } .playpen-list { background: none; - padding: 4px 6px; + padding: 4px 8px; } .playpen-list > row { border-radius: 8px; - margin: 1px 0; - padding: 5px 8px; - color: #b6afc7; + margin: 2px 0; + padding: 7px 8px; + color: @pp_text_dim; transition: background-color 120ms ease; } .playpen-list > row:hover { - background-color: #262133; + background-color: @pp_row_hover; } .playpen-list > row:selected { - background-color: #342c4a; - color: #f0ecf8; + background-color: @pp_row_selected; + color: @pp_text; } /* The kind icon picks up the accent on the current row. The status dot is @@ -43,7 +116,7 @@ specific enough to beat the state classes, which used to leave the selected row's dot accent-coloured whatever it was trying to say. */ .playpen-list > row:selected image:not(.playpen-status-dot) { - color: #b29df5; + color: @pp_accent_strong; } /* State on the row itself, not just on its dot. @@ -60,24 +133,24 @@ These come after the :selected rules deliberately. Equal specificity means source order decides, and a state outranks "this is the tab you are on". */ .playpen-list > row.playpen-attn-busy { - box-shadow: inset 3px 0 0 #b29df5; + box-shadow: inset 3px 0 0 @pp_accent_strong; } /* No wash for working. It is the most common state by far, and a sidebar where half the rows are tinted all afternoon teaches you to stop looking. */ .playpen-list > row.playpen-attn-done { - box-shadow: inset 3px 0 0 #7ddc9a; - background-color: rgba(125, 220, 154, 0.13); + box-shadow: inset 3px 0 0 @pp_ok; + background-color: alpha(@pp_ok, 0.14); } .playpen-list > row.playpen-attn-input { - box-shadow: inset 3px 0 0 #f0c069; - background-color: rgba(240, 192, 105, 0.13); + box-shadow: inset 3px 0 0 @pp_warn; + background-color: alpha(@pp_warn, 0.14); } .playpen-list > row.playpen-attn-failed { - box-shadow: inset 3px 0 0 #f2a0a0; - background-color: rgba(242, 160, 160, 0.13); + box-shadow: inset 3px 0 0 @pp_err; + background-color: alpha(@pp_err, 0.14); } /* The current row keeps its own background: the wash and the selection would @@ -86,7 +159,7 @@ .playpen-list > row:selected.playpen-attn-done, .playpen-list > row:selected.playpen-attn-input, .playpen-list > row:selected.playpen-attn-failed { - background-color: #342c4a; + background-color: @pp_row_selected; } /* Keep the close button unobtrusive until the row is hovered or current. */ @@ -107,12 +180,15 @@ } .playpen-content { - background-color: #0f0d14; + background-color: @pp_bg; } -/* A view is the container for one tab's terminals. */ +/* A view is the container for one tab's terminals. Even padding now that the + sidebar is inset too — the left edge used to be flush against the sidebar's + border, so the gutter around the content was open on three sides and closed + on the fourth. */ .playpen-view { - padding: 6px 6px 6px 0; + padding: 6px; } /* Paned itself draws nothing; the panes inside it carry the styling. */ @@ -123,32 +199,32 @@ /* Each terminal sits in its own rounded frame so multiple panes in a view read as distinct surfaces. */ .playpen-pane { - background-color: #16141c; + background-color: @pp_surface; border-radius: 10px; - border: 1px solid #2a2536; + border: 1px solid @pp_border; } .playpen-pane.active { - border-color: #5b4d80; + border-color: @pp_accent_muted; } .playpen-pane-header { padding: 2px 4px 2px 10px; - background-color: #1c1926; - border-bottom: 1px solid #262133; + background-color: @pp_surface_raised; + border-bottom: 1px solid @pp_border; } .playpen-pane.active .playpen-pane-header { - background-color: #241f33; + background-color: @pp_surface_raised_active; } .playpen-pane-title { font-size: 0.82em; - color: #8f87a3; + color: @pp_text_faint; } .playpen-pane.active .playpen-pane-title { - color: #ded7ef; + color: @pp_text; } .playpen-pane-button { @@ -168,12 +244,12 @@ } .playpen-pane-icon { - color: #6f6784; + color: @pp_text_faint; -gtk-icon-size: 14px; } .playpen-pane.active .playpen-pane-icon { - color: #b29df5; + color: @pp_accent_strong; } /* Status dots. One shared appearance for the sidebar row and the pane header, @@ -198,7 +274,7 @@ /* Working: the same accent as an active pane's border, so "busy" reads as ordinary activity rather than something gone wrong. */ .playpen-status-busy { - color: #b29df5; + color: @pp_accent_strong; animation: playpen-pulse 1.6s ease-in-out infinite; } @@ -206,17 +282,17 @@ distinct from still-working at a glance, which is the one distinction this whole indicator exists to make. */ .playpen-status-done { - color: #7ddc9a; + color: @pp_ok; } /* Blocked on you. Amber, and not animated — a pulsing dot reads as progress, and this is the opposite of progress. */ .playpen-status-input { - color: #f0c069; + color: @pp_warn; } .playpen-status-failed { - color: #f2a0a0; + color: @pp_err; } @keyframes playpen-pulse { @@ -235,15 +311,15 @@ than the header so the two rows don't compete. */ .playpen-nav { padding: 4px 6px; - background-color: #16141c; - border-bottom: 1px solid #262133; + background-color: @pp_surface; + border-bottom: 1px solid @pp_border; } .playpen-nav-button { min-width: 24px; min-height: 24px; padding: 0; - color: #b6afc7; + color: @pp_text_dim; } .playpen-nav-button:disabled { @@ -256,21 +332,21 @@ padding: 2px 8px; border-radius: 6px; font-size: 0.85em; - background-color: #0f0d14; - color: #ded7ef; - border: 1px solid #2a2536; + background-color: @pp_bg; + color: @pp_text; + border: 1px solid @pp_border; box-shadow: none; } .playpen-nav-entry:focus-within { - border-color: #5b4d80; + border-color: @pp_accent; } /* The pane currently filling its tab. The other panes are still open and still running, just not drawn, so this is marked quietly — a lit border rather than anything alarming. The header's toggle icon carries the rest of the message. */ .playpen-pane.zoomed { - border-color: #7a68a8; + border-color: @pp_accent; } .playpen-pane.zoomed .playpen-pane-button { @@ -290,15 +366,15 @@ you don't. `.dragging` still wins over all of it, further down: while you are moving a pane, feedback about the move is the only thing that matters. */ .playpen-pane.playpen-attn-done { - border-color: #7ddc9a; + border-color: @pp_ok; } .playpen-pane.playpen-attn-input { - border-color: #f0c069; + border-color: @pp_warn; } .playpen-pane.playpen-attn-failed { - border-color: #f2a0a0; + border-color: @pp_err; } /* The header carries a wash of the same colour. The border alone is a hairline @@ -306,22 +382,22 @@ the title, which is what makes a pane readable at a glance in a four-way split. */ .playpen-pane.playpen-attn-done .playpen-pane-header { - background-color: rgba(125, 220, 154, 0.12); + background-color: alpha(@pp_ok, 0.13); } .playpen-pane.playpen-attn-input .playpen-pane-header { - background-color: rgba(240, 192, 105, 0.12); + background-color: alpha(@pp_warn, 0.13); } .playpen-pane.playpen-attn-failed .playpen-pane-header { - background-color: rgba(242, 160, 160, 0.12); + background-color: alpha(@pp_err, 0.13); } /* The pane being dragged. There is no separate drop indicator: the layout rearranges live during the drag, so the view itself is the preview. */ .playpen-pane.dragging { opacity: 0.65; - border-color: #b29df5; + border-color: @pp_accent_strong; } /* Saved-layout menu, hanging off the sidebar header. */ @@ -336,13 +412,13 @@ .playpen-layout-empty { padding: 8px; - color: #8f87a3; + color: @pp_text_faint; font-size: 0.9em; } /* Layout dialogs: opening one, and saving the current tab as one. */ .playpen-dialog { - background-color: #16141c; + background-color: @pp_surface; } .playpen-dialog-content { @@ -351,47 +427,107 @@ .playpen-dialog-actions { padding: 12px 16px; - border-top: 1px solid #262133; + border-top: 1px solid @pp_border; } .playpen-dialog-heading { margin-top: 8px; font-weight: bold; - color: #ded7ef; + color: @pp_text; } .playpen-dialog-label { - color: #b6afc7; + color: @pp_text_dim; } .playpen-dialog-sublabel { font-size: 0.88em; - color: #8f87a3; + color: @pp_text_faint; } .playpen-dialog-hint { font-size: 0.85em; - color: #8f87a3; + color: @pp_text_faint; } .playpen-dialog-error { - color: #f2a0a0; + color: @pp_err; } /* Each captured pane in the save dialog, so the sections read apart. */ .playpen-dialog-pane { padding: 8px; - border: 1px solid #2a2536; + border: 1px solid @pp_border; border-radius: 8px; } +/* ------------------------------------------------------------------------- + Settings + + One section per group of preferences, each a titled card on the dialog's + surface. There is a single group today; the card is what stops the second + one from needing a redesign. */ + +.playpen-settings-group { + padding: 12px 14px; + border: 1px solid @pp_border; + border-radius: 10px; + background-color: @pp_surface_raised; +} + +.playpen-settings-title { + font-weight: bold; + color: @pp_text; +} + +/* The scheme picker: three linked buttons rather than a dropdown, so the whole + choice is visible without opening anything, and so the one you are on is + readable at a glance rather than by reading a word. */ +/* `background-image: none` on every rule below is load-bearing. Adwaita paints + button states with a gradient *over* the background colour, so setting the + colour alone leaves a washed-out version of it — most visibly on the checked + button, where the accent came out as a pale grey-blue. `:backdrop` is + spelled out for the same reason: Adwaita dims unfocused windows, and the + selected scheme is exactly the thing that has to stay readable while you are + looking at the window behind this one to see what it did. */ +.playpen-settings-choice > button { + padding: 5px 14px; + color: @pp_text_dim; + background-color: @pp_surface; + background-image: none; + border: 1px solid @pp_border; + box-shadow: none; +} + +.playpen-settings-choice > button:hover { + background-color: @pp_row_hover; + background-image: none; + color: @pp_text; +} + +.playpen-settings-choice > button:checked, +.playpen-settings-choice > button:checked:backdrop { + background-color: @pp_accent; + background-image: none; + border-color: @pp_accent; + /* Against a filled accent in either scheme — the light palette's accent is + dark enough that white text is the readable choice there too. */ + color: #ffffff; +} + +.playpen-settings-choice > button:checked:hover { + background-color: @pp_accent_strong; + background-image: none; + border-color: @pp_accent_strong; +} + /* Divider between panes. Wide enough to grab without hunting for it. */ .playpen-view paned > separator { - background-color: #0f0d14; + background-color: @pp_bg; min-width: 6px; min-height: 6px; } .playpen-view paned > separator:hover { - background-color: #4a3f6b; + background-color: @pp_accent_muted; } diff --git a/src/theme.zig b/src/theme.zig index 157f0d3..63e2778 100644 --- a/src/theme.zig +++ b/src/theme.zig @@ -3,6 +3,17 @@ //! The 256-color palette itself comes from the terminal's own color state //! (`Terminal.colors.palette`), which libghostty-vt initializes to the //! standard xterm palette and keeps updated as programs change it via OSC. +//! +//! What is left is the three colors a terminal falls back to when the program +//! running in it hasn't said otherwise — background, foreground and cursor — +//! and those have to flip with the rest of the window. They are kept here +//! rather than in the CSS because Cairo draws the terminal grid directly and +//! never consults the style tree; `appearance.zig` sets `scheme` at the same +//! moment it swaps the stylesheet, so the two stay in step. +//! +//! The values match `palette-dark.css` and `palette-light.css` deliberately: +//! `bg` is the same color as `@pp_surface` in each, so a terminal and the +//! frame drawn around it read as one surface rather than two. const vt = @import("ghostty-vt"); @@ -25,11 +36,120 @@ pub const Rgb = struct { } }; -/// Terminal default background, used when the program hasn't set one. -pub const bg: Rgb = .{ .r = 0x16, .g = 0x14, .b = 0x1c }; +/// A resolved color scheme. Not the same thing as the user's preference, +/// which has a third option — see `Settings.Theme`. By the time it reaches +/// here "system" has been resolved to one of these. +pub const Scheme = enum { light, dark }; -/// Terminal default foreground. -pub const fg: Rgb = .{ .r = 0xe2, .g = 0xde, .b = 0xea }; +const Palette = struct { + /// Terminal default background, used when the program hasn't set one. + bg: Rgb, -/// Cursor block color. -pub const cursor: Rgb = .{ .r = 0xb2, .g = 0x9d, .b = 0xf5 }; + /// Terminal default foreground. + fg: Rgb, + + /// Cursor block color. + cursor: Rgb, +}; + +const dark: Palette = .{ + .bg = .{ .r = 0x0e, .g = 0x16, .b = 0x24 }, + .fg = .{ .r = 0xdd, .g = 0xe6, .b = 0xf4 }, + .cursor = .{ .r = 0x61, .g = 0x91, .b = 0xf3 }, +}; + +/// Not a straight inversion. The cursor darkens rather than lightens, because +/// on a near-white background a bright accent block swallows the character +/// underneath it, and the foreground stops short of black — full-contrast +/// black on white is harsher to read a screen of text against than a very +/// dark navy is. +const light: Palette = .{ + .bg = .{ .r = 0xff, .g = 0xff, .b = 0xff }, + .fg = .{ .r = 0x16, .g = 0x1f, .b = 0x2e }, + .cursor = .{ .r = 0x2c, .g = 0x6b, .b = 0xed }, +}; + +var scheme: Scheme = .dark; + +/// Switch the palette the terminal renderer draws with. Does not repaint +/// anything by itself; `appearance.zig` owns that. +pub fn setScheme(to: Scheme) void { + scheme = to; +} + +pub fn currentScheme() Scheme { + return scheme; +} + +fn palette() Palette { + return switch (scheme) { + .dark => dark, + .light => light, + }; +} + +pub fn bg() Rgb { + return palette().bg; +} + +pub fn fg() Rgb { + return palette().fg; +} + +pub fn cursor() Rgb { + return palette().cursor; +} + +// ------------------------------------------------------------------------- +// The ANSI palette +// +// The 240 colors above index 16 are fixed by the xterm spec — a 6×6×6 cube and +// a grey ramp — and mean the same thing whatever the background is, so they are +// left exactly as libghostty-vt built them. The first 16 are the ones programs +// actually reach for by name, and they are the reason a light terminal needs a +// palette at all: the standard xterm yellow is #cdcd00, which on white is close +// to invisible, and every prompt and build tool uses it. + +/// The 16 named colors, retuned for a light background. +/// +/// Two entries change meaning rather than brightness. `white` (7) and +/// `bright white` (15) are the foreground half of "white text", and on a white +/// background they have to go dark or the text they colour disappears +/// altogether. Every light terminal theme makes this trade: a program that +/// asked for a white *background* gets a dark block instead, which is jarring +/// but rare, and the alternative is text that cannot be read at all, which is +/// neither. +const light_ansi = [16]Rgb{ + .{ .r = 0x1c, .g = 0x24, .b = 0x30 }, // black + .{ .r = 0xc2, .g = 0x26, .b = 0x1f }, // red + .{ .r = 0x1a, .g = 0x7f, .b = 0x37 }, // green + .{ .r = 0x8a, .g = 0x61, .b = 0x00 }, // yellow + .{ .r = 0x18, .g = 0x51, .b = 0xb4 }, // blue + .{ .r = 0x93, .g = 0x33, .b = 0xa8 }, // magenta + .{ .r = 0x0f, .g = 0x6f, .b = 0x83 }, // cyan + .{ .r = 0x4c, .g = 0x5c, .b = 0x73 }, // white + .{ .r = 0x6a, .g = 0x7c, .b = 0x93 }, // bright black + .{ .r = 0xd6, .g = 0x3a, .b = 0x30 }, // bright red + .{ .r = 0x1f, .g = 0x9d, .b = 0x4d }, // bright green + .{ .r = 0xa8, .g = 0x74, .b = 0x00 }, // bright yellow + .{ .r = 0x2c, .g = 0x6b, .b = 0xed }, // bright blue + .{ .r = 0xb1, .g = 0x3c, .b = 0xc9 }, // bright magenta + .{ .r = 0x12, .g = 0x89, .b = 0xa3 }, // bright cyan + .{ .r = 0x16, .g = 0x1f, .b = 0x2e }, // bright white +}; + +/// The palette a session should treat as its default under the current scheme. +/// +/// Handed to `DynamicPalette.changeDefault`, which is what makes this safe to +/// call on a terminal that has been running for hours: anything the program +/// inside set with OSC 4 is preserved, and an OSC 104 reset later returns to +/// the scheme's palette rather than to the one the app started in. +pub fn ansiPalette() vt.color.Palette { + var colors = vt.color.default; + if (scheme == .light) { + for (light_ansi, 0..) |c, i| { + colors[i] = .{ .r = c.r, .g = c.g, .b = c.b }; + } + } + return colors; +}