Add emoji prefix.

This commit is contained in:
Greyson Parrelli
2026-08-13 09:56:34 -04:00
parent 0836cbc772
commit 6c7c3bfa63
7 changed files with 3328 additions and 31 deletions
+411
View File
@@ -0,0 +1,411 @@
//! Per-tab settings: the preferences that belong to one tab rather than to the
//! whole app.
//!
//! Built in the same hand-rolled shape as `SettingsDialog`, and for the same
//! reason — a stock preferences window in the middle of a window this heavily
//! restyled reads as something belonging to a different program. Changes apply
//! the moment you make them, so there is no confirm button: picking an emoji
//! redraws the sidebar row behind the dialog, which makes the setting its own
//! preview.
//!
//! Only one is open at a time, and opening it for a second tab retargets it
//! rather than stacking a second window. Two of these side by side would both
//! be titled after their tab and otherwise identical, which is a good way to
//! change the wrong tab's emoji.
const std = @import("std");
const gtk = @import("gtk");
const emoji = @import("emoji.zig");
const TabSettingsDialog = @This();
/// Told that the tab's emoji changed. Null means "go back to the pane's icon".
///
/// The glyph handed over points into `emoji.table`, so it is static: the
/// receiver may hold onto it without copying it and without ever freeing it.
pub const Callback = *const fn (ctx: ?*anyopaque, glyph: ?[:0]const u8) void;
pub const Options = struct {
/// What the tab's row is showing, so the window can say which tab this is.
tab_name: []const u8,
/// The emoji the tab is wearing now, if any.
emoji: ?[]const u8,
};
/// How much of the search box we read. Long enough for any real query: matching
/// is per-word, so past a few terms every further one can only narrow the result
/// to nothing.
const query_max = 96;
/// The side of one cell in the picker's grid, in pixels. Comfortably wider than
/// a glyph at the size the CSS draws it, so the square rather than the glyph is
/// what decides the column width.
const cell_size = 30;
alloc: std.mem.Allocator,
window: *gtk.Window,
/// The picker's grid, one child per entry in `emoji.table` and in that order,
/// which is what lets a child's index stand in for the entry it shows.
grid: *gtk.FlowBox,
/// Large rendering of the current choice, or "None" when there isn't one.
preview: *gtk.Label,
/// The box that filters it. Held so the window can open with the cursor already
/// in it: the grid is two thousand glyphs deep, and typing is the way in.
search: *gtk.SearchEntry,
/// Shown in the grid's place when a search matches nothing, so an empty grid
/// reads as "no matches" rather than as a broken picker.
empty: *gtk.Label,
/// Drops back to the pane's icon. Insensitive when there is no emoji to drop.
clear: *gtk.Button,
/// The search text, as typed. `emoji.matches` folds case itself, so this is
/// stored verbatim rather than lowercased on the way in.
query_buf: [query_max]u8 = undefined,
query_len: usize = 0,
/// Index into `emoji.table` of the chosen glyph, or null for the pane's icon.
chosen: ?usize,
on_changed: Callback,
/// Whose tab this is. Also the identity `closeFor` matches on.
ctx: ?*anyopaque,
/// The open dialog, if there is one.
var open: ?*TabSettingsDialog = null;
pub fn present(
alloc: std.mem.Allocator,
parent: *gtk.Window,
opts: Options,
on_changed: Callback,
ctx: ?*anyopaque,
) !void {
if (open) |existing| {
if (existing.ctx == ctx) {
existing.window.present();
return;
}
// A different tab: the whole window is about that tab, so there is
// nothing worth keeping. Destroying it clears `open` through its own
// destroy handler.
existing.window.destroy();
}
const self = try alloc.create(TabSettingsDialog);
errdefer alloc.destroy(self);
self.* = .{
.alloc = alloc,
.window = gtk.Window.new(),
.grid = gtk.FlowBox.new(),
.preview = gtk.Label.new(null),
.search = gtk.SearchEntry.new(),
.empty = gtk.Label.new("No emoji match that search."),
.clear = gtk.Button.newWithLabel("Use the pane icon"),
.chosen = indexOf(opts.emoji),
.on_changed = on_changed,
.ctx = ctx,
};
var title_buf: [160]u8 = undefined;
const title = std.fmt.bufPrintZ(&title_buf, "Tab settings — {s}", .{
clip(opts.tab_name, 96),
}) catch "Tab settings";
self.window.setTitle(title);
self.window.setTransientFor(parent);
self.window.setModal(1);
self.window.setDefaultSize(430, 620);
self.window.as(gtk.Widget).addCssClass("playpen-dialog");
const content = gtk.Box.new(.vertical, 12);
content.as(gtk.Widget).addCssClass("playpen-dialog-content");
content.as(gtk.Widget).setVexpand(1);
content.append(self.buildEmojiGroup());
const buttons = gtk.Box.new(.horizontal, 8);
buttons.as(gtk.Widget).setHalign(.end);
buttons.as(gtk.Widget).addCssClass("playpen-dialog-actions");
// "Close" rather than "OK", as in the app's own settings: every change here
// has already been applied, so there is nothing to confirm.
const close = gtk.Button.newWithLabel("Close");
_ = gtk.Button.signals.clicked.connect(close, *TabSettingsDialog, &onClose, self, .{});
buttons.append(close.as(gtk.Widget));
const outer = gtk.Box.new(.vertical, 0);
outer.append(content.as(gtk.Widget));
outer.append(buttons.as(gtk.Widget));
self.window.setChild(outer.as(gtk.Widget));
_ = gtk.Widget.signals.destroy.connect(
self.window,
*TabSettingsDialog,
&onDestroy,
self,
.{},
);
open = self;
self.window.present();
_ = self.search.as(gtk.Widget).grabFocus();
}
/// Close the dialog if it belongs to `ctx`.
///
/// Called when a tab goes away. The dialog holds the tab as an opaque pointer
/// and would otherwise happily write an emoji into freed memory the next time
/// someone clicked a glyph.
pub fn closeFor(ctx: ?*anyopaque) void {
const existing = open orelse return;
if (existing.ctx != ctx) return;
existing.window.destroy();
}
/// `text` cut to at most `limit` bytes, never mid-codepoint.
///
/// The tab name comes off a terminal title, so it is any length and any script.
/// A cut through the middle of a UTF-8 sequence would hand GTK a string it
/// refuses, and the window would end up with no title at all rather than a
/// shortened one.
fn clip(text: []const u8, limit: usize) []const u8 {
if (text.len <= limit) return text;
var end = limit;
while (end > 0 and text[end] & 0xc0 == 0x80) end -= 1;
return text[0..end];
}
/// Where a glyph sits in `emoji.table`, which is how the dialog carries "the
/// current choice" without carrying a string.
///
/// A glyph that isn't in the table reads as no choice at all. That can only
/// happen if the table loses an entry it used to have, and the honest answer
/// then is that the picker no longer offers what the tab is wearing.
fn indexOf(glyph: ?[]const u8) ?usize {
const wanted = glyph orelse return null;
for (emoji.table, 0..) |entry, i| {
if (std.mem.eql(u8, entry.glyph, wanted)) return i;
}
return null;
}
/// The whole Emoji Label section: what is set now, then the picker for
/// changing it.
fn buildEmojiGroup(self: *TabSettingsDialog) *gtk.Widget {
const group = gtk.Box.new(.vertical, 10);
group.as(gtk.Widget).addCssClass("playpen-settings-group");
group.as(gtk.Widget).setVexpand(1);
const title = gtk.Label.new("Emoji Label");
title.setXalign(0);
title.as(gtk.Widget).addCssClass("playpen-settings-title");
group.append(title.as(gtk.Widget));
// Across the section rather than beside the preview: at this width a
// sentence squeezed into the gap between a swatch and a button wraps to four
// lines, and four lines of hint is louder than the thing it explains.
const hint = gtk.Label.new(
"Stands in for the pane's icon in the sidebar, for this tab only.",
);
hint.setXalign(0);
hint.setWrap(1);
hint.as(gtk.Widget).addCssClass("playpen-dialog-sublabel");
group.append(hint.as(gtk.Widget));
group.append(self.buildCurrent());
group.append(self.buildPicker());
return group.as(gtk.Widget);
}
/// The current choice, spelled out large enough to check at a glance, with the
/// way back to the icon across from it.
fn buildCurrent(self: *TabSettingsDialog) *gtk.Widget {
const row = gtk.Box.new(.horizontal, 12);
self.preview.as(gtk.Widget).addCssClass("playpen-emoji-preview");
self.preview.as(gtk.Widget).setValign(.center);
row.append(self.preview.as(gtk.Widget));
self.clear.as(gtk.Widget).setHexpand(1);
self.clear.as(gtk.Widget).setHalign(.end);
self.clear.as(gtk.Widget).setValign(.center);
_ = gtk.Button.signals.clicked.connect(
self.clear,
*TabSettingsDialog,
&onClear,
self,
.{},
);
row.append(self.clear.as(gtk.Widget));
return row.as(gtk.Widget);
}
/// Search box over a scrolling grid of every glyph in the table.
///
/// The grid is built once and filtered in place rather than rebuilt per
/// keystroke. Two thousand widgets is a fair amount to keep around, but GTK only
/// lays out and draws the ones on screen, and re-running a filter over them is
/// cheap where building them again between one letter and the next would not be.
fn buildPicker(self: *TabSettingsDialog) *gtk.Widget {
const box = gtk.Box.new(.vertical, 8);
box.as(gtk.Widget).setVexpand(1);
const search = self.search;
search.setPlaceholderText("Search emoji — try \"rocket\" or \"green circle\"");
_ = gtk.SearchEntry.signals.search_changed.connect(
search,
*TabSettingsDialog,
&onSearchChanged,
self,
.{},
);
box.append(search.as(gtk.Widget));
self.grid.setSelectionMode(.single);
self.grid.setActivateOnSingleClick(1);
self.grid.setMaxChildrenPerLine(12);
self.grid.setRowSpacing(2);
self.grid.setColumnSpacing(2);
self.grid.as(gtk.Widget).setValign(.start);
self.grid.as(gtk.Widget).addCssClass("playpen-emoji-grid");
self.grid.setFilterFunc(&filter, self, null);
_ = gtk.FlowBox.signals.child_activated.connect(
self.grid,
*TabSettingsDialog,
&onEmojiActivated,
self,
.{},
);
for (emoji.table) |entry| {
const label = gtk.Label.new(entry.glyph);
label.as(gtk.Widget).addCssClass("playpen-emoji-cell");
// Every cell asks for the same square, which is what keeps the grid in
// columns. `setHomogeneous` would have done it too, but it sizes every
// cell to the widest child, and a handful of the ZWJ sequences fall
// apart into two or three glyphs on a font that can't compose them —
// one of those in the table and the whole grid inherits its width. This
// way the odd wide one is wide on its own.
label.as(gtk.Widget).setSizeRequest(cell_size, cell_size);
const child = gtk.FlowBoxChild.new();
child.setChild(label.as(gtk.Widget));
// Names a glyph you can't quite make out at this size — which, at this
// size and across two thousand of them, is a good few.
child.as(gtk.Widget).setTooltipText(entry.name);
self.grid.append(child.as(gtk.Widget));
}
const scroller = gtk.ScrolledWindow.new();
scroller.setPolicy(.never, .automatic);
scroller.as(gtk.Widget).setVexpand(1);
scroller.as(gtk.Widget).addCssClass("playpen-emoji-scroller");
scroller.setChild(self.grid.as(gtk.Widget));
box.append(scroller.as(gtk.Widget));
self.empty.as(gtk.Widget).addCssClass("playpen-dialog-hint");
self.empty.as(gtk.Widget).setVisible(0);
box.append(self.empty.as(gtk.Widget));
// Only now that the grid has its children can the current choice be
// highlighted in it.
self.refreshCurrent();
return box.as(gtk.Widget);
}
/// The search text as typed.
fn query(self: *const TabSettingsDialog) []const u8 {
return self.query_buf[0..self.query_len];
}
/// Whether one grid child should show. Runs per child on every keystroke.
fn filter(child: *gtk.FlowBoxChild, data: ?*anyopaque) callconv(.c) c_int {
const self: *TabSettingsDialog = @ptrCast(@alignCast(data.?));
const index = child.getIndex();
if (index < 0 or index >= emoji.table.len) return 1;
return @intFromBool(emoji.matches(emoji.table[@intCast(index)], self.query()));
}
fn onSearchChanged(entry: *gtk.SearchEntry, self: *TabSettingsDialog) callconv(.c) void {
const typed = std.mem.span(entry.as(gtk.Editable).getText());
self.query_len = @min(typed.len, self.query_buf.len);
@memcpy(self.query_buf[0..self.query_len], typed[0..self.query_len]);
self.grid.invalidateFilter();
// Asked of the table rather than of the grid: GTK has no count of what
// survived a filter, and the table is the thing the filter is reading.
const any = for (emoji.table) |candidate| {
if (emoji.matches(candidate, self.query())) break true;
} else false;
self.empty.as(gtk.Widget).setVisible(@intFromBool(!any));
}
fn onEmojiActivated(
_: *gtk.FlowBox,
child: *gtk.FlowBoxChild,
self: *TabSettingsDialog,
) callconv(.c) void {
const index = child.getIndex();
if (index < 0 or index >= emoji.table.len) return;
self.choose(@intCast(index));
}
fn onClear(_: *gtk.Button, self: *TabSettingsDialog) callconv(.c) void {
self.choose(null);
}
/// Apply a choice: update what the dialog shows, then tell the tab.
fn choose(self: *TabSettingsDialog, index: ?usize) void {
self.chosen = index;
self.refreshCurrent();
self.on_changed(self.ctx, if (index) |i| emoji.table[i].glyph else null);
}
fn refreshCurrent(self: *TabSettingsDialog) void {
if (self.chosen) |i| {
self.preview.setText(emoji.table[i].glyph);
self.preview.as(gtk.Widget).removeCssClass("playpen-emoji-preview-none");
// Selecting the child rather than only filling the preview, so that a
// glyph already in force is findable in the grid it came from.
if (self.grid.getChildAtIndex(@intCast(i))) |child| self.grid.selectChild(child);
} else {
self.preview.setText("None");
self.preview.as(gtk.Widget).addCssClass("playpen-emoji-preview-none");
self.grid.unselectAll();
}
self.clear.as(gtk.Widget).setSensitive(@intFromBool(self.chosen != null));
}
fn onClose(_: *gtk.Button, self: *TabSettingsDialog) callconv(.c) void {
self.window.destroy();
}
fn onDestroy(_: *gtk.Window, self: *TabSettingsDialog) callconv(.c) void {
if (open == self) open = null;
// The filter func outlives nothing here — the grid goes down with the
// window — but dropping it makes that explicit rather than relying on
// teardown order.
self.grid.setFilterFunc(null, null, null);
self.alloc.destroy(self);
}
+161 -24
View File
@@ -19,6 +19,7 @@ const OpenLayoutDialog = @import("OpenLayoutDialog.zig");
const Pane = @import("Pane.zig");
const SaveLayoutDialog = @import("SaveLayoutDialog.zig");
const SettingsDialog = @import("SettingsDialog.zig");
const TabSettingsDialog = @import("TabSettingsDialog.zig");
const Terminal = @import("Terminal.zig");
const View = @import("View.zig");
const appearance = @import("appearance.zig");
@@ -80,6 +81,14 @@ const Tab = struct {
/// the sidebar without reading the title.
icon: *gtk.Image,
/// An emoji the user picked, shown in the icon's place. Unlike `custom_name`
/// this is not owned: the glyph points into `emoji.table`, which is static,
/// so there is nothing here to copy and nothing to free.
emoji: ?[:0]const u8 = null,
/// The widget that draws that emoji, sharing the icon's slot in the row.
emoji_label: *gtk.Label,
/// Status dot, hidden unless the tab has something to report.
dot: *gtk.Image,
@@ -87,6 +96,9 @@ const Tab = struct {
/// Null means the label tracks the content, which is the default.
custom_name: ?[]u8 = null,
/// The row's right-click menu, parented to this tab's row.
menu_popover: *gtk.Popover,
/// Popover holding the rename entry, parented to this tab's row.
rename_popover: *gtk.Popover,
rename_entry: *gtk.Entry,
@@ -312,7 +324,9 @@ fn newTabEmpty(self: *Window) !*Tab {
.row = gtk.ListBoxRow.new(),
.label = gtk.Label.new("shell"),
.icon = gtk.Image.newFromIconName("utilities-terminal-symbolic"),
.emoji_label = gtk.Label.new(null),
.dot = gtk.Image.newFromIconName(Pane.status_icon),
.menu_popover = gtk.Popover.new(),
.rename_popover = gtk.Popover.new(),
.rename_entry = gtk.Entry.new(),
.name = undefined,
@@ -325,7 +339,13 @@ fn newTabEmpty(self: *Window) !*Tab {
const row_box = gtk.Box.new(.horizontal, 6);
row_box.as(gtk.Widget).addCssClass("playpen-row");
// Both live in the row, and `refreshLabel` shows exactly one of them. The
// emoji is given the icon's width so that a sidebar of mixed rows still
// has its labels starting in one column.
row_box.append(tab.icon.as(gtk.Widget));
tab.emoji_label.as(gtk.Widget).addCssClass("playpen-tab-emoji");
tab.emoji_label.as(gtk.Widget).setVisible(0);
row_box.append(tab.emoji_label.as(gtk.Widget));
tab.label.setXalign(0);
tab.label.setEllipsize(.end);
@@ -345,6 +365,7 @@ fn newTabEmpty(self: *Window) !*Tab {
row_box.append(close.as(gtk.Widget));
tab.row.setChild(row_box.as(gtk.Widget));
self.buildRowMenu(tab, row_box);
self.buildRename(tab, row_box);
self.list.append(tab.row.as(gtk.Widget));
@@ -355,6 +376,122 @@ fn newTabEmpty(self: *Window) !*Tab {
return tab;
}
// -------------------------------------------------------------------------
// The row menu
//
// Right-clicking a row opens a menu rather than going straight to the rename
// entry, which is where it used to land. Renaming was the only thing a row
// could do, so it was reasonable for the gesture to *be* renaming; now that a
// row also has settings behind it, a gesture that silently picks one of the two
// would make the other one unreachable by the route people try first.
//
// It does not select the row it belongs to. Renaming a tab, or giving it an
// emoji, is not a reason to go and look at it — often it is the opposite, since
// the tab you are labelling is the one you are about to leave alone for a while.
/// Attach the row's context menu and the right-click that opens it.
fn buildRowMenu(self: *Window, tab: *Tab, anchor: *gtk.Box) void {
_ = self;
const box = gtk.Box.new(.vertical, 2);
box.as(gtk.Widget).addCssClass("playpen-row-menu");
box.append(menuItem("Rename", &onMenuRename, tab));
box.append(menuItem("Settings…", &onMenuSettings, tab));
tab.menu_popover.setChild(box.as(gtk.Widget));
tab.menu_popover.setHasArrow(0);
tab.menu_popover.as(gtk.Widget).setParent(anchor.as(gtk.Widget));
const secondary = gtk.GestureClick.new();
secondary.as(gtk.GestureSingle).setButton(3);
_ = gtk.GestureClick.signals.pressed.connect(
secondary,
*Tab,
&onRowSecondary,
tab,
.{},
);
anchor.as(gtk.Widget).addController(secondary.as(gtk.EventController));
}
/// One line of the row menu, styled like the layout menu's rows so the two
/// popovers read as the same kind of thing.
fn menuItem(
text: [:0]const u8,
handler: *const fn (*gtk.Button, *Tab) callconv(.c) void,
tab: *Tab,
) *gtk.Widget {
const button = gtk.Button.newWithLabel(text);
button.as(gtk.Widget).addCssClass("flat");
button.setHasFrame(0);
if (button.getChild()) |child| child.setHalign(.start);
_ = gtk.Button.signals.clicked.connect(button, *Tab, handler, tab, .{});
return button.as(gtk.Widget);
}
/// Open the menu where the pointer is, rather than centred on the row: with one
/// popover per row anchored to the whole row, a fixed position would put the
/// menu somewhere you weren't pointing.
fn onRowSecondary(
_: *gtk.GestureClick,
_: c_int,
x: f64,
y: f64,
tab: *Tab,
) callconv(.c) void {
const at: gdk.Rectangle = .{
.f_x = @intFromFloat(x),
.f_y = @intFromFloat(y),
.f_width = 1,
.f_height = 1,
};
tab.menu_popover.setPointingTo(&at);
tab.menu_popover.popup();
}
fn onMenuRename(_: *gtk.Button, tab: *Tab) callconv(.c) void {
tab.menu_popover.popdown();
tab.window.beginRename(tab);
}
fn onMenuSettings(_: *gtk.Button, tab: *Tab) callconv(.c) void {
tab.menu_popover.popdown();
tab.window.openTabSettings(tab);
}
// -------------------------------------------------------------------------
// Per-tab settings
/// Open the settings for one tab.
///
/// The tab is handed over as the dialog's opaque context and resolved again on
/// the way back, so the dialog never holds a pointer into anything it owns. What
/// it does hold is the tab itself, which is why `closeTab` closes it.
fn openTabSettings(self: *Window, tab: *Tab) void {
var buf: [128]u8 = undefined;
TabSettingsDialog.present(
self.alloc,
self.window.as(gtk.Window),
.{
.tab_name = self.tabName(tab, &buf),
.emoji = tab.emoji,
},
&onTabEmojiChanged,
tab,
) catch |err| {
std.log.err("failed to open tab settings: {s}", .{@errorName(err)});
};
}
/// The picker chose a glyph, or cleared the choice. The glyph is static, so
/// there is nothing to copy and nothing to release.
fn onTabEmojiChanged(ctx: ?*anyopaque, glyph: ?[:0]const u8) void {
const tab: *Tab = @ptrCast(@alignCast(ctx.?));
tab.emoji = glyph;
tab.window.refreshLabel(tab);
}
// -------------------------------------------------------------------------
// Renaming
//
@@ -393,19 +530,10 @@ fn buildRename(self: *Window, tab: *Tab, anchor: *gtk.Box) void {
tab.rename_popover.as(gtk.Widget).addCssClass("playpen-rename-popover");
tab.rename_popover.as(gtk.Widget).setParent(anchor.as(gtk.Widget));
// Right-click is the discoverable route; double-click matches how tab
// strips elsewhere behave. Both land in the same place.
const secondary = gtk.GestureClick.new();
secondary.as(gtk.GestureSingle).setButton(3);
_ = gtk.GestureClick.signals.pressed.connect(
secondary,
*Tab,
&onRowSecondary,
tab,
.{},
);
anchor.as(gtk.Widget).addController(secondary.as(gtk.EventController));
// Double-click still goes straight here, without passing through the menu:
// it matches how tab strips elsewhere behave, and it is the shortcut worth
// keeping for the one thing you rename a tab far more often than you
// configure it.
const double = gtk.GestureClick.new();
double.as(gtk.GestureSingle).setButton(1);
_ = gtk.GestureClick.signals.pressed.connect(
@@ -456,16 +584,6 @@ fn onRenameActivate(_: *gtk.Entry, tab: *Tab) callconv(.c) void {
self.refreshLabel(tab);
}
fn onRowSecondary(
_: *gtk.GestureClick,
_: c_int,
_: f64,
_: f64,
tab: *Tab,
) callconv(.c) void {
tab.window.beginRename(tab);
}
fn onRowDoubleClick(
_: *gtk.GestureClick,
n_press: c_int,
@@ -731,8 +849,12 @@ fn closeTab(self: *Window, tab: *Tab) void {
self.stack.remove(tab.view.widget());
// The dialog holds this tab as an opaque pointer, so it has to go first.
TabSettingsDialog.closeFor(tab);
// A popover attached with setParent is not an ordinary child, so it has
// to be detached by hand; letting the row take it down warns instead.
tab.menu_popover.as(gtk.Widget).unparent();
tab.rename_popover.as(gtk.Widget).unparent();
self.list.remove(tab.row.as(gtk.Widget));
@@ -838,7 +960,19 @@ fn refreshLabel(self: *Window, tab: *Tab) void {
tab.label.setText(buf[0..text.len :0]);
tab.label.as(gtk.Widget).setTooltipText(buf[0..text.len :0]);
tab.icon.setFromIconName(tab.view.iconName());
// 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
// saying it once — width the label is short of already.
if (tab.emoji) |glyph| {
tab.emoji_label.setText(glyph);
tab.emoji_label.as(gtk.Widget).setVisible(1);
tab.icon.as(gtk.Widget).setVisible(0);
} else {
tab.emoji_label.as(gtk.Widget).setVisible(0);
tab.icon.as(gtk.Widget).setVisible(1);
tab.icon.setFromIconName(tab.view.iconName());
}
self.refreshStatus(tab);
}
@@ -890,6 +1024,9 @@ fn onDestroy(_: *adw.ApplicationWindow, self: *Window) callconv(.c) void {
// 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| {
// As in closeTab: the tab settings dialog is not a child of this
// window, so nothing else takes it down before the tab it points at.
TabSettingsDialog.closeFor(tab);
tab.view.destroy();
if (tab.custom_name) |name| self.alloc.free(name);
self.alloc.destroy(tab);
+2086
View File
File diff suppressed because it is too large Load Diff
+88
View File
@@ -119,6 +119,17 @@ button.playpen-header-button:hover,
color: @pp_accent_strong;
}
/* An emoji standing in for that icon. Sized to the width the icon occupies
rather than left to the glyph, so a sidebar mixing emoji rows with icon rows
still starts every label in the same column — emoji vary in advance width and
without this the labels beside them would step in and out by a pixel or two
per row. The font size is a little under the icon's 16px because a colour
glyph fills its box where a symbolic icon leaves air around itself. */
.playpen-tab-emoji {
min-width: 16px;
font-size: 13px;
}
/* State on the row itself, not just on its dot.
A bar down the leading edge and a wash behind the whole row, both in the
@@ -307,6 +318,19 @@ button.playpen-header-button:hover,
min-width: 220px;
}
/* The row's right-click menu. Narrower than the rename popover and with tighter
padding: it holds two words per line, and a menu sized like a form reads as
though something failed to fill it. */
.playpen-row-menu {
padding: 4px;
min-width: 150px;
}
.playpen-row-menu > button {
padding: 5px 8px;
border-radius: 6px;
}
/* A web pane's navigation bar, below the pane header. Kept visually quieter
than the header so the two rows don't compete. */
.playpen-nav {
@@ -521,6 +545,70 @@ button.playpen-header-button:hover,
border-color: @pp_accent_strong;
}
/* -------------------------------------------------------------------------
The emoji picker
A tab's chosen glyph, then a search box over a grid of every glyph on offer.
The grid is a GtkFlowBox, so each cell is a `flowboxchild` wrapping a label
and the hover and selection states have to be drawn here — Adwaita styles a
flow box for lists of cards, which at this size leaves the cells looking like
nothing in particular. */
/* The current choice, big enough to check from across the dialog. Boxed to the
same square whether it holds a glyph or the word "None", so switching between
them doesn't shift the text beside it. */
.playpen-emoji-preview {
min-width: 44px;
min-height: 44px;
font-size: 26px;
border: 1px solid @pp_border;
border-radius: 10px;
background-color: @pp_surface;
}
.playpen-emoji-preview-none {
font-size: 0.85em;
color: @pp_text_faint;
}
/* Sunk into the dialog rather than raised off it: the grid is a well you look
into, and the section card it sits in is already the raised surface. */
.playpen-emoji-scroller {
border: 1px solid @pp_border;
border-radius: 10px;
background-color: @pp_surface;
}
.playpen-emoji-grid {
padding: 6px;
background: none;
}
.playpen-emoji-cell {
font-size: 19px;
}
.playpen-emoji-grid > flowboxchild {
padding: 0;
border-radius: 8px;
background: none;
transition: background-color 100ms ease;
}
.playpen-emoji-grid > flowboxchild:hover {
background-color: @pp_row_hover;
}
/* The glyph in force. `background-image: none` for the same reason as the theme
buttons above: Adwaita paints selection with a gradient over the colour, and
without this the accent comes out washed. */
.playpen-emoji-grid > flowboxchild:selected,
.playpen-emoji-grid > flowboxchild:selected:backdrop {
background-color: @pp_accent_muted;
background-image: none;
box-shadow: inset 0 0 0 1px @pp_accent;
}
/* Divider between panes. Wide enough to grab without hunting for it. */
.playpen-view paned > separator {
background-color: @pp_bg;