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
+58 -5
View File
@@ -126,6 +126,8 @@ key.zig GDK keyval -> libghostty-vt key mapping
theme.zig colors libghostty-vt has no opinion about, per scheme
Settings.zig preferences: model, JSON on disk
SettingsDialog.zig the settings page
TabSettingsDialog.zig one tab's own settings, and the emoji picker
emoji.zig generated: every emoji the picker offers, and the search over them
```
A tab is a **view**, and a view holds one or more **panes** arranged in a
@@ -325,8 +327,12 @@ in principle, but a terminal grid is small.
- **Pane status in the tab strip**, driven by OSC 9;4, so a tab can say whether
it is working, waiting on you, or finished and still unanswered. See
[Agent status](#agent-status)
- **Renaming a tab**: `Ctrl+Shift+R`, right-click or double-click a tab row.
A typed name pins the label; clearing it hands the label back to the panes
- **Renaming a tab**: `Ctrl+Shift+R`, double-click a tab row, or **Rename** in
its right-click menu. A typed name pins the label; clearing it hands the
label back to the panes
- **An emoji in place of a tab's icon**, from **Settings** in the same
right-click menu, picked out of the full emoji set with keyword search. See
[Tab settings](#tab-settings)
- **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)
@@ -383,6 +389,46 @@ that zoom is hiding. Closing the zoomed pane leaves zoom; a *different* pane
closing — a background shell exiting, say — does not, since what you are looking
at is still there.
## Tab settings
Right-clicking a tab row opens a small menu: **Rename**, which is the same
one-field popover `Ctrl+Shift+R` opens, and **Settings…**, which opens a window
belonging to that one tab. Double-clicking a row still goes straight to renaming
— it is the thing you do far more often than the other.
The window holds one setting today, **Emoji Label**: a glyph that stands in for
the pane icon in that tab's sidebar row. It replaces the icon rather than joining
it, since a row has one slot for "what is this tab" and the label needs the rest
of the width. **Use the pane icon** puts the icon back.
Picking one is a search box over a grid of the whole emoji set — 1906 glyphs, in
Unicode's own order and grouping, so it reads the way any other emoji keyboard
does. A glyph's tooltip is its name, for the ones you can't quite make out at
grid size.
Every whitespace-separated word in the query has to match, in any order, so
`green circle` and `circle green` both land on 🟢 while `circle` alone brings back
the whole family. Keywords are CLDR's, which is where `grin` finding 😀 comes
from, plus the group and subgroup each glyph belongs to — `fruit`, `arrow` and
`flags` each bring back a shelf — plus a table of synonyms for the words a
terminal user actually types: `deploy` finds 🚀, `hotfix` finds 🩹, `docker` finds
🐳, `rust` finds 🦀.
`src/emoji.zig` is generated by `tools/gen-emoji.py` from Unicode's
`emoji-test.txt` and CLDR's English annotations; run that rather than editing the
table, and add to its `SYNONYMS` when a glyph should answer to a word the data
files don't know. Two things it leaves out. Skin-tone variants, because just over
half of Unicode's 3944 sequences are the same gesture in five tones and a grid of
them is longer to look through rather than more complete — every base glyph is
there. And anything newer than emoji 16.0, because colour fonts trail Unicode by
a year or two and a glyph the font has never heard of draws as a hex-digit box,
which in a picker reads as a bug; `--max-version` raises the cutoff once fonts
have caught up.
The choice lives on the tab and not on disk, the same as a typed name: it lasts
as long as the tab does. Saving the tab as a layout does not carry it, since a
layout describes an arrangement of panes rather than what a row looks like.
## Agent status
Playpen is mostly used to keep several Claude Code sessions side by side, and
@@ -627,9 +673,16 @@ emit from and the client never sees it. Anything reachable only by clicking has
to be reached another way — a keyboard shortcut, or `zig build test` if the
thing being checked is logic rather than pixels.
`zig build test` runs the unit tests, which cover layout parsing and the
parameter/`$(...)` substitution pipeline. They are rooted at `Layouts.zig` so
the test binary never links GTK.
`zig build test` runs the unit tests, which cover layout parsing, the
parameter/`$(...)` substitution pipeline, and the emoji table and its search.
They build as two binaries, rooted at `Layouts.zig` and `emoji.zig`, so neither
links GTK — a test binary has one root, and those are the two files worth
testing in isolation that have nothing to do with each other.
`tools/gen-emoji.py` regenerates `src/emoji.zig` from Unicode's `emoji-test.txt`
and CLDR's annotations. It is not part of the build — its output is committed, so
a build never needs the network — and `--check` reports whether what is committed
is what it would write. See [Tab settings](#tab-settings).
Web panes need an EGL display and the headless compositor has no GPU, so
`shot.sh` forces the dev shell's mesa down to its software rasterizer. The mesa
+15 -2
View File
@@ -82,6 +82,19 @@ pub fn build(b: *std.Build) void {
});
tests.root_module.addImport("glib", gobject.module("glib2"));
const run_tests = b.addRunArtifact(tests);
b.step("test", "Run the tests").dependOn(&run_tests.step);
const test_step = b.step("test", "Run the tests");
test_step.dependOn(&b.addRunArtifact(tests).step);
// A second root for the same reason, one step further out: `emoji.zig` is a
// table and a search over it, and it imports nothing at all. It cannot hang
// off the root above because a test binary has exactly one root, and
// Layouts.zig has no reason to reach for the emoji table.
const emoji_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/emoji.zig"),
.target = target,
.optimize = optimize,
}),
});
test_step.dependOn(&b.addRunArtifact(emoji_tests).step);
}
+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;
+509
View File
@@ -0,0 +1,509 @@
#!/usr/bin/env python3
"""Regenerate src/emoji.zig from Unicode and CLDR data.
tools/gen-emoji.py # fetch the data, write src/emoji.zig
tools/gen-emoji.py --check # fail if the checked-in file is stale
tools/gen-emoji.py --emoji-test PATH --annotations PATH --derived PATH
The table it writes is committed, so this runs by hand rather than as part of
the build: a build that needs the network to compile is a build that fails on a
train. Re-run it when a new Unicode version lands, or when you want to add a
synonym to SYNONYMS below.
Three inputs, all canonical:
emoji-test.txt Unicode's own ordering, grouped and subgrouped, with
the fully-qualified form of every RGI emoji. This is
what every emoji keyboard is laid out from.
annotations/en.xml CLDR's English keywords per emoji — the synonyms that
make search work ("grin" finding the grinning face).
annotationsDerived/en.xml
The same for sequences CLDR derives rather than names
outright, which is most of the ZWJ ones.
Skin-tone variants are left out. Unicode lists 3944 fully-qualified emoji and
just over half of those are the same gesture five more times; a grid of them is
harder to look through, not more complete. Every base glyph is present, which
is the same choice GTK's own emoji chooser, iOS and Slack all make.
So is anything newer than MAX_VERSION. Colour emoji fonts trail Unicode by a
year or two, and a glyph the font has never heard of draws as a hex-digit box —
which in a picker reads as a bug rather than as a font that needs updating. At
the time of writing, Noto Color Emoji could draw all but 7 of the E17.0
additions and everything older. Raise MAX_VERSION when fonts have caught up;
`--max-version 99` turns the cutoff off entirely.
"""
import argparse
import collections
import os
import re
import sys
import urllib.request
# "latest" rather than a pinned version: the per-version directories under
# /Public/emoji/ stop being published once a version ships, so a pinned URL is
# one that 404s later. What version "latest" turned out to be is recorded in the
# generated file's header, and MAX_VERSION below is what actually decides the
# contents.
EMOJI_TEST = "https://unicode.org/Public/emoji/latest/emoji-test.txt"
ANNOTATIONS = "https://raw.githubusercontent.com/unicode-org/cldr/main/common/annotations/en.xml"
DERIVED = "https://raw.githubusercontent.com/unicode-org/cldr/main/common/annotationsDerived/en.xml"
# Terms no data file will ever give you: what a glyph means to someone labelling
# a terminal tab. CLDR knows a rocket is a "space ship"; it does not know it is
# what you reach for when the tab is a deploy.
SYNONYMS = {
"🚀": "deploy ship launch release",
"📦": "release bundle package ship artifact",
"🏷️": "version tag release",
"🎉": "ship shipped release celebrate tada",
"🐛": "bug issue defect regression",
"🩹": "hotfix patch bandaid",
"🔥": "hot lit onfire urgent",
"💥": "crash boom broke",
"💀": "dead deprecated killed",
"🦖": "legacy ancient",
"": "zap fast perf quick",
"⏱️": "benchmark perf timing latency",
"🧪": "test experiment trial",
"🧫": "test lab",
"🔬": "inspect investigate research",
"🔍": "search find grep lookup",
"👀": "review look watch eyes",
"": "pass passing green done ok",
"": "fail failing red broken",
"⚠️": "warn warning caution",
"🚧": "wip work in progress unfinished",
"🏗️": "wip building scaffolding",
"🧹": "cleanup refactor tidy sweep",
"♻️": "refactor reuse recycle",
"🔄": "sync retry refresh reload",
"🔀": "shuffle random merge",
"⚙️": "settings config gear options",
"🔒": "secure private locked",
"🔓": "public unlocked open",
"🔑": "auth key password access token secret",
"🛡️": "security hardening defense",
"📈": "metrics growth up analytics",
"📉": "metrics down regression analytics",
"📊": "metrics analytics stats dashboard",
"🔔": "alert notification ping",
"🔕": "mute silence snooze",
"💻": "dev code local laptop",
"🖥️": "server desktop box host",
"🗄️": "database storage archive",
"🐳": "docker container whale",
"🐧": "linux tux",
"🦀": "rust cargo",
"🐍": "python",
"🐫": "perl camel",
"": "java coffee jvm",
"💎": "ruby gem",
"🐘": "postgres php elephant memory",
"🍎": "apple mac macos",
"🪟": "windows",
"🤖": "bot agent ai automation claude",
"🧑‍💻": "dev developer engineer coding",
"📝": "todo note notes scratch",
"🗑️": "delete trash remove drop",
"🌈": "pride rainbow",
"🎯": "goal target focus",
"🧭": "navigate direction bearings",
"🚦": "ci status pipeline signal",
"🏁": "done finished race",
"🧊": "freeze frozen cold pinned",
"🕸️": "stale abandoned cobweb",
"🧠": "think smart reasoning",
}
# Nothing in a keyword list should be a word you cannot type. Everything else is
# kept, including the non-ASCII names of places, because a term only has to
# match to be worth carrying.
STRIP = re.compile(r"[\"\\|,:;()\[\]{}!?“”]+")
SKIN_TONES = range(0x1F3FB, 0x1F400)
# Newest emoji version to include. See the note at the top of this file.
MAX_VERSION = "16.0"
def version_tuple(text):
""""E16.0" or "16" as something comparable. Unknown sorts newest."""
try:
return tuple(int(p) for p in text.lstrip("Ee").split("."))
except ValueError:
return (999,)
def read(source):
"""Contents of a URL or a path, whichever `source` looks like."""
if source.startswith(("http://", "https://")):
with urllib.request.urlopen(source, timeout=60) as response:
return response.read().decode("utf-8")
with open(source, encoding="utf-8") as handle:
return handle.read()
def parse_emoji_test(text, max_version):
"""Unicode's list, in Unicode's order: (glyph, group, subgroup, name)."""
out = []
skipped = []
group = subgroup = ""
version = "unknown"
for line in text.splitlines():
if line.startswith("# Version:"):
version = line.split(":", 1)[1].strip()
continue
if line.startswith("# group:"):
group = line.split(":", 1)[1].strip()
continue
if line.startswith("# subgroup:"):
subgroup = line.split(":", 1)[1].strip()
continue
if not line.strip() or line.startswith("#"):
continue
codepoints, rest = line.split(";", 1)
status, _, comment = rest.partition("#")
if status.strip() != "fully-qualified":
continue
points = [int(c, 16) for c in codepoints.split()]
if any(p in SKIN_TONES for p in points):
continue
# The comment is "<glyph> E<version> <name>": the version each sequence
# was introduced in, which is what the font-coverage cutoff reads.
parts = comment.strip().split(" ", 2)
introduced = parts[1] if len(parts) > 1 else "E0"
name = parts[2] if len(parts) > 2 else ""
if version_tuple(introduced) > version_tuple(max_version):
skipped.append(introduced)
continue
out.append(("".join(chr(p) for p in points), group, subgroup, name))
return version, out, collections.Counter(skipped)
def parse_annotations(text):
"""CLDR's keywords and short name per emoji, merged into one dict."""
keywords = {}
names = {}
for match in re.finditer(
r'<annotation cp="([^"]*)"(?P<tts> type="tts")?>(.*?)</annotation>',
text,
re.DOTALL,
):
cp, tts, body = match.group(1), match.group("tts"), match.group(3)
body = (
body.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", '"')
)
if tts:
names[cp] = body.strip()
else:
keywords.setdefault(cp, []).extend(p.strip() for p in body.split("|"))
return keywords, names
def escape(text):
"""`text` as a Zig string literal body.
Only the display name needs this. Keywords go through `tokenize`, which
drops every character that would have to be escaped in the first place.
"""
return text.replace("\\", "\\\\").replace('"', '\\"')
def tokenize(*phrases):
"""Lowercase words from `phrases`, deduplicated, in first-seen order."""
seen = []
for phrase in phrases:
if not phrase:
continue
cleaned = STRIP.sub(" ", phrase.replace("-", " ").replace("_", " "))
for word in cleaned.lower().split():
# A bare "&" survives group names like "Smileys & Emotion".
if word == "&":
continue
if word not in seen:
seen.append(word)
return seen
def build(emoji_test, annotations, derived, max_version):
version, entries, skipped = parse_emoji_test(emoji_test, max_version)
keywords, names = parse_annotations(annotations)
derived_keywords, derived_names = parse_annotations(derived)
for source, into in ((derived_keywords, keywords), (derived_names, names)):
for cp, value in source.items():
if cp not in into:
into[cp] = value
rows = []
for glyph, group, subgroup, name in entries:
# CLDR keys on the emoji without its presentation selector as often as
# with it, so try both before giving up and using Unicode's own name.
bare = glyph.replace("\ufe0f", "")
short = names.get(glyph) or names.get(bare) or name
words = tokenize(
short,
" ".join(keywords.get(glyph, keywords.get(bare, []))),
# The group and subgroup make whole shelves reachable by name:
# "flags", "fruit", "arrow", "zodiac".
subgroup,
group,
SYNONYMS.get(glyph, ""),
)
rows.append((glyph, group, escape(short), " ".join(words)))
return version, rows, skipped
def render(version, rows, max_version):
out = []
out.append(
HEADER
% {"version": version, "count": len(rows), "max_version": max_version}
)
group = None
for glyph, row_group, short, words in rows:
if row_group != group:
group = row_group
if out[-1].endswith("},\n"):
out.append("\n")
out.append(" // ---- %s %s\n" % (group, "-" * max(3, 60 - len(group))))
out.append(
' .{ .glyph = "%s", .name = "%s", .keywords = "%s" },\n'
% (glyph, short, words)
)
out.append("};\n")
out.append(TESTS)
return "".join(out)
HEADER = '''//! Every emoji a tab can wear in place of its icon, and the search that finds
//! them.
//!
//! Generated — run `tools/gen-emoji.py` rather than editing this file. It reads
//! Unicode's `emoji-test.txt` (currently version %(version)s) for the set and its
//! ordering, and CLDR's English annotations for the keywords, so the picker is
//! laid out and searchable the same way every other emoji keyboard is.
//!
//! %(count)d entries: the RGI set up to emoji %(max_version)s, minus skin-tone variants.
//! Unicode lists nearly twice this many fully-qualified sequences and the
//! difference is almost entirely the same gesture in five tones, which makes a
//! grid longer to look through without making it more complete. The version
//! cutoff is there because colour emoji fonts trail Unicode, and a glyph the
//! font has never heard of draws as a hex-digit box; the generator's header
//! explains how to raise it.
//!
//! Keywords are CLDR's, plus the group and subgroup a glyph belongs to — so
//! "fruit", "arrow" and "flags" each bring back a whole shelf — plus a table of
//! synonyms in the generator for the words a terminal user would actually type:
//! a rocket answers to "deploy", a bandage to "hotfix", a whale to "docker".
const std = @import("std");
pub const Emoji = struct {
/// The glyph itself, NUL-terminated so it can go straight into a label.
glyph: [:0]const u8,
/// CLDR's short name — "red apple", "flag: Kenya" — as the picker shows it
/// in a tooltip. NUL-terminated for the same reason as the glyph, and kept
/// apart from `keywords` because a name is one phrase and a keyword list is
/// twenty words: readable in a tooltip, and unreadable in one.
name: [:0]const u8,
/// Space-separated search terms, lowercase, the name's own words among them.
keywords: []const u8,
};
/// Whether `emoji` should show for `query`.
///
/// Every whitespace-separated term has to match somewhere, which is what makes
/// "red circle" and "circle red" both land on the same glyph while "red" alone
/// still brings back the whole family. An empty query matches everything, so
/// the unfiltered grid falls out of the same path as a filtered one.
pub fn matches(emoji: Emoji, query: []const u8) bool {
var terms = std.mem.tokenizeAny(u8, query, " \\t");
while (terms.next()) |term| {
if (std.ascii.indexOfIgnoreCase(emoji.keywords, term) == null) return false;
}
return true;
}
/// Every emoji the picker offers, in Unicode's order.
pub const table = [_]Emoji{
'''
TESTS = '''
// -------------------------------------------------------------------------
// Tests
//
// The generator is what keeps the table right; these are the properties the
// picker depends on it having. They run against whatever is checked in, so a
// bad regeneration fails here rather than in the dialog.
test "every entry is usable" {
for (table) |entry| {
try std.testing.expect(entry.glyph.len > 0);
try std.testing.expect(std.unicode.utf8ValidateSlice(entry.glyph));
// Long enough for the longest RGI sequence, short enough that nothing
// here is quietly a whole word.
try std.testing.expect(entry.glyph.len <= 40);
try std.testing.expect(entry.name.len > 0);
try std.testing.expect(std.unicode.utf8ValidateSlice(entry.name));
try std.testing.expect(entry.keywords.len > 0);
for (entry.keywords) |c| try std.testing.expect(!std.ascii.isUpper(c));
}
}
test "glyphs are distinct" {
// A child's position in the picker's grid is how the dialog names the entry
// it shows, and `indexOf` maps the other way by comparing glyphs. Both stop
// being true if a glyph appears twice.
for (table, 0..) |entry, i| {
for (table[i + 1 ..]) |other| {
try std.testing.expect(!std.mem.eql(u8, entry.glyph, other.glyph));
}
}
}
test "the whole set is here" {
// Unicode 17 has 1914 RGI emoji once skin-tone variants are folded away.
// A table that has drifted far from that has lost a group.
try std.testing.expect(table.len > 1800);
}
/// The one entry a search test leans on, looked up rather than indexed so that
/// regenerating the table doesn't rewrite the tests.
fn find(glyph: []const u8) Emoji {
for (table) |entry| {
if (std.mem.eql(u8, entry.glyph, glyph)) return entry;
}
unreachable;
}
test "an empty query matches everything" {
for (table) |entry| {
try std.testing.expect(matches(entry, ""));
try std.testing.expect(matches(entry, " "));
}
}
test "a term matches part of a keyword, in any case" {
const rocket = find("\\u{1F680}");
try std.testing.expect(matches(rocket, "rocket"));
try std.testing.expect(matches(rocket, "ROCKET"));
try std.testing.expect(matches(rocket, "Rock"));
try std.testing.expect(!matches(rocket, "banana"));
}
test "every term has to match, in any order" {
const green = find("\\u{1F7E2}");
try std.testing.expect(matches(green, "green circle"));
try std.testing.expect(matches(green, "circle green"));
try std.testing.expect(matches(green, " green circle "));
// The second term is what rules the other circles out.
const red = find("\\u{1F534}");
try std.testing.expect(matches(red, "circle"));
try std.testing.expect(!matches(red, "green circle"));
// And a term matching nothing rules out a glyph the rest of the query hit.
try std.testing.expect(!matches(green, "green circle sideways"));
}
test "names are the phrase, not the keyword list" {
// What separates the two fields: the tooltip stays short enough to read.
for (table) |entry| try std.testing.expect(entry.name.len <= 64);
try std.testing.expectEqualStrings("red apple", find("\\u{1F34E}").name);
try std.testing.expectEqualStrings("rocket", find("\\u{1F680}").name);
}
test "CLDR keywords reach a glyph its name would not" {
try std.testing.expect(matches(find("\\u{1F600}"), "grin"));
try std.testing.expect(matches(find("\\u{1F60A}"), "blush"));
try std.testing.expect(matches(find("\\u{1F4A9}"), "poop"));
}
test "a group or subgroup brings back its whole shelf" {
try std.testing.expect(matches(find("\\u{1F34E}"), "fruit"));
try std.testing.expect(matches(find("\\u{1F1FA}\\u{1F1F8}"), "flags"));
try std.testing.expect(matches(find("\\u{2B06}\\u{FE0F}"), "arrow"));
}
test "synonyms reach the glyph the data would not" {
try std.testing.expect(matches(find("\\u{1F680}"), "deploy"));
try std.testing.expect(matches(find("\\u{1FA79}"), "hotfix"));
try std.testing.expect(matches(find("\\u{1F433}"), "docker"));
try std.testing.expect(matches(find("\\u{1F427}"), "linux"));
try std.testing.expect(matches(find("\\u{1F980}"), "rust"));
try std.testing.expect(matches(find("\\u{2705}"), "pass"));
}
'''
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--emoji-test", default=EMOJI_TEST)
parser.add_argument("--annotations", default=ANNOTATIONS)
parser.add_argument("--derived", default=DERIVED)
parser.add_argument("--out", default=None)
parser.add_argument(
"--max-version",
default=MAX_VERSION,
help="newest emoji version to include (default %s)" % MAX_VERSION,
)
parser.add_argument(
"--check",
action="store_true",
help="exit non-zero if the file on disk is not what we would write",
)
args = parser.parse_args()
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
out = args.out or os.path.join(root, "src", "emoji.zig")
version, rows, skipped = build(
read(args.emoji_test),
read(args.annotations),
read(args.derived),
args.max_version,
)
text = render(version, rows, args.max_version)
if args.check:
current = open(out, encoding="utf-8").read() if os.path.exists(out) else ""
if current != text:
print("%s is stale; re-run tools/gen-emoji.py" % out, file=sys.stderr)
return 1
print("%s is up to date (%d emoji)" % (out, len(rows)))
return 0
with open(out, "w", encoding="utf-8") as handle:
handle.write(text)
print("wrote %s: %d emoji from Unicode %s" % (out, len(rows), version))
for introduced, count in sorted(skipped.items()):
print(" held back %d from %s (newer than %s)" % (count, introduced, args.max_version))
return 0
if __name__ == "__main__":
sys.exit(main())