From 3721ec38ba24506a9076a392c3b918ceebf7bbf6 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Tue, 25 Aug 2026 15:54:43 -0400 Subject: [PATCH] Add layout dialog helper. --- README.md | 68 +++++- build.zig | 16 ++ src/Layouts.zig | 73 ++++++ src/OpenLayoutDialog.zig | 37 ++- src/PathEntry.zig | 501 +++++++++++++++++++++++++++++++++++++++ src/SaveLayoutDialog.zig | 35 ++- src/SettingsDialog.zig | 39 ++- src/paths.zig | 412 ++++++++++++++++++++++++++++++++ src/style.css | 29 +++ 9 files changed, 1185 insertions(+), 25 deletions(-) create mode 100644 src/PathEntry.zig create mode 100644 src/paths.zig diff --git a/README.md b/README.md index f8e2d85..4d97f07 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/build.zig b/build.zig index 008b1f2..556cd21 100644 --- a/build.zig +++ b/build.zig @@ -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 diff --git a/src/Layouts.zig b/src/Layouts.zig index 8fb1297..bf6c3d0 100644 --- a/src/Layouts.zig +++ b/src/Layouts.zig @@ -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(), + ); +} diff --git a/src/OpenLayoutDialog.zig b/src/OpenLayoutDialog.zig index 3d00dee..338e4e0 100644 --- a/src/OpenLayoutDialog.zig +++ b/src/OpenLayoutDialog.zig @@ -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)); diff --git a/src/PathEntry.zig b/src/PathEntry.zig new file mode 100644 index 0000000..fc3c6f0 --- /dev/null +++ b/src/PathEntry.zig @@ -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); +} diff --git a/src/SaveLayoutDialog.zig b/src/SaveLayoutDialog.zig index fe228e5..bdbebb8 100644 --- a/src/SaveLayoutDialog.zig +++ b/src/SaveLayoutDialog.zig @@ -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; diff --git a/src/SettingsDialog.zig b/src/SettingsDialog.zig index aa865da..f9361c5 100644 --- a/src/SettingsDialog.zig +++ b/src/SettingsDialog.zig @@ -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); } diff --git a/src/paths.zig b/src/paths.zig new file mode 100644 index 0000000..6cbf63c --- /dev/null +++ b/src/paths.zig @@ -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); +} diff --git a/src/style.css b/src/style.css index 46e33aa..fdc5739 100644 --- a/src/style.css +++ b/src/style.css @@ -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;