Add emoji prefix.
This commit is contained in:
@@ -126,6 +126,8 @@ key.zig GDK keyval -> libghostty-vt key mapping
|
|||||||
theme.zig colors libghostty-vt has no opinion about, per scheme
|
theme.zig colors libghostty-vt has no opinion about, per scheme
|
||||||
Settings.zig preferences: model, JSON on disk
|
Settings.zig preferences: model, JSON on disk
|
||||||
SettingsDialog.zig the settings page
|
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
|
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
|
- **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
|
it is working, waiting on you, or finished and still unanswered. See
|
||||||
[Agent status](#agent-status)
|
[Agent status](#agent-status)
|
||||||
- **Renaming a tab**: `Ctrl+Shift+R`, right-click or double-click a tab row.
|
- **Renaming a tab**: `Ctrl+Shift+R`, double-click a tab row, or **Rename** in
|
||||||
A typed name pins the label; clearing it hands the label back to the panes
|
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
|
- **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
|
`Ctrl+Shift+Z`. Nothing closes and nothing moves — hidden panes keep running
|
||||||
and the split comes back exactly as it was. See [Zoom](#zoom)
|
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
|
closing — a background shell exiting, say — does not, since what you are looking
|
||||||
at is still there.
|
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
|
## Agent status
|
||||||
|
|
||||||
Playpen is mostly used to keep several Claude Code sessions side by side, and
|
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
|
to be reached another way — a keyboard shortcut, or `zig build test` if the
|
||||||
thing being checked is logic rather than pixels.
|
thing being checked is logic rather than pixels.
|
||||||
|
|
||||||
`zig build test` runs the unit tests, which cover layout parsing and the
|
`zig build test` runs the unit tests, which cover layout parsing, the
|
||||||
parameter/`$(...)` substitution pipeline. They are rooted at `Layouts.zig` so
|
parameter/`$(...)` substitution pipeline, and the emoji table and its search.
|
||||||
the test binary never links GTK.
|
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
|
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
|
`shot.sh` forces the dev shell's mesa down to its software rasterizer. The mesa
|
||||||
|
|||||||
@@ -82,6 +82,19 @@ pub fn build(b: *std.Build) void {
|
|||||||
});
|
});
|
||||||
tests.root_module.addImport("glib", gobject.module("glib2"));
|
tests.root_module.addImport("glib", gobject.module("glib2"));
|
||||||
|
|
||||||
const run_tests = b.addRunArtifact(tests);
|
const test_step = b.step("test", "Run the tests");
|
||||||
b.step("test", "Run the tests").dependOn(&run_tests.step);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -19,6 +19,7 @@ const OpenLayoutDialog = @import("OpenLayoutDialog.zig");
|
|||||||
const Pane = @import("Pane.zig");
|
const Pane = @import("Pane.zig");
|
||||||
const SaveLayoutDialog = @import("SaveLayoutDialog.zig");
|
const SaveLayoutDialog = @import("SaveLayoutDialog.zig");
|
||||||
const SettingsDialog = @import("SettingsDialog.zig");
|
const SettingsDialog = @import("SettingsDialog.zig");
|
||||||
|
const TabSettingsDialog = @import("TabSettingsDialog.zig");
|
||||||
const Terminal = @import("Terminal.zig");
|
const Terminal = @import("Terminal.zig");
|
||||||
const View = @import("View.zig");
|
const View = @import("View.zig");
|
||||||
const appearance = @import("appearance.zig");
|
const appearance = @import("appearance.zig");
|
||||||
@@ -80,6 +81,14 @@ const Tab = struct {
|
|||||||
/// the sidebar without reading the title.
|
/// the sidebar without reading the title.
|
||||||
icon: *gtk.Image,
|
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.
|
/// Status dot, hidden unless the tab has something to report.
|
||||||
dot: *gtk.Image,
|
dot: *gtk.Image,
|
||||||
|
|
||||||
@@ -87,6 +96,9 @@ const Tab = struct {
|
|||||||
/// Null means the label tracks the content, which is the default.
|
/// Null means the label tracks the content, which is the default.
|
||||||
custom_name: ?[]u8 = null,
|
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.
|
/// Popover holding the rename entry, parented to this tab's row.
|
||||||
rename_popover: *gtk.Popover,
|
rename_popover: *gtk.Popover,
|
||||||
rename_entry: *gtk.Entry,
|
rename_entry: *gtk.Entry,
|
||||||
@@ -312,7 +324,9 @@ fn newTabEmpty(self: *Window) !*Tab {
|
|||||||
.row = gtk.ListBoxRow.new(),
|
.row = gtk.ListBoxRow.new(),
|
||||||
.label = gtk.Label.new("shell"),
|
.label = gtk.Label.new("shell"),
|
||||||
.icon = gtk.Image.newFromIconName("utilities-terminal-symbolic"),
|
.icon = gtk.Image.newFromIconName("utilities-terminal-symbolic"),
|
||||||
|
.emoji_label = gtk.Label.new(null),
|
||||||
.dot = gtk.Image.newFromIconName(Pane.status_icon),
|
.dot = gtk.Image.newFromIconName(Pane.status_icon),
|
||||||
|
.menu_popover = gtk.Popover.new(),
|
||||||
.rename_popover = gtk.Popover.new(),
|
.rename_popover = gtk.Popover.new(),
|
||||||
.rename_entry = gtk.Entry.new(),
|
.rename_entry = gtk.Entry.new(),
|
||||||
.name = undefined,
|
.name = undefined,
|
||||||
@@ -325,7 +339,13 @@ fn newTabEmpty(self: *Window) !*Tab {
|
|||||||
const row_box = gtk.Box.new(.horizontal, 6);
|
const row_box = gtk.Box.new(.horizontal, 6);
|
||||||
row_box.as(gtk.Widget).addCssClass("playpen-row");
|
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));
|
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.setXalign(0);
|
||||||
tab.label.setEllipsize(.end);
|
tab.label.setEllipsize(.end);
|
||||||
@@ -345,6 +365,7 @@ fn newTabEmpty(self: *Window) !*Tab {
|
|||||||
row_box.append(close.as(gtk.Widget));
|
row_box.append(close.as(gtk.Widget));
|
||||||
|
|
||||||
tab.row.setChild(row_box.as(gtk.Widget));
|
tab.row.setChild(row_box.as(gtk.Widget));
|
||||||
|
self.buildRowMenu(tab, row_box);
|
||||||
self.buildRename(tab, row_box);
|
self.buildRename(tab, row_box);
|
||||||
self.list.append(tab.row.as(gtk.Widget));
|
self.list.append(tab.row.as(gtk.Widget));
|
||||||
|
|
||||||
@@ -355,6 +376,122 @@ fn newTabEmpty(self: *Window) !*Tab {
|
|||||||
return 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
|
// 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).addCssClass("playpen-rename-popover");
|
||||||
tab.rename_popover.as(gtk.Widget).setParent(anchor.as(gtk.Widget));
|
tab.rename_popover.as(gtk.Widget).setParent(anchor.as(gtk.Widget));
|
||||||
|
|
||||||
// Right-click is the discoverable route; double-click matches how tab
|
// Double-click still goes straight here, without passing through the menu:
|
||||||
// strips elsewhere behave. Both land in the same place.
|
// it matches how tab strips elsewhere behave, and it is the shortcut worth
|
||||||
const secondary = gtk.GestureClick.new();
|
// keeping for the one thing you rename a tab far more often than you
|
||||||
secondary.as(gtk.GestureSingle).setButton(3);
|
// configure it.
|
||||||
_ = gtk.GestureClick.signals.pressed.connect(
|
|
||||||
secondary,
|
|
||||||
*Tab,
|
|
||||||
&onRowSecondary,
|
|
||||||
tab,
|
|
||||||
.{},
|
|
||||||
);
|
|
||||||
anchor.as(gtk.Widget).addController(secondary.as(gtk.EventController));
|
|
||||||
|
|
||||||
const double = gtk.GestureClick.new();
|
const double = gtk.GestureClick.new();
|
||||||
double.as(gtk.GestureSingle).setButton(1);
|
double.as(gtk.GestureSingle).setButton(1);
|
||||||
_ = gtk.GestureClick.signals.pressed.connect(
|
_ = gtk.GestureClick.signals.pressed.connect(
|
||||||
@@ -456,16 +584,6 @@ fn onRenameActivate(_: *gtk.Entry, tab: *Tab) callconv(.c) void {
|
|||||||
self.refreshLabel(tab);
|
self.refreshLabel(tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn onRowSecondary(
|
|
||||||
_: *gtk.GestureClick,
|
|
||||||
_: c_int,
|
|
||||||
_: f64,
|
|
||||||
_: f64,
|
|
||||||
tab: *Tab,
|
|
||||||
) callconv(.c) void {
|
|
||||||
tab.window.beginRename(tab);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn onRowDoubleClick(
|
fn onRowDoubleClick(
|
||||||
_: *gtk.GestureClick,
|
_: *gtk.GestureClick,
|
||||||
n_press: c_int,
|
n_press: c_int,
|
||||||
@@ -731,8 +849,12 @@ fn closeTab(self: *Window, tab: *Tab) void {
|
|||||||
|
|
||||||
self.stack.remove(tab.view.widget());
|
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
|
// 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.
|
// 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();
|
tab.rename_popover.as(gtk.Widget).unparent();
|
||||||
|
|
||||||
self.list.remove(tab.row.as(gtk.Widget));
|
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.setText(buf[0..text.len :0]);
|
||||||
tab.label.as(gtk.Widget).setTooltipText(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);
|
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.
|
// Each terminal owns a session, which owns a PTY and its child process.
|
||||||
// Dropping them here reaps the children rather than orphaning them.
|
// Dropping them here reaps the children rather than orphaning them.
|
||||||
for (self.tabs.items) |tab| {
|
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();
|
tab.view.destroy();
|
||||||
if (tab.custom_name) |name| self.alloc.free(name);
|
if (tab.custom_name) |name| self.alloc.free(name);
|
||||||
self.alloc.destroy(tab);
|
self.alloc.destroy(tab);
|
||||||
|
|||||||
+2086
File diff suppressed because it is too large
Load Diff
@@ -119,6 +119,17 @@ button.playpen-header-button:hover,
|
|||||||
color: @pp_accent_strong;
|
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.
|
/* 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
|
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;
|
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
|
/* A web pane's navigation bar, below the pane header. Kept visually quieter
|
||||||
than the header so the two rows don't compete. */
|
than the header so the two rows don't compete. */
|
||||||
.playpen-nav {
|
.playpen-nav {
|
||||||
@@ -521,6 +545,70 @@ button.playpen-header-button:hover,
|
|||||||
border-color: @pp_accent_strong;
|
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. */
|
/* Divider between panes. Wide enough to grab without hunting for it. */
|
||||||
.playpen-view paned > separator {
|
.playpen-view paned > separator {
|
||||||
background-color: @pp_bg;
|
background-color: @pp_bg;
|
||||||
|
|||||||
Executable
+509
@@ -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("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace(""", '"')
|
||||||
|
)
|
||||||
|
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())
|
||||||
Reference in New Issue
Block a user