From 4eef6a28d38d518283daf9728d47304165450e35 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Sun, 23 Aug 2026 14:35:47 -0400 Subject: [PATCH] Fix icon sizing. --- README.md | 6 ++ src/Window.zig | 248 +++++++++++++++++++++++++++++++++++++++++++--- src/shortcuts.zig | 6 ++ src/style.css | 22 +++- 4 files changed, 262 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index ab62a92..e88acb7 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/src/Window.zig b/src/Window.zig index 0be5341..d65525b 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -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 diff --git a/src/shortcuts.zig b/src/shortcuts.zig index 3ee39da..321cf70 100644 --- a/src/shortcuts.zig +++ b/src/shortcuts.zig @@ -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; } diff --git a/src/style.css b/src/style.css index c7d073e..5c114d0 100644 --- a/src/style.css +++ b/src/style.css @@ -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;