summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/builtins.zig31
-rw-r--r--src/config.zig5
-rw-r--r--src/fonts.zig11
-rw-r--r--src/pardes.zig129
-rw-r--r--src/tutor.txt9
5 files changed, 176 insertions, 9 deletions
diff --git a/src/builtins.zig b/src/builtins.zig
index 8b3d44d0..bb1a1273 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -202,6 +202,36 @@ pub const Restore = struct {
}
};
+// ---- the message row ----
+
+/// TEXT onto this pane's transient message row — the row a failed save, a
+/// refused Look and a language server that would not start all report through
+/// (Pardes.setMessage, and Pardes.reportError one line above it). Every writer
+/// of that row is something going wrong, so until this word there was no way
+/// to look at it without breaking something on purpose: no wording could be
+/// checked against a narrow pane, and no test could pin the row without
+/// arranging a real failure first.
+///
+/// Bare, it reports ITSELF through the error path, because that is the other
+/// half of the same machinery — `reportError` is `setMessage` plus an
+/// `<operation>: <Error>` — and because a word that needs no argument to
+/// demonstrate one is a word you can also just click.
+///
+/// Whether the row is FREE is not asked here and is not this word's business:
+/// an armed prompt outranks a message at render time, so posting under one is
+/// stored and invisible, exactly as a save finishing under one is. Nor does
+/// anything here decide when it goes away — your next key or click does, on
+/// every pane at once, because a message is exactly as old as your last input.
+pub const Msg = struct {
+ pub const takes_arg = true;
+ pub fn run(c: Ctx) void {
+ if (c.arg) |text|
+ c.p.setMessage(c.id, text)
+ else
+ c.p.reportError(c.id, comptime word(@This()), error.NoMessage);
+ }
+};
+
// ---- display toggles ----
pub const Debug = struct {
@@ -357,6 +387,7 @@ pub const Font = struct {
if (path.len > fonts.want_buf.len) return;
@memcpy(fonts.want_buf[0..path.len], path);
fonts.want = fonts.want_buf[0..path.len];
+ fonts.chosen = std.fs.path.stem(fonts.want.?);
}
};
diff --git a/src/config.zig b/src/config.zig
index 107eb1a7..de971db9 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -136,6 +136,11 @@ pub const leader_path = paths: {
.Newcol = "cn",
.Delcol = "cd",
.Debug = "td",
+ // `Msg` takes the text to post, so it has no path, for the reason
+ // `Theme` and the two acme verbs below have none: a key path names a
+ // builtin and can never carry an argument. Bare `Msg` still runs — it
+ // reports itself through the error path.
+ .Msg = null,
.Colors = "tc",
.Wrap = "tw",
.Tagbottom = "tb",
diff --git a/src/fonts.zig b/src/fonts.zig
index e4744daf..1e82956e 100644
--- a/src/fonts.zig
+++ b/src/fonts.zig
@@ -70,6 +70,17 @@ pub const Font = struct { name: []const u8, path: []const u8 };
pub var want_buf: [4096]u8 = undefined;
pub var want: ?[]const u8 = null;
+/// ...and the same request as a NAME, for the Debug overlay to show. A slice
+/// of `want_buf` rather than a second buffer: the stem of the path IS the name
+/// the picker listed, and the only writer of one is the writer of the other.
+///
+/// What was CHOSEN, not what is worn: the shell may turn a file down (gui.zig
+/// keeps the face that works when FreeType refuses one) and it alone knows
+/// what it booted in — embedded Adwaita Mono under SDL, the system monospace
+/// face under AppKit. Empty means nothing has been chosen and the shell is
+/// still wearing whichever of those it is.
+pub var chosen: []const u8 = "";
+
/// Optional fallback faces, in preference order after the always-available
/// embedded Adwaita Mono face. Keep text faces before symbol faces so ordinary
/// letters retain terminal-like metrics; DejaVu Sans is the final broad,
diff --git a/src/pardes.zig b/src/pardes.zig
index da6e540c..3db27b86 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -51,6 +51,14 @@ pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config"
/// disabled so much as meaningless — see builtins.zig.
pub const font_picker = platform == .gui or platform == .macos;
+/// ...and the one fact about that face the core keeps: the name `Font` last
+/// resolved, which the Debug overlay prints. Behind the same comptime shim
+/// builtins.zig and macos.zig import this file with, so a tty or web binary
+/// never analyses a font-directory walk it cannot use.
+const fonts = if (font_picker) @import("fonts.zig") else struct {
+ pub const chosen: []const u8 = "";
+};
+
/// Native PDF quality is a shell property, but the core owns MuPDF and the
/// RGBA cache. Kitty favors wire bandwidth; SDL favors physical-pixel text
/// quality and asks the renderer to cover either fit axis without upscaling.
@@ -2220,6 +2228,71 @@ test "New requests repeat, stay anchored, and reject stale or full completions"
try std.testing.expect(std.mem.indexOf(u8, p.panes[source].?.msg[0..p.panes[source].?.msg_len], "help: NoPaneSlots") != null);
}
+test "Msg writes the transient row by hand, bare or with text, and input ends it" {
+ const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const row = struct {
+ fn of(pp: *Pardes, i: usize) []const u8 {
+ const pane = pp.panes[i].?;
+ return pane.msg[0..pane.msg_len];
+ }
+ }.of;
+
+ // the whole tail, spaces and all: the row is a sentence, not a word
+ try std.testing.expect(p.executeBuiltinLine(2, "Msg saved /etc/hosts"));
+ try std.testing.expectEqualStrings("saved /etc/hosts", row(p, 2));
+ // ...on the pane that ran it, and nowhere else
+ try std.testing.expectEqualStrings("", row(p, 0));
+
+ // Bare, it reports ITSELF through reportError — the `<operation>: <Error>`
+ // every failed save and refused Look arrives in.
+ try std.testing.expect(p.executeBuiltinLine(2, "Msg"));
+ try std.testing.expectEqualStrings("Msg: NoMessage", row(p, 2));
+
+ // A message is exactly as old as your last input.
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expectEqualStrings("", row(p, 2));
+
+ // Longer than the row's buffer is CUT, never refused and never overrun:
+ // this word is the only way to reach that boundary without a 300-byte path.
+ var long: [600]u8 = @splat('x');
+ var line: [608]u8 = undefined;
+ const cmd = try std.fmt.bufPrint(&line, "Msg {s}", .{&long});
+ try std.testing.expect(p.executeBuiltinLine(2, cmd));
+ try std.testing.expectEqual(p.panes[2].?.msg.len, row(p, 2).len);
+}
+
+test "Font records the face it chose, which is the name the Debug overlay prints" {
+ if (comptime !font_picker) return; // there is no Font builtin on tty or web
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer p.deinit();
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const installed = fonts.list(arena.allocator(), null);
+ if (installed.len == 0) return; // a machine with no monospace face at all
+
+ // module state, and the shell takes it on its next pass: leave neither the
+ // request nor the name behind for whatever test runs after this one
+ defer {
+ fonts.want = null;
+ fonts.chosen = "";
+ }
+ var line: [512]u8 = undefined;
+ const cmd = try std.fmt.bufPrint(&line, "Font {s}", .{installed[0].name});
+ try std.testing.expect(p.executeBuiltinLine(p.active, cmd));
+ // the PATH is what the shell needs and the NAME is what a person reads;
+ // one is the other's stem, so the two can never name different files
+ try std.testing.expect(fonts.want != null);
+ try std.testing.expectEqualStrings(installed[0].name, fonts.chosen);
+ try std.testing.expectEqualStrings(fonts.chosen, std.fs.path.stem(fonts.want.?));
+
+ // ...and a name nothing answers to changes neither, so the overlay goes on
+ // naming the face that is actually on
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Font zzz-no-such-face"));
+ try std.testing.expectEqualStrings(installed[0].name, fonts.chosen);
+}
+
test "startup config overrides restored styling before initFromDump returns" {
const source = try Pardes.init(std.testing.allocator, .{ .startup_config = "Theme dark\n" });
defer source.deinit();
@@ -7824,7 +7897,44 @@ pub const Pardes = struct {
.grep
else
.text;
- p.runSearch(id, armed[slash + 1 ..], kind, .top) catch |err| p.reportError(id, "search", err);
+ p.runSearch(id, armed[slash + 1 ..], kind, .top) catch |err| return p.reportError(id, "search", err);
+ // ...and the bare `/` GOES there. Find and Grep answer with OTHER
+ // files, and opening the first of them on submit would rearrange the
+ // screen before you have read what was found; `/` searched the text
+ // already in front of you, so its first hit is a scroll, not a jump.
+ if (kind == .text) p.lookFirstHit(id);
+ }
+
+ /// Land ON the first hit of a `/`, instead of beside a list of them.
+ ///
+ /// No new motion: the results buffer is FOCUSED and then the two ordinary
+ /// verbs run in the order a hand would run them — the step `n` is, and the
+ /// look Enter is. Focusing is the part that cannot be skipped and the
+ /// reason this is not just a call to the walk: lookWalkPanes deliberately
+ /// leaves the ACTIVE pane out of the ring, so `n` pressed straight after a
+ /// search steps whichever list was looked most recently, which in a
+ /// session with any history at all is not the one that just answered.
+ ///
+ /// Everything the walk needs to be reversible from here is left by the
+ /// step itself (landLookSpot's `look_at`), so `N` afterwards goes back to
+ /// the row above exactly as it would have if you had pressed `n` yourself.
+ fn lookFirstHit(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ const rid = pane.search_pane orelse return;
+ const rp = p.panes[rid] orelse return;
+ // Nothing matched: the empty buffer is the answer, and aiming the walk
+ // at a list with no rows would send it round the ring into a NEIGHBOUR
+ // and open a row from some other search.
+ if ((rp.file orelse return).content.len == 0) return;
+ p.active = rid;
+ p.lookWalk(1);
+ if (p.active != rid) return; // walked out of the list; leave it there
+ const txt = p.currentSelText(rp) orelse return;
+ // spent, exactly as the look chord spends it (handleKey)
+ rp.vsel.active = false;
+ rp.msel.active = false;
+ rp.select = false;
+ p.runBuiltin(config.look_cmd, rid, "", txt);
}
/// Fill this pane's results buffer with everything matching `pat_raw`
@@ -13373,14 +13483,21 @@ pub const Pardes = struct {
sb_off = sb.offset;
sb_total = sb.total;
}
- var ov_buf: [192]u8 = undefined;
+ // The face belongs to the SHELL, so the core can only name the one
+ // it was asked for; nothing has asked when this is empty and the
+ // shell is still in whatever it booted in. Clamped because a name
+ // is a file stem and a path component can be as long as a path.
+ const font_name = if (fonts.chosen.len == 0) "default" else fonts.chosen;
+ var ov_buf: [256]u8 = undefined;
const text = std.fmt.bufPrint(
&ov_buf,
- "pardes resizes={d}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}\npinch {d:6.4}\ntscroll {d:7.4}",
- .{ p.resize_count, nlive, p.ncol, p.active, at.cols, at.rows, sb_off, sb_total, p.ov_pinch_scale, p.ov_touch_scroll_delta },
+ "pardes resizes={d}\ntheme {s}\nfont {s}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}\npinch {d:6.4}\ntscroll {d:7.4}",
+ .{ p.resize_count, themes[p.theme_idx].name, font_name[0..@min(font_name.len, 64)], nlive, p.ncol, p.active, at.cols, at.rows, sb_off, sb_total, p.ov_pinch_scale, p.ov_touch_scroll_delta },
) catch "";
- const ow: u16 = 30;
- const oh: u16 = 8;
+ // wide enough for the longest theme name compiled in, which is the
+ // only row here whose width is not ours to choose
+ const ow: u16 = 38;
+ const oh: u16 = 10;
const ox: u16 = if (s.cols > ow) s.cols - ow else 0;
if (ox + ow <= s.cols and TOPBAR_H + oh <= s.rows) {
// vaxis single_rounded border ring, default style
diff --git a/src/tutor.txt b/src/tutor.txt
index 9c73e5c9..c2db239d 100644
--- a/src/tutor.txt
+++ b/src/tutor.txt
@@ -195,14 +195,17 @@
— a file's text, a shell's scrollback, anything — for the pattern, plain
and case-insensitive, and writes one row per matching LINE into a
"+Search" pane — the FIRST hit on that line, named as a location, with
- the line's own text after it.
+ the line's own text after it — and then GOES to the first of those rows.
+ That last part is nothing new to learn: it is the step `n` is and the
+ look Enter is, run for you in the list that has just answered. A pattern
+ that matched nothing opens its empty buffer and goes nowhere.
That is an OUTPUT BUFFER: a file pane with no file behind it, so it has
no Save, but everything else about it is an ordinary buffer you can read,
edit, select and look in. It is not a document, though, and never takes a
column of its own: it opens BELOW the pane that asked for it, in that
pane's column, be that a file or a shell — and a file you then open from
- its rows goes where files go, not under the list. `n`/`N` walk those
- rows, and walking is ALL they do: a press moves the SELECTION to the
+ its rows goes where files go, not under the list. `n`/`N` carry on down
+ those rows, and walking is ALL they do: a press moves the SELECTION to the
next look-able thing and opens nothing, so you can step past nine hits
to reach the tenth without opening the nine. Enter — the look chord —
opens the one you stopped on. The walk is a RING over PANES and not