Improve styling.
This commit is contained in:
@@ -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:
|
||||
|
||||
Vendored
+16
-9
@@ -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 |
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user