Improve styling.

This commit is contained in:
Greyson Parrelli
2026-08-12 21:15:07 -04:00
parent 4e0e400372
commit e16b147e16
13 changed files with 1104 additions and 119 deletions
+66 -5
View File
@@ -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:
+16 -9
View File
@@ -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.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
<defs>
<linearGradient id="pane" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#241f31"/>
<stop offset="1" stop-color="#14111b"/>
<stop offset="0" stop-color="#182740"/>
<stop offset="1" stop-color="#0b1320"/>
</linearGradient>
<linearGradient id="accent" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#c7b3ff"/>
<stop offset="1" stop-color="#9b7ff0"/>
<stop offset="0" stop-color="#6191f3"/>
<stop offset="1" stop-color="#2c6bed"/>
</linearGradient>
<!-- Everything is clipped to the rounded body so the sidebar picks up
the same corner radius without repeating the path. -->
@@ -24,18 +31,18 @@
<rect x="8" y="12" width="112" height="104" rx="24" fill="url(#pane)"/>
<g clip-path="url(#body)">
<rect x="8" y="12" width="42" height="104" fill="#2f2743"/>
<rect x="49" y="12" width="2" height="104" fill="#3d3357"/>
<rect x="8" y="12" width="42" height="104" fill="#1c2c45"/>
<rect x="49" y="12" width="2" height="104" fill="#2a3c55"/>
<!-- Tab rows: the active one carries the accent, the rest are muted. -->
<rect x="16" y="30" width="26" height="12" rx="6" fill="url(#accent)"/>
<rect x="16" y="50" width="26" height="12" rx="6" fill="#5d5280"/>
<rect x="16" y="70" width="26" height="12" rx="6" fill="#493f66"/>
<rect x="16" y="50" width="26" height="12" rx="6" fill="#3f5573"/>
<rect x="16" y="70" width="26" height="12" rx="6" fill="#2f4460"/>
</g>
<!-- Prompt chevron and cursor, the universal shorthand for a terminal. -->
<path d="M66 51 L79 64 L66 77"
fill="none" stroke="#e8e2f7" stroke-width="9"
fill="none" stroke="#dde6f4" stroke-width="9"
stroke-linecap="round" stroke-linejoin="round"/>
<rect x="86" y="70" width="22" height="8" rx="4" fill="url(#accent)"/>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

+15
View File
@@ -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);
+170
View File
@@ -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;
}
+197
View File
@@ -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);
}
+3 -3
View File
@@ -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);
+82 -3
View File
@@ -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;
+141
View File
@@ -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;
}
+4 -28
View File
@@ -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,
);
}
+47
View File
@@ -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;
+36
View File
@@ -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;
+201 -65
View File
@@ -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;
}
+126 -6
View File
@@ -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;
}