Fix icon sizing.

This commit is contained in:
Greyson Parrelli
2026-08-23 14:35:47 -04:00
parent 63203433dc
commit 4eef6a28d3
4 changed files with 262 additions and 20 deletions
+6
View File
@@ -379,6 +379,11 @@ in principle, but a terminal grid is small.
its pane count once a view holds more than one
- Tabs: create, close, switch, and reorder by dragging a sidebar row, with the
rows shuffling live as you drag; closing the last one closes the window
- **A resizable sidebar**: the gutter beside it is a real divider, so drag it
to taste. Dragging it all the way in — or `Ctrl+Shift+S`, or the chevron at
the sidebar's foot — collapses the column to just each tab's emoji or icon
(hover a row for its name; the status bar and wash still show); the same
toggle expands it back to the width it had
- Multiple panes per tab in an arbitrary split tree, rearranged by keyboard
or by dragging a pane's header, with draggable dividers between them;
closing the last pane in a view closes its tab
@@ -432,6 +437,7 @@ in principle, but a terminal grid is small.
| `Ctrl+Shift+C` | copy the terminal selection | `copy` |
| `Ctrl+Shift+R` | rename the current tab (empty name = follow the terminal) | `rename_tab` |
| `Ctrl+Shift+Z`, `Ctrl+Shift+F` | zoom the focused pane to fill the tab, and back | `toggle_zoom` |
| `Ctrl+Shift+S` | collapse the sidebar to its emoji column, and back | `toggle_sidebar` |
| `Ctrl+F` | find in the focused web page (web panes only — see [Find in page](#find-in-page)) | `find` |
| `Ctrl+,` | settings | `open_settings` |
| `Ctrl+PageUp/PageDown`, `Alt+Shift+K/J` | previous / next tab | `prev_tab`, `next_tab` |
+233 -15
View File
@@ -31,18 +31,55 @@ const shortcuts = @import("shortcuts.zig");
const Window = @This();
/// Total width of the sidebar column, margins and border included — GTK folds
/// Default 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.
///
/// A default rather than a fixture now that the divider beside the sidebar is
/// draggable: it is where the column starts, and where the collapse toggle
/// returns to when nothing has been dragged.
const sidebar_width = 236;
/// The sidebar folded down to its leading column: the margin, border, list and
/// row padding, and the 16px slot the emoji (or kind icon) draws in. Nothing
/// else fits at this width, which is the point — see `applySidebarCollapsed`.
const sidebar_collapsed_width = 56;
alloc: std.mem.Allocator,
window: *adw.ApplicationWindow,
/// Holds one page per tab; the visible page is the active terminal.
stack: *gtk.Stack,
/// The paned holding the sidebar and the content, whose divider position *is*
/// the sidebar's width. A paned rather than a box so the divider is draggable.
split: *gtk.Paned,
/// Sidebar chrome that collapsing has to reach: the header (whose window
/// controls go), the layouts button beside the new-tab one, the footer (which
/// stacks its buttons when the column is narrow) and the toggle itself.
header: *adw.HeaderBar,
layout_button: *gtk.MenuButton,
footer: *gtk.Box,
collapse_button: *gtk.Button,
/// Whether the sidebar is folded down to its emoji column.
sidebar_collapsed: bool = false,
/// The width to put back when the sidebar expands: wherever the divider last
/// sat while the chrome was showing, so a collapse costs no arrangement.
sidebar_expanded_width: c_int = sidebar_width,
/// The position a drag has to reach before a collapsed sidebar unfolds. Set to
/// the expanded minimum measured at the moment of collapse, so folding and
/// unfolding by drag happen at the same width.
sidebar_expand_at: c_int = sidebar_width,
/// Set while we're moving the divider ourselves, so the position handler
/// doesn't mistake it for a drag.
applying_position: bool = false,
/// One row per tab, in the same order as `tabs`.
///
/// Kept in that order by a sort function rather than by moving rows around:
@@ -138,6 +175,10 @@ const Tab = struct {
/// Status dot, hidden unless the tab has something to report.
dot: *gtk.Image,
/// The row's close button, hidden along with the label while the sidebar
/// is collapsed.
close: *gtk.Button,
/// A name the user typed, which wins over whatever the panes report.
/// Null means the label tracks the content, which is the default.
custom_name: ?[]u8 = null,
@@ -191,9 +232,14 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
.alloc = alloc,
.window = window,
.stack = gtk.Stack.new(),
.split = gtk.Paned.new(.horizontal),
.list = gtk.ListBox.new(),
.layouts = .init(alloc),
.layout_popover = gtk.Popover.new(),
.header = adw.HeaderBar.new(),
.layout_button = gtk.MenuButton.new(),
.footer = gtk.Box.new(.horizontal, 0),
.collapse_button = gtk.Button.newFromIconName("go-previous-symbolic"),
};
self.layouts.load();
if (self.layouts.load_error) |message| std.log.warn("{s}", .{message});
@@ -203,13 +249,17 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
// ---- sidebar -------------------------------------------------------
const sidebar = gtk.Box.new(.vertical, 0);
sidebar.as(gtk.Widget).addCssClass("playpen-sidebar");
sidebar.as(gtk.Widget).setSizeRequest(sidebar_width, -1);
// The request is the *floor*, not the width: the paned's divider decides
// how wide the column actually is, and this is as far in as it may be
// pushed once the chrome is out of the way.
sidebar.as(gtk.Widget).setSizeRequest(sidebar_collapsed_width, -1);
// The header bar lives inside the sidebar rather than spanning the
// window, which is what gives the Zen-style look. It also carries the
// window controls, which we still need since GTK draws its own
// decorations on Wayland.
const header = adw.HeaderBar.new();
const header = self.header;
header.setShowTitle(0);
header.as(gtk.Widget).addCssClass("flat");
@@ -234,7 +284,7 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
// 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();
const layout_button = self.layout_button;
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");
@@ -273,7 +323,7 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
// 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);
const footer = self.footer;
footer.as(gtk.Widget).addCssClass("playpen-sidebar-footer");
const settings_button = gtk.Button.newFromIconName("emblem-system-symbolic");
@@ -288,6 +338,26 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
.{},
);
footer.append(settings_button.as(gtk.Widget));
// An empty stretch between the two buttons, so the collapse toggle sits at
// the far end. It has no height of its own, so when the collapsed footer
// turns vertical it simply does nothing rather than pushing the buttons
// apart.
const footer_spacer = gtk.Box.new(.horizontal, 0);
footer_spacer.as(gtk.Widget).setHexpand(1);
footer.append(footer_spacer.as(gtk.Widget));
self.collapse_button.as(gtk.Widget).addCssClass("flat");
self.collapse_button.as(gtk.Widget).addCssClass("playpen-settings-button");
self.collapse_button.as(gtk.Widget).setTooltipText("Collapse the sidebar (Ctrl+Shift+S)");
_ = gtk.Button.signals.clicked.connect(
self.collapse_button,
*Window,
&onCollapseClicked,
self,
.{},
);
footer.append(self.collapse_button.as(gtk.Widget));
sidebar.append(footer.as(gtk.Widget));
// ---- content -------------------------------------------------------
@@ -295,11 +365,29 @@ pub fn create(alloc: std.mem.Allocator, app: *adw.Application) !*Window {
self.stack.as(gtk.Widget).setVexpand(1);
self.stack.as(gtk.Widget).addCssClass("playpen-content");
const content = gtk.Box.new(.horizontal, 0);
content.append(sidebar.as(gtk.Widget));
content.append(self.stack.as(gtk.Widget));
// The divider between the sidebar and the content is the 6px gutter that
// was already there, now draggable. Only the content flexes with the
// window — the sidebar keeps whatever width it was given — and neither
// side may be squeezed below its minimum, which for the sidebar is the
// emoji-only column.
self.split.as(gtk.Widget).addCssClass("playpen-root-split");
self.split.setStartChild(sidebar.as(gtk.Widget));
self.split.setEndChild(self.stack.as(gtk.Widget));
self.split.setResizeStartChild(0);
self.split.setResizeEndChild(1);
self.split.setShrinkStartChild(0);
self.split.setShrinkEndChild(0);
self.split.setWideHandle(1);
self.setSidebarPosition(sidebar_width);
_ = gobject.Object.signals.notify.connect(
self.split,
*Window,
&onSidebarPosition,
self,
.{ .detail = "position" },
);
window.setContent(content.as(gtk.Widget));
window.setContent(self.split.as(gtk.Widget));
// Window-level shortcuts run in the capture phase so they are handled
// before the focused terminal turns the key into a VT sequence.
@@ -385,6 +473,7 @@ fn newTabEmpty(self: *Window) !*Tab {
.icon = gtk.Image.newFromIconName("utilities-terminal-symbolic"),
.emoji_label = gtk.Label.new(null),
.dot = gtk.Image.newFromIconName(Pane.status_icon),
.close = gtk.Button.newFromIconName("window-close-symbolic"),
.menu_popover = gtk.Popover.new(),
.rename_popover = gtk.Popover.new(),
.rename_entry = gtk.Entry.new(),
@@ -417,11 +506,10 @@ fn newTabEmpty(self: *Window) !*Tab {
Pane.applyAttention(tab.row.as(gtk.Widget), tab.dot, .none);
row_box.append(tab.dot.as(gtk.Widget));
const close = gtk.Button.newFromIconName("window-close-symbolic");
close.as(gtk.Widget).addCssClass("flat");
close.as(gtk.Widget).addCssClass("playpen-close");
_ = gtk.Button.signals.clicked.connect(close, *Tab, &onCloseClicked, tab, .{});
row_box.append(close.as(gtk.Widget));
tab.close.as(gtk.Widget).addCssClass("flat");
tab.close.as(gtk.Widget).addCssClass("playpen-close");
_ = gtk.Button.signals.clicked.connect(tab.close, *Tab, &onCloseClicked, tab, .{});
row_box.append(tab.close.as(gtk.Widget));
tab.row.setChild(row_box.as(gtk.Widget));
self.buildRowMenu(tab, row_box);
@@ -429,6 +517,9 @@ fn newTabEmpty(self: *Window) !*Tab {
installRowDragSource(tab, row_box);
self.list.append(tab.row.as(gtk.Widget));
// A tab opened while the sidebar is collapsed starts collapsed too.
self.applyRowCollapsed(tab);
_ = self.stack.addNamed(view.widget(), tab.pageName());
try self.tabs.append(self.alloc, tab);
@@ -1384,6 +1475,128 @@ fn closeTab(self: *Window, tab: *Tab) void {
self.select(self.tabs.items[next]);
}
// -------------------------------------------------------------------------
// Sidebar width
//
// The divider beside the sidebar is a real paned handle, so the column is
// resizable by drag. Below a certain width the sidebar stops being a list of
// names and becomes a list of emoji, and that switch is a *mode*, not a
// squeeze: the labels, dots, close buttons, layouts button and window controls
// all leave, because at emoji width every one of them would be clipped rather
// than small. The mode is entered three ways — the footer toggle, the
// `toggle_sidebar` shortcut, and dragging the divider all the way in — and all
// three meet in `applySidebarCollapsed`.
fn onCollapseClicked(_: *gtk.Button, self: *Window) callconv(.c) void {
self.toggleSidebar();
}
/// Toggle between the emoji-only column and the last expanded width — which is
/// the default width until a drag has said otherwise.
fn toggleSidebar(self: *Window) void {
if (self.sidebar_collapsed) {
self.applySidebarCollapsed(false);
self.setSidebarPosition(self.sidebar_expanded_width);
} else {
self.sidebar_expanded_width = self.split.getPosition();
self.applySidebarCollapsed(true);
self.setSidebarPosition(sidebar_collapsed_width);
}
}
/// Show or hide everything that doesn't fit an emoji-wide column. The width
/// itself is the caller's business: the toggle sets it, and a drag is already
/// setting it.
fn applySidebarCollapsed(self: *Window, collapsed: bool) void {
if (self.sidebar_collapsed == collapsed) return;
// Measured before anything hides: this is the narrowest the expanded
// sidebar can be, so it is where a drag has to reach before the chrome is
// given back — the same width folding happened at, which is what keeps a
// slow drag from flickering between the two modes.
if (collapsed) {
self.sidebar_expand_at = @max(
minPosition(self.split),
sidebar_collapsed_width + 24,
);
}
self.sidebar_collapsed = collapsed;
const shown: c_int = @intFromBool(!collapsed);
// The window controls belong to the desktop, but at 56px they would be
// clipped mid-button, which is worse than absent. Closing the window is
// still one expand away, or a keyboard shortcut that never left.
self.header.setShowStartTitleButtons(shown);
self.header.setShowEndTitleButtons(shown);
self.layout_button.as(gtk.Widget).setVisible(shown);
// Two buttons side by side don't fit the collapsed column, so the footer
// stacks them instead of losing one.
self.footer.as(gtk.Orientable).setOrientation(if (collapsed) .vertical else .horizontal);
self.collapse_button.setIconName(if (collapsed)
"go-next-symbolic"
else
"go-previous-symbolic");
self.collapse_button.as(gtk.Widget).setTooltipText(if (collapsed)
"Expand the sidebar (Ctrl+Shift+S)"
else
"Collapse the sidebar (Ctrl+Shift+S)");
for (self.tabs.items) |tab| self.applyRowCollapsed(tab);
}
/// One row's share of the collapse: everything but the emoji (or kind icon)
/// goes. The status dot goes too — the bar down the row's leading edge and the
/// wash behind it carry the state at this width, and they were designed to be
/// the glanceable half of the pair anyway.
fn applyRowCollapsed(self: *Window, tab: *Tab) void {
const shown: c_int = @intFromBool(!self.sidebar_collapsed);
tab.label.as(gtk.Widget).setVisible(shown);
tab.dot.as(gtk.Widget).setVisible(shown);
tab.close.as(gtk.Widget).setVisible(shown);
}
/// Move the divider ourselves, without the position handler reading it as a
/// drag.
fn setSidebarPosition(self: *Window, position: c_int) void {
self.applying_position = true;
defer self.applying_position = false;
self.split.setPosition(position);
}
/// The divider moved by hand. Expanded, the paned won't let a drag go below
/// the chrome's own minimum — so a position *at* that minimum means the drag is
/// pinned against it and wants less, and the answer is to fold. Hiding the
/// chrome lowers the paned's minimum, which is what lets the same drag carry on
/// down to the emoji column. Dragging back out past where the fold happened
/// unfolds again.
fn onSidebarPosition(paned: *gtk.Paned, _: *gobject.ParamSpec, self: *Window) callconv(.c) void {
if (self.applying_position) return;
const position = paned.getPosition();
if (self.sidebar_collapsed) {
if (position >= self.sidebar_expand_at) self.applySidebarCollapsed(false);
return;
}
if (position <= minPosition(paned)) {
self.applySidebarCollapsed(true);
return;
}
self.sidebar_expanded_width = position;
}
fn minPosition(paned: *gtk.Paned) c_int {
var value = gobject.ext.Value.new(c_int);
defer value.unset();
paned.as(gobject.Object).getProperty("min-position", &value);
return gobject.ext.Value.get(&value, c_int);
}
// -------------------------------------------------------------------------
// Settings
@@ -1472,7 +1685,11 @@ fn refreshLabel(self: *Window, tab: *Tab) void {
buf[text.len] = 0;
tab.label.setText(buf[0..text.len :0]);
tab.label.as(gtk.Widget).setTooltipText(buf[0..text.len :0]);
// On the row rather than the label, so a collapsed sidebar — where the
// label is hidden and the emoji is all a row shows — still says which tab
// an emoji is when you hover it.
tab.row.as(gtk.Widget).setTooltipText(buf[0..text.len :0]);
// An emoji replaces the icon rather than joining it. The row has one slot
// for "what is this tab", and filling it twice would spend twice the width
@@ -1672,6 +1889,7 @@ fn perform(self: *Window, action: shortcuts.Action) bool {
.new_web => self.addPane(.web),
.rename_tab => if (self.activeTab()) |tab| self.beginRename(tab),
.toggle_zoom => if (self.activeTab()) |tab| tab.view.toggleZoomFocused(),
.toggle_sidebar => self.toggleSidebar(),
.open_settings => self.openSettings(),
// Only a terminal needs us to encode a paste for it. A web pane has its
+6
View File
@@ -110,6 +110,7 @@ pub const Action = enum {
new_web,
rename_tab,
toggle_zoom,
toggle_sidebar,
paste,
copy,
find,
@@ -158,6 +159,7 @@ pub const defaults: []const Binding = &.{
.{ .chord = chord("ctrl+shift+r"), .action = .rename_tab },
.{ .chord = chord("ctrl+shift+z"), .action = .toggle_zoom },
.{ .chord = chord("ctrl+shift+f"), .action = .toggle_zoom },
.{ .chord = chord("ctrl+shift+s"), .action = .toggle_sidebar },
.{ .chord = chord("ctrl+shift+v"), .action = .paste },
.{ .chord = chord("ctrl+shift+c"), .action = .copy },
.{ .chord = chord("ctrl+f"), .action = .find },
@@ -193,6 +195,10 @@ pub const defaults: []const Binding = &.{
/// default is a build failure rather than a shortcut that quietly isn't there.
fn chord(comptime text: []const u8) Chord {
comptime {
// The whole defaults table parses in one comptime evaluation, and the
// key-name lookup walks a large enum per entry; the default quota is
// a few chords too small for the table's current size.
@setEvalBranchQuota(100_000);
const parsed = parse(text) orelse @compileError("not a chord: " ++ text);
return parsed;
}
+17 -5
View File
@@ -225,12 +225,12 @@ dnd.playpen-tab-drag {
background-color: @pp_bg;
}
/* 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. */
/* A view is the container for one tab's terminals. The left side has no
padding of its own: the root split's separator is the 6px gutter there, so
the content stays evenly inset while that gap doubles as the drag handle
that resizes the sidebar. */
.playpen-view {
padding: 6px;
padding: 6px 6px 6px 0;
}
/* Paned itself draws nothing; the panes inside it carry the styling. */
@@ -724,6 +724,18 @@ dnd.playpen-tab-drag {
box-shadow: inset 0 0 0 1px @pp_accent;
}
/* The divider between the sidebar and the content: the gutter that was always
there, made draggable. It paints as the backdrop so nothing looks different
until it is hovered, the same way the pane dividers behave. */
.playpen-root-split > separator {
background-color: @pp_bg;
min-width: 6px;
}
.playpen-root-split > separator:hover {
background-color: @pp_accent_muted;
}
/* Divider between panes. Wide enough to grab without hunting for it. */
.playpen-view paned > separator {
background-color: @pp_bg;