Add layout dialog helper.

This commit is contained in:
Greyson Parrelli
2026-08-25 15:54:43 -04:00
parent 448e31ae88
commit 3721ec38ba
9 changed files with 1185 additions and 25 deletions
+61 -7
View File
@@ -135,6 +135,8 @@ web/ the review UI: React + Vite, built by build.zig and embedded
Layouts.zig saved tab templates: model, JSON on disk, {{substitution}}
OpenLayoutDialog.zig prompts for a layout's parameters
SaveLayoutDialog.zig turns the current tab into a saved layout
PathEntry.zig a directory field: completion popover plus a Browse button
paths.zig what a half-typed path completes to, GTK-free and tested
Session.zig libghostty-vt Terminal + parser, fed by the PTY
Pty.zig openpt/fork/exec, controlling terminal setup
script.zig runs a command for `$(...)` in a layout's directory
@@ -232,6 +234,46 @@ leading `~` is expanded afterwards. An unknown `{{name}}` is left as written
rather than blanked, so a typo shows up in the pane instead of silently
producing an empty path.
### Parameter types
A parameter has a **type**, picked beside its name in the layout editor. The
type only ever changes how the value is *asked for* — every value is text and is
substituted as text — so it is a better prompt rather than a different layout.
- **Text** is a plain entry, and what every parameter was before types existed.
- **Directory** is a path field: it completes directory names as you type, and
carries a **Browse…** button that opens the file chooser. Which is what most
layout parameters already are — a layout is usually aimed at a project.
The completion is shell-shaped rather than GTK-shaped. `Tab` completes as far as
the matches agree — `~/src/pl` to `~/src/playpen/`, and no further than the
common prefix when several match — the arrow keys walk the list, `Enter` takes
the highlighted one, and `Escape` puts the list away. With nothing highlighted,
`Enter` still opens the layout, so typing a path you already know stays
type-and-go. Only directories are offered, hidden ones only once you have typed
the `.`, and a long list says how many it is not showing rather than looking
like the whole answer. A value still holding `{{a parameter}}` or `$(a command)`
is left alone: it is not a path yet, so there is nothing to complete it against.
`GtkEntryCompletion` would have been the stock answer. It is deprecated as of
GTK 4.10, and its inline completion goes to the *first* match rather than to the
longest common prefix, which among sibling directories guesses wrong more often
than it helps. The list is a popover that deliberately does not autohide: an
autohiding popover takes a grab, and the grab would send the next keystroke —
the one that narrows the list — to the popover instead of the field.
In the file, a type is a string beside the parameter's name, and saying nothing
means `string`:
```json
{ "name": "path", "description": "Project directory", "type": "directory" }
```
A type this build doesn't recognise is asked for in a text box rather than
refusing the file — unlike a pane `kind`, which is a pane it cannot build. The
startup list asks for its values the same way, so a directory parameter gets the
same field there.
### Directories from a script
A **directory** can also be `$(a command)`, and what the command prints becomes
@@ -293,7 +335,8 @@ one.
{
"name": "Project",
"parameters": [
{ "name": "path", "description": "Project directory", "default": "~" }
{ "name": "path", "description": "Project directory",
"default": "~", "type": "directory" }
],
"root": {
"split": "horizontal",
@@ -432,7 +475,9 @@ in principle, but a terminal grid is small.
reply inline. See [Code review](#code-review)
- **Saved layouts**: whole tabs — panes, splits, ratios, per-pane directories
and scripts — opened in one go, parameterised by `{{name}}`, authored by
arranging a tab and saving it. See [Layouts](#layouts)
arranging a tab and saving it. A parameter can be declared a **directory**, and
is then asked for with a completing path field and a file chooser. See
[Layouts](#layouts)
- **A startup list**: the tabs to open at launch, each a layout with its
parameters filled in, fillable from the tabs you have open now. See
[Startup tabs](#startup-tabs)
@@ -1115,17 +1160,26 @@ throwaway key has to go inside the same invocation:
Note that `shot.sh` quotes `$KEYS`, so a chord needs the quoting relaxed for
the words to reach `wtype` as separate arguments.
**Typed text is unreliable, chords and named keys are not.** `wtype` uploads its
own keymap for the characters it needs, and GTK goes on interpreting keycodes
with the keymap it already had until it catches up — so `wtype hello` lands as
mangled characters or as nothing, while `-k Tab`, `-k Down` and
`-M ctrl -k comma` land. Drive the UI with chords and named keys; check anything
that depends on what was *typed* with `zig build test` instead.
**Synthetic clicks do not work.** `swaymsg seat seat0 cursor set/press` is
accepted and reports success, but the headless backend has no pointer device to
emit from and the client never sees it. Anything reachable only by clicking has
emit from and the client never sees it. Neither does the virtual-pointer
protocol: `wlrctl pointer move/click` reports success and the client sees
nothing. 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. They cover layout parsing, the
parameter/`$(...)` substitution pipeline, the palette, the settings file, the
shortcut table, the emoji table and its search, and — for the review server —
git's own output formats, the rule for picking a base ref, and the round trip a
review file makes through disk. Each is its own binary with its own root, since a
parameter/`$(...)` substitution pipeline, what a half-typed directory completes
to, the palette, the settings file, the shortcut table, the emoji table and its
search, and — for the review server — git's own output formats, the rule for
picking a base ref, and the round trip a review file makes through disk. Each is its own binary with its own root, since a
test binary has exactly one, and none of those roots links GTK, so they all run
without a display.
+16
View File
@@ -134,6 +134,22 @@ pub fn build(b: *std.Build) void {
const test_step = b.step("test", "Run the tests");
test_step.dependOn(&b.addRunArtifact(tests).step);
// The directory field's rules are their own root, for the same reason as
// the layouts file: which directory a half-typed path names, which entries
// in it are offered and how far they agree is text and filesystem, and the
// popover above it is the part that wants a display. It reaches GLib and
// nothing else, so it tests without one.
const paths_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/paths.zig"),
.target = target,
.optimize = optimize,
.link_libc = true,
}),
});
paths_tests.root_module.addImport("glib", gobject.module("glib2"));
test_step.dependOn(&b.addRunArtifact(paths_tests).step);
// The palette is the third root: a table of colours, the rules for
// resolving one, and the CSS it is written out as. It reaches libghostty-vt
// for the terminal's own default palette and nothing else, so it tests
+73
View File
@@ -53,11 +53,21 @@ pub const Orientation = enum { horizontal, vertical };
/// repository to point it at.
pub const Kind = enum { terminal, web, review };
/// What a parameter holds, which is only ever a hint about how to *ask* for it.
/// Every value ends up as text and is substituted as text, whatever its type
/// says — a type buys a better prompt, not a different substitution.
///
/// `directory` gets a path field: completion as you type, and a browse button
/// that opens the file chooser. That is the type worth having first because it
/// is what almost every layout parameter already is.
pub const Type = enum { string, directory };
/// A value the user supplies when opening a layout.
pub const Parameter = struct {
name: []const u8,
description: []const u8 = "",
default: []const u8 = "",
type: Type = .string,
};
/// One pane's starting state. Which fields matter depends on `kind`; the
@@ -243,6 +253,7 @@ fn parseLayout(self: *Layouts, obj: std.json.ObjectMap) ParseError!void {
.name = try self.dupeString(p.get("name") orelse return error.Malformed),
.description = try self.optionalString(p.get("description")),
.default = try self.optionalString(p.get("default")),
.type = parseType(p.get("type")),
};
}
layout.parameters = params;
@@ -290,6 +301,21 @@ fn parseNode(self: *Layouts, raw: std.json.Value) ParseError!*Node {
return node;
}
/// A parameter's type, defaulting to a plain string.
///
/// An unrecognised type is a string rather than a parse error, which is the
/// opposite of how a leaf's `kind` is treated — and deliberately. A `kind` this
/// build has never heard of is a pane it cannot build; a *type* it has never
/// heard of is only a prompt it cannot improve on, and asking for it in a text
/// box is always a workable answer. Refusing the file would cost every layout
/// in it to gain nothing.
fn parseType(raw: ?std.json.Value) Type {
return switch (raw orelse return .string) {
.string => |s| std.meta.stringToEnum(Type, s) orelse .string,
else => .string,
};
}
/// Keep dividers away from the extremes, where a pane would open with no
/// usable area at all.
fn clampRatio(value: f64) f64 {
@@ -387,6 +413,12 @@ fn writeLayout(json: *std.json.Stringify, layout: *const Layout) SaveError!void
try json.objectField("default");
try json.write(p.default);
}
// A string is the default, and saying so in every entry would only
// add noise to a file that is meant to be hand-edited too.
if (p.type != .string) {
try json.objectField("type");
try json.write(@tagName(p.type));
}
try json.endObject();
}
try json.endArray();
@@ -497,6 +529,7 @@ pub fn put(
.name = try alloc.dupe(u8, p.name),
.description = try alloc.dupe(u8, p.description),
.default = try alloc.dupe(u8, p.default),
.type = p.type,
};
}
@@ -863,3 +896,43 @@ test "a review leaf without a directory still round-trips" {
try writeNode(&json, root);
try std.testing.expectEqualStrings("{\"kind\":\"review\"}", out.written());
}
// A parameter's type is only ever a hint about how to ask for it, but the hint
// has to survive the file — the prompt is built from what was loaded, not from
// what was typed into the editor that saved it.
test "a parameter's type round-trips" {
const gpa = std.testing.allocator;
var layouts: Layouts = .init(gpa);
defer layouts.deinit();
try layouts.parse(
\\{"version":1,"layouts":[{"name":"Work","parameters":[
\\ {"name":"path","type":"directory"},
\\ {"name":"branch"},
\\ {"name":"other","type":"nonsense"}],
\\ "root":{"kind":"terminal"}}]}
);
const params = layouts.items.items[0].parameters;
try std.testing.expectEqual(Type.directory, params[0].type);
// Saying nothing is saying "string", which is what every layout written
// before types existed says.
try std.testing.expectEqual(Type.string, params[1].type);
// And a type this build has never heard of is asked for in a text box
// rather than costing the whole file.
try std.testing.expectEqual(Type.string, params[2].type);
var out: std.Io.Writer.Allocating = .init(gpa);
defer out.deinit();
var json: std.json.Stringify = .{ .writer = &out.writer, .options = .{} };
try writeLayout(&json, layouts.items.items[0]);
// Only the directory says its type on the way out: a string is the default,
// and the unknown type was read as one.
try std.testing.expectEqualStrings(
\\{"name":"Work","parameters":[{"name":"path","type":"directory"},{"name":"branch"},{"name":"other"}],"root":{"kind":"terminal"}}
,
out.written(),
);
}
+28 -9
View File
@@ -3,6 +3,11 @@
//! A layout with no parameters never gets here — the caller opens it straight
//! away rather than showing an empty dialog with nothing to fill in.
//!
//! What a field *is* comes from the parameter's type: a string gets a text box,
//! a directory gets a path field with completion and a chooser behind a button.
//! Either way what comes back is text, and the underlying entry is what this
//! reads, so the rest of the dialog doesn't branch on it.
//!
//! The values are handed back as borrowed slices pointing into the entries,
//! which is safe because the callback runs while the dialog is still up and
//! the view copies everything during substitution.
@@ -11,6 +16,7 @@ const std = @import("std");
const gtk = @import("gtk");
const Layouts = @import("Layouts.zig");
const PathEntry = @import("PathEntry.zig");
const OpenLayoutDialog = @This();
@@ -24,7 +30,9 @@ alloc: std.mem.Allocator,
window: *gtk.Window,
layout: *Layouts.Layout,
/// One entry per parameter, in the layout's own order.
/// One entry per parameter, in the layout's own order. For a directory
/// parameter this is the text box inside its path field — the field frees
/// itself with its widgets, so nothing else about it has to be held.
entries: []*gtk.Entry,
on_open: Callback,
@@ -78,14 +86,25 @@ pub fn present(
label.as(gtk.Widget).addCssClass("playpen-dialog-label");
fields.attach(label.as(gtk.Widget), 0, @intCast(i), 1, 1);
const entry = gtk.Entry.new();
entry.as(gtk.Widget).setHexpand(1);
setEntryText(entry, param.default);
var hint_buf: [128]u8 = undefined;
entry.setPlaceholderText(
std.fmt.bufPrintZ(&hint_buf, "{{{{{s}}}}}", .{param.name}) catch null,
);
const placeholder: ?[*:0]const u8 =
std.fmt.bufPrintZ(&hint_buf, "{{{{{s}}}}}", .{param.name}) catch null;
const entry, const field = switch (param.type) {
.string => blk: {
const e = gtk.Entry.new();
e.as(gtk.Widget).setHexpand(1);
e.setPlaceholderText(placeholder);
setEntryText(e, param.default);
break :blk .{ e, e.as(gtk.Widget) };
},
.directory => blk: {
const path = try PathEntry.create(alloc);
path.setPlaceholderText(placeholder);
path.setText(param.default);
break :blk .{ path.entry, path.widget() };
},
};
// Enter anywhere in the form opens the layout, so a one-parameter
// layout is type-and-go.
@@ -97,7 +116,7 @@ pub fn present(
.{},
);
fields.attach(entry.as(gtk.Widget), 1, @intCast(i), 1, 1);
fields.attach(field, 1, @intCast(i), 1, 1);
entries[i] = entry;
}
content.append(fields.as(gtk.Widget));
+501
View File
@@ -0,0 +1,501 @@
//! A directory field: a text box that completes directory names as you type,
//! next to a button that opens the file chooser.
//!
//! This is what a `directory` parameter is asked for with, in the layout prompt
//! and in the startup list alike. It is deliberately still a text box: for
//! anyone who knows where they are going, typing the path is faster than
//! clicking down to it, and a field that could *only* be filled in from a
//! chooser would have made the better-typed case worse. So the text box is the
//! field, and the chooser is the way out of it when you don't know the path.
//!
//! Completion is shell-shaped rather than GTK-shaped: `Tab` completes as far as
//! the matches agree, the arrow keys pick out of the list, and `Enter` takes
//! the highlighted one or, with nothing highlighted, means what it means
//! everywhere else in the dialog. `GtkEntryCompletion` would have been the
//! stock answer and is deprecated as of GTK 4.10, and its inline behaviour was
//! never this: it completes to the *first* match rather than to the longest
//! common prefix, which in a directory of siblings guesses wrong more often
//! than it helps.
//!
//! Only directories are offered, since only a directory can be the answer. A
//! value that still holds `{{a parameter}}` or `$(a command)` is left alone: it
//! is not a path yet, and there is nothing on disk to complete it against.
const std = @import("std");
const gdk = @import("gdk");
const gio = @import("gio");
const glib = @import("glib");
const gobject = @import("gobject");
const gtk = @import("gtk");
const paths = @import("paths.zig");
const PathEntry = @This();
/// Longest path this will build. Same bound the layouts file uses.
const max_path = paths.max_path;
/// How many completions the popover offers at once.
const max_shown = 12;
alloc: std.mem.Allocator,
box: *gtk.Box,
entry: *gtk.Entry,
/// The completion list, parented to the entry so it follows it around.
popover: *gtk.Popover,
list: *gtk.ListBox,
/// What is on offer, in the order the rows are in, or null when the popover is
/// down. Re-read from the filesystem on every keystroke.
offer: ?paths.Completions = null,
/// Which match is highlighted, if any. Nothing is highlighted until you ask for
/// something to be, which is what leaves `Enter` free to submit the form.
selected: ?usize = null,
/// Set while we are writing into the entry ourselves for a reason that should
/// not re-open the list — the initial value, or the chooser's answer.
quiet: bool = false,
/// The file chooser that is up, if one is. Held so that going away underneath
/// it can close it and tell its callback to leave us alone.
browse: ?*Browse = null,
/// One trip through the file chooser. Separate from `PathEntry` because it
/// outlives the click and may outlive the field: `owner` is cleared if the
/// field is destroyed first, which is what makes the callback safe to run
/// against a dialog whose window has gone.
const Browse = struct {
alloc: std.mem.Allocator,
owner: ?*PathEntry,
cancellable: *gio.Cancellable,
fn deinit(self: *Browse) void {
self.cancellable.as(gobject.Object).unref();
self.alloc.destroy(self);
}
};
/// Build the field. It frees itself when its widgets are destroyed, so callers
/// hold it only for as long as they want to read it.
pub fn create(alloc: std.mem.Allocator) !*PathEntry {
const self = try alloc.create(PathEntry);
errdefer alloc.destroy(self);
self.* = .{
.alloc = alloc,
.box = gtk.Box.new(.horizontal, 4),
.entry = gtk.Entry.new(),
.popover = gtk.Popover.new(),
.list = gtk.ListBox.new(),
};
self.entry.as(gtk.Widget).setHexpand(1);
self.box.append(self.entry.as(gtk.Widget));
const browse = gtk.Button.newWithLabel("Browse…");
browse.as(gtk.Widget).addCssClass("flat");
browse.as(gtk.Widget).setTooltipText("Pick a directory");
_ = gtk.Button.signals.clicked.connect(browse, *PathEntry, &onBrowse, self, .{});
self.box.append(browse.as(gtk.Widget));
// The list hangs off the entry rather than the row, so it lines up with the
// text it is completing rather than with the button beside it.
self.popover.as(gtk.Widget).setParent(self.entry.as(gtk.Widget));
self.popover.setPosition(.bottom);
self.popover.setHasArrow(0);
// Not autohiding is the whole trick. An autohiding popover takes a grab,
// and a grab means the keystroke that would narrow the list goes to the
// popover instead of to the entry — so the list would close the moment it
// became useful. Without the grab it is a hint hanging under the field, and
// the field keeps the focus and the typing; the cost is that every way of
// dismissing it is one we have to write, which is what `hide` is called
// from Escape, focus-out and a value with nothing to offer.
self.popover.setAutohide(0);
self.popover.as(gtk.Widget).addCssClass("playpen-path-list");
self.list.setSelectionMode(.single);
_ = gtk.ListBox.signals.row_activated.connect(
self.list,
*PathEntry,
&onRowActivated,
self,
.{},
);
const scroller = gtk.ScrolledWindow.new();
scroller.setPolicy(.never, .automatic);
scroller.setPropagateNaturalHeight(1);
scroller.setMaxContentHeight(320);
scroller.setChild(self.list.as(gtk.Widget));
self.popover.setChild(scroller.as(gtk.Widget));
_ = gtk.Editable.signals.changed.connect(
self.entry,
*PathEntry,
&onChanged,
self,
.{},
);
// Capture phase, for the same reason the find bar's is: the entry treats
// Enter and Tab as its own the moment they reach it, so a completion that
// wants either has to see it on the way down.
const keys = gtk.EventControllerKey.new();
keys.as(gtk.EventController).setPropagationPhase(.capture);
_ = gtk.EventControllerKey.signals.key_pressed.connect(
keys,
*PathEntry,
&onKey,
self,
.{},
);
self.entry.as(gtk.Widget).addController(keys.as(gtk.EventController));
const focus = gtk.EventControllerFocus.new();
_ = gtk.EventControllerFocus.signals.leave.connect(
focus,
*PathEntry,
&onFocusLeave,
self,
.{},
);
self.entry.as(gtk.Widget).addController(focus.as(gtk.EventController));
// On the entry, not the box: the popover is the entry's child, and this is
// the last moment at which it can be taken off it. GTK emits `destroy` at
// the top of dispose, before the widget's own children are torn down.
_ = gtk.Widget.signals.destroy.connect(
self.entry,
*PathEntry,
&onDestroy,
self,
.{},
);
return self;
}
/// The widget to put in a form: the entry and its button.
pub fn widget(self: *PathEntry) *gtk.Widget {
return self.box.as(gtk.Widget);
}
pub fn setPlaceholderText(self: *PathEntry, placeholder: ?[*:0]const u8) void {
self.entry.setPlaceholderText(placeholder);
}
/// Fill the field in without treating it as typing — no list opens.
pub fn setText(self: *PathEntry, value: []const u8) void {
self.quiet = true;
defer self.quiet = false;
var buf: [max_path]u8 = undefined;
const z = std.fmt.bufPrintZ(&buf, "{s}", .{value}) catch return;
self.entry.as(gtk.Editable).setText(z);
self.hide();
}
pub fn text(self: *PathEntry) []const u8 {
return std.mem.span(self.entry.as(gtk.Editable).getText());
}
// -------------------------------------------------------------------------
// Completion
fn onChanged(_: *gtk.Entry, self: *PathEntry) callconv(.c) void {
if (self.quiet) return;
self.refresh();
}
/// Read the directory the value points into and offer what it holds.
///
/// Called on every keystroke. `paths.completions` is what bounds that: one
/// `g_dir_open`, a capped number of names, and a `stat` per name that survives
/// the prefix test.
fn refresh(self: *PathEntry) void {
self.clearOffer();
const found = paths.completions(self.alloc, self.text(), .{ .max = max_shown }) catch |err| {
std.log.warn("could not complete a directory: {s}", .{@errorName(err)});
return self.hide();
};
self.offer = found orelse return self.hide();
self.fill();
// Only once the field is actually in a window. A popover realizes against
// the surface its parent is on, and popping one up before there is one
// takes the process with it — which a field filled in before it is mounted
// would otherwise do.
if (self.entry.as(gtk.Widget).getRoot() == null) return;
self.popover.popup();
}
/// Build a row per name on offer, plus a row saying what was left out.
fn fill(self: *PathEntry) void {
const offer = self.offer orelse return;
self.list.removeAll();
self.selected = null;
for (offer.names) |name| {
var buf: [max_path]u8 = undefined;
const label = gtk.Label.new(std.fmt.bufPrintZ(&buf, "{s}/", .{name}) catch continue);
label.setXalign(0);
label.as(gtk.Widget).addCssClass("playpen-path-row");
self.list.append(label.as(gtk.Widget));
}
const hidden = offer.total - offer.names.len;
if (hidden == 0) return;
var buf: [64]u8 = undefined;
const counted = std.fmt.bufPrintZ(&buf, "… {d} more", .{hidden}) catch return;
const label = gtk.Label.new(counted);
label.setXalign(0);
label.as(gtk.Widget).addCssClass("playpen-path-more");
self.list.append(label.as(gtk.Widget));
// It counts rather than offers, so it can't be landed on by the arrow keys
// or clicked into the entry.
if (self.list.getRowAtIndex(@intCast(offer.names.len))) |row| {
row.setSelectable(0);
row.setActivatable(0);
}
}
fn clearOffer(self: *PathEntry) void {
if (self.offer) |*offer| offer.deinit(self.alloc);
self.offer = null;
self.selected = null;
}
fn hide(self: *PathEntry) void {
self.selected = null;
self.popover.popdown();
}
fn showing(self: *PathEntry) bool {
return self.popover.as(gtk.Widget).getVisible() != 0;
}
/// Highlight the next or previous name, wrapping at both ends.
fn move(self: *PathEntry, delta: isize) void {
const offer = self.offer orelse return;
const count: isize = @intCast(offer.names.len);
if (count == 0) return;
const from: isize = if (self.selected) |i| @intCast(i) else if (delta > 0) -1 else 0;
const to = @mod(from + delta, count);
self.selected = @intCast(to);
if (self.list.getRowAtIndex(@intCast(to))) |row| self.list.selectRow(row);
}
/// Put the highlighted — or, on `Tab` with nothing highlighted, the agreed —
/// completion into the entry.
///
/// A completed directory gets its trailing `/`, which is what makes the next
/// keystroke offer its children: the field is a path, and a path is completed a
/// segment at a time.
fn accept(self: *PathEntry, index: usize) void {
const offer = self.offer orelse return;
if (index >= offer.names.len) return;
const value = self.text();
if (offer.dir_len > value.len) return self.hide();
var buf: [max_path]u8 = undefined;
const z = std.fmt.bufPrintZ(&buf, "{s}{s}/", .{
value[0..offer.dir_len],
offer.names[index],
}) catch return;
// Not quiet: writing this re-reads the directory it names, so completing
// `~/src/pl` to `~/src/playpen/` immediately offers what is inside it.
self.entry.as(gtk.Editable).setText(z);
self.entry.as(gtk.Editable).setPosition(-1);
}
/// `Tab`: complete as far as the matches agree.
///
/// One match completes the whole name. Several complete to their common
/// prefix, and if they already agree on everything that was typed, the list is
/// the answer and it is already on screen.
fn complete(self: *PathEntry) void {
const offer = self.offer orelse return;
if (offer.names.len == 0) return;
if (offer.names.len == 1) return self.accept(0);
const value = self.text();
if (offer.dir_len > value.len) return self.hide();
const typed = value.len - offer.dir_len;
const shared = paths.commonPrefix(offer.names);
if (shared <= typed) return;
var buf: [max_path]u8 = undefined;
const z = std.fmt.bufPrintZ(&buf, "{s}{s}", .{
value[0..offer.dir_len],
offer.names[0][0..shared],
}) catch return;
self.entry.as(gtk.Editable).setText(z);
self.entry.as(gtk.Editable).setPosition(-1);
}
// -------------------------------------------------------------------------
// Input
fn onKey(
_: *gtk.EventControllerKey,
keyval: c_uint,
_: c_uint,
state: gdk.ModifierType,
self: *PathEntry,
) callconv(.c) c_int {
if (!self.showing()) return 0;
// A held modifier means the keystroke is someone else's — Shift+Tab is the
// way back out of the field, and Ctrl+anything is a shortcut.
if (state.control_mask or state.alt_mask or state.shift_mask) return 0;
switch (keyval) {
gdk.KEY_Down => self.move(1),
gdk.KEY_Up => self.move(-1),
gdk.KEY_Tab => self.complete(),
gdk.KEY_Escape => self.hide(),
gdk.KEY_Return, gdk.KEY_KP_Enter, gdk.KEY_ISO_Enter => {
// Enter takes the highlighted completion, and means what it means
// everywhere else in the dialog when there isn't one. Typing a
// whole path and pressing Enter therefore opens the layout, rather
// than silently landing on whichever directory sorted first.
const index = self.selected orelse {
self.hide();
return 0;
};
self.accept(index);
},
else => return 0,
}
return 1;
}
fn onRowActivated(_: *gtk.ListBox, row: *gtk.ListBoxRow, self: *PathEntry) callconv(.c) void {
self.accept(@intCast(row.getIndex()));
_ = self.entry.as(gtk.Widget).grabFocus();
}
/// Leaving the field puts the list away. It is a hint about what is being
/// typed, and nothing is being typed here any more.
fn onFocusLeave(_: *gtk.EventControllerFocus, self: *PathEntry) callconv(.c) void {
self.hide();
}
// -------------------------------------------------------------------------
// The file chooser
fn onBrowse(_: *gtk.Button, self: *PathEntry) callconv(.c) void {
// One at a time. A second chooser over the first would leave the first's
// answer overwriting the second's, in whichever order they were dismissed.
if (self.browse != null) return;
const request = self.alloc.create(Browse) catch |err| {
std.log.err("could not open the directory chooser: {s}", .{@errorName(err)});
return;
};
request.* = .{
.alloc = self.alloc,
.owner = self,
.cancellable = gio.Cancellable.new(),
};
self.browse = request;
const dialog = gtk.FileDialog.new();
dialog.setTitle("Choose a directory");
dialog.setModal(1);
// Start where the field already points, when it points somewhere that
// exists — the common case is adjusting a path, not finding one from
// scratch.
var dir_buf: [max_path]u8 = undefined;
if (paths.existingDirectory(&dir_buf, self.text())) |dir| {
const file = gio.File.newForPath(dir.ptr);
defer file.as(gobject.Object).unref();
dialog.setInitialFolder(file);
}
self.hide();
dialog.selectFolder(
windowOf(self.box.as(gtk.Widget)),
request.cancellable,
&onFolderChosen,
request,
);
}
fn onFolderChosen(
source: ?*gobject.Object,
result: *gio.AsyncResult,
data: ?*anyopaque,
) callconv(.c) void {
const request: *Browse = @ptrCast(@alignCast(data.?));
defer request.deinit();
if (request.owner) |owner| owner.browse = null;
const dialog = gobject.ext.cast(gtk.FileDialog, source orelse return) orelse return;
// Ours since `new`, and GTK held its own reference for the duration.
defer dialog.as(gobject.Object).unref();
var err: ?*glib.Error = null;
const file = dialog.selectFolderFinish(result, &err) orelse {
// Dismissing the chooser is how most trips through it end, and
// cancelling it is how a field that has gone away ends one, so neither
// is worth a word.
if (err) |e| e.free();
return;
};
defer file.as(gobject.Object).unref();
const path = file.getPath() orelse return;
defer glib.free(path);
// The field may be gone — its dialog can be closed while the chooser is
// still up, in which case the answer has nowhere to go.
const owner = request.owner orelse return;
owner.setText(std.mem.span(path));
_ = owner.entry.as(gtk.Widget).grabFocus();
}
/// The window a widget is in, for a dialog to be transient for. Read at the
/// moment it is needed rather than remembered, so it is never a window that has
/// since closed.
fn windowOf(w: *gtk.Widget) ?*gtk.Window {
const root = w.getRoot() orelse return null;
return gobject.ext.cast(gtk.Window, root);
}
// -------------------------------------------------------------------------
// Teardown
fn onDestroy(_: *gtk.Entry, self: *PathEntry) callconv(.c) void {
// Anything the chooser answers after this has nobody to answer to. The
// cancel closes it; clearing the owner is what makes its callback safe.
if (self.browse) |request| {
request.owner = null;
request.cancellable.cancel();
self.browse = null;
}
// The popover is the entry's child and has to come off it by hand, before
// the entry it hangs on is finalized.
self.popover.as(gtk.Widget).unparent();
self.clearOffer();
self.alloc.destroy(self);
}
+34 -1
View File
@@ -14,6 +14,7 @@
//! until the confirm button, so cancelling an edit leaves the layout alone.
const std = @import("std");
const gio = @import("gio");
const gtk = @import("gtk");
const Layouts = @import("Layouts.zig");
@@ -28,10 +29,15 @@ const ParamRow = struct {
dialog: *SaveLayoutDialog,
box: *gtk.Box,
name: *gtk.Entry,
kind: *gtk.DropDown,
description: *gtk.Entry,
default: *gtk.Entry,
};
/// The type picker's items, in `Layouts.Type` order — the selected index *is*
/// the type, so the two lists have to stay in step.
const type_labels = [_][:0]const u8{ "Text", "Directory" };
/// One captured pane and the fields that fill it in.
const PaneRow = struct {
node: *Layouts.Node,
@@ -143,7 +149,9 @@ pub fn present(
// ---- parameters ----------------------------------------------------
content.append(heading("Parameters"));
content.append(hint(
"Referred to as {{name}} in any directory, script or address below.",
"Referred to as {{name}} in any directory, script or address below. " ++
"A directory parameter is asked for with a path field: completion as " ++
"you type, and a chooser behind a button.",
));
content.append(self.params_box.as(gtk.Widget));
@@ -308,6 +316,7 @@ fn addParamRow(self: *SaveLayoutDialog, param: Layouts.Parameter) !void {
.dialog = self,
.box = gtk.Box.new(.horizontal, 6),
.name = gtk.Entry.new(),
.kind = newTypePicker(param.type),
.description = gtk.Entry.new(),
.default = gtk.Entry.new(),
};
@@ -316,6 +325,9 @@ fn addParamRow(self: *SaveLayoutDialog, param: Layouts.Parameter) !void {
setEntryText(row.name, param.name);
row.box.append(row.name.as(gtk.Widget));
row.kind.as(gtk.Widget).setTooltipText("How the value is asked for");
row.box.append(row.kind.as(gtk.Widget));
row.description.setPlaceholderText("what it means");
row.description.as(gtk.Widget).setHexpand(1);
setEntryText(row.description, param.description);
@@ -334,6 +346,26 @@ fn addParamRow(self: *SaveLayoutDialog, param: Layouts.Parameter) !void {
self.params_box.append(row.box.as(gtk.Widget));
}
/// A picker holding one item per parameter type, set to the one the parameter
/// already has.
fn newTypePicker(selected: Layouts.Type) *gtk.DropDown {
const model = gtk.StringList.new(null);
for (type_labels) |label| model.append(label.ptr);
const dropdown = gtk.DropDown.new(model.as(gio.ListModel), null);
dropdown.setSelected(@intFromEnum(selected));
return dropdown;
}
/// The type a row is set to. An out-of-range selection — which the picker can
/// only reach by holding nothing — reads as the plain string a parameter is by
/// default.
fn selectedType(row: *ParamRow) Layouts.Type {
const index = row.kind.getSelected();
if (index >= type_labels.len) return .string;
return @enumFromInt(index);
}
fn onAddParam(_: *gtk.Button, self: *SaveLayoutDialog) callconv(.c) void {
self.addParamRow(.{ .name = "" }) catch |err| {
std.log.err("failed to add parameter row: {s}", .{@errorName(err)});
@@ -424,6 +456,7 @@ fn commit(self: *SaveLayoutDialog) void {
.name = param_name,
.description = entryText(row.description),
.default = entryText(row.default),
.type = selectedType(row),
}) catch {
self.showError("Out of memory.");
return;
+31 -8
View File
@@ -33,6 +33,7 @@ const gtk = @import("gtk");
const Layouts = @import("Layouts.zig");
const PaletteEditor = @import("PaletteEditor.zig");
const PathEntry = @import("PathEntry.zig");
const Settings = @import("Settings.zig");
const appearance = @import("appearance.zig");
@@ -64,6 +65,10 @@ const plain_label = "Plain terminal";
/// be reading freed memory at the next keystroke.
const ParamField = struct {
name: []u8,
/// The text box holding the value. For a directory parameter it is the one
/// inside a `PathEntry`, which frees itself with its widgets — so a row
/// rebuilt for a different layout leaves nothing of it behind to track.
entry: *gtk.Entry,
};
@@ -553,9 +558,33 @@ fn fillParams(
const field = try self.alloc.create(ParamField);
errdefer self.alloc.destroy(field);
var hint_buf: [128]u8 = undefined;
const placeholder: ?[*:0]const u8 =
std.fmt.bufPrintZ(&hint_buf, "{{{{{s}}}}}", .{param.name}) catch null;
const value = valueOf(values, param.name) orelse param.default;
// The same two shapes the layout prompt offers, for the same reason: a
// startup entry names the same directories, and picking one out of a
// chooser here is worth as much as it is there.
const entry, const widget = switch (param.type) {
.string => blk: {
const e = gtk.Entry.new();
e.setPlaceholderText(placeholder);
e.as(gtk.Widget).setHexpand(1);
setEntryText(e, value);
break :blk .{ e, e.as(gtk.Widget) };
},
.directory => blk: {
const path = try PathEntry.create(self.alloc);
path.setPlaceholderText(placeholder);
path.setText(value);
break :blk .{ path.entry, path.widget() };
},
};
field.* = .{
.name = try self.alloc.dupe(u8, param.name),
.entry = gtk.Entry.new(),
.entry = entry,
};
errdefer self.alloc.free(field.name);
@@ -568,12 +597,6 @@ fn fillParams(
label.as(gtk.Widget).addCssClass("playpen-dialog-sublabel");
grid.attach(label.as(gtk.Widget), 0, @intCast(i), 1, 1);
var hint_buf: [128]u8 = undefined;
field.entry.setPlaceholderText(
std.fmt.bufPrintZ(&hint_buf, "{{{{{s}}}}}", .{param.name}) catch null,
);
field.entry.as(gtk.Widget).setHexpand(1);
setEntryText(field.entry, valueOf(values, param.name) orelse param.default);
_ = gtk.Editable.signals.changed.connect(
field.entry,
*StartupRow,
@@ -581,7 +604,7 @@ fn fillParams(
row,
.{},
);
grid.attach(field.entry.as(gtk.Widget), 1, @intCast(i), 1, 1);
grid.attach(widget, 1, @intCast(i), 1, 1);
try row.params.append(self.alloc, field);
}
+412
View File
@@ -0,0 +1,412 @@
//! What a typed path completes to: the half of the directory field that is
//! only rules and strings.
//!
//! Kept out of `PathEntry.zig` so that the part worth being sure about — which
//! directory a half-typed value names, which entries in it are offered, how far
//! they agree — can be tested without a display, in the same way the layouts
//! file and the palette are. The widget above it is a popover and a keymap and
//! wants a person looking at it.
//!
//! Filesystem access goes through GLib for the reason the layouts file gives:
//! it is already linked, and Zig's own filesystem API moved behind `std.Io` in
//! 0.16, which would mean carrying an `Io` here for two calls.
const std = @import("std");
const glib = @import("glib");
/// Longest path this will build. Same bound the layouts file uses.
pub const max_path = 4096;
pub const Options = struct {
/// How many names to offer. A directory of a hundred siblings is a list
/// nobody reads; the answer there is another keystroke.
max: usize = 12,
/// How many names to read out of a directory before giving up on counting.
/// `/nix/store` is a real directory with six figures of entries in it, and
/// this runs on every keystroke.
scan: usize = 4096,
};
pub const Completions = struct {
/// How much of the typed value the names attach to — everything through
/// its last `/`.
dir_len: usize,
/// Matching directory names, sorted, at most `Options.max` of them.
names: [][]u8,
/// How many matched altogether, which is more than `names.len` when the
/// cap cut the list short. Kept so the list can say what it isn't showing
/// rather than looking like the whole answer.
total: usize,
pub fn deinit(self: *Completions, alloc: std.mem.Allocator) void {
for (self.names) |name| alloc.free(name);
alloc.free(self.names);
self.* = undefined;
}
};
/// The directories `value` could be completed to, or null when there is nothing
/// to complete it against.
///
/// Null covers every case where the question doesn't arise: a value that is
/// still a template, one with no separator to complete after, and a directory
/// that isn't there — which is the normal state halfway through typing one.
pub fn completions(
alloc: std.mem.Allocator,
value: []const u8,
opts: Options,
) !?Completions {
// Not a path yet, so there is nothing on disk to look it up against. Left
// alone rather than guessed at — half of `~/src/{{repo}}` is not a
// directory.
if (!isPlainPath(value)) return null;
// Completion starts at the last separator: everything before it names the
// directory to read, everything after it is what to match in it. With no
// separator there is no directory to read, since a bare word could be
// relative to anywhere.
const cut = (std.mem.lastIndexOfScalar(u8, value, '/') orelse return null) + 1;
const partial = value[cut..];
var dir_buf: [max_path]u8 = undefined;
const dir = resolve(&dir_buf, value[0..cut]) orelse return null;
var err: ?*glib.Error = null;
const handle = glib.Dir.open(dir.ptr, 0, &err) orelse {
if (err) |e| e.free();
return null;
};
defer handle.close();
// Hidden directories are offered only once the value asks for one, the way
// a shell does it: `~/.c` completes to `~/.config`, `~/` does not list it.
const want_hidden = partial.len > 0 and partial[0] == '.';
var names: std.ArrayListUnmanaged([]u8) = .empty;
errdefer {
for (names.items) |name| alloc.free(name);
names.deinit(alloc);
}
var scanned: usize = 0;
var total: usize = 0;
while (readName(handle)) |name| {
scanned += 1;
if (scanned > opts.scan) break;
if (!std.mem.startsWith(u8, name, partial)) continue;
if (!want_hidden and name[0] == '.') continue;
if (!isDirectory(dir, name)) continue;
total += 1;
try names.append(alloc, try alloc.dupe(u8, name));
}
if (names.items.len == 0) {
names.deinit(alloc);
return null;
}
std.mem.sort([]u8, names.items, {}, lessThan);
// Everything past the cap is dropped from the offer, not from the count.
const shown = @min(names.items.len, opts.max);
for (names.items[shown..]) |extra| alloc.free(extra);
names.shrinkRetainingCapacity(shown);
return .{
.dir_len = cut,
.names = try names.toOwnedSlice(alloc),
.total = total,
};
}
fn lessThan(_: void, a: []u8, b: []u8) bool {
return std.mem.order(u8, a, b) == .lt;
}
/// How many leading bytes every name shares. Empty for no names, which is what
/// makes it safe to compare against what has been typed.
pub fn commonPrefix(names: []const []const u8) usize {
if (names.len == 0) return 0;
var shared = names[0].len;
for (names[1..]) |name| {
shared = @min(shared, name.len);
var i: usize = 0;
while (i < shared and name[i] == names[0][i]) i += 1;
shared = i;
}
return shared;
}
/// True if the value is a path rather than something on its way to being one.
pub fn isPlainPath(value: []const u8) bool {
if (std.mem.indexOf(u8, value, "{{") != null) return false;
if (std.mem.indexOf(u8, value, "$(") != null) return false;
return true;
}
/// A typed directory as the filesystem spells it: a leading `~` expanded, and
/// nothing else touched. A trailing separator is kept, since callers join onto
/// it.
pub fn resolve(buf: []u8, dir: []const u8) ?[:0]const u8 {
if (dir.len == 0) return null;
if (dir[0] == '~' and (dir.len == 1 or dir[1] == '/')) {
const home = std.mem.span(glib.getHomeDir());
if (home.len == 0) return null;
return std.fmt.bufPrintZ(buf, "{s}{s}", .{ home, dir[1..] }) catch null;
}
return std.fmt.bufPrintZ(buf, "{s}", .{dir}) catch null;
}
/// The typed value, if it is a directory that exists. Used for where to open
/// the chooser: a value that is half-typed, templated or simply wrong just
/// means the chooser opens wherever it would have.
pub fn existingDirectory(buf: []u8, value: []const u8) ?[:0]const u8 {
if (!isPlainPath(value)) return null;
const path = resolve(buf, value) orelse return null;
if (glib.fileTest(path.ptr, .{ .is_dir = true }) == 0) return null;
return path;
}
fn isDirectory(dir: [:0]const u8, name: []const u8) bool {
var buf: [max_path]u8 = undefined;
const separator: []const u8 = if (std.mem.endsWith(u8, dir, "/")) "" else "/";
const path = std.fmt.bufPrintZ(&buf, "{s}{s}{s}", .{ dir, separator, name }) catch
return false;
return glib.fileTest(path.ptr, .{ .is_dir = true }) != 0;
}
/// `g_dir_read_name` answers `NULL` at the end of a directory, and the
/// generated binding types its return as non-optional — so it is declared here
/// with the type it actually has rather than worked around at the call site.
extern fn g_dir_read_name(dir: *glib.Dir) ?[*:0]const u8;
fn readName(dir: *glib.Dir) ?[]const u8 {
return std.mem.span(g_dir_read_name(dir) orelse return null);
}
// -------------------------------------------------------------------------
// Tests
//
// These build a real directory tree in a temporary directory and complete
// against it. The point of the module is what comes back from the filesystem,
// and a fake filesystem would only prove the string handling.
const testing = std.testing;
/// A throwaway directory tree, cleaned up by `deinit`.
const Tree = struct {
root: [:0]const u8,
fn create(names: []const []const u8, files: []const []const u8) !Tree {
var err: ?*glib.Error = null;
const raw = glib.Dir.makeTmp("playpen-paths-XXXXXX", &err) orelse {
if (err) |e| e.free();
return error.SkipZigTest;
};
defer glib.free(raw);
const root = try testing.allocator.dupeZ(u8, std.mem.span(raw));
for (names) |name| {
var buf: [max_path]u8 = undefined;
const dir = try std.fmt.bufPrintZ(&buf, "{s}/{s}", .{ root, name });
if (glib.mkdirWithParents(dir.ptr, 0o700) != 0) return error.MkdirFailed;
}
for (files) |name| {
var buf: [max_path]u8 = undefined;
const file = try std.fmt.bufPrintZ(&buf, "{s}/{s}", .{ root, name });
if (glib.fileSetContents(file.ptr, "x", 1, &err) == 0) {
if (err) |e| e.free();
return error.WriteFailed;
}
}
return .{ .root = root };
}
/// A value naming this tree, with `tail` appended.
fn path(self: Tree, buf: []u8, tail: []const u8) ![]const u8 {
return std.fmt.bufPrint(buf, "{s}/{s}", .{ self.root, tail });
}
fn deinit(self: Tree, names: []const []const u8, files: []const []const u8) void {
for (files) |name| self.remove(name);
// Deepest first, so a nested directory doesn't keep its parent alive.
var i = names.len;
while (i > 0) {
i -= 1;
self.remove(names[i]);
}
_ = glib.remove(self.root.ptr);
testing.allocator.free(self.root);
}
fn remove(self: Tree, name: []const u8) void {
var buf: [max_path]u8 = undefined;
const target = std.fmt.bufPrintZ(&buf, "{s}/{s}", .{ self.root, name }) catch return;
_ = glib.remove(target.ptr);
}
};
const tree_dirs = [_][]const u8{ "alpha", "alpine", "beta", ".hidden" };
const tree_files = [_][]const u8{"alfalfa"};
test "completions offer the directories a partial name matches" {
const tree = try Tree.create(&tree_dirs, &tree_files);
defer tree.deinit(&tree_dirs, &tree_files);
var buf: [max_path]u8 = undefined;
var result = (try completions(testing.allocator, try tree.path(&buf, "al"), .{})).?;
defer result.deinit(testing.allocator);
// Sorted, and a *file* called `alfalfa` is not an answer to "which
// directory" however well it matches.
try testing.expectEqual(@as(usize, 2), result.names.len);
try testing.expectEqualStrings("alpha", result.names[0]);
try testing.expectEqualStrings("alpine", result.names[1]);
try testing.expectEqual(@as(usize, 2), result.total);
// The names attach after the last separator, which is what an accepted
// completion keeps of what was typed.
try testing.expectEqualStrings(
try tree.path(&buf, ""),
(try tree.path(&buf, "al"))[0..result.dir_len],
);
}
test "an empty partial name offers everything but the hidden" {
const tree = try Tree.create(&tree_dirs, &tree_files);
defer tree.deinit(&tree_dirs, &tree_files);
var buf: [max_path]u8 = undefined;
var result = (try completions(testing.allocator, try tree.path(&buf, ""), .{})).?;
defer result.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 3), result.names.len);
try testing.expectEqualStrings("alpha", result.names[0]);
try testing.expectEqualStrings("alpine", result.names[1]);
try testing.expectEqualStrings("beta", result.names[2]);
}
// The shell's rule: a hidden directory shows up once you have said `.`.
test "a leading dot asks for the hidden ones" {
const tree = try Tree.create(&tree_dirs, &tree_files);
defer tree.deinit(&tree_dirs, &tree_files);
var buf: [max_path]u8 = undefined;
var result = (try completions(testing.allocator, try tree.path(&buf, "."), .{})).?;
defer result.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 1), result.names.len);
try testing.expectEqualStrings(".hidden", result.names[0]);
}
// The cap is on what is *offered*. The count is what was there, so the list can
// admit to holding back rather than reading as the whole answer.
test "the cap shortens the list without losing the count" {
const tree = try Tree.create(&tree_dirs, &tree_files);
defer tree.deinit(&tree_dirs, &tree_files);
var buf: [max_path]u8 = undefined;
var result = (try completions(
testing.allocator,
try tree.path(&buf, "al"),
.{ .max = 1 },
)).?;
defer result.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 1), result.names.len);
try testing.expectEqual(@as(usize, 2), result.total);
}
test "nothing matching offers nothing" {
const tree = try Tree.create(&tree_dirs, &tree_files);
defer tree.deinit(&tree_dirs, &tree_files);
var buf: [max_path]u8 = undefined;
try testing.expect(try completions(testing.allocator, try tree.path(&buf, "zz"), .{}) == null);
}
test "a directory that isn't there offers nothing" {
var buf: [max_path]u8 = undefined;
const value = try std.fmt.bufPrint(&buf, "/nonesuch-{s}/x", .{"playpen"});
try testing.expect(try completions(testing.allocator, value, .{}) == null);
}
// A value still carrying a template or a command is not a path yet, and
// completing the half of it that looks like one would be a guess.
test "a templated value offers nothing" {
try testing.expect(try completions(testing.allocator, "~/src/{{repo}}/l", .{}) == null);
try testing.expect(try completions(testing.allocator, "~/src/$(pick)/l", .{}) == null);
}
test "a value with no separator offers nothing" {
try testing.expect(try completions(testing.allocator, "alp", .{}) == null);
}
test "~ completes against the home directory" {
const home = std.mem.span(glib.getHomeDir());
if (home.len == 0) return error.SkipZigTest;
// Whatever is in there, asking for it by `~/` and by its real path has to
// give the same answer — that equivalence is the whole of the expansion.
var buf: [max_path]u8 = undefined;
const spelled = try std.fmt.bufPrint(&buf, "{s}/", .{home});
const tilde = try completions(testing.allocator, "~/", .{});
const plain = try completions(testing.allocator, spelled, .{});
if (tilde) |t| {
var mine = t;
defer mine.deinit(testing.allocator);
var theirs = plain.?;
defer theirs.deinit(testing.allocator);
try testing.expectEqual(theirs.total, mine.total);
for (mine.names, theirs.names) |a, b| try testing.expectEqualStrings(b, a);
} else {
try testing.expect(plain == null);
}
}
test "commonPrefix is how far the names agree" {
try testing.expectEqual(@as(usize, 0), commonPrefix(&.{}));
try testing.expectEqual(@as(usize, 5), commonPrefix(&.{"alpha"}));
try testing.expectEqual(@as(usize, 3), commonPrefix(&.{ "alpha", "alpine" }));
try testing.expectEqual(@as(usize, 0), commonPrefix(&.{ "alpha", "beta" }));
// A name that is a prefix of the other stops it there rather than past it.
try testing.expectEqual(@as(usize, 3), commonPrefix(&.{ "src", "src-old" }));
}
test "resolve expands a leading ~ and nothing else" {
const home = std.mem.span(glib.getHomeDir());
if (home.len == 0) return error.SkipZigTest;
var buf: [max_path]u8 = undefined;
const expected = try std.fmt.allocPrint(testing.allocator, "{s}/src/", .{home});
defer testing.allocator.free(expected);
try testing.expectEqualStrings(expected, resolve(&buf, "~/src/").?);
try testing.expectEqualStrings("/tmp/", resolve(&buf, "/tmp/").?);
// Only a leading one, and only when it stands for the whole segment.
try testing.expectEqualStrings("/a/~/b", resolve(&buf, "/a/~/b").?);
try testing.expectEqualStrings("~user/x", resolve(&buf, "~user/x").?);
try testing.expect(resolve(&buf, "") == null);
}
test "existingDirectory answers only for a directory that is there" {
var buf: [max_path]u8 = undefined;
try testing.expectEqualStrings("/tmp", existingDirectory(&buf, "/tmp").?);
try testing.expect(existingDirectory(&buf, "/tmp/nonesuch-playpen") == null);
try testing.expect(existingDirectory(&buf, "~/{{repo}}") == null);
}
+29
View File
@@ -529,6 +529,35 @@ dnd.playpen-tab-drag {
color: @pp_err;
}
/* A directory parameter's field, and the completions hanging under it. The list
is a hint rather than a menu — it never takes the focus — so it is styled
quietly: the surface it sits on, and rows that only stand out when they are
the one the arrow keys have landed on. */
.playpen-path-list contents {
padding: 4px;
min-width: 240px;
background-color: @pp_surface_raised;
}
.playpen-path-list list {
background-color: transparent;
}
.playpen-path-list row {
border-radius: 6px;
}
.playpen-path-row {
padding: 3px 8px;
color: @pp_text;
}
.playpen-path-more {
padding: 3px 8px;
font-size: 0.85em;
color: @pp_text_faint;
}
/* Each captured pane in the save dialog, so the sections read apart. */
.playpen-dialog-pane {
padding: 8px;