Add layout dialog helper.
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user