Update tab styling.

This commit is contained in:
Greyson Parrelli
2026-08-27 11:02:32 -04:00
parent 0bf85b3b2a
commit 553e1f5424
4 changed files with 68 additions and 49 deletions
+8 -5
View File
@@ -1998,14 +1998,17 @@ fn applySidebarCollapsed(self: *Window, collapsed: bool) void {
for (self.tabs.items) |tab| self.applyRowCollapsed(tab); for (self.tabs.items) |tab| self.applyRowCollapsed(tab);
} }
/// One row's share of the collapse: everything but the emoji (or kind icon) /// One row's share of the collapse: the title and the close button go, the
/// goes. The status dot goes too — the bar down the row's leading edge carries /// emoji (or kind icon) and the status dot stay.
/// the state at this width (with a wash behind it for the states that have one), ///
/// and it was designed to be the glanceable half of the pair anyway. /// The dot used to go as well, back when every state also drew a bar down the
/// row's leading edge and that bar could carry the state on its own. Only
/// "finished" draws one now, so at this width the dot is the whole signal for
/// the other three — and a collapsed sidebar is exactly where you are relying on
/// a glance rather than on reading anything.
fn applyRowCollapsed(self: *Window, tab: *Tab) void { fn applyRowCollapsed(self: *Window, tab: *Tab) void {
const shown: c_int = @intFromBool(!self.sidebar_collapsed); const shown: c_int = @intFromBool(!self.sidebar_collapsed);
tab.label.as(gtk.Widget).setVisible(shown); tab.label.as(gtk.Widget).setVisible(shown);
tab.dot.as(gtk.Widget).setVisible(shown);
tab.close.as(gtk.Widget).setVisible(shown); tab.close.as(gtk.Widget).setVisible(shown);
} }
+14 -4
View File
@@ -184,6 +184,7 @@ pub const Key = enum {
accent_muted, accent_muted,
// ---- status ---- // ---- status ----
busy,
ok, ok,
warn, warn,
err, err,
@@ -226,7 +227,7 @@ pub const Key = enum {
.text, .text_dim, .text_faint => .text, .text, .text_dim, .text_faint => .text,
.row_hover, .row_selected => .rows, .row_hover, .row_selected => .rows,
.accent, .accent_strong, .accent_muted => .accents, .accent, .accent_strong, .accent_muted => .accents,
.ok, .warn, .err => .states, .busy, .ok, .warn, .err => .states,
.term_bg, .term_fg, .term_cursor, .term_selection_bg, .term_selection_fg => .terminal, .term_bg, .term_fg, .term_cursor, .term_selection_bg, .term_selection_fg => .terminal,
else => .ansi, else => .ansi,
}; };
@@ -289,6 +290,7 @@ pub const Key = enum {
.accent_strong => "Accent, strong", .accent_strong => "Accent, strong",
.accent_muted => "Accent, muted", .accent_muted => "Accent, muted",
.busy => "Working",
.ok => "Finished", .ok => "Finished",
.warn => "Waiting on you", .warn => "Waiting on you",
.err => "Failed", .err => "Failed",
@@ -329,6 +331,7 @@ pub const Key = enum {
.text_faint => "Things that are labeled rather than read, like the title of a pane you aren't in", .text_faint => "Things that are labeled rather than read, like the title of a pane you aren't in",
.accent_strong => "Small marks that have to stay legible on a surface: icons, dots, a row's state bar", .accent_strong => "Small marks that have to stay legible on a surface: icons, dots, a row's state bar",
.accent_muted => "Washes and the border of the focused pane", .accent_muted => "Washes and the border of the focused pane",
.busy => "A pane that is still working. Its own color rather than the accent, so it stays distinct from \"finished\" under any accent",
.ok => "A pane that finished and hasn't been looked at", .ok => "A pane that finished and hasn't been looked at",
.warn => "A pane waiting for you to answer something", .warn => "A pane waiting for you to answer something",
.err => "A pane that failed", .err => "A pane that failed",
@@ -493,9 +496,15 @@ const dark: Table = .initDefault(unset, .{
.accent_strong = h("#6191f3"), .accent_strong = h("#6191f3"),
.accent_muted = h("#2f5296"), .accent_muted = h("#2f5296"),
// The three states that are news. Kept away from the accent on purpose: the // The states. Kept away from the accent on purpose: the accent means "here"
// accent means "here" and these mean "something happened", and a sidebar // and these mean "something happened", and a sidebar where those are the
// where those are the same color answers neither question. // same color answers neither question.
//
// `busy` starts out where the strong accent does, because that is the blue
// this palette was built around — but it is a key of its own so that an
// accent moved somewhere else (a green one especially) cannot land it on top
// of `ok`, which is the one distinction the dots exist to make.
.busy = h("#6191f3"),
.ok = h("#3ecf8e"), .ok = h("#3ecf8e"),
.warn = h("#f0b849"), .warn = h("#f0b849"),
.err = h("#f2717b"), .err = h("#f2717b"),
@@ -534,6 +543,7 @@ const light: Table = .initDefault(unset, .{
.accent_strong = h("#1851b4"), .accent_strong = h("#1851b4"),
.accent_muted = h("#a9c4f7"), .accent_muted = h("#a9c4f7"),
.busy = h("#1851b4"),
.ok = h("#12805a"), .ok = h("#12805a"),
.warn = h("#8a5a00"), .warn = h("#8a5a00"),
.err = h("#c23b47"), .err = h("#c23b47"),
+34 -33
View File
@@ -191,54 +191,54 @@ button.playpen-header-button:hover,
/* State on the row itself, not just on its dot. /* State on the row itself, not just on its dot.
A bar down the leading edge, and behind the row a wash of the same color for One bar and two washes, and that is the whole vocabulary. Finished gets the
the two states that actually want you. The dot says what a row is doing once bar down its leading edge; the two states that actually want you get a wash
you are reading it; these are what let you sweep a sidebar of a dozen tabs of their own color behind the row. The dot says what a row is doing once you
and land on the one that wants you without reading any of them. are reading it; these are what let you sweep a sidebar of a dozen tabs and
land on the one you care about without reading any of them.
The bar is an inset shadow rather than a real border because a border takes Only finished carries a bar. Every state used to have one, drawn as an inset
part in layout: 3px of it would shove every label sideways as a tab changed shadow, and a shadow is clipped to the row's 8px corners: what landed was a
state, and a sidebar that twitches is worse than one that is hard to read. short bar with a curl at each end, four rows deep in slightly different
colors. Read as ornament rather than as signal, so it is gone from everything
but the one state whose bar is a plain rectangle.
These come after the :selected rules deliberately. Equal specificity means These come after the :selected rules deliberately. Equal specificity means
source order decides, and a state outranks "this is the tab you are on". */ source order decides, and a state outranks "this is the tab you are on". */
/* No wash for working. It is the most common state by far, and a sidebar where /* Working is the dot alone. It is the most common state by far, and a sidebar
half the rows are tinted all afternoon teaches you to stop looking. */ where half the rows are marked all afternoon teaches you to stop looking. */
.playpen-list > row.playpen-attn-busy {
box-shadow: inset 3px 0 0 @pp_accent_strong;
}
/* No wash for finished either, for a different reason: a tinted row is what /* No wash for finished, for a different reason: a tinted row is what selection
selection looks like in this list, so washing the finished ones left "the tab looks like in this list, so washing the finished ones left "the tab you are
you are on" and "the tab that just finished" saying the same thing. The dot on" and "the tab that just finished" saying the same thing. The dot and the
and a bar are enough — nothing here is waiting on you. bar are enough — nothing here is waiting on you.
The bar is a background image rather than the inset shadow the other states The bar is a background image rather than an inset shadow precisely because
use, because a shadow is clipped to the row's 8px corners and comes out it is not clipped to the corners, so it stays a rectangle at any height. Tall
tapered at both ends. Kept short enough to clear those corners entirely, so enough now to read as a deliberate mark rather than a stray pixel, and still
what lands is a plain rectangle. */ short of the row's full height so it reads as pinned to the middle of the
row. */
.playpen-list > row.playpen-attn-done { .playpen-list > row.playpen-attn-done {
background-image: linear-gradient(@pp_ok, @pp_ok); background-image: linear-gradient(@pp_ok, @pp_ok);
background-size: 3px 12px; background-size: 3px 18px;
background-position: left center; background-position: left center;
background-repeat: no-repeat; background-repeat: no-repeat;
} }
.playpen-list > row.playpen-attn-input { .playpen-list > row.playpen-attn-input {
box-shadow: inset 3px 0 0 @pp_warn;
background-color: alpha(@pp_warn, 0.14); background-color: alpha(@pp_warn, 0.14);
} }
.playpen-list > row.playpen-attn-failed { .playpen-list > row.playpen-attn-failed {
box-shadow: inset 3px 0 0 @pp_err;
background-color: alpha(@pp_err, 0.14); background-color: alpha(@pp_err, 0.14);
} }
/* The current row keeps its own background: the wash and the selection would /* The current row keeps its own background: the wash and the selection would
otherwise blend into a color that reads as neither. The bar survives, which otherwise blend into a color that reads as neither, and the dot is left to
is the part that carries the state. Done is absent because it no longer has a carry the state on its own — which on the row you are already looking at is
wash to suppress. */ enough. Done is absent because it has no wash to suppress, only a bar, and a
bar and a selection sit together fine. */
.playpen-list > row:selected.playpen-attn-input, .playpen-list > row:selected.playpen-attn-input,
.playpen-list > row:selected.playpen-attn-failed { .playpen-list > row:selected.playpen-attn-failed {
background-color: @pp_row_selected; background-color: @pp_row_selected;
@@ -256,7 +256,6 @@ button.playpen-header-button:hover,
opacity: 0.65; opacity: 0.65;
background-color: alpha(@pp_accent_strong, 0.18); background-color: alpha(@pp_accent_strong, 0.18);
background-image: none; background-image: none;
box-shadow: inset 3px 0 0 @pp_accent_strong;
} }
/* The copy of that row traveling with the pointer, which GTK draws into a /* The copy of that row traveling with the pointer, which GTK draws into a
@@ -384,16 +383,18 @@ dnd.playpen-tab-drag {
opacity: 1; opacity: 1;
} }
/* Working: the same accent as an active pane's border, so "busy" reads as /* Working: blue, and pulsing. Its own palette color rather than the accent —
ordinary activity rather than something gone wrong. */ the accent is whatever the theme is built around, and a green one put "still
working" in the same color as "finished", which is the one distinction this
whole indicator exists to make. The pulse carries the rest of it: a mark that
moves reads as something in progress before you have registered its color. */
.playpen-status-busy { .playpen-status-busy {
color: @pp_accent_strong; color: @pp_busy;
animation: playpen-pulse 1.6s ease-in-out infinite; animation: playpen-pulse 1.6s ease-in-out infinite;
} }
/* Finished while you were elsewhere. Green rather than the accent so it is /* Finished while you were elsewhere. Green, and still — the dot that has
distinct from still-working at a glance, which is the one distinction this stopped moving is the one that has stopped working. */
whole indicator exists to make. */
.playpen-status-done { .playpen-status-done {
color: @pp_ok; color: @pp_ok;
} }
+12 -7
View File
@@ -270,6 +270,7 @@ const dark_stops: Stops = .initDefault(inherited, .{
.accent_strong = accent(0.0742, 0.797), .accent_strong = accent(0.0742, 0.797),
.accent_muted = accent(-0.1450, 0.610), .accent_muted = accent(-0.1450, 0.610),
.busy = named(0.6682, 0.1549, 263.2),
.ok = named(0.7624, 0.1544, 159.4), .ok = named(0.7624, 0.1544, 159.4),
.warn = named(0.8142, 0.1403, 81.3), .warn = named(0.8142, 0.1403, 81.3),
.err = named(0.7043, 0.1588, 17.3), .err = named(0.7043, 0.1588, 17.3),
@@ -324,6 +325,7 @@ const light_stops: Stops = .initDefault(inherited, .{
.accent_strong = accent(-0.1040, 0.812), .accent_strong = accent(-0.1040, 0.812),
.accent_muted = accent(0.2529, 0.377), .accent_muted = accent(0.2529, 0.377),
.busy = named(0.4613, 0.1680, 260.8),
.ok = named(0.5329, 0.1101, 163.1), .ok = named(0.5329, 0.1101, 163.1),
.warn = named(0.5078, 0.1080, 73.3), .warn = named(0.5078, 0.1080, 73.3),
.err = named(0.5530, 0.1708, 19.9), .err = named(0.5530, 0.1708, 19.9),
@@ -527,12 +529,15 @@ test "a gray base makes a gray theme, and keeps the status colors apart" {
try testing.expect(oklab.fromRgb(derive(key, scheme, tint)).c < 0.01); try testing.expect(oklab.fromRgb(derive(key, scheme, tint)).c < 0.01);
} }
// The three states still have to be three states. // The four states still have to be four states. `busy` against `ok` is
const ok = derive(.ok, scheme, tint); // the pair that matters most — a working tab and a finished one are what
const warn = derive(.warn, scheme, tint); // the dots are read for — but a gray theme is where any two of these
const err = derive(.err, scheme, tint); // would collapse if they were going to.
try testing.expect(drift(ok, warn) > 20); const states = [_]Key{ .busy, .ok, .warn, .err };
try testing.expect(drift(warn, err) > 20); for (states, 0..) |a, i| {
try testing.expect(drift(ok, err) > 20); for (states[i + 1 ..]) |b| {
try testing.expect(drift(derive(a, scheme, tint), derive(b, scheme, tint)) > 20);
}
}
} }
} }