Add layout dialog helper.

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