summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Messages.zig10
-rw-r--r--src/Presentation.zig59
-rw-r--r--src/Terminal.zig50
-rw-r--r--src/animation.zig288
-rw-r--r--src/body_layer.zig21
-rw-r--r--src/builtins.zig8
-rw-r--r--src/colors.zig25
-rw-r--r--src/config.zig103
-rw-r--r--src/detached/server.zig14
-rw-r--r--src/detached/wire.zig34
-rw-r--r--src/draw.zig637
-rw-r--r--src/dump.zig34
-rw-r--r--src/edit.zig214
-rw-r--r--src/exec.zig19
-rw-r--r--src/fs.zig3
-rw-r--r--src/gui/gui.zig923
-rw-r--r--src/host_io.zig88
-rw-r--r--src/look.zig120
-rw-r--r--src/macos.zig24
-rw-r--r--src/mouse.zig112
-rw-r--r--src/ninep/addr.zig4
-rw-r--r--src/ninep/ctl.zig96
-rw-r--r--src/ninep/events.zig7
-rw-r--r--src/ninep/pane.zig46
-rw-r--r--src/ninep/pty.zig101
-rw-r--r--src/ninep/screen.zig2
-rw-r--r--src/ninep/testing.zig9
-rw-r--r--src/ninep/tree.zig20
-rw-r--r--src/panes.zig18
-rw-r--r--src/pardes.zig245
-rw-r--r--src/regexp.zig41
-rw-r--r--src/sam_edit.zig56
-rw-r--r--src/surface.zig26
-rw-r--r--src/tagline.zig151
-rw-r--r--src/themes/acme.zig33
-rw-r--r--src/tty/tty.zig9
36 files changed, 3293 insertions, 357 deletions
diff --git a/src/Messages.zig b/src/Messages.zig
index cc4b3656..8b6c795d 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -356,11 +356,14 @@ pub fn messageMotion(p: *const Pardes, life: Pane.MessageLife) MessageMotion {
// last frames only settle it. No overshoot: at a row's height
// and a dozen frames, a follow-through is a pixel or two that
// reads as a jitter, not a bounce.
- const left = 1 - t;
+ // The drop follows the Motion flavour (its wind-up, overshoot
+ // and all) over the fall's length.
+ const left = 1 - animation.Motion.of(p.settings.motion).ease(t);
// The colour leads the motion: solid by half way, so what
// lands is already legible rather than still fading up.
const faded = 1 - @min(1, 2 * t);
- break :blk .{ .alpha = 1 - faded * faded, .slide = -(left * left * left) };
+ // And never behind it, however fast the flavour leaves.
+ break :blk .{ .alpha = @max(1 - faded * faded, std.math.clamp(1 - left, 0, 1)), .slide = -left };
},
.leaving => blk: {
// Counted to its last frame rather than past it, so that frame
@@ -644,6 +647,9 @@ test "a message falls in, lingers past the input that dismisses it, and dissolve
while (p.nextEffect()) |_| {}
const pane = p.panes[p.active].?;
try std.testing.expect(p.settings.message_animation);
+ // Crisp: the flavour whose drop leaves at full speed and lands without
+ // passing its row (the others are the Motion tests').
+ p.settings.motion = .crisp;
try std.testing.expect(p.executeBuiltinLine(p.active, "MessageLinger 160"));
try std.testing.expect(p.executeBuiltinLine(p.active, "Msg hello"));
try std.testing.expectEqual(Pane.MessagePhase.entering, pane.msg_life.phase);
diff --git a/src/Presentation.zig b/src/Presentation.zig
index 3b5bb3e0..15ed9ea6 100644
--- a/src/Presentation.zig
+++ b/src/Presentation.zig
@@ -183,7 +183,7 @@ pub fn sync(self: *Presentation, p: *Pardes) void {
prior.?.from
else
old.box;
- const next: Track = .{
+ const next = stamp(p, .{
.serial = pane.serial,
.pane = @intCast(id),
.phase = if (!same_lifetime or
@@ -191,19 +191,19 @@ pub fn sync(self: *Presentation, p: *Pardes) void {
.effect = effect,
.from = from,
.to = target,
- };
+ });
self.tracks[id] = next;
changed = true;
animated_change = true;
} else {
- const next: Track = .{
+ const next = stamp(p, .{
.serial = pane.serial,
.pane = @intCast(id),
.phase = .opening,
.effect = effect,
.from = openingBox(effect, target, p.screen_w),
.to = target,
- };
+ });
self.tracks[id] = next;
changed = true;
animated_change = true;
@@ -215,6 +215,21 @@ pub fn sync(self: *Presentation, p: *Pardes) void {
if (changed and self.acknowledged) self.pending = true;
}
+/// A pane that moves (slides, zooms or rises) follows the Motion flavour:
+/// its curve, and a length in proportion (animation.Motion.span).
+fn stamp(p: *const Pardes, track: Track) Track {
+ var stamped = track;
+ switch (track.effect) {
+ .slide, .zoom, .vertical => {
+ const motion = animation.Motion.of(p.settings.motion);
+ stamped.motion = @intFromEnum(p.settings.motion) + 1;
+ stamped.frame_count = @intFromFloat(@max(2, @round(@as(f32, @floatFromInt(track.effect.frames())) * motion.span)));
+ },
+ else => {},
+ }
+ return stamped;
+}
+
fn appendClosing(self: *Presentation, track: Track) bool {
std.debug.assert(track.phase == .closing);
const baseline = self.previous_layout[track.pane] orelse return false;
@@ -388,6 +403,10 @@ pub fn pointerFractional(self: *const Presentation, cols: u16, rows: u16, x: f32
.slide, .zoom, .vertical => {
const target = track.contentBox();
if (track.effect == .vertical and !boxContainsPoint(target, x, y)) continue;
+ // Past its mark (a flavour's overshoot or wind-up) a pane
+ // is drawn over a neighbour; a point there is the
+ // neighbour's, never the moving pane's.
+ if (pastMark(track) and !boxContainsPoint(target, x, y)) continue;
const shown = track.presented();
if (!boxContainsPoint(shown, x, y)) {
if (track.effect == .vertical) return null;
@@ -410,6 +429,13 @@ pub fn pointerFractional(self: *const Presentation, cols: u16, rows: u16, x: f32
return .{ .x = x, .y = y };
}
+/// A flavour's overshoot or wind-up: the pane is drawn beyond the path from
+/// where it was to where it goes.
+fn pastMark(track: Track) bool {
+ const t = track.amount();
+ return t < 0 or t > 1;
+}
+
fn boxContainsPoint(box: Box, x: f32, y: f32) bool {
return box.w > 0 and box.h > 0 and x >= box.x and y >= box.y and x < box.x + box.w and y < box.y + box.h;
}
@@ -436,6 +462,8 @@ pub fn pointer(self: *const Presentation, cols: u16, rows: u16, col: u16, row: u
switch (track.effect) {
.slide, .zoom => {
const shown = track.presented();
+ // Past its mark, the neighbour's cells stay the neighbour's.
+ if (pastMark(track) and !boxContainsCell(track.to, col, row)) continue;
if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) continue;
const x = @as(f32, @floatFromInt(col)) + 0.5;
const y = @as(f32, @floatFromInt(row)) + 0.5;
@@ -688,6 +716,29 @@ test "presentation submission failure preserves shown geometry until retry is ac
try std.testing.expectEqual(CellPosition{ .col = 5, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 5, 3).?);
}
+test "a pane past its mark never takes a neighbour's click" {
+ // Bouncy, at its peak: pane 0 slides right to 0..40 and swings past it,
+ // over the neighbour at 40..80.
+ var presentation: Presentation = .{};
+ var track: Track = .{ .serial = 7, .effect = .slide, .motion = @intFromEnum(animation.Motion.Flavour.bouncy) + 1, .frame_count = 20, .from = .{ .x = -40, .y = 0, .w = 40, .h = 20 }, .to = .{ .x = 0, .y = 0, .w = 40, .h = 20 } };
+ var peak: u16 = 0;
+ var furthest: f32 = -1e9;
+ while (track.frame < track.frames()) : (track.frame += 1) {
+ if (track.presented().x > furthest) {
+ furthest = track.presented().x;
+ peak = track.frame;
+ }
+ }
+ track.frame = peak;
+ try std.testing.expect(track.presented().x > 2);
+ presentation.shown_tracks[0] = track;
+ // A cell of the neighbour under the overshoot is the neighbour's own.
+ const col: u16 = 41;
+ try std.testing.expectEqual(CellPosition{ .col = col, .row = 5 }, presentation.pointer(80, 20, col, 5).?);
+ const point = presentation.pointerFractional(80, 20, 41.5, 5.5).?;
+ try std.testing.expectApproxEqAbs(@as(f32, 41.5), point.x, 1e-4);
+}
+
test "fractional panel inverse preserves compact header rows through slide and zoom" {
const layer: pardes.Layer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 30, .h = 10 }, .cols = 30, .rows = 15, .context_rows = 4 };
for ([_]Transition{ .slide, .zoom, .vertical }) |effect| {
diff --git a/src/Terminal.zig b/src/Terminal.zig
index 91c23305..76be29d0 100644
--- a/src/Terminal.zig
+++ b/src/Terminal.zig
@@ -659,6 +659,36 @@ test "replay restores terminal cells from the retained stream" {
try std.testing.expectEqualSlices(u8, try replayBytes(pane, gpa), try replayBytes(restored, gpa));
}
+test "a restored command pane comes back finished, its command not run again" {
+ if (comptime !enabled or pardes.isolated) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 16 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const cmd = p.freeSlot().?;
+ _ = try p.newCommand(cmd, "/tmp", "make test");
+ feedOutput(p, p.panes[cmd].?, "ok\r\n");
+ p.update(.{ .exited = .{ .pane = @intCast(cmd), .status = 2 } });
+ try dump.dumpState(p);
+ const restored = try dump.initFromDump(gpa, .{ .tty_only = true, .cols = 60, .rows = 16 }, p.dump_out.?);
+ defer restored.deinit();
+ var spawns: usize = 0;
+ while (restored.nextEffect()) |effect| switch (effect) {
+ .spawn => spawns += 1,
+ else => {},
+ };
+ try std.testing.expectEqual(@as(usize, 1), spawns); // the shell, not the command
+ const again = for (restored.panes) |slot| {
+ if (slot) |pane| if (pane.command != null) break pane;
+ } else return error.CommandPaneLost;
+ try std.testing.expectEqualStrings("make test", again.command.?);
+ try std.testing.expect(again.command_done);
+ try std.testing.expectEqual(@as(?u8, 2), again.command_status);
+ const text = try screenTextAlloc(again, gpa);
+ defer gpa.free(text);
+ try std.testing.expect(std.mem.indexOf(u8, text, "ok") != null);
+}
+
test "a restored terminal keeps its history above a marker and gets a shell where it was" {
if (comptime !enabled or pardes.isolated) return error.SkipZigTest;
const gpa = std.testing.allocator;
@@ -668,6 +698,7 @@ test "a restored terminal keeps its history above a marker and gets a shell wher
const pane = p.panes[0].?;
feedOutput(p, pane, "old output\r\n$ ls\r\n");
try pane.setOwnedCwd("/tmp");
+ pane.shell = try gpa.dupe(u8, "bash"); // `Tty bash`: it comes back as bash
try dump.dumpState(p);
const restored = try dump.initFromDump(gpa, .{ .tty_only = true, .cols = 40, .rows = 12 }, p.dump_out.?);
defer restored.deinit();
@@ -678,6 +709,8 @@ test "a restored terminal keeps its history above a marker and gets a shell wher
};
try std.testing.expect(spawned);
const again = restored.panes[0].?;
+ try std.testing.expectEqualStrings("bash", again.shell.?);
+ try std.testing.expect(again.command == null);
try std.testing.expectEqual(.spawn, again.pending_command.wait);
const text = try screenTextAlloc(again, gpa);
defer gpa.free(text);
@@ -961,6 +994,15 @@ pub const CommandOutput = struct {
/// What the newest finished command printed: the screen text between its
/// C and D marks, read from at most `max_rows` rows above its end, so a
/// command that printed the whole history costs no more than its tail.
+/// The shell exited in the middle of a command (its `exit 3`): no end mark
+/// will come, so the command's output ends where the cursor is.
+pub fn endOutputHere(pane: *Pane) void {
+ if (comptime !enabled) return;
+ const state = pane.terminal orelse return;
+ const marks = &state.stream.handler;
+ if (marks.phase == .running and marks.out_end == null) marks.pinCursor(&marks.out_end, true);
+}
+
pub fn commandOutput(pane: *Pane, gpa: std.mem.Allocator, max_rows: usize) !CommandOutput {
// Unless there is text to read, whether the nothing it printed is the
// whole of it.
@@ -1427,6 +1469,9 @@ pub fn dumpPane(
.collapsed = pane.collapsed,
.terminal = .{
.cwd = try arena.dupe(u8, pane.cwdSlice()),
+ .shell = try arena.dupe(u8, pane.shell orelse ""),
+ .command = try arena.dupe(u8, pane.command orelse ""),
+ .status = pane.command_status,
.stream = try arena.dupe(u8, text),
.stream_b64 = &.{},
.cursor = .{ .col = 0, .row = 0 },
@@ -1476,6 +1521,9 @@ pub fn dumpPane(
.collapsed = pane.collapsed,
.terminal = .{
.cwd = try arena.dupe(u8, pane.cwdSlice()),
+ .shell = try arena.dupe(u8, pane.shell orelse ""),
+ .command = try arena.dupe(u8, pane.command orelse ""),
+ .status = pane.command_status,
.stream = stream[0..len],
.stream_b64 = try dump.encodeBytes(arena, try screenVt(pane, arena)),
.stream_is_screen = true,
@@ -2111,6 +2159,8 @@ test "terminal Filter keys indexed truecolor OSC and background-only cells throu
defer p.deinit();
while (p.nextEffect()) |_| {}
const pane = p.panes[0].?;
+ pane.tag_expanded = false; // a one-row tag keeps this small screen's rows
+ p.sync();
try testing.expect(pane.tty_filter);
pane.tty_filter = false;
diff --git a/src/animation.zig b/src/animation.zig
index 948a6679..9b43644e 100644
--- a/src/animation.zig
+++ b/src/animation.zig
@@ -129,7 +129,9 @@ pub const Track = extern struct {
pane: u8 = 0,
phase: Phase = .moving,
effect: Transition = .off,
- _padding: u8 = 0,
+ /// The Motion flavour a pane's move follows, plus one; 0 is the
+ /// effect's own easing (Presentation.stamp).
+ motion: u8 = 0,
frame: u16 = 0,
frame_count: u16 = 0,
from: Box = .{},
@@ -148,11 +150,47 @@ pub const Track = extern struct {
// accelerates down out of the fixed clip.
if (track.phase == .closing and track.effect == .vertical)
return progressEased(.in_cubic, track.frames(), track.frame);
+ if (track.flavour()) |motion|
+ return motion.ease(track.progress());
return progressEased(track.effect.easing(), track.frames(), track.frame);
}
+ /// The flavour an opening or moving pane follows; a closing one keeps
+ /// its effect's own exit.
+ fn flavour(track: Track) ?Motion {
+ if (track.motion == 0 or track.phase == .closing) return null;
+ const which = std.enums.fromInt(Motion.Flavour, track.motion - 1) orelse return null;
+ return Motion.of(which);
+ }
+
+ fn progress(track: Track) f32 {
+ if (track.frames() <= 1 or track.frame >= track.frames() - 1) return 1;
+ return @as(f32, @floatFromInt(track.frame)) / @as(f32, @floatFromInt(track.frames() - 1));
+ }
+
pub fn presented(track: Track) Box {
- return lerpBox(track.from, track.to, track.amount());
+ // A flavour's wind-up goes back past where the move began.
+ const t = track.amount();
+ // Landed, it is exactly where it goes: no stretch or float residue.
+ if (track.flavour() != null and track.progress() >= 1) return track.to;
+ var box = if (t < 0) lerpBox(track.to, track.from, 1 - t) else lerpBox(track.from, track.to, t);
+ // Squash and stretch: along its path a moving pane lengthens with
+ // its speed and, landing past its mark and coming back, squashes
+ // (the user's choice: its text with it; a pointer inverts the same
+ // box, so a click lands on the glyph it is drawn as).
+ const motion = track.flavour() orelse return box;
+ if (motion.stretch == 0) return box;
+ const factor = 1 + motion.stretch * std.math.clamp(motion.speed(track.progress()) / 4, -1, 1);
+ if (@abs(track.to.x - track.from.x) >= @abs(track.to.y - track.from.y)) {
+ const w = box.w * factor;
+ box.x -= (w - box.w) / 2;
+ box.w = w;
+ } else {
+ const h = box.h * factor;
+ box.y -= (h - box.h) / 2;
+ box.h = h;
+ }
+ return box;
}
pub fn visualBox(track: Track) Box {
@@ -586,6 +624,252 @@ pub const frame_ms: u32 = 16;
pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms;
pub const transition_steps: u16 = 10;
+/// How the fx track moves (`Motion <flavour>`, docs/effects.md): one set of
+/// parameters a flavour picks, read by every animation that follows the
+/// classic principles, so a flavour is data and no effect branches on it.
+pub const Motion = struct {
+ pub const Flavour = enum(u8) { off, crisp, smooth, bouncy, playful };
+
+ /// Nothing moves: every change lands at once (reduced motion).
+ instant: bool = false,
+ /// Timing: the spring's natural frequency, rad/s; slow in / slow out
+ /// come from the spring itself.
+ omega: f32 = 36,
+ /// Follow-through: 1 is critically damped (no overshoot); below 1 it
+ /// settles past its target and back.
+ zeta: f32 = 1,
+ /// Anticipation: a move from rest first winds up the other way, by
+ /// this fraction of the distance.
+ anticipate: f32 = 0,
+ /// Squash and stretch: how much a moving thing lengthens along its
+ /// velocity and squashes as it lands (G3's cursor, G7's panes).
+ stretch: f32 = 0,
+ /// Secondary action: how far behind a follower trails its leader (the
+ /// shadow behind a pane, a notice's text behind its chip), as the
+ /// follower's frequency over the leader's; 1 is in step.
+ follow: f32 = 1,
+ /// Arcs: how far a two-dimensional path bows, as a fraction of its
+ /// length (the cursor's glide).
+ arc: f32 = 0,
+ /// Exaggeration: how far a small motion goes, as a multiple of its
+ /// plain distance (a lift of 1 rises `gain`), so a flavour's character
+ /// shows on a motion only a few pixels big.
+ gain: f32 = 1,
+ /// Timing: a move from rest leaves at this fraction of full speed (1:
+ /// out of the gate at once and easing in, 0: slow in and slow out).
+ launch: f32 = 0,
+ /// For a motion of fixed length (a pane's move, a notice's drop): its
+ /// length as a multiple of the effect's own, and the spring's frequency
+ /// over that length (`ease`).
+ span: f32 = 1,
+ norm: f32 = 10,
+
+ pub fn of(flavour: Flavour) Motion {
+ return switch (flavour) {
+ .off => .{ .instant = true },
+ // Productivity: quick and exact, nothing past its mark.
+ .crisp => .{ .omega = 60, .launch = 1, .span = 0.6, .norm = 10 },
+ // Longer and softer, easing in and out, still without a bounce.
+ // The default: within §8.1's 220 ms for an arrival.
+ .smooth => .{ .omega = 34, .follow = 0.85, .arc = 0.04, .span = 1.1, .norm = 6 },
+ // Clearly past its mark and back, once or twice, stretching
+ // with its speed.
+ .bouncy => .{ .omega = 24, .zeta = 0.35, .stretch = 0.15, .follow = 0.8, .arc = 0.08, .gain = 1.25, .span = 1.4, .norm = 13 },
+ // A cartoon's: winds up, flies past, squashes as it lands,
+ // its followers trailing.
+ .playful => .{ .omega = 18, .zeta = 0.28, .anticipate = 0.15, .stretch = 0.35, .follow = 0.55, .arc = 0.15, .gain = 1.4, .span = 1.7, .norm = 18 },
+ };
+ }
+
+ /// A move of fixed length from 0 to 1, `u` of the way through it: the
+ /// flavour's spring (wind-up, overshoot and all), landing exactly on 1
+ /// at its end. `instant` is there at once.
+ pub fn ease(motion: Motion, u: f32) f32 {
+ if (motion.instant or u >= 1) return 1;
+ if (u <= 0) return 0;
+ const spring: Spring = .{ .omega = motion.norm, .zeta = motion.zeta, .target = 1, .from = 0, .velocity = (motion.launch - 2 * motion.anticipate) * motion.norm, .settled = false };
+ return spring.value(@intFromFloat(u * std.time.ns_per_s));
+ }
+
+ /// The same move's speed at `u`, in lengths per unit of `u`.
+ pub fn speed(motion: Motion, u: f32) f32 {
+ const h: f32 = 1.0 / 256.0;
+ return (motion.ease(@min(1, u + h)) - motion.ease(@max(0, u - h))) / (@min(1, u + h) - @max(0, u - h));
+ }
+};
+
+/// A value that follows its target on a damped spring, in closed form
+/// (docs/render-pipeline.md §7.2), critically damped or under: a new target
+/// taken partway keeps the value AND its velocity, so a quick run of changes
+/// never snaps or starts over. A move from rest can wind up first
+/// (Motion.anticipate). Settled, it is exactly its target and asks for no
+/// frames.
+pub const Spring = struct {
+ omega: f32 = 36,
+ zeta: f32 = 1,
+ target: f32 = 0,
+ /// The value and velocity (per second) at `from_ns`.
+ from: f32 = 0,
+ velocity: f32 = 0,
+ from_ns: u64 = 0,
+ settled: bool = true,
+
+ const State = struct { value: f32, velocity: f32 };
+
+ fn at(spring: *const Spring, now_ns: u64) State {
+ if (spring.settled) return .{ .value = spring.target, .velocity = 0 };
+ const t: f32 = @floatCast(@as(f64, @floatFromInt(now_ns -| spring.from_ns)) / std.time.ns_per_s);
+ const w = spring.omega;
+ const x0 = spring.from - spring.target;
+ const v0 = spring.velocity;
+ if (spring.zeta >= 1) {
+ const c = v0 + w * x0;
+ const decay = @exp(-w * t);
+ return .{ .value = spring.target + (x0 + c * t) * decay, .velocity = (v0 - w * t * c) * decay };
+ }
+ // Underdamped: it rings about its target as it decays.
+ const z = spring.zeta;
+ const wd = w * @sqrt(1 - z * z);
+ const b = (v0 + z * w * x0) / wd;
+ const decay = @exp(-z * w * t);
+ const cos = @cos(wd * t);
+ const sin = @sin(wd * t);
+ return .{
+ .value = spring.target + decay * (x0 * cos + b * sin),
+ .velocity = decay * ((b * wd - z * w * x0) * cos - (x0 * wd + z * w * b) * sin),
+ };
+ }
+
+ pub fn value(spring: *const Spring, now_ns: u64) f32 {
+ return spring.at(now_ns).value;
+ }
+
+ /// Heads for `target` from wherever it is at `now_ns`, moving as it was,
+ /// at the pace and damping `motion` gives: at once when it is `off`.
+ pub fn retarget(spring: *Spring, target: f32, now_ns: u64, motion: Motion) void {
+ if (target == spring.target) return;
+ if (motion.instant) {
+ spring.* = .{ .omega = motion.omega, .zeta = motion.zeta, .target = target, .from = target };
+ return;
+ }
+ const state = spring.at(now_ns);
+ // From rest, a launch, or a wind-up: set off the other way, a little.
+ const kick: f32 = if (spring.settled) (motion.launch - 2 * motion.anticipate) * (target - state.value) * motion.omega else 0;
+ spring.* = .{ .omega = motion.omega, .zeta = motion.zeta, .target = target, .from = state.value, .velocity = state.velocity + kick, .from_ns = now_ns, .settled = false };
+ }
+
+ /// Settles once it is within half a percent of its target and barely
+ /// moving (no pixel of a shadow tells the rest): it is its target from
+ /// then on. True while it still moves.
+ pub fn step(spring: *Spring, now_ns: u64) bool {
+ if (spring.settled) return false;
+ const state = spring.at(now_ns);
+ if (@abs(state.value - spring.target) < 5e-3 and @abs(state.velocity) < 5e-2) spring.settled = true;
+ return !spring.settled;
+ }
+};
+
+test "a spring settles without overshoot and keeps its velocity when retargeted" {
+ var spring: Spring = .{};
+ const ms = std.time.ns_per_ms;
+ const crisp = Motion.of(.crisp);
+ spring.retarget(1, 0, crisp);
+ var last: f32 = 0;
+ var t: u64 = 0;
+ while (spring.step(t)) : (t += frame_ns) {
+ const v = spring.value(t);
+ try std.testing.expect(v >= last and v <= 1);
+ last = v;
+ }
+ try std.testing.expect(t >= 80 * ms and t <= 200 * ms);
+ try std.testing.expectEqual(@as(f32, 1), spring.value(t + 5 * ms));
+ // Back the other way partway up: it carries on up for a moment, then
+ // turns, never jumping.
+ spring = .{};
+ spring.retarget(1, 0, crisp);
+ const before = spring.at(60 * ms);
+ spring.retarget(0, 60 * ms, crisp);
+ const after = spring.at(60 * ms);
+ try std.testing.expectApproxEqAbs(before.value, after.value, 1e-6);
+ try std.testing.expectApproxEqAbs(before.velocity, after.velocity, 1e-4);
+ try std.testing.expect(spring.value(60 * ms + 500_000) > before.value);
+}
+
+test "each flavour is its own motion: off lands at once, bouncy overshoots, playful winds up first" {
+ const ms = std.time.ns_per_ms;
+ var spring: Spring = .{};
+ spring.retarget(1, 0, Motion.of(.off));
+ try std.testing.expect(spring.settled);
+ try std.testing.expectEqual(@as(f32, 1), spring.value(0));
+ var settle: [5]u64 = @splat(0);
+ for ([_]Motion.Flavour{ .crisp, .smooth, .bouncy, .playful }) |flavour| {
+ spring = .{};
+ spring.retarget(1, 0, Motion.of(flavour));
+ var peak: f32 = 0;
+ var low: f32 = 0;
+ var t: u64 = 0;
+ var last = spring.at(0);
+ while (spring.step(t)) : (t += ms) {
+ const now = spring.at(t);
+ peak = @max(peak, now.value);
+ low = @min(low, now.value);
+ // Continuous, a millisecond at a time: no jump anywhere.
+ try std.testing.expect(@abs(now.value - last.value) < 0.07);
+ last = now;
+ }
+ try std.testing.expect(t < 1600 * ms);
+ settle[@intFromEnum(flavour)] = t;
+ switch (flavour) {
+ .crisp, .smooth => try std.testing.expect(peak <= 1.0005 and low >= 0),
+ // Clearly past the mark, not a hint of it.
+ .bouncy => try std.testing.expect(peak > 1.25 and low >= 0),
+ .playful => try std.testing.expect(peak > 1.35 and low < -0.03),
+ .off => unreachable,
+ }
+ }
+ // Smooth takes twice crisp's time and more: a character, not a tuning.
+ // Yet as the default a pane's arrival (a slide's 12 frames, scaled) is
+ // inside §8.1's 220 ms, and a lift is still before 300 ms.
+ try std.testing.expect(settle[@intFromEnum(Motion.Flavour.smooth)] > 2 * settle[@intFromEnum(Motion.Flavour.crisp)]);
+ try std.testing.expect(settle[@intFromEnum(Motion.Flavour.smooth)] <= 300 * ms);
+ try std.testing.expect(@round(@as(f32, 12) * Motion.of(.smooth).span) * frame_ms <= 220);
+ // A fixed-length move lands exactly on its mark, whatever the flavour.
+ for (std.enums.values(Motion.Flavour)) |flavour| try std.testing.expectEqual(@as(f32, 1), Motion.of(flavour).ease(1));
+}
+
+test "a flavour's stretched pane lands exactly on its box, and a click finds the glyph drawn there" {
+ const from: Box = .{ .x = 0, .y = 2, .w = 30, .h = 10 };
+ const to: Box = .{ .x = 37.3, .y = 2, .w = 30, .h = 10 };
+ for (std.enums.values(Motion.Flavour)) |flavour| {
+ var track: Track = .{ .effect = .slide, .motion = @intFromEnum(flavour) + 1, .frame_count = 20, .from = from, .to = to };
+ track.frame = track.frames() - 1;
+ const landed = track.presented();
+ // Bit for bit: no stretch or float residue once it lands.
+ try std.testing.expectEqual(to, landed);
+ }
+ // Playful at its most stretched: the pane is drawn wider than its box,
+ // and the drawn place of a cell maps back to that cell (the pointer
+ // inverts the same box: target + (x - shown.x) / shown.w * target.w).
+ var track: Track = .{ .effect = .slide, .motion = @intFromEnum(Motion.Flavour.playful) + 1, .frame_count = 20, .from = from, .to = to };
+ var most: u16 = 0;
+ var widest: f32 = 0;
+ while (track.frame < track.frames()) : (track.frame += 1) {
+ if (track.presented().w > widest) {
+ widest = track.presented().w;
+ most = track.frame;
+ }
+ }
+ track.frame = most;
+ const shown = track.presented();
+ try std.testing.expect(shown.w > to.w * 1.05);
+ for (0..30) |k| {
+ const cell = to.x + @as(f32, @floatFromInt(k)) + 0.5;
+ const drawn = shown.x + (cell - to.x) / to.w * shown.w;
+ const back = to.x + (drawn - shown.x) / shown.w * to.w;
+ try std.testing.expectApproxEqAbs(cell, back, 1e-3);
+ }
+}
+
/// A displayed value that fades from one target to the next over
/// `transition_steps` frames; the chrome colours are one.
pub fn Fade(comptime Value: type) type {
diff --git a/src/body_layer.zig b/src/body_layer.zig
index a0195fb8..6e1fe8f1 100644
--- a/src/body_layer.zig
+++ b/src/body_layer.zig
@@ -72,7 +72,7 @@ fn paintTerminalSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: [
hi = @min(hi, line.len);
if (lo >= hi) continue;
var col = panes.File.rawDisplayCol(line, lo);
- const end = panes.File.rawDisplayCol(line, hi);
+ const end = if (row.to_edge and hi == line.len) width else panes.File.rawDisplayCol(line, hi);
while (col < end and col < width) : (col += 1) {
const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(visible)));
cell.default = false;
@@ -103,7 +103,7 @@ fn paintSourceSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: []c
const hi = @min(end, row.hi);
if (lo >= hi) continue;
var col = prefix + @as(i32, @intCast(panes.File.rawDisplayCol(line[start..], lo - start)));
- const last = prefix + @as(i32, @intCast(panes.File.rawDisplayCol(line[start..], hi - start)));
+ const last = if (row.to_edge and hi == end) @as(i32, width) else prefix + @as(i32, @intCast(panes.File.rawDisplayCol(line[start..], hi - start)));
while (col < last and col < width) : (col += 1) {
const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(visible)));
cell.default = false;
@@ -130,13 +130,12 @@ pub fn paintPointerSelection(
fg: ?[3]u8,
) void {
if (pane.pointerSelection(sl)) |rows| return paintSourceSelection(p, s, pane, r, rows, bg, fg);
- const r0 = @min(sl.r0, sl.r1);
- const r1 = @max(sl.r0, sl.r1);
- const c0 = @max(0, @min(sl.c0, sl.c1));
- const c1 = @max(0, @max(sl.c0, sl.c1));
var row: u16 = 0;
while (row < r.h) : (row += 1) {
- if (@as(i32, row) < r0 or @as(i32, row) > r1 or row < from_row) continue;
+ if (row < from_row) continue;
+ const cols = edit.sweepCols(sl, row) orelse continue;
+ const c0: i32 = if (cols.first) cols.lo else 0;
+ const c1: i32 = cols.hi orelse std.math.maxInt(i16);
const sy = if (row < pane.tag_rows) tag_y + row else body_y + row - pane.tag_rows;
// File line numbers occupy a gutter only in the body. The tag is
// row zero in Sel space and starts at its real first text cell.
@@ -156,6 +155,7 @@ pub fn paintPointerSelection(
/// The selection colour of each pointer button's sweep: the select button's
/// and the look and exec buttons', each tinted toward its own accent.
pub fn buttonTints(th: *const colors.Theme) [3][3]u8 {
+ if (th.sweep_bg) |own| return own;
const accent2 = if (std.mem.eql(u8, &th.num, &th.str)) th.comment else th.num;
return .{
colors.mix(th.sel_bg, colors.mix(th.sel_bg, th.kw)),
@@ -164,6 +164,11 @@ pub fn buttonTints(th: *const colors.Theme) [3][3]u8 {
};
}
+/// The ink over each button's sweep.
+pub fn buttonInks(th: *const colors.Theme) [3][3]u8 {
+ return th.sweep_fg orelse @splat(th.sel_fg);
+}
+
/// A pane's body, painted once. A file body with compact tree-sitter
/// context rows is painted into its body layer -- more rows than the grid
/// has, the context rows at the tagline's height -- and the grid's rows are
@@ -312,7 +317,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane
// A select sweep over the tag is the tag's selection, painted there
// in the tag's own selection colours.
if (b == pardes.sel_slot and @min(sl.r0, sl.r1) < pane.tag_rows) continue;
- paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sl, pane.tag_rows, sel_btn[b], th.sel_fg);
+ paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sl, pane.tag_rows, sel_btn[b], buttonInks(th)[b]);
}
// modal line selection (helix `x`): whole body rows, normal mode only
if ((pane.body.mode == .normal or modal_hover) and pane.body.msel.active) {
diff --git a/src/builtins.zig b/src/builtins.zig
index 7b34b376..657f49d0 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -28,6 +28,8 @@ pub const capabilities: config.Runtime.Capabilities = .{
// Only the SDL shell shapes with HarfBuzz and honours the setting. macOS
// draws CoreText ligatures of its own, but nothing there reads it yet.
.ligatures = pardes.platform == .gui,
+ // Soft shadows are the SDL shell's decor.
+ .lift = pardes.platform == .gui,
};
/// Where a builtin acts: at the pane it runs from, or on the editor as a
@@ -169,8 +171,8 @@ pub const registry = struct {
inline for (comptime settingList(), manualBuiltinCount()..) |setting, i|
if (@intFromEnum(b) == i) return switch (setting.action) {
// a switch flips bare, and DumpDir bare is the default
- .toggle, .transition, .scene, .dump_dir, .choice => false,
- .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation => true,
+ .toggle, .transition, .scene, .dump_dir, .choice, .lift => false,
+ .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation, .motion, .inactive_dim, .grip_width => true,
};
unreachable;
}
@@ -854,6 +856,8 @@ pub const Del = struct {
{
c.pane.prompt = .del_side;
c.p.active = c.id;
+ var rec: [32]u8 = undefined;
+ pardes.ctlfs.events.notePath(c.p, "ask", std.fmt.bufPrint(&rec, "{d} del k j", .{c.pane.serial}) catch "");
// The question says Del already.
if (c.pane.msg_announcement) c.pane.msg_len = 0;
return;
diff --git a/src/colors.zig b/src/colors.zig
index 401b77f1..224591d3 100644
--- a/src/colors.zig
+++ b/src/colors.zig
@@ -65,6 +65,29 @@ pub const Theme = struct {
// The current line number is a quiet foreground cue, not a tag-colored
// background block. Older themes fall back to their normal gutter ink.
lineno_active: ?[3]u8 = null,
+ /// A tag's selection, where it differs from the body's (acme's #9eeeee).
+ tag_sel_bg: ?[3]u8 = null,
+ /// Each pointer button's sweep, select, exec and look (Mouse.Button
+ /// order), and the ink over it. Unset, tints of `sel_bg` in `sel_fg`.
+ sweep_bg: ?[3][3]u8 = null,
+ sweep_fg: ?[3][3]u8 = null,
+ /// A pixel shell's rules, in pixels (2 unset); the rule between a tag
+ /// and its body is always one.
+ rule_px: ?u8 = null,
+ /// A grip is acme's button: `box` filled when focused, else a ring of
+ /// `box_border` (unset: `box_dim`) round the tag's ground; `box_dirty`
+ /// (unset: `diagnostic_warning`, then `num`) fills it, in either ring,
+ /// while its file is unsaved.
+ box_border: ?[3]u8 = null,
+ box_dirty: ?[3]u8 = null,
+ /// The rule between a pane's tag and its body, one pixel, quieter than
+ /// the rules between panes (acme's #8888cc under its 2px black ones).
+ /// Unset, halfway between the tag's ground and the page.
+ tag_rule: ?[3]u8 = null,
+ /// A pixel shell's scroll column, the grip's button over the scrollbar,
+ /// in pixels at a 17px tagline, the thumb one narrower (acme's 12px
+ /// Scrollwid, the default).
+ rail_px: ?u8 = null,
};
const curated = struct {
@@ -110,6 +133,8 @@ fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme {
}
pub const themes = fold(curated) ++ fold(@import("generated_themes"));
+/// The first `native_count` of `themes` are pardes's own; the rest imported.
+pub const native_count = @typeInfo(curated).@"struct".decls.len;
/// Original and classic-inspired Pardes palettes precede retained imports.
pub const native_theme_count = 15;
diff --git a/src/config.zig b/src/config.zig
index 01a5daf8..24c48770 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -119,6 +119,10 @@ pub const leader_path = paths: {
if (builtins.capabilities.window_opacity) table.set(.WindowOpacity, null);
if (builtins.capabilities.window_blur) table.set(.WindowBlur, null);
if (builtins.capabilities.ligatures) table.set(.Ligatures, null);
+ if (builtins.capabilities.lift) table.set(.Lift, null);
+ table.set(.Motion, null);
+ table.set(.InactiveDim, null);
+ if (builtins.capabilities.lift) table.set(.GripWidth, null);
if (builtins.capabilities.panel_transitions) {
table.set(.PanelSlide, "as");
table.set(.PanelZoom, "az");
@@ -621,6 +625,24 @@ pub const Runtime = struct {
/// A font's programming ligatures (`->`, `!=`) draw across their cells.
/// Only a shell that shapes text has the setting (`Capabilities.ligatures`).
ligatures: bool = true,
+ /// How the focused pane and the notices stand off the page (G1,
+ /// docs/render-pipeline.md §9.1), or off. Opt-in until the focus lift's
+ /// default is decided. Only a pixel shell draws it (`Capabilities.lift`).
+ lift: LiftStyle = .off,
+ /// Blends the unfocused panes' text toward each cell's own ground,
+ /// percent (0 off): the focused pane wins by comparison, its tag tint
+ /// untouched. Kept above min(each cell's contrast, 4.5).
+ inactive_dim: u8 = 0,
+ /// The grip's button and the scrollbar under it, one width, as a percent
+ /// of the theme's `rail_px` (acme's 12px Scrollwid at a 17px tagline):
+ /// a pixel shell's (docs/themes.md).
+ grip_width: u16 = 150,
+ /// How the fx track's animations move (animation.Motion): off for
+ /// reduced motion.
+ /// Smooth by default, tuned so arrivals land inside §8.1's 220 ms; the
+ /// slower flavours are a choice.
+ motion: animation.Motion.Flavour = .smooth,
+
/// Builtins announce themselves on the message row.
verbose: bool = true,
/// A message falls into its row and dissolves out of it.
@@ -678,6 +700,16 @@ pub const Runtime = struct {
/// A setting that is one of a few words, each an enum field of Runtime.
pub const Choice = enum { placement, boot_shell };
+ /// shadow: a soft drop shadow. rim: a light hairline along the top of
+ /// the focused pane's tag rows. auto: a shadow on a light page; on a
+ /// dark one, where a shadow has nothing to darken, the other panes
+ /// recede instead (InactiveDim, at `auto_dim` unless it is set).
+ pub const LiftStyle = enum(u8) { off, shadow, rim, auto };
+ /// What `Lift auto` dims the unfocused panes by on a dark page when
+ /// InactiveDim is 0: enough to read at a glance (the user's own init
+ /// sets 35), not so much that they stop being worth reading.
+ pub const auto_dim: u8 = 30;
+
/// Each pass the bundled Crt or a Shadertoy file.
pub const Post = struct {
pub const max = 8;
@@ -822,6 +854,8 @@ pub const Runtime = struct {
window_blur: bool = false,
/// The shell shapes text, so it can draw ligatures and stop drawing them.
ligatures: bool = false,
+ /// The shell draws soft shadows under what stands off the page.
+ lift: bool = false,
};
pub const Capability = std.meta.FieldEnum(Capabilities);
@@ -843,6 +877,13 @@ pub const Runtime = struct {
window_blur,
message_ms: MessageTime,
dump_dir,
+ /// A lift style, `on` (auto) or `off`; bare flips auto and off.
+ lift,
+ /// A motion flavour.
+ motion,
+ /// A percentage, 0 through 100.
+ inactive_dim,
+ grip_width,
transition: animation.Transition,
scene: Scene,
/// A Shadertoy file into the post chain or out of it; `off` empties
@@ -877,6 +918,10 @@ pub const Runtime = struct {
.{ .word = "Verbose", .action = .{ .toggle = .verbose } },
.{ .word = "MessageAnimation", .action = .{ .toggle = .message_animation } },
.{ .word = "Ligatures", .action = .{ .toggle = .ligatures }, .availability = .ligatures },
+ .{ .word = "Lift", .action = .lift, .availability = .lift },
+ .{ .word = "Motion", .action = .motion },
+ .{ .word = "InactiveDim", .action = .inactive_dim },
+ .{ .word = "GripWidth", .action = .grip_width, .availability = .lift },
.{ .word = "MessageLinger", .action = .{ .message_ms = .linger } },
.{ .word = "MessageFall", .action = .{ .message_ms = .fall } },
.{ .word = "MessageDissolve", .action = .{ .message_ms = .dissolve } },
@@ -940,6 +985,8 @@ pub const Runtime = struct {
@compileError("scene effects must use the scene-shader capability"),
// A toggle is plain state any shell can hold, except one for
// what only some shells draw: that one exists where they do.
+ .lift, .grip_width => if (setting.availability != .lift)
+ @compileError("Lift and GripWidth must use the lift capability (a pixel shell's)"),
.toggle => |field| if (field == .ligatures) {
if (setting.availability != .ligatures)
@compileError("the ligatures toggle must use the ligatures capability");
@@ -1073,6 +1120,31 @@ pub const Runtime = struct {
std.meta.stringToEnum(T, text) orelse return false;
},
},
+ .lift => {
+ const text = std.mem.trim(u8, argument orelse "", " \t\r\n");
+ state.lift = if (text.len == 0)
+ (if (state.lift == .off) .auto else .off)
+ else if (std.mem.eql(u8, text, "on"))
+ .auto
+ else
+ std.meta.stringToEnum(LiftStyle, text) orelse return false;
+ },
+ .motion => {
+ const text = std.mem.trim(u8, argument orelse return false, " \t\r\n");
+ state.motion = std.meta.stringToEnum(animation.Motion.Flavour, text) orelse return false;
+ },
+ .inactive_dim => {
+ const text = std.mem.trim(u8, argument orelse return false, " \t\r\n%");
+ const percent = std.fmt.parseInt(u8, text, 10) catch return false;
+ if (percent > 100) return false;
+ state.inactive_dim = percent;
+ },
+ .grip_width => {
+ const text = std.mem.trim(u8, argument orelse return false, " \t\r\n%");
+ const percent = std.fmt.parseInt(u16, text, 10) catch return false;
+ if (percent < 50 or percent > 300) return false;
+ state.grip_width = percent;
+ },
.theme, .font => return false,
}
return true;
@@ -1245,6 +1317,13 @@ pub const Runtime = struct {
.choice => |which| switch (which) {
inline else => |field| try out.print("{s} {s}\n", .{ setting.word, @tagName(@field(state, @tagName(field))) }),
},
+ .lift => if (setting.enabled(context.capabilities))
+ try out.print("{s} {s}\n", .{ setting.word, @tagName(state.lift) })
+ else
+ try out.print("{s} unsupported\n", .{setting.word}),
+ .motion => try out.print("{s} {s}\n", .{ setting.word, @tagName(state.motion) }),
+ .inactive_dim => try out.print("{s} {d}\n", .{ setting.word, state.inactive_dim }),
+ .grip_width => try out.print("{s} {d}\n", .{ setting.word, state.grip_width }),
};
if (context.startup_config_path) |path|
@@ -1376,6 +1455,21 @@ pub const Runtime = struct {
try std.testing.expectEqual(@as(u8, 0), state.post.level(.crt));
}
+ test "Lift takes a style, on is auto, and bare flips it off and back to auto" {
+ var state: Runtime = .{};
+ const lift = find("Lift").?;
+ try std.testing.expect(apply(&state, lift, null));
+ try std.testing.expectEqual(LiftStyle.auto, state.lift);
+ try std.testing.expect(apply(&state, lift, "rim"));
+ try std.testing.expectEqual(LiftStyle.rim, state.lift);
+ try std.testing.expect(apply(&state, lift, null));
+ try std.testing.expectEqual(LiftStyle.off, state.lift);
+ try std.testing.expect(apply(&state, lift, "on"));
+ try std.testing.expectEqual(LiftStyle.auto, state.lift);
+ try std.testing.expect(!apply(&state, lift, "sparkle"));
+ try std.testing.expectEqual(LiftStyle.auto, state.lift);
+ }
+
test "tagline size validates before mutating live state" {
const setting = find("TaglineSize").?;
var state: Runtime = .{};
@@ -1478,6 +1572,7 @@ pub const Runtime = struct {
.window_opacity = true,
.window_blur = true,
.ligatures = true,
+ .lift = true,
},
.state = &state,
};
@@ -1500,6 +1595,10 @@ pub const Runtime = struct {
.placement => "pardes",
.boot_shell => "replace",
},
+ .lift => "rim",
+ .motion => "playful",
+ .inactive_dim => "35",
+ .grip_width => "200",
else => null,
};
try std.testing.expect(apply(&state, setting, argument));
@@ -1553,6 +1652,10 @@ pub const Runtime = struct {
.placement => "Placement pardes\n",
.boot_shell => "BootShell replace\n",
},
+ .lift => "Lift rim\n",
+ .motion => "Motion playful\n",
+ .inactive_dim => "InactiveDim 35\n",
+ .grip_width => "GripWidth 200\n",
.theme, .font => unreachable,
};
try std.testing.expect(std.mem.indexOf(u8, report, expected) != null);
diff --git a/src/detached/server.zig b/src/detached/server.zig
index 3921d8d9..857591d4 100644
--- a/src/detached/server.zig
+++ b/src/detached/server.zig
@@ -533,7 +533,7 @@ pub const Session = struct {
const pt = s.ptys[pane];
// A command whose exit is recorded is reaped: its group may be another's.
if (pt.fd < 0 or pt.cmd.exited) return;
- host_io.signalTty(pt.pid, pt.fd, sig);
+ host_io.signalTty(pt.pid, pt.fd, sig, pt.cmd.watched);
}
fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool {
@@ -706,16 +706,14 @@ pub const Session = struct {
// it would hang up one that runs on without it.
// Its exit, if it came first, is told now its output is in.
if (pt.cmd.watched) return host_io.commandEof(s.core, &s.ptys, pane, s, closeWatched);
- // Unwatched, a command's exit is read here, as its end.
+ // Unwatched, a command's exit is read here, as its end, and a
+ // shell's, for a run waiting on it and the log.
const unwatched = if (s.core.panes[pane]) |pn| pn.command != null else false;
- var status: ?u8 = null;
- if (unwatched) {
- status = host_io.exitStatus(pt.pid, 100);
- if (status != null) pt.pid = 0; // reaped: no shell to retire
- }
+ const status = host_io.exitStatus(pt.pid, 100);
+ if (status != null) pt.pid = 0; // reaped: no shell to retire
s.closePty(pane);
s.harvest();
- if (unwatched) s.core.update(.{ .exited = .{ .pane = pane, .status = status } });
+ if (unwatched or status != null) s.core.update(.{ .exited = .{ .pane = pane, .status = status } });
s.core.update(.{ .eof = .{ .pane = pane } });
}
diff --git a/src/detached/wire.zig b/src/detached/wire.zig
index b1b81eda..c0e17054 100644
--- a/src/detached/wire.zig
+++ b/src/detached/wire.zig
@@ -11,6 +11,19 @@ const limits = @import("../memory.zig").limits;
/// line), and a frame carries the core's placed regions after its layers.
pub const version: u16 = 8;
+/// The Chrome palette is carried field by field as its declaration lists
+/// them, so any change to it changes the wire: bump `version`, then this.
+const chrome_layout: u64 = 0x7e3d0952317bfee;
+comptime {
+ @setEvalBranchQuota(10_000);
+ var hash = std.hash.Fnv1a_64.init();
+ for (@typeInfo(pardes.Chrome).@"struct".fields) |field| {
+ hash.update(field.name);
+ hash.update(@typeName(field.type));
+ }
+ if (hash.final() != chrome_layout) @compileError(std.fmt.comptimePrint("pardes.Chrome changed (layout 0x{x}): bump wire.version, then chrome_layout", .{hash.final()}));
+}
+
pub const Error = error{ Truncated, Overlong, BadTag, BadValue, Trailing, NoSpace };
pub const max_cols: u16 = 512;
@@ -248,11 +261,17 @@ pub const Frame = struct {
region.owner = try r.getByte();
region.serial = try r.getU32();
region.rect = .{ .x = try r.getU16(), .y = try r.getU16(), .w = try r.getU16(), .h = try r.getU16() };
- region.active = try r.getBool();
+ const flags = try r.getByte();
+ if (flags > 3) return error.BadValue;
+ region.active = flags & 1 != 0;
+ region.dirty = flags & 2 != 0;
region.thumb_y = try r.getU16();
region.thumb_h = try r.getU16();
region.notice = try r.getByte();
region.clip = try r.getU16();
+ region.lift = @bitCast(try r.getU32());
+ // A springy flavour overshoots, and a wind-up dips below.
+ if (!(region.lift >= -1 and region.lift <= 2)) return error.BadValue;
if (region.rect.x > f.cols or region.rect.w > f.cols - region.rect.x or
region.rect.y > f.rows or region.rect.h > f.rows - region.rect.y or
region.clip > region.rect.w or region.thumb_y > region.rect.h or region.thumb_h > region.rect.h - region.thumb_y) return error.BadValue;
@@ -265,6 +284,8 @@ pub const Frame = struct {
rgb.* = .{ 0, 0, 0 };
for (&rgb.*.?) |*channel| channel.* = try r.getByte();
}
+ } else if (field.type == u8) {
+ rgb.* = try r.getByte();
} else for (rgb) |*channel| channel.* = try r.getByte();
}
try r.end();
@@ -723,7 +744,7 @@ pub fn layersBound(layers: []const pardes.Layer, tags: []const pardes.Layer, reg
return n + regions.len * region_len + chrome_len;
}
-const region_len = 1 + 1 + 4 + 8 + 1 + 2 + 2 + 1 + 2;
+const region_len = 1 + 1 + 4 + 8 + 1 + 2 + 2 + 1 + 2 + 4;
/// The palette: each colour three bytes, a theme's optional ones a flag
/// before them.
const chrome_len = @typeInfo(pardes.Chrome).@"struct".fields.len * 4;
@@ -802,17 +823,20 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point
try w.putU16(region.rect.y);
try w.putU16(region.rect.w);
try w.putU16(region.rect.h);
- try w.putBool(region.active);
+ try w.putByte(@as(u8, @intFromBool(region.active)) | @as(u8, @intFromBool(region.dirty)) << 1);
try w.putU16(region.thumb_y);
try w.putU16(region.thumb_h);
try w.putByte(region.notice);
try w.putU16(region.clip);
+ try w.putU32(@bitCast(region.lift));
}
inline for (@typeInfo(pardes.Chrome).@"struct".fields) |field| {
const rgb = @field(chrome, field.name);
if (field.type == ?[3]u8) {
try w.putBool(rgb != null);
if (rgb) |value| for (value) |channel| try w.putByte(channel);
+ } else if (field.type == u8) {
+ try w.putByte(rgb);
} else for (rgb) |channel| try w.putByte(channel);
}
}
@@ -1890,10 +1914,10 @@ test "detached wire carries compact body rows and exact pointer metrics" {
var tag_cells: [3]pardes.Cell = @splat(.{});
tag_cells[2] = logical[10];
const tag: pardes.Layer = .{ .kind = .column, .id = 1, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 1 }, .cols = 3, .rows = 1, .bg = .{ 11, 22, 33 }, .cells = &tag_cells, .cursor = .{ .x = 2, .y = 0, .bar = true } };
- const placed = [_]pardes.Region{.{ .kind = .rail, .owner = 3, .serial = 42, .rect = .{ .x = 0, .y = 1, .w = 2, .h = 3 }, .active = true, .thumb_y = 1, .thumb_h = 2, .clip = 1 }};
+ const placed = [_]pardes.Region{.{ .kind = .rail, .owner = 3, .serial = 42, .rect = .{ .x = 0, .y = 1, .w = 2, .h = 3 }, .active = true, .dirty = true, .thumb_y = 1, .thumb_h = 2, .clip = 1, .lift = 0.5 }};
const out = try gpa.alloc(u8, frameBound(2, 4) + layersBound(&.{layer}, &.{tag}, &placed));
defer gpa.free(out);
- const palette: pardes.Chrome = .{ .page = .{ 1, 2, 3 }, .border = .{ 4, 5, 6 }, .tag_focus_bg = .{ 7, 8, 9 }, .scroll_thumb = .{ 10, 11, 12 } };
+ const palette: pardes.Chrome = .{ .page = .{ 1, 2, 3 }, .border = .{ 4, 5, 6 }, .tag_focus_bg = .{ 7, 8, 9 }, .scroll_thumb = .{ 10, 11, 12 }, .rule_px = 3, .tag_rule = .{ 13, 14, 15 }, .rail_px = 12, .grip_border = .{ 16, 17, 18 }, .grip_focus_ring = .{ 19, 20, 21 }, .grip_dirty = .{ 22, 23, 24 }, .lift_style = 2 };
const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag}, &placed, palette);
const packet = (try framed(encoded)).?;
const frame = (try decodeServer(packet.tag, packet.payload)).frame;
diff --git a/src/draw.zig b/src/draw.zig
index 8f2f95f9..963d61c1 100644
--- a/src/draw.zig
+++ b/src/draw.zig
@@ -18,6 +18,7 @@ const mouse = @import("mouse.zig");
const tagline = @import("tagline.zig");
const body_layer = @import("body_layer.zig");
const Messages = @import("Messages.zig");
+const builtins = @import("builtins.zig");
const Color = @import("surface.zig").Color;
const FontRole = @import("surface.zig").FontRole;
const CellStyle = @import("surface.zig").CellStyle;
@@ -57,7 +58,7 @@ pub fn place(p: *Pardes, s: *Surface) void {
const active = id == p.active;
const tag_y = p.tagTop(pane, r);
const body_y = p.bodyTop(pane, r);
- s.addRegion(.{ .kind = .grip, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = tag_y, .w = config.GUTTER, .h = pane.tag_rows } });
+ s.addRegion(.{ .kind = .grip, .owner = owner, .serial = pane.serial, .active = active, .dirty = tagline.dirty(pane), .rect = .{ .x = r.x, .y = tag_y, .w = config.GUTTER, .h = pane.tag_rows } });
s.addRegion(.{ .kind = .tag, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = tag_y, .w = r.w - config.GUTTER, .h = pane.tag_rows } });
// A folded pane is its tag rows and nothing else.
if (pane.collapsed) continue;
@@ -119,6 +120,29 @@ pub fn place(p: *Pardes, s: *Surface) void {
s.addRegion(.{ .kind = .workspace_tag, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = p.topBarHeight() } });
// A column carried to the window's right edge shows where it lands as a
// rail in the last cell: the layers under it stop a cell short.
+ // Lift: the focused pane rises, the others settle back, each on its
+ // spring; a notice floats while Lift is on.
+ const lift = liftStyle(p);
+ for (&p.panes, &p.lifts, &p.lift_serials, 0..) |slot, *spring, *serial, id| {
+ const pane = slot orelse continue;
+ if (serial.* != pane.serial) {
+ serial.* = pane.serial;
+ spring.* = .{};
+ }
+ // How focused it is, which both a lift and a dim follow: at this
+ // frame's own time, not the stepped animations' 16 ms grid. A grid
+ // snaps (it has no in-between), and so does nothing that shows it.
+ const shown = lift != .off or inactiveDim(p) != 0;
+ const motion = animation.Motion.of(if (shown and builtins.capabilities.lift) p.settings.motion else .off);
+ // A flavour exaggerates what is only a few pixels (Motion.gain).
+ spring.retarget(if (id == p.active) animation.Motion.of(p.settings.motion).gain else 0, p.now_ns, motion);
+ _ = spring.step(p.now_ns);
+ }
+ for (s.regions[0..s.nregions]) |*region| switch (region.kind) {
+ .grip, .tag, .body, .rail => region.lift = if (lift != .off) std.math.clamp(p.lifts[region.owner].value(p.now_ns), -1, 2) else 0,
+ .notice => region.lift = if (lift != .off) 1 else 0,
+ else => {},
+ };
if (p.drag == .column_move) rail: {
const d = p.drag.column_move;
const source = layout.columnBySerial(p, d.serial) orelse break :rail;
@@ -137,6 +161,185 @@ pub fn place(p: *Pardes, s: *Surface) void {
}
}
+/// A pane's grip in the grid: `box` focused, else halfway between its ring
+/// and `box_dim`, so it shows on its tag without the ring a pixel shell
+/// draws (gui.zig draws its button from the region and Chrome instead).
+fn gripBg(p: *const Pardes, active: bool) [3]u8 {
+ const chrome = p.chromeTheme();
+ if (active) return chrome.box;
+ return colors.mix(boxBorder(p), chrome.box_dim);
+}
+
+/// An unfocused grip's ring, and a dirty one's fill (colors.Theme).
+pub fn boxBorder(p: *const Pardes) [3]u8 {
+ return p.theme().box_border orelse p.chromeTheme().box_dim;
+}
+pub fn boxDirty(p: *const Pardes) [3]u8 {
+ const th = p.theme();
+ return th.box_dirty orelse th.diagnostic_warning orelse th.num;
+}
+
+fn inkOn(bg: [3]u8) [3]u8 {
+ const lum = (@as(u16, bg[0]) * 3 + @as(u16, bg[1]) * 6 + @as(u16, bg[2])) / 10;
+ return if (lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff };
+}
+
+/// The Lift style as drawn: `auto` is a shadow on a light page, and on a
+/// dark one, where a shadow has nothing to darken, no lift at all (the other
+/// panes recede instead, `inactiveDim`).
+pub fn liftStyle(p: *const Pardes) config.Runtime.LiftStyle {
+ return switch (p.settings.lift) {
+ .auto => if (darkPage(p)) .off else .shadow,
+ else => |style| style,
+ };
+}
+
+/// How far, in percent, the unfocused panes' text fades toward its ground:
+/// InactiveDim, or under `Lift auto` on a dark page `auto_dim` while that
+/// is unset.
+pub fn inactiveDim(p: *const Pardes) u8 {
+ if (p.settings.inactive_dim != 0) return p.settings.inactive_dim;
+ return if (p.settings.lift == .auto and darkPage(p)) config.Runtime.auto_dim else 0;
+}
+
+fn darkPage(p: *const Pardes) bool {
+ return if (p.theme().bg) |bg| luminance(bg) < 0.18 else true;
+}
+
+/// sRGB channel to linear light.
+const linear: [256]f32 = blk: {
+ @setEvalBranchQuota(100_000);
+ var table: [256]f32 = undefined;
+ for (&table, 0..) |*out, i| {
+ const c = @as(f64, @floatFromInt(i)) / 255;
+ out.* = @floatCast(if (c <= 0.04045) c / 12.92 else std.math.pow(f64, (c + 0.055) / 1.055, 2.4));
+ }
+ break :blk table;
+};
+
+fn luminance(rgb: [3]u8) f32 {
+ return 0.2126 * linear[rgb[0]] + 0.7152 * linear[rgb[1]] + 0.0722 * linear[rgb[2]];
+}
+
+fn contrast(a: [3]u8, b: [3]u8) f32 {
+ const x = luminance(a);
+ const y = luminance(b);
+ return (@max(x, y) + 0.05) / (@min(x, y) + 0.05);
+}
+
+/// Pane `id`'s text, `amount` (0 to 1) of the way to its ground, on the grid
+/// and on every layer of it: its tag, its body, its notices. Its grip keeps
+/// its colour, which already says it is not the focused one.
+fn recede(p: *Pardes, s: *Surface, id: usize, amount: f32) void {
+ const th = p.theme();
+ const chrome = p.chromeTheme();
+ const r = p.rects[id];
+ const palette = th.palette;
+ var memo: Memo = .{};
+ const text_x = r.x + @min(config.GUTTER, r.w);
+ var row = r.y;
+ while (row < @min(@as(u32, r.y) + r.h, s.rows)) : (row += 1) {
+ var col = text_x;
+ while (col < @min(@as(u32, r.x) + r.w, s.cols)) : (col += 1) {
+ const cell = s.at(col, row);
+ if (cell.default) continue;
+ const tagline_role = cell.style.font_role == .tagline;
+ fadeStyle(&cell.style, if (tagline_role) chrome.tag_fg else th.fg, if (tagline_role) chrome.tag_bg else th.bg, palette, amount, &memo);
+ }
+ }
+ const body = &s.body_layers[id];
+ if (body.rows != 0) for (body.cells[0..@as(usize, body.cols) * body.rows]) |*cell| fadeStyle(&cell.style, th.fg, th.bg, palette, amount, &memo);
+ const tag = &s.tag_layers[id];
+ if (tag.rows != 0) for (tag.cells[0..@as(usize, tag.cols) * tag.rows]) |*cell| fadeStyle(&cell.style, chrome.tag_fg, tag.bg, palette, amount, &memo);
+ for (0..Pane.Notices.max) |i| {
+ const notice = &s.tag_layers[NOTICE_LAYER_BASE + id * Pane.Notices.max + i];
+ if (notice.rows != 0) for (notice.cells[0..@as(usize, notice.cols) * notice.rows]) |*cell| fadeStyle(&cell.style, chrome.tag_fg, notice.bg, palette, amount, &memo);
+ }
+}
+
+/// The few ink and paper pairs a pane has, each faded once a frame.
+const Memo = struct {
+ keys: [32]?[6]u8 = @splat(null),
+ values: [32][3]u8 = undefined,
+
+ fn faded(memo: *Memo, ink: [3]u8, paper: [3]u8, amount: f32) [3]u8 {
+ const key = ink ++ paper;
+ const slot = (@as(usize, ink[0]) *% 31 +% ink[1] *% 7 +% ink[2] +% @as(usize, paper[0]) *% 13 +% paper[1] *% 3 +% paper[2]) % memo.keys.len;
+ if (memo.keys[slot]) |held| if (std.mem.eql(u8, &held, &key)) return memo.values[slot];
+ // Contrast falls as the ink nears its paper: the most of `amount`
+ // that keeps the pair at its own contrast or 4.5, whichever is lower.
+ const floor = @min(contrast(ink, paper), 4.5);
+ var t = amount;
+ if (contrast(blend(ink, paper, t), paper) < floor) {
+ var lo: f32 = 0;
+ var hi: f32 = t;
+ for (0..8) |_| {
+ const mid = (lo + hi) / 2;
+ if (contrast(blend(ink, paper, mid), paper) >= floor) lo = mid else hi = mid;
+ }
+ t = lo;
+ }
+ memo.keys[slot] = key;
+ memo.values[slot] = blend(ink, paper, t);
+ return memo.values[slot];
+ }
+};
+
+/// A cell's ink blended `amount` of the way toward its paper, never below
+/// the pair's own contrast or 4.5, whichever is lower (docs/effects.md):
+/// faint resolved first, reverse honoured, as InactiveDim always did.
+fn fadeStyle(style: *CellStyle, default_fg: ?[3]u8, default_bg: ?[3]u8, palette: ?[16][3]u8, amount: f32, memo: *Memo) void {
+ if (style.invisible) return;
+ var fg = resolved(style.fg, default_fg, palette) orelse return;
+ const bg = resolved(style.bg, default_bg, palette) orelse return;
+ if (style.dim) for (&fg) |*channel| {
+ channel.* = @intCast(@as(u16, channel.*) * 6 / 10);
+ };
+ const ink = if (style.reverse) bg else fg;
+ const paper = if (style.reverse) fg else bg;
+ const rgb = memo.faded(ink, paper, amount);
+ if (style.reverse) {
+ style.bg = .{ .rgb = rgb };
+ if (style.dim) style.fg = .{ .rgb = fg };
+ } else style.fg = .{ .rgb = rgb };
+ style.dim = false;
+}
+
+/// `t` of the way from one colour to the other in linear light (§8.2), so a
+/// coloured ink does not muddy on its way to the paper.
+fn blend(from: [3]u8, to: [3]u8, t: f32) [3]u8 {
+ var out: [3]u8 = undefined;
+ for (&out, from, to) |*o, a, b| {
+ const l = linear[a] * (1 - t) + linear[b] * t;
+ o.* = encoded[@intFromFloat(@round(std.math.clamp(l, 0, 1) * (encoded.len - 1)))];
+ }
+ return out;
+}
+
+/// Linear light back to an sRGB channel, in 4096 steps.
+const encoded: [4096]u8 = blk: {
+ @setEvalBranchQuota(1_000_000);
+ var table: [4096]u8 = undefined;
+ for (&table, 0..) |*out, i| {
+ const l = @as(f64, @floatFromInt(i)) / 4095;
+ const c = if (l <= 0.0031308) l * 12.92 else 1.055 * std.math.pow(f64, l, 1.0 / 2.4) - 0.055;
+ out.* = @intFromFloat(@round(c * 255));
+ }
+ break :blk table;
+};
+
+fn resolved(color: Color, default: ?[3]u8, palette: ?[16][3]u8) ?[3]u8 {
+ return switch (color) {
+ .rgb => |rgb| rgb,
+ .default => default,
+ // A theme's own sixteen, as the painters give them (Terminal.palColor).
+ .index => |index| if (palette != null and index < 16) palette.?[index] else if (comptime pardes.terminal_panes) blk: {
+ const rgb = @import("ghostty-vt").color.default[index];
+ break :blk .{ rgb.r, rgb.g, rgb.b };
+ } else null,
+ };
+}
+
pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
panes.File.refreshHighlights(p);
const s = &p.surface;
@@ -184,6 +387,16 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
.tag_fg = chrome.tag_fg,
.scroll_track = chrome.scroll_track,
.scroll_thumb = chrome.scroll_thumb,
+ .accent = chrome.box,
+ .sel_fg = p.theme().sel_fg,
+ .sel_bg = p.theme().sel_bg,
+ .lift_style = @intFromEnum(liftStyle(p)),
+ .rule_px = p.theme().rule_px orelse 2,
+ .grip_border = boxBorder(p),
+ .grip_focus_ring = chrome.box,
+ .grip_dirty = boxDirty(p),
+ .tag_rule = p.theme().tag_rule,
+ .rail_px = @intCast(@min(255, @as(u32, p.theme().rail_px orelse 12) * p.settings.grip_width / 100)),
};
place(p, s);
// Separators have their own role; a scrollbar need not frame the page.
@@ -204,6 +417,13 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
if (region.kind == .notice) try joinNotice(p, s, arena, region);
}
+ for (p.panes, p.lifts, 0..) |slot, spring, id| {
+ if (slot == null or id == p.active) continue;
+ const focus = spring.value(p.now_ns) / animation.Motion.of(p.settings.motion).gain;
+ const amount = @as(f32, @floatFromInt(inactiveDim(p))) / 100 * std.math.clamp(1 - focus, 0, 1);
+ if (amount > 0.001) recede(p, s, id, amount);
+ }
+
try paintHeaders(p, s, arena);
switch (p.drag) {
@@ -479,16 +699,30 @@ fn renderPane(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r:
if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } });
}
- const box_bg = if (active) chrome.box else chrome.box_dim;
- const box_lum = (@as(u16, box_bg[0]) * 3 + @as(u16, box_bg[1]) * 6 + @as(u16, box_bg[2])) / 10;
- const box_ink: [3]u8 = if (box_lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff };
+ // The grip is acme's button: the focused pane's filled, the others'
+ // a ring round the tag's ground, a dirty one's the dirty colour inside
+ // its ring. A pixel shell draws the ring (gui.zig gripDecor); a grid,
+ // which cannot, marks dirty in the grip's second cell with a bold `*`
+ // on the dirty colour, a cue that survives any palette.
+ const band = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg;
+ const dirty = tagline.dirty(pane);
+ const box_bg = gripBg(p, active);
const box_style: CellStyle = .{
- .fg = .{ .rgb = box_ink },
+ .fg = .{ .rgb = inkOn(box_bg) },
.bg = .{ .rgb = box_bg },
// Grips keep physical grid dimensions, independently of tag text.
.font_role = .tagline,
};
- s.fill(r.x, tag_y, config.GUTTER, pane.tag_rows, box_style);
+ // On the first row alone, as acme's button is; the rows under it are
+ // the tag band's, and still grab the pane (the region spans them all,
+ // as acme's button column does).
+ s.fill(r.x, tag_y, config.GUTTER, @min(1, pane.tag_rows), box_style);
+ if (dirty and pane.tag_rows > 0 and config.GUTTER > 1)
+ s.set(r.x + config.GUTTER - 1, tag_y, "*", .{ .fg = .{ .rgb = inkOn(boxDirty(p)) }, .bg = .{ .rgb = boxDirty(p) }, .bold = true, .font_role = .tagline });
+ s.fill(r.x, tag_y + 1, config.GUTTER, pane.tag_rows -| 1, .{
+ .bg = .{ .rgb = band },
+ .font_role = .tagline,
+ });
s.set(r.x, tag_y, switch (pane.body.mode) {
.normal => config.tag_normal,
.insert => config.tag_insert,
@@ -552,7 +786,8 @@ pub fn paintHeaders(p: *Pardes, s: *Surface, arena: std.mem.Allocator) !void {
const grip_bg = if (grabbed) chrome.column_box else chrome.column_box_dim;
const lum = (@as(u16, grip_bg[0]) * 3 + @as(u16, grip_bg[1]) * 6 + @as(u16, grip_bg[2])) / 10;
const ink: [3]u8 = if (lum > 140) .{ 0, 0, 0 } else .{ 255, 255, 255 };
- s.fill(p.col_x[c], region.rect.y, grip, column_rows, .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline });
+ s.fill(p.col_x[c], region.rect.y, grip, @min(1, column_rows), .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline });
+ s.fill(p.col_x[c], region.rect.y + 1, grip, column_rows -| 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline });
s.fill(p.col_x[c] + grip, region.rect.y, inset - grip, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline });
}
const top = if (p.header_focus and p.header_column == c) p.header_top else 0;
@@ -685,7 +920,7 @@ fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, layer: *pardes.Layer,
var cell = panes.File.rawDisplayCol(shown, bounds.lo) -| scroll;
const end = panes.File.rawDisplayCol(shown, bounds.hi) -| scroll;
while (cell < end and cell < columns) : (cell += 1) {
- surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg };
+ surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().tag_sel_bg orelse p.theme().sel_bg };
surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg };
}
};
@@ -695,7 +930,7 @@ fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, layer: *pardes.Layer,
var col = panes.File.rawDisplayCol(shown, span.lo) -| scroll;
const end = panes.File.rawDisplayCol(shown, span.hi) -| scroll;
while (col < end and col < columns) : (col += 1) {
- surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg };
+ surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().tag_sel_bg orelse p.theme().sel_bg };
surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg };
}
}
@@ -714,9 +949,9 @@ fn joinPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r
if (r.w <= TAG_TEXT_INSET) return paintPaneTag(p, s, arena, pane, r, id, active, capacity);
const chrome = p.chromeTheme();
const region = s.region(.tag, id).?;
- const tag = try tagline.tagText(p, arena, pane);
+ const tag = (try tagline.shownTag(p, arena, pane)).text;
const rows = pane.tag_rows;
- // As wide as the widest line it shows, and never narrower than its
+ // As wide as the widest row it shows, and never narrower than its
// viewport.
var widest: usize = 0;
var split = std.mem.splitScalar(u8, tag, '\n');
@@ -860,8 +1095,10 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa
// A row per line shown, from `tag_top`: the tag's first line carries
// the prefix, the rest are the tag's own text alone.
const top = pane.tag_top;
+ // Its lines wrapped at its width (tagline.shownTag): a row per row shown.
+ const shown = try tagline.shownTag(p, arena, pane);
var lines: [MAX_TAG_ROWS][]const u8 = @splat("");
- var split = std.mem.splitScalar(u8, whole, '\n');
+ var split = std.mem.splitScalar(u8, shown.text, '\n');
for (0..top) |_| _ = split.next();
for (lines[0..rows]) |*line| line.* = split.next() orelse "";
// The keyboard's caret, as a row and a byte offset in that row's
@@ -870,42 +1107,45 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa
const Caret = struct { row: usize, at: usize };
const caret: ?Caret = if (!active)
null
- else if (pane.prompt == .name)
- .{ .row = 0, .at = @min(lines[0].len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) }
- else if (pane.focus == .tag and pane.prompt == .none and pane.tag.cur_row >= top and pane.tag.cur_row < top + rows) caret: {
- const row: usize = @intCast(pane.tag.cur_row - top);
- break :caret .{ .row = row, .at = @min(lines[row].len, @as(usize, @intCast(@max(0, pane.tag.cur_col)))) };
+ else if (pane.prompt == .name) caret: {
+ // A drafted name is the prefix: its caret is on row 0 of the tag,
+ // the rows it wrapped onto included.
+ const at = modal.positionAt(shown.text, shown.fromReal(@intCast(@max(0, pane.input.cur_col))));
+ if (at.row < top or at.row >= top + rows) break :caret null;
+ break :caret .{ .row = at.row - top, .at = @min(lines[at.row - top].len, at.col) };
+ }
+ else if (pane.focus == .tag and pane.prompt == .none) caret: {
+ const real = modal.offsetAt(whole, .{ .row = @intCast(@max(0, pane.tag.cur_row)), .col = @intCast(@max(0, pane.tag.cur_col)) });
+ const at = modal.positionAt(shown.text, shown.fromReal(real));
+ if (at.row < top or at.row >= top + rows) break :caret null;
+ const row: usize = at.row - top;
+ break :caret .{ .row = row, .at = @min(lines[row].len, at.col) };
} else null;
- if (caret) |at| {
- const line = lines[at.row];
- const col = panes.File.rawDisplayCol(line, at.at);
- var scroll: usize = pane.tag_scroll;
- if (col < scroll) scroll = col;
- if (col >= scroll + capacity) scroll = col - capacity + 1;
- var raw = panes.File.rawAtDisplay(line, scroll);
- scroll = panes.File.rawDisplayCol(line, raw);
- if (col >= scroll + capacity) {
- raw = modal.nextGrapheme(line, raw);
- scroll = panes.File.rawDisplayCol(line, raw);
- }
- // A line past a u16 of columns keeps its caret off the band.
- pane.tag_scroll = @intCast(@min(scroll, std.math.maxInt(u16)));
- } else if (pane.focus != .tag) pane.tag_scroll = 0;
+ // Its rows are wrapped to fit: a pane tag never scrolls sideways.
+ _ = capacity;
+ pane.tag_scroll = 0;
for (lines[0..rows], 0..) |line, row| {
// The scroll is the caret's line's: a shorter row shows nothing.
const start = @min(line.len, panes.File.rawAtDisplay(line, pane.tag_scroll));
_ = s.print(tx, tag_y + @as(u16, @intCast(row)), tw, line[start..], tag_style);
}
- const tag = lines[0];
- if (top == 0) if (tagTintRange(pane, tag, prefix_len)) |name| {
- const lo = @max(name.lo, panes.File.rawAtDisplay(tag, pane.tag_scroll));
- const hi = @min(name.hi, tag.len);
- if (lo < hi) {
- var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll;
- const end = panes.File.rawDisplayCol(tag, hi) -| pane.tag_scroll;
- const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg;
+ // The name's tint, in the tag's first line, over whichever rows it
+ // wrapped onto.
+ const first_line = whole[0 .. std.mem.indexOfScalar(u8, whole, '\n') orelse whole.len];
+ if (tagTintRange(pane, first_line, prefix_len)) |name| if (name.lo < name.hi) {
+ const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg;
+ const a = modal.positionAt(shown.text, shown.fromReal(name.lo));
+ const b = modal.positionAt(shown.text, shown.fromReal(@min(name.hi, first_line.len)));
+ var row = @max(a.row, top);
+ while (row <= b.row and row < top + rows) : (row += 1) {
+ const line = lines[row - top];
+ const from = if (row == a.row) a.col else 0;
+ const to = if (row == b.row) b.col else line.len;
+ var col = panes.File.rawDisplayCol(line, @min(from, line.len));
+ const end = panes.File.rawDisplayCol(line, @min(to, line.len));
+ const y = tag_y + @as(u16, @intCast(row - top));
while (col < end and col < tw) : (col += 1)
- s.at(tx + @as(u16, @intCast(col)), tag_y).style.fg = .{ .rgb = name_fg };
+ s.at(tx + @as(u16, @intCast(col)), y).style.fg = .{ .rgb = name_fg };
}
};
if (p.look_hover_preview) |preview| {
@@ -923,8 +1163,8 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa
const lo = @min(range.anchor, range.head);
const hi = @max(range.anchor, range.head);
if (modal.nextGrapheme(whole, lo) >= hi) continue;
- const first = modal.positionAt(whole, lo);
- const last = modal.positionAt(whole, hi);
+ const first = modal.positionAt(shown.text, shown.fromReal(lo));
+ const last = modal.positionAt(shown.text, shown.fromReal(hi));
var row = @max(first.row, top);
while (row <= last.row and row < top + rows) : (row += 1) {
const line = lines[row - top];
@@ -936,7 +1176,7 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa
while (col + pane.tag_scroll < end and col < tw) : (col += 1) {
const cell = s.at(tx + @as(u16, @intCast(col)), y);
cell.default = false;
- cell.style.bg = .{ .rgb = th.sel_bg };
+ cell.style.bg = .{ .rgb = th.tag_sel_bg orelse th.sel_bg };
cell.style.fg = .{ .rgb = th.sel_fg };
}
}
@@ -956,7 +1196,7 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa
for (pane.sel, 0..) |selection, button| {
if (selection.state == .none or (selection.state == .done and button != 0)) continue;
if (button != pardes.sel_slot or @min(selection.r0, selection.r1) >= rows) continue;
- body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, th.sel_bg, th.sel_fg);
+ body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, th.tag_sel_bg orelse th.sel_bg, th.sel_fg);
}
if (p.look_hover_preview) |preview| {
if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) {
@@ -965,10 +1205,100 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa
};
}
const sel_btn = body_layer.buttonTints(th);
+ const sel_ink = body_layer.buttonInks(th);
for (pane.sel, 0..) |selection, button| {
if (selection.state == .none or (selection.state == .done and button != 0)) continue;
if (button == pardes.sel_slot or @min(selection.r0, selection.r1) >= rows) continue;
- body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, sel_btn[button], th.sel_fg);
+ body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, sel_btn[button], sel_ink[button]);
+ }
+}
+
+test "an unfocused grip shows on its tag in the grid, in every native theme" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 });
+ defer p.deinit();
+ _ = try p.setTestFile("text\n");
+ p.newScratchBelow(0);
+ p.animate_theme_changes = false;
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ for (pardes.themes[0..colors.native_count], 0..) |theme, index| {
+ colors.setThemeIndex(p, index);
+ p.active = 1;
+ _ = arena.reset(.retain_capacity);
+ const s = try p.render(arena.allocator());
+ const r = p.rects[0];
+ const grip = s.at(r.x, p.tagTop(p.panes[0].?, r)).style.bg.rgb;
+ const ratio = contrast(grip, p.chromeTheme().tag_bg);
+ // ponytail: dark and helix have always had a faint grip (1.2:1); flagged, not changed here.
+ const floor: f32 = if (std.mem.eql(u8, theme.name, "dark") or std.mem.eql(u8, theme.name, "helix")) 1.2 else 1.5;
+ if (ratio < floor) std.debug.print("{s}: grip {d:.2}\n", .{ theme.name, ratio });
+ try std.testing.expect(ratio >= floor);
+ }
+}
+
+test "a grip's four states, focused or not and clean or dirty, are each its own in every native theme" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 30 });
+ defer p.deinit();
+ const pane = try p.setTestFile("text\n");
+ p.newScratchBelow(0);
+ p.animate_theme_changes = false;
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const State = struct { first: Cell, second: Cell, dirty: bool };
+ for (0..colors.native_count) |index| {
+ colors.setThemeIndex(p, index);
+ var states: [4]State = undefined;
+ for (&states, 0..) |*state, i| {
+ p.active = if (i & 1 == 0) 0 else 1;
+ const saved = pane.file.?.saved_revision;
+ pane.file.?.revision = if (i & 2 != 0) saved + 1 else saved;
+ _ = arena.reset(.retain_capacity);
+ const s = try p.render(arena.allocator());
+ const r = p.rects[0];
+ const y = p.tagTop(pane, r);
+ state.* = .{ .first = s.at(r.x, y).*, .second = s.at(r.x + 1, y).*, .dirty = s.region(.grip, 0).?.dirty };
+ try std.testing.expectEqual(i & 2 != 0, state.dirty);
+ if (i & 2 == 0) continue;
+ // Dirty is more than a shade: the grid prints a bold `*` (one
+ // grid for every shell; a pixel shell draws its button over it).
+ try std.testing.expectEqualStrings("*", state.second.grapheme());
+ try std.testing.expect(state.second.style.bold);
+ }
+ for (states, 0..) |a, i| for (states[i + 1 ..]) |b| {
+ const same = std.meta.eql(a.first.style.bg, b.first.style.bg) and a.second.visuallyEqual(&b.second);
+ try std.testing.expect(!same);
+ };
+ }
+ pane.file.?.revision = pane.file.?.saved_revision;
+}
+
+test "a tag of three rows has its grip on the first, and band under it that still grabs the pane" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ const pane = try p.setTestFile("text\n");
+ pane.tag.own = try std.testing.allocator.dupe(u8, "Second\nthird\nfourth");
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ for ([_]bool{ false, true }) |bottom| {
+ p.settings.tag_bottom = bottom;
+ p.sync();
+ _ = arena.reset(.retain_capacity);
+ const s = try p.render(arena.allocator());
+ const r = p.rects[0];
+ const top = p.tagTop(pane, r);
+ try std.testing.expect(pane.tag_rows >= 3);
+ const chrome = p.chromeTheme();
+ const band = if (p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg;
+ for (0..config.GUTTER) |dx| {
+ const x = r.x + @as(u16, @intCast(dx));
+ try std.testing.expectEqual(Color{ .rgb = chrome.box }, s.at(x, top).style.bg);
+ for (1..pane.tag_rows) |line| {
+ const cell = s.at(x, top + @as(u16, @intCast(line)));
+ try std.testing.expectEqual(Color{ .rgb = band }, cell.style.bg);
+ try std.testing.expectEqualStrings(" ", cell.grapheme());
+ }
+ }
+ try std.testing.expectEqual(pane.tag_rows, s.region(.grip, 0).?.rect.h);
}
}
@@ -1108,6 +1438,221 @@ test "a notice wider than its band, as a shader's compile error is, draws its en
}
}
+test "a lift is sampled at each frame's own time, and asks for frames only while it moves" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer p.deinit();
+ // A grid has no in-between: there a lift snaps.
+ if (!builtins.capabilities.lift) return error.SkipZigTest;
+ _ = try p.setTestFile("text\n");
+ p.newScratchBelow(0);
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const ms = std.time.ns_per_ms;
+ p.settings.lift = .shadow;
+ p.advance(1000 * ms);
+ _ = try p.render(arena.allocator());
+ p.advance(3000 * ms);
+ _ = arena.reset(.retain_capacity);
+ _ = try p.render(arena.allocator());
+ try std.testing.expect(!p.continuous());
+ p.active = 0;
+ var lifts: [3]f32 = undefined;
+ // 144 Hz: 6.9 ms apart, none of them on a 16 ms step of its own.
+ for (&lifts, 0..) |*lift, i| {
+ p.advance(3000 * ms + @as(u64, @intCast(i)) * 6_944_444);
+ _ = arena.reset(.retain_capacity);
+ const s = try p.render(arena.allocator());
+ lift.* = for (s.regionList()) |region| {
+ if (region.kind == .grip and region.owner == p.active) break region.lift;
+ } else return error.TestExpectedGrip;
+ try std.testing.expect(p.continuous());
+ }
+ try std.testing.expect(lifts[0] < lifts[1] and lifts[1] < lifts[2]);
+ // Settled, it is exactly up, and the core is idle again.
+ p.advance(5000 * ms);
+ _ = arena.reset(.retain_capacity);
+ _ = try p.render(arena.allocator());
+ try std.testing.expect(!p.continuous());
+ try std.testing.expectEqual(@as(?u64, null), p.nextWake());
+}
+
+/// The contrast a pane's first body glyph `g` is drawn at, on the grid.
+fn glyphContrast(p: *Pardes, s: *Surface, id: usize, g: u8) f32 {
+ const r = p.rects[id];
+ const th = p.theme();
+ var row = p.bodyTop(p.panes[id].?, r);
+ while (row < r.y + r.h) : (row += 1) {
+ var col = r.x + config.GUTTER;
+ while (col < r.x + r.w) : (col += 1) {
+ const cell = s.at(col, row);
+ if (cell.default or cell.len != 1 or cell.text[0] != g) continue;
+ var fg = resolved(cell.style.fg, th.fg orelse .{ 255, 255, 255 }, th.palette).?;
+ const bg = resolved(cell.style.bg, th.bg orelse .{ 0, 0, 0 }, th.palette).?;
+ if (cell.style.dim) for (&fg) |*channel| {
+ channel.* = @intCast(@as(u16, channel.*) * 6 / 10);
+ };
+ return contrast(fg, bg);
+ }
+ }
+ unreachable;
+}
+
+test "a focused pane's text is never at less contrast than an unfocused one's, in any Lift, dim or theme" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer p.deinit();
+ _ = try p.setTestFile("text\n");
+ p.newScratchBelow(0);
+ p.animate_theme_changes = false;
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const s_ns = std.time.ns_per_s;
+ var now: u64 = s_ns;
+ for (0..pardes.themes.len) |theme| {
+ colors.setThemeIndex(p, theme);
+ for (std.enums.values(config.Runtime.LiftStyle)) |style| for ([_]u8{ 0, 100 }) |dim| {
+ p.settings.lift = style;
+ p.settings.inactive_dim = dim;
+ var seen: [2]f32 = undefined;
+ for (&seen, [_]usize{ 0, 1 }) |*c, focus| {
+ p.active = focus;
+ _ = arena.reset(.retain_capacity);
+ _ = try p.render(arena.allocator());
+ now += 2 * s_ns;
+ p.advance(now);
+ _ = arena.reset(.retain_capacity);
+ c.* = glyphContrast(p, try p.render(arena.allocator()), 0, 't');
+ }
+ try std.testing.expect(seen[0] >= seen[1] - 1e-3);
+ // And the unfocused one is still legible.
+ try std.testing.expect(seen[1] >= @min(seen[0], 4.5) - 2e-2);
+ };
+ }
+}
+
+test "a dim leaves the focused pane's tag, grip and cells exactly as they were" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer p.deinit();
+ _ = try p.setTestFile("text\n");
+ p.newScratchBelow(0);
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ p.active = 0;
+ p.advance(std.time.ns_per_s);
+ const plain = try p.render(arena.allocator());
+ const r = p.rects[0];
+ var before: std.ArrayList(Cell) = .empty;
+ defer before.deinit(std.testing.allocator);
+ for (r.y..r.y + r.h) |row| try before.appendSlice(std.testing.allocator, plain.cells[row * plain.cols + r.x ..][0..r.w]);
+ const tag_bg = plain.tag_layers[0].bg;
+ const tag_cells = try std.testing.allocator.dupe(Cell, plain.tag_layers[0].cells[0 .. @as(usize, plain.tag_layers[0].cols) * plain.tag_layers[0].rows]);
+ defer std.testing.allocator.free(tag_cells);
+ p.settings.inactive_dim = 100;
+ p.advance(3 * std.time.ns_per_s);
+ const dimmed = try p.render(arena.allocator());
+ var i: usize = 0;
+ for (r.y..r.y + r.h) |row| for (dimmed.cells[row * dimmed.cols + r.x ..][0..r.w]) |cell| {
+ try std.testing.expect(cell.visuallyEqual(&before.items[i]));
+ i += 1;
+ };
+ try std.testing.expectEqual(tag_bg, dimmed.tag_layers[0].bg);
+ for (tag_cells, dimmed.tag_layers[0].cells[0..tag_cells.len]) |a, b| try std.testing.expect(a.visuallyEqual(&b));
+}
+
+test "a dim never takes a pair below its own contrast or 4.5" {
+ for (pardes.themes) |theme| {
+ const fg = theme.fg orelse continue;
+ const bg = theme.bg orelse continue;
+ for ([_]bool{ false, true }) |reverse| for ([_]bool{ false, true }) |faint| {
+ var style: CellStyle = .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .reverse = reverse, .dim = faint };
+ var ink = fg;
+ if (faint) for (&ink) |*channel| {
+ channel.* = @intCast(@as(u16, channel.*) * 6 / 10);
+ };
+ var memo: Memo = .{};
+ fadeStyle(&style, null, null, null, 1, &memo);
+ const after = contrast(style.fg.rgb, style.bg.rgb);
+ try std.testing.expect(after >= @min(contrast(ink, bg), 4.5) - 2e-2);
+ };
+ }
+}
+
+test "a dim leaves a pair already under 4.5 alone, and reads a theme's own sixteen" {
+ var memo: Memo = .{};
+ var low: CellStyle = .{ .fg = .{ .rgb = .{ 0x80, 0x80, 0x80 } }, .bg = .{ .rgb = .{ 0x60, 0x60, 0x60 } } };
+ fadeStyle(&low, null, null, null, 1, &memo);
+ try std.testing.expectEqual([3]u8{ 0x80, 0x80, 0x80 }, low.fg.rgb);
+ // acme's bright white is black: faded, it stays a grey, never a white.
+ const acme = for (pardes.themes) |theme| {
+ if (std.mem.eql(u8, theme.name, "acme")) break theme;
+ } else unreachable;
+ var bright: CellStyle = .{ .fg = .{ .index = 15 } };
+ memo = .{};
+ fadeStyle(&bright, acme.fg, acme.bg, acme.palette, 0.3, &memo);
+ const rgb = bright.fg.rgb;
+ try std.testing.expect(rgb[0] == rgb[1] and rgb[0] < 0xc0);
+ // Reverse: the paper moves, the ink stays.
+ var reverse: CellStyle = .{ .fg = .{ .rgb = .{ 0, 0, 0 } }, .bg = .{ .rgb = .{ 255, 255, 255 } }, .reverse = true };
+ memo = .{};
+ fadeStyle(&reverse, null, null, null, 0.3, &memo);
+ try std.testing.expectEqual([3]u8{ 0, 0, 0 }, reverse.fg.rgb);
+ try std.testing.expect(reverse.bg.rgb[0] < 255);
+}
+
+test "with no lift and no dim a focus switch asks for no frames" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer p.deinit();
+ _ = try p.setTestFile("text\n");
+ p.newScratchBelow(0);
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ p.advance(std.time.ns_per_s);
+ _ = try p.render(arena.allocator());
+ p.active = 0;
+ _ = arena.reset(.retain_capacity);
+ _ = try p.render(arena.allocator());
+ try std.testing.expect(!p.continuous());
+}
+
+test "each Motion flavour reaches the lift as its own trajectory" {
+ // A grid snaps; the flavours are a pixel shell's.
+ if (!builtins.capabilities.lift) return error.SkipZigTest;
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer p.deinit();
+ _ = try p.setTestFile("text\n");
+ p.newScratchBelow(0);
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ p.settings.lift = .shadow;
+ var peaks: [5]f32 = undefined;
+ const ms = std.time.ns_per_ms;
+ var start: u64 = std.time.ns_per_s;
+ for (std.enums.values(animation.Motion.Flavour), &peaks) |flavour, *peak| {
+ p.settings.motion = flavour;
+ p.active = 1;
+ p.advance(start);
+ _ = arena.reset(.retain_capacity);
+ _ = try p.render(arena.allocator());
+ start += 3 * std.time.ns_per_s;
+ p.advance(start);
+ _ = try p.render(arena.allocator());
+ p.active = 0;
+ peak.* = 0;
+ var t: u64 = 0;
+ while (t <= 1500 * ms) : (t += 4 * ms) {
+ p.advance(start + t);
+ _ = arena.reset(.retain_capacity);
+ const s = try p.render(arena.allocator());
+ const lift = for (s.regionList()) |region| {
+ if (region.kind == .grip and region.owner == 0) break region.lift;
+ } else unreachable;
+ peak.* = @max(peak.*, lift);
+ }
+ start += 3 * std.time.ns_per_s;
+ }
+ // Bouncy and playful pass their (exaggerated) mark; smooth never does.
+ try std.testing.expect(peaks[2] <= 1.001 and peaks[3] > 1.25 and peaks[4] > peaks[3]);
+}
+
test "a monkey over notices, prompts, tags and tiny panes at a gui's metrics never panics" {
// The snapshot harness runs the tty, whose tagline pitch is the body's,
// so it never reaches what a narrower tagline does to a notice's band:
diff --git a/src/dump.zig b/src/dump.zig
index 8e669b77..ef442ddb 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -99,6 +99,13 @@ pub const PaneKind = enum {
pub const Terminal = struct {
cwd: []const u8 = "",
+ /// The shell it runs when not the configured one (`Tty bash`); empty,
+ /// the configured one, as in dumps from before.
+ shell: []const u8 = "",
+ /// A command pane's line and how it ended (null: unknown, or still
+ /// running when dumped); empty for an interactive terminal.
+ command: []const u8 = "",
+ status: ?u8 = null,
/// The rendered scrollback and screen as plain text, one row per line.
stream: []const u8 = "",
stream_b64: []const u8 = "",
@@ -161,6 +168,9 @@ pub const Pane = struct {
rows: u16 = 0,
vweight: f32 = 1,
collapsed: bool = false,
+ /// Its serial when dumped (0 in older dumps): a Restore logs
+ /// `restored <old> <new>` for it.
+ serial: u32 = 0,
/// Exact editable tail when the user touched it. Null retains the live
/// default; a present empty slice deliberately restores an empty tail.
tag_tail: ?[]const u8 = null,
@@ -626,6 +636,7 @@ pub fn dumpState(p: *Pardes) !void {
else
try pardes.panes.Terminal.dumpPane(pane, arena, tag, body, scroll);
dp.tag_tail = pane.tag.own;
+ dp.serial = pane.serial;
dump_panes[panes_len] = dp;
panes_len += 1;
}
@@ -676,16 +687,22 @@ pub fn restore(p: *Pardes, zon_bytes: []const u8, from: []const u8) !*Pardes {
var opts = p.opts;
opts.cols = p.screen_w;
opts.rows = p.screen_h;
- const replacement = try initDump(p.gpa, opts, zon_bytes, p);
+ var old: [MAX_PANES]u32 = @splat(0);
+ const replacement = try initDump(p.gpa, opts, zon_bytes, p, &old);
pardes.ctlfs.events.notePath(replacement, "restore", from);
+ // Which serial each pane had, for a script that held the old ones.
+ for (replacement.panes, old) |slot, was| if (slot) |pane| if (was != 0) {
+ var rec: [32]u8 = undefined;
+ pardes.ctlfs.events.notePath(replacement, "restored", std.fmt.bufPrint(&rec, "{d} {d}", .{ was, pane.serial }) catch continue);
+ };
return replacement;
}
pub fn initFromDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8) !*Pardes {
- return initDump(gpa, opts, zon_bytes, null);
+ return initDump(gpa, opts, zon_bytes, null, null);
}
-fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previous: ?*const Pardes) !*Pardes {
+fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previous: ?*const Pardes, old_serials: ?*[MAX_PANES]u32) !*Pardes {
const image_gpa = opts.image_allocator orelse gpa;
const pdf_gpa = opts.pdf_allocator orelse gpa;
const tree_sitter_gpa = opts.tree_sitter_allocator orelse gpa;
@@ -735,17 +752,26 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo
}
for (st.panes, 0..) |src, i| {
if (i >= MAX_PANES) break;
+ if (old_serials) |o| o[i] = src.serial;
const pane: *pardes.panes.Pane = switch (src.kind) {
.terminal => terminal: {
const t = src.terminal.?;
// A host that runs shells gets them back: the old history
// above a new shell in the directory it was in. The web
// shell and an isolated build only ever replay it.
+ // A command pane comes back finished, showing what it said
+ // and how it ended: its command is not run again.
const live = comptime (terminal_panes and platform != .web and !isolated);
- const revive = live and t.cwd.len <= effect_path_cap;
+ const revive = live and t.cwd.len <= effect_path_cap and t.command.len == 0;
const restored = try pardes.panes.Terminal.restore(p, src, revive);
p.installPane(i, restored);
try restored.setOwnedCwd(t.cwd);
+ if (t.shell.len > 0) restored.shell = try gpa.dupe(u8, t.shell);
+ if (t.command.len > 0) {
+ restored.command = try gpa.dupe(u8, t.command);
+ restored.command_done = true;
+ restored.command_status = t.status;
+ }
if (std.mem.startsWith(u8, src.tag, "TTY ")) restored.body.mode = .tty;
if (revive) p.emit(.{ .spawn = .{ .pane = @intCast(i), .serial = restored.serial, .cwd = .from(t.cwd) } });
break :terminal restored;
diff --git a/src/edit.zig b/src/edit.zig
index 84bbb292..a194ef48 100644
--- a/src/edit.zig
+++ b/src/edit.zig
@@ -202,7 +202,7 @@ pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void {
/// (the tag's lines first, the ones it shows)
pub fn paneText(p: *Pardes, pane: *Pane) ![]u8 {
const arena = p.scratch.allocator();
- const tag = try tagline.tagText(p, arena, pane);
+ const tag = (try tagline.shownTag(p, arena, pane)).text;
const from = modal.lineStartOffset(tag, pane.tag_top);
const cut = modal.lineStartOffset(tag, pane.tag_top + pane.tag_rows);
const shown = if (cut < tag.len) tag[from .. cut - 1] else tag[from..];
@@ -234,10 +234,13 @@ pub fn capturePointerSelection(p: *Pardes, pane: *Pane, slot: usize) !void {
errdefer p.gpa.free(rows);
const raw_text = if (raw) try p.gpa.dupe(u8, try selectionText(p, pane, gesture)) else null;
const prefix: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0;
- const left = @max(0, @min(gesture.c0, gesture.c1));
- const right = @max(0, @max(gesture.c0, gesture.c1));
for (rows, 0..) |*row, index| {
const visible = first + @as(i32, @intCast(index));
+ // A stream: only the first row starts where the press was; the
+ // others start after the line numbers, and all but the last run on.
+ const cols = sweepCols(gesture, visible + @as(i32, pane.tag_rows)).?;
+ const left: i32 = if (cols.first) cols.lo else prefix;
+ const right: i32 = cols.hi orelse std.math.maxInt(i32) / 2;
var source = pane.wrapAt(visible);
if (raw) source = .{ .line = panes.Terminal.gridOffset(pane) + visible, .at = 0 };
const line = if (raw) modal.lineSlice(body, @intCast(visible)) else pointerSourceLine(pane, terminal_lines, source.line);
@@ -250,7 +253,8 @@ pub fn capturePointerSelection(p: *Pardes, pane: *Pane, slot: usize) !void {
row.* = .{
.row = source.line,
.lo = lo,
- .hi = if (right < prefix) lo else @min(end, modal.nextGrapheme(line, at)),
+ .hi = if (right < prefix) lo else if (cols.hi == null) end else @min(end, modal.nextGrapheme(line, at)),
+ .to_edge = cols.hi == null,
.raw_terminal = raw,
.prompt_bytes = if (raw) panes.Terminal.promptPrefixBytes(pane, source.line, line) else 0,
};
@@ -294,40 +298,55 @@ pub fn selectionText(p: *Pardes, pane: *Pane, sl: Pane.Sel) ![]const u8 {
}
return result;
}
- const r0 = @min(sl.r0, sl.r1);
- const r1 = @max(sl.r0, sl.r1);
- const c0: usize = @intCast(@max(0, @min(sl.c0, sl.c1)));
- const c1: usize = @intCast(@max(0, @max(sl.c0, sl.c1)));
const text = try paneText(p, pane);
- var total: usize = 0;
- var selected: usize = 0;
- var count_it = std.mem.splitAny(u8, text, "\n");
- var count_row: i32 = 0;
- while (count_it.next()) |line| : (count_row += 1) {
- if (count_row < r0 or count_row > r1) continue;
- const b0 = @min(panes.File.renderedLineByteCol(pane, count_row, line, c0), line.len);
- const b1 = modal.nextGrapheme(line, @min(panes.File.renderedLineByteCol(pane, count_row, line, c1), line.len));
- total += b1 - b0 + @intFromBool(selected > 0);
- selected += 1;
- }
- const out = try arena.alloc(u8, total);
- var at: usize = 0;
+ const shown = try tagline.shownTag(p, arena, pane);
+ var out: std.ArrayList(u8) = .empty;
var it = std.mem.splitAny(u8, text, "\n");
var v: i32 = 0;
var first = true;
while (it.next()) |line| : (v += 1) {
- if (v < r0 or v > r1) continue;
- if (!first) {
- out[at] = '\n';
- at += 1;
- }
+ const cols = sweepCols(sl, v) orelse continue;
+ // A row the tag's wrapping began carries on its text: no newline.
+ const soft = v > 0 and v < pane.tag_rows and std.mem.indexOfScalar(
+ usize,
+ shown.soft,
+ modal.lineStartOffset(shown.text, @as(usize, @intCast(v)) + pane.tag_top) -| 1,
+ ) != null;
+ if (!first and !soft) try out.append(arena, '\n');
first = false;
- const b0 = @min(panes.File.renderedLineByteCol(pane, v, line, c0), line.len);
- const b1 = modal.nextGrapheme(line, @min(panes.File.renderedLineByteCol(pane, v, line, c1), line.len));
- @memcpy(out[at..][0 .. b1 - b0], line[b0..b1]);
- at += b1 - b0;
+ // Past the first row a body row starts after its line numbers.
+ const gutter: i32 = if (pane.file != null and v >= pane.tag_rows) panes.File.gutterWidth(pane) else 0;
+ const lo: usize = @intCast(if (cols.first) cols.lo else gutter);
+ const b0 = @min(panes.File.renderedLineByteCol(pane, v, line, lo), line.len);
+ const b1 = if (cols.hi) |hi|
+ @max(b0, modal.nextGrapheme(line, @min(panes.File.renderedLineByteCol(pane, v, line, @intCast(hi)), line.len)))
+ else
+ line.len;
+ try out.appendSlice(arena, line[b0..b1]);
}
- return out;
+ return out.items;
+}
+
+/// A sweep's columns on `row`, in its Sel space: acme's stream, not a
+/// rectangle (libframe/frselect.c:115-140, frselectpaint): the first row
+/// from the press to its end, the rows between whole, the last from its
+/// start to the release. `hi` null runs to the end of the row.
+pub const SweepCols = struct { lo: i32, hi: ?i32, first: bool };
+
+pub fn sweepCols(sl: Pane.Sel, row: i32) ?SweepCols {
+ const start, const end = sweepEnds(sl);
+ if (row < start[0] or row > end[0]) return null;
+ return .{
+ .lo = if (row == start[0]) @max(0, start[1]) else 0,
+ .hi = if (row == end[0]) @max(0, end[1]) else null,
+ .first = row == start[0],
+ };
+}
+
+/// The sweep's two ends in reading order, each (row, col).
+pub fn sweepEnds(sl: Pane.Sel) struct { [2]i32, [2]i32 } {
+ const fwd = sl.r0 < sl.r1 or (sl.r0 == sl.r1 and sl.c0 <= sl.c1);
+ return if (fwd) .{ .{ sl.r0, sl.c0 }, .{ sl.r1, sl.c1 } } else .{ .{ sl.r1, sl.c1 }, .{ sl.r0, sl.c0 } };
}
pub fn spanHas(r: i32, c: i32, ar: i32, ac: i32, br: i32, bc: i32) bool {
@@ -738,6 +757,19 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void {
Key.left, Key.right, Key.up, Key.down => {
const lines = paneCursorLines(p, t) catch return;
const cur0 = t.toModalCursor();
+ // In a pane's tag, Up on its first line collapses it and Down
+ // on its last expands it, where they would move nothing; acme's
+ // arrows in a tag always do (plan9port text.c:776, Tagdown/Tagup).
+ if (t.what == .tag) if (t.pane()) |pane| {
+ if (key.cp == Key.up and cur0.row == 0) {
+ pane.tag_expanded = false;
+ return;
+ }
+ if (key.cp == Key.down and cur0.row + 1 >= lines.len) {
+ pane.tag_expanded = true;
+ return;
+ }
+ };
const nc = switch (key.cp) {
Key.left => modal.charLeft(lines, cur0),
Key.right => modal.charRight(lines, cur0),
@@ -2000,7 +2032,8 @@ pub fn pushUndo(p: *Pardes, t: *Text) void {
// one keystroke, one undo step — even when it edited at ten cursors.
if (p.multi_on and !p.multi_first) return;
switch (t.what) {
- .body => {},
+ // A B1 chord took its one undo point when it began (mouse.zig).
+ .body => if (p.chord_hold) return,
.tag, .column, .workspace => return t.remember(p.gpa, if (t.own) |own| .{ .text = own } else null),
// a prompt's answer is typed once and gone; it keeps no history
.input => return,
@@ -2215,3 +2248,120 @@ test "only the SPC clipboard commands cross to the system clipboard" {
p.update(.{ .key = .{ .cp = 'l' } });
try std.testing.expect(p.clip_pending == null);
}
+
+/// acme's double-click (plan9port acme text.c:1407, textdoubleclick): from
+/// the point `q` (before the character clicked), the text up to the match
+/// of a bracket just before it or just after it (text.c:1386, `{[(<«` and
+/// their closers), the whole line at a line's start or end, the quoted text
+/// next to `' " \``, else the word, acme's isalnum (util.c:328) run.
+pub fn doubleClickRange(text: []const u8, q_in: usize) [2]usize {
+ const sets = [_][2][]const u21{
+ .{ &.{ '{', '[', '(', '<', 0xab }, &.{ '}', ']', ')', '>', 0xbb } },
+ .{ &.{'\n'}, &.{'\n'} },
+ .{ &.{ '\'', '"', '`' }, &.{ '\'', '"', '`' } },
+ };
+ const q = @min(q_in, text.len);
+ for (sets) |set| {
+ const left, const right = set;
+ // the character to the left, looking right
+ const before = if (q == 0) .{ @as(u21, '\n'), @as(usize, 0) } else runeBefore(text, q);
+ if (std.mem.indexOfScalar(u21, left, before[0])) |k| {
+ var at = q;
+ if (clickMatch(text, before[0], right[k], true, &at)) {
+ const closer = if (before[0] == '\n') 0 else runeLen(right[k]);
+ return .{ q, at - closer };
+ }
+ return .{ q, q };
+ }
+ // the character to the right, looking left
+ const after = if (q == text.len) @as(u21, '\n') else runeAt(text, q);
+ if (std.mem.indexOfScalar(u21, right, after)) |k| {
+ var at = q;
+ if (clickMatch(text, after, left[k], false, &at)) {
+ const q1 = q + @as(usize, @intFromBool(q < text.len and after == '\n'));
+ var q0 = at;
+ if (after != '\n' or at != 0 or (text.len > 0 and text[0] == '\n')) q0 += runeLen(left[k]);
+ return .{ @min(q0, q1), q1 };
+ }
+ return .{ q, q };
+ }
+ }
+ var lo = q;
+ var hi = q;
+ while (hi < text.len and isAlnum(runeAt(text, hi))) hi += std.unicode.utf8ByteSequenceLength(text[hi]) catch 1;
+ while (lo > 0 and isAlnum(runeBefore(text, lo)[0])) lo = runeBefore(text, lo)[1];
+ return .{ lo, hi };
+}
+
+/// acme's textclickmatch (text.c:1457): the nesting match of `cl` by `cr`
+/// from `q`, leaving `q` just past it (forward) or on it (back).
+fn clickMatch(text: []const u8, cl: u21, cr: u21, forward: bool, q: *usize) bool {
+ var nest: usize = 1;
+ while (true) {
+ var c: u21 = undefined;
+ if (forward) {
+ if (q.* >= text.len) break;
+ c = runeAt(text, q.*);
+ q.* += std.unicode.utf8ByteSequenceLength(text[q.*]) catch 1;
+ } else {
+ if (q.* == 0) break;
+ const b = runeBefore(text, q.*);
+ q.* = b[1];
+ c = b[0];
+ }
+ if (c == cr) {
+ nest -= 1;
+ if (nest == 0) return true;
+ } else if (c == cl) nest += 1;
+ }
+ return cl == '\n' and nest == 1;
+}
+
+fn runeLen(c: u21) usize {
+ return std.unicode.utf8CodepointSequenceLength(c) catch 1;
+}
+
+fn runeAt(text: []const u8, at: usize) u21 {
+ const n = std.unicode.utf8ByteSequenceLength(text[at]) catch return text[at];
+ if (at + n > text.len) return text[at];
+ return std.unicode.utf8Decode(text[at..][0..n]) catch text[at];
+}
+
+/// The rune ending at `at`, and where it starts.
+fn runeBefore(text: []const u8, at: usize) struct { u21, usize } {
+ var s = at - 1;
+ while (s > 0 and text[s] & 0xC0 == 0x80 and at - s < 4) s -= 1;
+ return .{ runeAt(text, s), s };
+}
+
+fn isAlnum(c: u21) bool {
+ if (c <= ' ') return false;
+ if (c >= 0x7F and c <= 0xA0) return false;
+ if (c < 0x80 and std.mem.indexOfScalar(u8, "!\"#$%&'()*+,-./:;<=>?@[\\]^`{|}~", @intCast(c)) != null) return false;
+ return true;
+}
+
+test "double-click selects as acme's does" {
+ const t = "fn foo_bar(a, b) {\n x\n}\n";
+ const Case = struct { q: usize, want: []const u8 };
+ for ([_]Case{
+ .{ .q = 4, .want = "foo_bar" }, // in a word
+ .{ .q = 11, .want = "a, b" }, // just after (
+ .{ .q = 15, .want = "a, b" }, // just before )
+ .{ .q = 18, .want = "\n x\n" }, // just after {
+ .{ .q = 0, .want = "fn foo_bar(a, b) {\n" }, // a line's start
+ }) |c| {
+ const r = doubleClickRange(t, c.q);
+ try std.testing.expectEqualStrings(c.want, t[r[0]..r[1]]);
+ }
+ const quoted = "say \"hello there\" now";
+ const r = doubleClickRange(quoted, 5);
+ try std.testing.expectEqualStrings("hello there", quoted[r[0]..r[1]]);
+ const french = "«oui»";
+ const f = doubleClickRange(french, 2);
+ try std.testing.expectEqualStrings("oui", french[f[0]..f[1]]);
+ // a line's end: the whole line, its newline included
+ const lines = "one\ntwo\n";
+ const e = doubleClickRange(lines, 7);
+ try std.testing.expectEqualStrings("two\n", lines[e[0]..e[1]]);
+}
diff --git a/src/exec.zig b/src/exec.zig
index 22aae10a..56e6a2a9 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -435,9 +435,21 @@ fn routeToRepl(p: *Pardes, from: usize, text: []const u8) Routed {
src.question_len = @intCast(w.buffered().len);
src.prompt = .repl_choice;
p.active = from;
+ var rec: [96]u8 = undefined;
+ var rw = std.Io.Writer.fixed(&rec);
+ rw.print("{d} repl", .{src.serial}) catch {};
+ for (bound[0..n]) |i| rw.print(" {c}", .{p.panes[i].?.repl.?.letter}) catch {};
+ pardes.ctlfs.events.notePath(p, "ask", rw.buffered());
return .asked;
}
+fn pythonBlockOpen(text: []const u8) bool {
+ const trimmed = std.mem.trimEnd(u8, text, " \t\n");
+ const start = if (std.mem.lastIndexOfScalar(u8, trimmed, '\n')) |nl| nl + 1 else 0;
+ const last = trimmed[start..];
+ return last.len > 0 and (last[0] == ' ' or last[0] == '\t' or last[last.len - 1] == ':');
+}
+
/// Types `text` into the REPL in `to`, bracketed if its program asked for
/// that, then Enter; says so on `from`'s message row and in the log.
pub fn sendToRepl(p: *Pardes, from: usize, to: usize, text: []const u8) void {
@@ -465,6 +477,11 @@ pub fn sendToRepl(p: *Pardes, from: usize, to: usize, text: []const u8) void {
}
edit.typeToTty(p, to, dst, clean[0..n]);
p.emitWrite(to, "\r");
+ // A Python block whose last line is indented, or a bare header, is not
+ // over at one Enter, pasted into 3.13's REPL or typed into the old one:
+ // the blank line a second Enter gives ends it.
+ if ((if (dst.repl) |r| r.lang == python_lang else false) and pythonBlockOpen(clean[0..n]))
+ p.emitWrite(to, "\r");
// Line by line, a blank line inside a Python block ends the block.
if (!panes.Terminal.bracketedPaste(dst) and !dst.repl_warned and (if (dst.repl) |r| r.lang == python_lang else false) and
std.mem.indexOfScalar(u8, text, '\n') != null)
@@ -602,7 +619,7 @@ fn echoCommand(p: *Pardes, pane: *Pane, line: []const u8) void {
/// The whole line, one record as `msg` carries its text: its control
/// characters, a newline too, become spaces (events.pushLog). A new pane is
/// announced first, so `new` comes before the `run` in it.
-fn noteRun(p: *Pardes, pane: *Pane, what: []const u8, said: []const u8) void {
+pub fn noteRun(p: *Pardes, pane: *Pane, what: []const u8, said: []const u8) void {
pardes.ctlfs.events.announce(p);
var buf: [command_max + 16]u8 = undefined;
pardes.ctlfs.events.notePath(p, what, std.fmt.bufPrint(&buf, "{d} {s}", .{ pane.serial, std.mem.trim(u8, said, " \t\r\n") }) catch return);
diff --git a/src/fs.zig b/src/fs.zig
index 42d761ca..0a4473d6 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -598,7 +598,8 @@ test "virtual body writes can read their input from the same pane" {
const path = try std.fmt.bufPrint(&path_buffer, "/virtual/pane/{d}/body", .{pane.serial});
try write(p, path, pane.file.?.content);
try std.testing.expectEqualStrings("same pane contents\n", pane.file.?.content);
- try std.testing.expectEqual(@as(usize, 2), pane.file.?.history.undo_len);
+ // The truncation and the write are one undo step.
+ try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len);
const events = &p.panes[p.active].?.fs.events;
try std.testing.expect(std.mem.startsWith(u8, events.peek().?, "ED"));
events.pop();
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 26e9e24d..5dfacee9 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -505,6 +505,8 @@ const initial_image_capacity: u32 = pardes.MAX_PANES;
const old_layer_bit: u32 = 0x8000_0000;
const clear_bg_bit: u32 = 0x4000_0000;
const opaque_bg_bit: u32 = 0x2000_0000;
+/// Decor that is a soft shadow, not a flat fill (decor.frag.glsl).
+const soft_shadow_bit: u32 = 0x1000_0000;
const SavedImagePlace = struct {
key: pardes.ImageCacheKey,
@@ -1874,6 +1876,10 @@ const Gui = struct {
/// Pixels the capture holds past the grid (PARDES_TEST_PAD).
capture_pad: u32 = 0,
capture_dir: []const u8 = "",
+ /// PARDES_TEST_SERIES: every captured frame is kept too, numbered, for a
+ /// feel review's frame series (docs/render-pipeline.md §8.4).
+ capture_series: bool = false,
+ captured: u32 = 0,
capture_tex: ?*c.SDL_GPUTexture = null,
capture_tex_w: u32 = 0,
capture_tex_h: u32 = 0,
@@ -2124,7 +2130,13 @@ const Cover = packed struct(u16) {
focus: bool = false,
/// At a column's left edge, where a tag's anchor is inset.
anchor: bool = false,
- _: u7 = 0,
+ /// In a dirty pane's grip.
+ dirty: bool = false,
+ /// In a pane's grip (not a column's).
+ pane_grip: bool = false,
+ /// In a grip's first row, where its button is.
+ grip_top: bool = false,
+ _: u4 = 0,
};
/// Marks the grid cells a body or tag layer covers, which the layers paint
@@ -2156,10 +2168,13 @@ fn coverFrame(cover: []Cover, cols: u16, rows: u16, body_layers: []const pardes.
if (region.kind == .column_grip) bar = region.rect;
}
for (regions) |region| switch (region.kind) {
- .grip => {
+ .grip, .column_grip => {
mark.rect(cover, cols, rows, region.rect, "grip", true);
for (0..@min(region.rect.w, 16)) |offset| mark.rect(cover, cols, rows, .{ .x = region.rect.x +| @as(u16, @intCast(offset)), .y = region.rect.y, .w = 1, .h = region.rect.h }, "grip_offset", @as(u4, @intCast(offset)));
- if (region.active) mark.rect(cover, cols, rows, region.rect, "focus", true);
+ if (region.kind == .grip and region.active) mark.rect(cover, cols, rows, region.rect, "focus", true);
+ if (region.dirty) mark.rect(cover, cols, rows, region.rect, "dirty", true);
+ if (region.kind == .grip) mark.rect(cover, cols, rows, region.rect, "pane_grip", true);
+ mark.rect(cover, cols, rows, .{ .x = region.rect.x, .y = region.rect.y, .w = region.rect.w, .h = @min(1, region.rect.h) }, "grip_top", true);
},
.tag => if (region.active) mark.rect(cover, cols, rows, region.rect, "focus", true),
.column => {
@@ -2480,6 +2495,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
.glyphs = std.AutoHashMap(GlyphKey, Slot).init(gpa),
.capture = test_mode,
.capture_pad = if (test_mode) envU16(env, "PARDES_TEST_PAD") orelse 0 else 0,
+ .capture_series = test_mode and env.get("PARDES_TEST_SERIES") != null,
.capture_dir = capture_dir orelse "",
.soft_present = soft_present,
.transparent = config.gui_transparent,
@@ -2629,6 +2645,7 @@ fn localSession(
.fs = fs,
.test_mode = test_mode,
.test_clock = if (test_mode) host_io.testClock() else null,
+ .test_hz = if (std.c.getenv("PARDES_TEST_HZ")) |hz| @max(1, std.fmt.parseInt(u64, std.mem.span(hz), 10) catch 60) else 60,
};
defer shell.shutdownPtys();
const host = shell.host();
@@ -3764,9 +3781,10 @@ test "tagline pointer mapping retains physical grips and carries logical text co
try std.testing.expect(grip.tag_hit == null);
try std.testing.expectEqual(r.x + config.GUTTER - 1, grip.col);
- const body = gridCellAtDimensions(core, x, (@as(f32, @floatFromInt(tag_y + 1)) + 0.5) * body_h, body_w, body_h, tag_w);
+ const body_y = tag_y + core.panes[core.active].?.tag_rows; // a long tag wraps
+ const body = gridCellAtDimensions(core, x, (@as(f32, @floatFromInt(body_y)) + 0.5) * body_h, body_w, body_h, tag_w);
try std.testing.expectEqual(pixelCell(x, 10), body.col);
- try std.testing.expectEqual(tag_y + 1, body.row);
+ try std.testing.expectEqual(body_y, body.row);
try std.testing.expect(body.tag_hit == null);
const topbar = gridCellAtDimensions(core, 3.5 * tag_w, 0.5 * body_h, body_w, body_h, tag_w);
@@ -3914,6 +3932,8 @@ const Shell = struct {
retired_shells: [pardes.MAX_PANES]RetiredShell = @splat(.{}),
/// PARDES_TEST_CLOCK's virtual time (host_io.testClock), in test mode.
test_clock: ?u64 = null,
+ /// The display rate a virtual clock stands in for (PARDES_TEST_HZ).
+ test_hz: u64 = 60,
fn reap(s: *Shell) void {
const now = shellClock();
@@ -4079,15 +4099,13 @@ const Shell = struct {
host_io.commandEof(s.core, s.ptys, e.pane, s, closeWatched);
break :eof;
}
- // Unwatched, a command's exit is read here, as its end.
+ // Unwatched, a command's exit is read here, as its end,
+ // and a shell's, for a run waiting on it and the log.
const unwatched = if (s.core.panes[e.pane]) |pane| pane.command != null else false;
- var status: ?u8 = null;
- if (unwatched) {
- status = host_io.exitStatus(pt.pid, 100);
- if (status != null) pt.pid = 0; // reaped: no shell to retire
- }
+ const status = host_io.exitStatus(pt.pid, 100);
+ if (status != null) pt.pid = 0; // reaped: no shell to retire
s.closePty(e.pane);
- if (unwatched) s.core.update(.{ .exited = .{ .pane = e.pane, .status = status } });
+ if (unwatched or status != null) s.core.update(.{ .exited = .{ .pane = e.pane, .status = status } });
s.core.update(.{ .eof = .{ .pane = e.pane } });
}
s.saw_event = true;
@@ -4166,10 +4184,17 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
pardes.turn.rest();
const got = c.SDL_WaitEventTimeout(&sev, ms);
pardes.turn.wake();
- // The core's wake ran out: a virtual clock moves exactly there.
- if (!got and timeout_ms != 0) if (s.test_clock) |*virtual| {
- virtual.* = @max(virtual.*, core.nextWake() orelse virtual.*);
- };
+ // A virtual clock: while a render-time animation moves, each loop
+ // is a presented frame and moves it by one display frame
+ // (PARDES_TEST_HZ), events or not, as a real display's present
+ // would; otherwise a wait that ran out moves it exactly to the
+ // core's wake.
+ if (s.test_clock) |*virtual| {
+ if (core.continuous()) {
+ const display = virtual.* + std.time.ns_per_s / s.test_hz;
+ virtual.* = if (core.nextWake()) |due| @max(virtual.*, @min(due, display)) else display;
+ } else if (!got and timeout_ms != 0) virtual.* = @max(virtual.*, core.nextWake() orelse virtual.*);
+ }
if (got) {
// A run of queued motions is one move to its last position (the
// handler reads only absolute x/y and held buttons): acting on
@@ -4425,7 +4450,7 @@ fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void {
fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void {
const s = shellOf(ctx);
// A command whose exit is recorded is reaped: its group may be another's.
- if (s.ptys[pane]) |pt| if (pt.fd >= 0 and !pt.cmd.exited) host_io.signalTty(pt.pid, pt.fd, sig);
+ if (s.ptys[pane]) |pt| if (pt.fd >= 0 and !pt.cmd.exited) host_io.signalTty(pt.pid, pt.fd, sig, pt.cmd.watched);
}
fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool {
@@ -5526,7 +5551,13 @@ fn renderFrame(
const track_index = groupAt(&groups, layer.viewport.x, layer.viewport.y);
// A notice floats over the panes, moving with its own.
const group_index = if (layer.kind == .notice) noticeGroup(&groups, layer) else track_index;
+ const start = cell_next[group_index];
emitTagLayer(g, instances, &cell_next[group_index], layer, win_w, win_h, groups.items[track_index].track, page, cursors_shown, false);
+ // A notice dropping past its row (a flavour's overshoot) stays
+ // inside its pane's body.
+ if (layer.kind == .notice and layer.slide > 0) if (bodyBottom(surface, layer.id, layout)) |bottom| {
+ for (instances[start..cell_next[group_index]]) |*instance| clipInstance(instance, .{ -1e9, -1e9, 1e9, bottom }, win_w, win_h);
+ };
}
for (groups.items[1..][0..groups.tracks], 1..) |group, index| {
const track = group.track.?;
@@ -5894,6 +5925,14 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand
}
}
+/// The bottom of pane `owner`'s body, in window pixels.
+fn bodyBottom(surface: *const pardes.Surface, owner: u16, layout: CellLayout) ?f32 {
+ for (surface.regionList()) |region| {
+ if (region.kind == .body and region.owner == owner) return @as(f32, @floatFromInt(region.rect.y + region.rect.h)) * layout.h;
+ }
+ return null;
+}
+
fn tagLayerCellCount(g: *const Gui, layer: *const pardes.Layer) u32 {
const pixels = @as(u32, layer.viewport.w) * g.cell_w;
return @min(layer.cols, (pixels + g.tagline_width - 1) / g.tagline_width);
@@ -6031,6 +6070,67 @@ fn anchorInset(g: *const Gui, row: u16, win_h: f32) u32 {
return taglineBandOffset(row, win_h, g.cell_h, g.tagline_height);
}
+/// acme's Scrollwid column at a pane's or column's left edge, in window
+/// pixels: where its button sits over its scrollbar, one width for both
+/// (`rail_px` at acme's 17px font, scaled with the tagline, and always
+/// short of the cell so the text keeps a gap), right of the column's rule.
+fn scrollColumn(g: *const Gui, chrome: *const pardes.Chrome, col: u16, layout: CellLayout) [2]f32 {
+ const rule: f32 = if (col == 0) 0 else rulePx(chrome);
+ const x0 = @round(@as(f32, @floatFromInt(col)) * layout.w + rule);
+ const scaled: f32 = @round(@as(f32, @floatFromInt(@max(1, chrome.rail_px))) * @as(f32, @floatFromInt(g.tagline_height)) / 17);
+ // The rail and the grip are GUTTER cells wide; the text starts after.
+ const w = std.math.clamp(scaled, 4, @max(4, @floor(layout.w * @as(f32, @floatFromInt(config.GUTTER))) - rule - 3));
+ return .{ x0, x0 + w };
+}
+
+/// acme's button for the grip whose first cell is (col, row), in window
+/// pixels: the scroll column wide, its tag row tall, from the rule above to
+/// the rule below, so button and scrollbar read as one strip.
+fn gripButton(g: *const Gui, chrome: *const pardes.Chrome, col: u16, row: u16, layout: CellLayout, win_h: f32) [4]f32 {
+ _ = win_h;
+ const x = scrollColumn(g, chrome, col, layout);
+ const y0 = @round(@as(f32, @floatFromInt(row)) * layout.h);
+ return .{ x[0], y0, x[1], @round(@as(f32, @floatFromInt(row + 1)) * layout.h) };
+}
+
+/// A pane's button fill, from its region's state, not the grid's cells
+/// (which are drawn for cell shells): dirty, focused, or the tag's ground.
+fn gripFill(chrome: *const pardes.Chrome, focus: bool, dirty: bool) [3]u8 {
+ if (dirty) return chrome.grip_dirty;
+ return if (focus) chrome.grip_focus_ring else chrome.tag_bg;
+}
+
+fn inkOn(bg: [3]u8) [3]u8 {
+ const lum = (@as(u16, bg[0]) * 3 + @as(u16, bg[1]) * 6 + @as(u16, bg[2])) / 10;
+ return if (lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff };
+}
+
+/// Cuts an instance's quad to a pixel rectangle, its atlas window with it so
+/// the glyph neither moves nor stretches; nothing left, an empty quad.
+fn clipInstance(instance: *CellInstance, r: [4]f32, win_w: f32, win_h: f32) void {
+ const x0 = (instance.x0 + 1) / 2 * win_w;
+ const x1 = (instance.x1 + 1) / 2 * win_w;
+ const y0 = (1 - instance.y0) / 2 * win_h;
+ const y1 = (1 - instance.y1) / 2 * win_h;
+ const left = @max(0, r[0] - x0);
+ const right = @max(0, x1 - r[2]);
+ const top = @max(0, r[1] - y0);
+ const bottom = @max(0, y1 - r[3]);
+ if (x1 - right <= x0 + left or y1 - bottom <= y0 + top) {
+ instance.x1 = instance.x0;
+ instance.y1 = instance.y0;
+ return;
+ }
+ instance.x0 += left / win_w * 2;
+ instance.u0 += left / atlas_w;
+ instance.x1 -= right / win_w * 2;
+ instance.u1 -= right / atlas_w;
+ instance.y0 -= top / win_h * 2;
+ instance.v0 += top / atlas_h;
+ instance.y1 += bottom / win_h * 2;
+ instance.v1 -= bottom / atlas_h;
+}
+
fn emitSurfaceCell(
g: *Gui,
chrome: *const pardes.Chrome,
@@ -6063,33 +6163,82 @@ fn emitSurfaceCell(
emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .body, (&band_base)[0..1], 0, null, old_layer, false, page);
next.* += 1;
// Tag glyphs keep their font size inside a full physical-width grip.
- emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, line, at, cursor, old_layer, is_cursor, page);
+ // A pane's grip cells are the cell shells' form (a dirty `*`): the
+ // button draws its own glyph over a blank of them.
+ var blank = line[at];
+ blank.text[0] = ' ';
+ blank.len = 1;
+ if (here.pane_grip)
+ emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, (&blank)[0..1], 0, null, old_layer, false, page)
+ else
+ emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, line, at, cursor, old_layer, is_cursor, page);
// A grip's glyph is the cell at its left edge, and a blank one is
// no glyph: its marks are decor.
const glyph = &line[at - @min(here.grip_offset, at)];
- const grip = here.grip and !glyph.default and glyph.printableAscii() != ' ' and !glyph.style.invisible;
+ const grip = here.grip and here.grip_top and !glyph.default and glyph.printableAscii() != ' ' and !glyph.style.invisible;
const offset: u16 = if (grip) @min(here.grip_offset, at) else 0;
// An anchor starts at its column's rule: its left edge is inset by
// its top margin so the band frames it on the left as above and
// below, and its mark moves right by half that to stay centered.
const inset: u32 = if (line_cover[at - offset].anchor) anchorInset(g, row, win_h) else 0;
+ // A grip is acme's button: its fill is this cell's ground, cut to
+ // the button (and a dirty one's inset a pixel inside its ring, so
+ // the ring reads round it), its glyph centered in it.
+ const button = if (here.grip and here.grip_top) gripButton(g, chrome, col - @min(here.grip_offset, @as(u16, @intCast(at))), row, body_layout, win_h) else null;
if (grip) {
// A grip is one glyph centered across the gutter, not text: it is
// looked up by its codepoint's glyph, never shaped.
const drawn = glyphForCodepoint(g, cellCodepoint(glyph));
const cached = cachedGlyph(g, .{ .face = drawn.face, .glyph = drawn.glyph, .role = .tagline, .decoration = GlyphDecoration.fromCell(glyph), .centered = true });
const slot = cached orelse g.space_slot;
- const shift: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, offset) * g.cell_w)) - @as(f32, @floatFromInt(inset / 2)) else 0;
+ const b = button.?;
+ const center = (b[0] + b[2]) / 2 - @as(f32, @floatFromInt(col - offset)) * body_layout.w;
+ const shift: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, offset) * g.cell_w)) + @as(f32, @floatFromInt(config.GUTTER * g.cell_w)) / 2 - center else 0;
const resolved = resolveCell(g, glyph[0..1], 0, null, .tagline, false, page);
- instances[next.*].fr = @as(f32, @floatFromInt(resolved.fg[0])) / 255;
- instances[next.*].fg = @as(f32, @floatFromInt(resolved.fg[1])) / 255;
- instances[next.*].fb = @as(f32, @floatFromInt(resolved.fg[2])) / 255;
+ const ink = if (here.pane_grip) inkOn(gripFill(chrome, here.focus, here.dirty)) else resolved.fg;
+ instances[next.*].fr = @as(f32, @floatFromInt(ink[0])) / 255;
+ instances[next.*].fg = @as(f32, @floatFromInt(ink[1])) / 255;
+ instances[next.*].fb = @as(f32, @floatFromInt(ink[2])) / 255;
instances[next.*].u0 = (@as(f32, @floatFromInt(slot.u)) + shift) / atlas_w;
instances[next.*].v0 = @as(f32, @floatFromInt(slot.v)) / atlas_h;
instances[next.*].v1 = @as(f32, @floatFromInt(slot.v + g.tagline_height)) / atlas_h;
}
instances[next.*].u1 = instances[next.*].u0 + @as(f32, @floatFromInt(g.cell_w)) / atlas_w;
- if (inset > 0 and (!grip or offset == 0)) {
+ if (button) |b| {
+ // acme's button fills its tag row from the rule above to the rule
+ // below: the underlay is its fill, cut to its columns; a pane's
+ // from its region's state, not the grid's cell form (whose dirty
+ // `*` is the cell shells'), a column's its cell's own ground.
+ const base = &instances[next.* - 1];
+ const own: [3]f32 = .{ instances[next.*].br, instances[next.*].bg, instances[next.*].bb };
+ if (here.pane_grip) {
+ const fill = gripFill(chrome, here.focus, here.dirty);
+ base.br = @as(f32, @floatFromInt(fill[0])) / 255;
+ base.bg = @as(f32, @floatFromInt(fill[1])) / 255;
+ base.bb = @as(f32, @floatFromInt(fill[2])) / 255;
+ } else {
+ base.br = own[0];
+ base.bg = own[1];
+ base.bb = own[2];
+ }
+ base.effect &= ~clear_bg_bit;
+ clipInstance(base, .{ b[0], -1e9, b[2], 1e9 }, win_w, win_h);
+ // Over it, only its own glyph; the band round it and the ring
+ // are decor (gripDecor).
+ if (here.pane_grip and !grip) {
+ instances[next.*].u0 = @as(f32, @floatFromInt(g.space_slot.u)) / atlas_w;
+ instances[next.*].u1 = instances[next.*].u0 + @as(f32, @floatFromInt(g.cell_w)) / atlas_w;
+ instances[next.*].v0 = @as(f32, @floatFromInt(g.space_slot.v)) / atlas_h;
+ instances[next.*].v1 = @as(f32, @floatFromInt(g.space_slot.v + g.tagline_height)) / atlas_h;
+ }
+ // Its ground is the fill again (a see-through ground would be
+ // written, not blended, in an opaque window).
+ instances[next.*].br = base.br;
+ instances[next.*].bg = base.bg;
+ instances[next.*].bb = base.bb;
+ instances[next.*].effect &= ~clear_bg_bit;
+ clipInstance(&instances[next.*], b, win_w, win_h);
+ } else if (inset > 0) {
instances[next.*].x0 += @as(f32, @floatFromInt(inset)) / win_w * 2;
instances[next.*].u0 += @as(f32, @floatFromInt(inset)) / atlas_w;
}
@@ -6100,6 +6249,125 @@ fn emitSurfaceCell(
next.* += 1;
}
+test "every pixel of a grip column is band, button or ring, and the button sits over the scrollbar" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 60, .rows = 20 });
+ defer core.deinit();
+ const pane = try core.setTestFile("text\n" ** 60);
+ pane.tag.own = try gpa.dupe(u8, "second\nthird");
+ core.newScratchBelow(0);
+ for ([_]struct { w: u32, h: u32, tag: u32 }{ .{ .w = 10, .h = 20, .tag = 12 }, .{ .w = 17, .h = 35, .tag = 30 }, .{ .w = 9, .h = 19, .tag = 15 }, .{ .w = 12, .h = 24, .tag = 24 } }) |m| for ([_]bool{ false, true }) |bottom| for ([_]usize{ 0, 1 }) |focus| for ([_]bool{ false, true }) |dirty| {
+ core.settings.tag_bottom = bottom;
+ core.active = focus;
+ pane.file.?.revision = pane.file.?.saved_revision + @intFromBool(dirty);
+ pardes.test_api.sync(core);
+ const s = try core.render(core.scratch.allocator());
+ var g: Gui = undefined;
+ g.words = .{};
+ g.decor = .empty;
+ defer g.decor.deinit(gpa);
+ g.cell_w = m.w;
+ g.cell_h = m.h;
+ g.tagline_width = m.w;
+ g.tagline_height = m.tag;
+ g.space_slot = .{ .u = 0, .v = 0 };
+ const layout = fixedCellLayout(&g);
+ const win_w: f32 = @floatFromInt(60 * m.w);
+ const win_h: f32 = @floatFromInt(20 * m.h);
+ const cover = try gpa.alloc(Cover, @as(usize, s.cols) * s.rows);
+ defer gpa.free(cover);
+ coverFrame(cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers(), s.regionList());
+ var groups = makeGroups(&.{}, false);
+ try buildDecor(&g, gpa, s, &groups, layout, win_w, win_h, true);
+ const grip = s.region(.grip, 0).?;
+ // The grip column's pixels, band first, then its cells, then decor.
+ const x_px: u32 = grip.rect.x * m.w;
+ const w_px: u32 = config.GUTTER * m.w;
+ const y_px: u32 = grip.rect.y * m.h;
+ const h_px: u32 = grip.rect.h * m.h;
+ const pixels = try gpa.alloc(?[3]f32, w_px * h_px);
+ defer gpa.free(pixels);
+ @memset(pixels, null);
+ const paint = struct {
+ fn quad(into: []?[3]f32, x0n: f32, y0n: f32, x1n: f32, y1n: f32, rgb: [3]f32, ww: f32, wh: f32, ox: u32, oy: u32, pw: u32, ph: u32) void {
+ const px0: i64 = @intFromFloat(@round((x0n + 1) / 2 * ww));
+ const px1: i64 = @intFromFloat(@round((x1n + 1) / 2 * ww));
+ const py0: i64 = @intFromFloat(@round((1 - y0n) / 2 * wh));
+ const py1: i64 = @intFromFloat(@round((1 - y1n) / 2 * wh));
+ var y = @max(py0, oy);
+ while (y < @min(py1, oy + ph)) : (y += 1) {
+ var x = @max(px0, ox);
+ while (x < @min(px1, ox + pw)) : (x += 1) into[@intCast((y - oy) * pw + (x - ox))] = rgb;
+ }
+ }
+ };
+ var instances: [4]CellInstance = undefined;
+ for (0..grip.rect.h) |line| for (0..config.GUTTER) |dx| {
+ const col: u16 = grip.rect.x + @as(u16, @intCast(dx));
+ const row: u16 = grip.rect.y + @as(u16, @intCast(line));
+ const at = @as(usize, row) * s.cols;
+ var next: u32 = 0;
+ emitSurfaceCell(&g, &s.chrome, &instances, &next, col, row, layout, win_w, win_h, null, s.cells[at..][0..s.cols], cover[at..][0..s.cols], col, null, false, false, Ground.opaqueRgb(bg_default));
+ for (instances[0..next]) |i| paint.quad(pixels, i.x0, i.y0, i.x1, i.y1, .{ i.br, i.bg, i.bb }, win_w, win_h, x_px, y_px, w_px, h_px);
+ };
+ for (g.decor.items) |i| if (i.fr >= 1 and i.clip_x1 < i.clip_x0) paint.quad(pixels, i.x0, i.y0, i.x1, i.y1, .{ i.br, i.bg, i.bb }, win_w, win_h, x_px, y_px, w_px, h_px);
+ const rgb = struct {
+ fn of(v: [3]u8) [3]f32 {
+ return .{ @as(f32, @floatFromInt(v[0])) / 255, @as(f32, @floatFromInt(v[1])) / 255, @as(f32, @floatFromInt(v[2])) / 255 };
+ }
+ }.of;
+ const chrome = &s.chrome;
+ const band = if (focus == 0) chrome.tag_focus_bg else chrome.tag_bg;
+ // Band, fills, rings, the rules between stacked panes and under the
+ // tag, and the mode marks' ink (the grip's first cell's).
+ const ink = inkOn(gripFill(chrome, grip.active, grip.dirty));
+ const allowed = [_][3]f32{ rgb(band), rgb(core.chromeTheme().box), rgb(pardes.draw.boxDirty(core)), rgb(chrome.grip_border), rgb(chrome.grip_focus_ring), rgb(chrome.border), rgb(ink), rgb(chrome.tag_rule orelse mixRgb(chrome.tag_bg, chrome.page orelse bg_default)) };
+ for (pixels, 0..) |pixel, n| {
+ const got = pixel orelse return error.TestUnpaintedGripPixel;
+ const known = for (allowed) |a| {
+ if (std.meta.eql(a, got)) break true;
+ } else false;
+ // Under the first row there is no button: band and rules only.
+ if (n / w_px >= m.h and n / w_px < h_px - 1 and !std.meta.eql(got, rgb(band)) and !std.meta.eql(got, rgb(chrome.border)))
+ return error.TestButtonUnderFirstRow;
+ if (!known) std.debug.print("sliver {any} at {d},{d} of {d}x{d} (metrics {d}x{d}/{d}, bottom {}, focus {d}, dirty {}) page {any}\n", .{ got, n % w_px, n / w_px, w_px, h_px, m.w, m.h, m.tag, bottom, focus, dirty, s.chrome.page });
+ try std.testing.expect(known);
+ }
+ // The rule under the tag runs unbroken across the grip column.
+ if (!bottom) for (pixels[(h_px - 1) * w_px ..][0..w_px]) |pixel|
+ try std.testing.expectEqual(rgb(chrome.tag_rule orelse mixRgb(chrome.tag_bg, chrome.page orelse bg_default)), pixel.?);
+ // The button's edges are the scrollbar's.
+ const button = gripButton(&g, chrome, grip.rect.x, grip.rect.y, layout, win_h);
+ const column = scrollColumn(&g, chrome, s.region(.rail, 0).?.rect.x, layout);
+ try std.testing.expectEqual(column[0], button[0]);
+ try std.testing.expectEqual(column[1], button[2]);
+ };
+ pane.file.?.revision = pane.file.?.saved_revision;
+}
+
+test "a rising pane past its mark stays inside its box" {
+ // Bouncy, a vertical rise at its highest overshoot: drawn past its box,
+ // clipped to it.
+ var g: Gui = undefined;
+ g.cell_w = 10;
+ g.cell_h = 20;
+ var track: pardes.animation.Track = .{ .serial = 3, .phase = .opening, .effect = .vertical, .motion = @intFromEnum(pardes.animation.Motion.Flavour.bouncy) + 1, .frame_count = 20, .from = .{ .x = 0, .y = 10, .w = 40, .h = 10 }, .to = .{ .x = 0, .y = 0, .w = 40, .h = 10 } };
+ var peak: u16 = 0;
+ var highest: f32 = 1e9;
+ while (track.frame < track.frames()) : (track.frame += 1) {
+ if (track.presented().y < highest) {
+ highest = track.presented().y;
+ peak = track.frame;
+ }
+ }
+ track.frame = peak;
+ try std.testing.expect(track.presented().y < 0);
+ var instance: CellInstance = std.mem.zeroes(CellInstance);
+ setTransitionFields(&instance, track, fixedCellLayout(&g), 800, 480, 0);
+ try std.testing.expectEqual(@as(f32, 0), instance.clip_y0);
+ try std.testing.expectEqual(@as(f32, 200), instance.clip_y1);
+}
+
test "small tagline fonts leave no background gutters between adjacent tags" {
const chrome: pardes.Chrome = .{};
var g: Gui = undefined;
@@ -6787,9 +7055,9 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" {
try std.testing.expect(gripCellChanged(&surface, &cover, &previous_cover, 1, 0));
try std.testing.expect(!gripCellChanged(&surface, &cover, &previous_cover, 2, 0));
try std.testing.expect(!cover[2].grip);
- // A column's grip has no glyph of a pane's.
+ // A column's grip is a button too, cut the same way (it has no glyph).
coverFrame(&cover, 6, 2, &.{}, &.{}, &.{.{ .kind = .column_grip, .rect = .{ .x = 0, .y = 0, .w = 2, .h = 1 } }});
- try std.testing.expect(!cover[0].grip);
+ try std.testing.expect(cover[0].grip);
coverFrame(&cover, 6, 2, &.{}, &.{}, &grip);
const chrome: pardes.Chrome = .{};
@@ -7327,6 +7595,8 @@ fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, t
try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, "latest");
var name: [24]u8 = undefined;
if (transition) |frame| try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, std.fmt.bufPrint(&name, "transition-{d}", .{frame}) catch unreachable);
+ if (g.capture_series) try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, std.fmt.bufPrint(&name, "frame-{d:0>5}", .{g.captured}) catch unreachable);
+ g.captured +%= 1;
}
fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32, name: []const u8) !void {
@@ -7401,6 +7671,303 @@ fn addDecor(g: *Gui, gpa: std.mem.Allocator, x0_in: f32, y0_in: f32, x1_in: f32,
try g.decor.append(gpa, instance);
}
+/// How a lift of 0 to 1 stands off the page: the shadow's blur and offset
+/// in cell heights, and its darkest, where it leaves the caster's edge.
+/// Within §8.2's budget: sigma 0.4-0.8 cell, at most 30% darker at the edge.
+const Shadow = struct {
+ // A key light above: the drop is most of the blur, so the shadow hangs
+ // below and to the right and barely reaches over the caster's top.
+ const sigma: f32 = 0.42;
+ const drop: f32 = 0.34;
+ const side: f32 = 0.1;
+ const strength: f32 = 0.42;
+};
+
+/// A soft rectangle around `caster` (window pixels), blurred by `sigma`
+/// and moved by (dx, dy): black is a shadow, a light colour a rim or a
+/// glow. The caster's own rectangle is left alone, and `track` moves it all
+/// as its caster moves.
+fn addSoft(g: *Gui, gpa: std.mem.Allocator, caster: [4]f32, sigma_in: f32, dx: f32, dy: f32, strength: f32, rgb: [3]u8, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void {
+ if (strength <= 0.002) return;
+ const sigma = @max(0.5, sigma_in);
+ const x0 = caster[0] + dx - 3 * sigma;
+ const y0 = caster[1] + dy - 3 * sigma;
+ const x1 = caster[2] + dx + 3 * sigma;
+ const y1 = caster[3] + dy + 3 * sigma;
+ const w = x1 - x0;
+ const ht = y1 - y0;
+ if (w <= 0 or ht <= 0) return;
+ // In the quad's own 0..1 space (decor.frag.glsl): sigma per axis, the
+ // caster's offset from its shadow, the strength.
+ var instance: CellInstance = .{
+ .x0 = (x0 / win_w) * 2.0 - 1.0,
+ .y0 = 1.0 - (y0 / win_h) * 2.0,
+ .x1 = (x1 / win_w) * 2.0 - 1.0,
+ .y1 = 1.0 - (y1 / win_h) * 2.0,
+ .u0 = sigma / w,
+ .v0 = sigma / ht,
+ .u1 = sigma / w,
+ .v1 = sigma / ht,
+ .fr = strength,
+ .fg = dx / w,
+ .fb = dy / ht,
+ .br = @as(f32, @floatFromInt(rgb[0])) / 255,
+ .bg = @as(f32, @floatFromInt(rgb[1])) / 255,
+ .bb = @as(f32, @floatFromInt(rgb[2])) / 255,
+ };
+ setTransitionFields(&instance, track, fixedCellLayout(g), win_w, win_h, 0);
+ instance.effect |= soft_shadow_bit;
+ try g.decor.append(gpa, instance);
+}
+
+/// How far each lift style may go on this theme without its text falling
+/// below min(its contrast, 4.5): a shadow darkens, the others lighten.
+const LiftCeilings = struct {
+ shadow: f32,
+ white: f32,
+ /// A rim is light on a dark page and shade on a light one, where light
+ /// would not show.
+ rim: [3]u8,
+
+ /// Over the text on the page, and over a selection's text on its own
+ /// ground: the lower of the two.
+ fn of(chrome: *const pardes.Chrome) LiftCeilings {
+ const fg = chrome.fg orelse fg_default;
+ const bg = chrome.page orelse bg_default;
+ return .{
+ .shadow = @min(shadowCeiling(fg, bg), shadowCeiling(chrome.sel_fg, chrome.sel_bg)),
+ .white = @min(lightenCeiling(fg, bg, .{ 255, 255, 255 }), lightenCeiling(chrome.sel_fg, chrome.sel_bg, .{ 255, 255, 255 })),
+ .rim = if (luminance(bg) < 0.18) .{ 255, 255, 255 } else .{ 0, 0, 0 },
+ };
+ }
+};
+
+/// What stands a lifted part off the page, `lift` 0 to 1 (a pane's spring,
+/// a notice's 1), in the style chosen: a soft shadow, or a rim along the
+/// top of the focused pane's tag rows (`rim_edge`, a pane's only:
+/// never a notice's).
+fn addLift(g: *Gui, gpa: std.mem.Allocator, style: pardes.config.Runtime.LiftStyle, ceilings: LiftCeilings, caster: [4]f32, rim_edge: ?[3]f32, bodies: []const [4]f32, lift: f32, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void {
+ if (lift <= 0.001) return;
+ const h: f32 = @floatFromInt(g.cell_h);
+ // The core sends `auto` already resolved (draw.liftStyle).
+ const shadow = switch (style) {
+ .off => return,
+ .shadow, .auto => true,
+ .rim => false,
+ };
+ if (shadow) {
+ // Nothing of a shadow may touch a focus indicator (docs/effects.md):
+ // it falls on pane bodies and rails, never a tag, a grip or a header.
+ const first = g.decor.items.len;
+ try addSoft(g, gpa, caster, Shadow.sigma * h * lift, Shadow.side * h * lift, Shadow.drop * h * lift, @min(Shadow.strength * lift, ceilings.shadow), .{ 0, 0, 0 }, track, win_w, win_h);
+ try clipToBodies(g, gpa, first, bodies);
+ }
+ // A hairline just above the focused tag rows, never on them: it adds to
+ // the indicator without changing its colour.
+ if (style == .rim) if (rim_edge) |edge| {
+ const light = ceilings.rim[0] == 255;
+ const alpha = @min(Lift.rim * lift, if (light) ceilings.white else ceilings.shadow);
+ try addDecor(g, gpa, edge[0], edge[2] - 1, edge[1], edge[2], ceilings.rim, alpha, track, win_w, win_h);
+ };
+}
+
+/// A rim's strength, in the §8.2 budget for coloured light.
+const Lift = struct {
+ const rim: f32 = 0.35;
+};
+
+/// A body region in window pixels.
+fn bodyPixels(region: pardes.Region, layout: CellLayout) [4]f32 {
+ return .{
+ @as(f32, @floatFromInt(region.rect.x)) * layout.w,
+ @as(f32, @floatFromInt(region.rect.y)) * layout.h,
+ @as(f32, @floatFromInt(region.rect.x + region.rect.w)) * layout.w,
+ @as(f32, @floatFromInt(region.rect.y + region.rect.h)) * layout.h,
+ };
+}
+
+/// Replaces the decor from `first` on with a copy per pane body it reaches,
+/// each clipped to that body (and to what a vertical transition already
+/// clipped it to): the lift then never reaches a tag, a grip or a header.
+fn clipToBodies(g: *Gui, gpa: std.mem.Allocator, first: usize, bodies: []const [4]f32) !void {
+ const added = g.decor.items.len - first;
+ if (added == 0) return;
+ var own: [8]CellInstance = undefined;
+ const n = @min(added, own.len);
+ @memcpy(own[0..n], g.decor.items[first..][0..n]);
+ g.decor.shrinkRetainingCapacity(first);
+ for (own[0..n]) |instance| for (bodies) |body| {
+ var copy = instance;
+ const vertical = copy.clip_x1 >= copy.clip_x0;
+ copy.clip_x0 = if (vertical) @max(copy.clip_x0, body[0]) else body[0];
+ copy.clip_y0 = if (vertical) @max(copy.clip_y0, body[1]) else body[1];
+ copy.clip_x1 = if (vertical) @min(copy.clip_x1, body[2]) else body[2];
+ copy.clip_y1 = if (vertical) @min(copy.clip_y1, body[3]) else body[3];
+ if (copy.clip_x1 <= copy.clip_x0 or copy.clip_y1 <= copy.clip_y0) continue;
+ try g.decor.append(gpa, copy);
+ };
+}
+
+
+/// Like shadowCeiling for a light colour laid over at `a` (sRGB blend, as
+/// the decor pipeline blends a colour that is not black): the largest `a`
+/// that keeps text and page apart by min(their contrast, 4.5).
+fn lightenCeiling(fg: [3]u8, bg: [3]u8, rgb: [3]u8) f32 {
+ const contrast = struct {
+ fn of(a: [3]u8, b: [3]u8) f32 {
+ const la = luminance(a);
+ const lb = luminance(b);
+ return (@max(la, lb) + 0.05) / (@min(la, lb) + 0.05);
+ }
+ fn blend(under: [3]u8, over: [3]u8, a: f32) [3]u8 {
+ var out: [3]u8 = undefined;
+ for (&out, under, over) |*o, x, y| o.* = @intFromFloat(@round(@as(f32, @floatFromInt(x)) * (1 - a) + @as(f32, @floatFromInt(y)) * a));
+ return out;
+ }
+ };
+ const target = @min(contrast.of(fg, bg), 4.5);
+ var lo: f32 = 0;
+ var hi: f32 = 1;
+ for (0..24) |_| {
+ const mid = (lo + hi) / 2;
+ if (contrast.of(contrast.blend(fg, rgb, mid), contrast.blend(bg, rgb, mid)) >= target - 1e-3) lo = mid else hi = mid;
+ }
+ return lo;
+}
+
+/// sRGB's relative luminance (WCAG).
+fn luminance(rgb: [3]u8) f32 {
+ var sum: f32 = 0;
+ for (rgb, [3]f32{ 0.2126, 0.7152, 0.0722 }) |channel, weight| {
+ const v = @as(f32, @floatFromInt(channel)) / 255;
+ sum += weight * (if (v <= 0.04045) v / 12.92 else std.math.pow(f32, (v + 0.055) / 1.055, 2.4));
+ }
+ return sum;
+}
+
+/// How dark a shadow may make the page and its text, both together, before
+/// the pair drops below its own contrast or 4.5, whichever is lower (§8.2):
+/// darkening in linear light by k leaves (k L1 + .05) / (k L2 + .05).
+fn shadowCeiling(fg: [3]u8, bg: [3]u8) f32 {
+ const a = luminance(fg);
+ const b = luminance(bg);
+ const light = @max(a, b);
+ const dark = @min(a, b);
+ const target = @min((light + 0.05) / (dark + 0.05), 4.5);
+ const room = light - target * dark;
+ if (room <= 0) return 0;
+ return std.math.clamp(1 - 0.05 * (target - 1) / room, 0, 1);
+}
+
+test "a rim never lightens a native theme's text below its contrast or 4.5" {
+ for (pardes.themes) |theme| {
+ const fg = theme.fg orelse fg_default;
+ const bg = theme.bg orelse bg_default;
+ for ([_][3]u8{.{ 255, 255, 255 }}) |light| {
+ const a = @min(Lift.rim, lightenCeiling(fg, bg, light));
+ const blend = struct {
+ fn of(under: [3]u8, over: [3]u8, t: f32) [3]u8 {
+ var out: [3]u8 = undefined;
+ for (&out, under, over) |*o, x, y| o.* = @intFromFloat(@round(@as(f32, @floatFromInt(x)) * (1 - t) + @as(f32, @floatFromInt(y)) * t));
+ return out;
+ }
+ }.of;
+ const ratio = struct {
+ fn of(x: [3]u8, y: [3]u8) f32 {
+ return (@max(luminance(x), luminance(y)) + 0.05) / (@min(luminance(x), luminance(y)) + 0.05);
+ }
+ }.of;
+ try std.testing.expect(ratio(blend(fg, light, a), blend(bg, light, a)) >= @min(ratio(fg, bg), 4.5) - 2e-3);
+ }
+ }
+}
+
+test "a lift never touches a tag, a grip or a header, on a light theme or a dark one" {
+ const gpa = std.testing.allocator;
+ for ([_][]const u8{ "forge", "daybreak" }) |name| {
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 });
+ defer core.deinit();
+ core.update(.{ .resize = .{ .cols = 80, .rows = 24, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } });
+ _ = try core.newShell(1, "");
+ try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false));
+ var line: [64]u8 = undefined;
+ try std.testing.expect(core.executeBuiltinLine(0, try std.fmt.bufPrint(&line, "Theme {s}", .{name})));
+ for (0..40) |_| core.update(.tick);
+ for (std.enums.values(pardes.config.Runtime.LiftStyle)) |style| {
+ core.settings.lift = style;
+ try std.testing.expect(core.executeBuiltinLine(0, "Msg a floating notice"));
+ core.advance(core.now_ns + 2 * std.time.ns_per_s);
+ pardes.test_api.sync(core);
+ const s = try core.render(core.scratch.allocator());
+ core.advance(core.now_ns + 2 * std.time.ns_per_s);
+ const settled = try core.render(core.scratch.allocator());
+ _ = s;
+ // The focus indicator itself: the focused pane's tag is not the
+ // others' colour, whatever the lift.
+ var active: ?[3]u8 = null;
+ var other: ?[3]u8 = null;
+ for (settled.tagLayers()) |layer| if (layer.rows != 0 and layer.kind == .pane) {
+ if (layer.id == core.active) active = layer.bg else other = layer.bg;
+ };
+ try std.testing.expect(!std.mem.eql(u8, &active.?, &other.?));
+ var g: Gui = undefined;
+ g.cell_w = 10;
+ g.cell_h = 20;
+ g.tagline_width = 6;
+ g.tagline_height = 12;
+ g.decor = .empty;
+ defer g.decor.deinit(gpa);
+ var groups = makeGroups(&.{}, false);
+ try buildDecor(&g, gpa, settled, &groups, fixedCellLayout(&g), 800, 480, true);
+ var lifted: usize = 0;
+ for (g.decor.items) |item| {
+ if (item.clip_x1 < item.clip_x0) continue;
+ lifted += 1;
+ // Inside a body or a rail, all of it.
+ const inside = for (settled.regionList()) |region| {
+ if (region.kind != .body and region.kind != .rail) continue;
+ const r = bodyPixels(region, fixedCellLayout(&g));
+ if (item.clip_x0 >= r[0] and item.clip_y0 >= r[1] and item.clip_x1 <= r[2] and item.clip_y1 <= r[3]) break true;
+ } else false;
+ try std.testing.expect(inside);
+ }
+ const light_page = std.mem.eql(u8, name, "daybreak");
+ try std.testing.expectEqual(style == .shadow or (style == .auto and light_page), lifted != 0);
+ // A rim is one hairline, just above the focused pane's tag row.
+ var rims: usize = 0;
+ for (g.decor.items) |item| {
+ if (item.clip_x1 >= item.clip_x0) continue;
+ const top = (1 - item.y0) / 2 * 480;
+ const height = (item.y0 - item.y1) / 2 * 480;
+ const on_active_tag = for (settled.regionList()) |region| {
+ if (region.kind == .tag and region.owner == core.active and @abs(top + 1 - @as(f32, @floatFromInt(region.rect.y)) * 20) < 0.01 and @abs(height - 1) < 0.01) break true;
+ } else false;
+ // Not the rule under a column bar, which may run there too.
+ rims += @intFromBool(on_active_tag and item.fr < 1);
+ }
+ try std.testing.expectEqual(@as(usize, @intFromBool(style == .rim)), rims);
+ }
+ }
+}
+
+test "a shadow never darkens a native theme's text below its contrast or 4.5" {
+ var shaded: usize = 0;
+ for (pardes.themes) |theme| {
+ const fg = theme.fg orelse fg_default;
+ const bg = theme.bg orelse bg_default;
+ const ceiling = shadowCeiling(fg, bg);
+ const k = 1 - @min(Shadow.strength, ceiling);
+ const light = @max(luminance(fg), luminance(bg));
+ const dark = @min(luminance(fg), luminance(bg));
+ const before = (light + 0.05) / (dark + 0.05);
+ const after = (k * light + 0.05) / (k * dark + 0.05);
+ try std.testing.expect(after >= @min(before, 4.5) - 1e-3);
+ shaded += @intFromBool(ceiling > 0.05);
+ }
+ // And most themes have room for one.
+ try std.testing.expect(shaded * 2 > pardes.themes.len);
+}
+
/// The frame's decor, group by group (docs/render-pipeline.md §3.4), all of it
/// read from the regions and the palette: the page's rules and the still
/// panes' rails, rules and grip marks in tier 0, a moving or closing pane's
@@ -7409,9 +7976,27 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
g.decor.clearRetainingCapacity();
const chrome = &surface.chrome;
const regions = surface.regionList();
+ const ceilings = LiftCeilings.of(chrome);
+ var current_buf: [pardes.MAX_PANES][4]f32 = undefined;
+ var ncurrent: usize = 0;
+ for (regions) |part| if ((part.kind == .body or part.kind == .rail) and ncurrent < current_buf.len) {
+ current_buf[ncurrent] = bodyPixels(part, layout);
+ ncurrent += 1;
+ };
+ const current_bodies = current_buf[0..ncurrent];
+ const lift_style = std.enums.fromInt(pardes.config.Runtime.LiftStyle, chrome.lift_style) orelse .off;
+ const notice_lift = for (regions) |region| {
+ if (region.kind == .notice) break region.lift;
+ } else 0;
for (groups.items[0..groups.len], 0..) |*group, index| {
group.decor_start = @intCast(g.decor.items.len);
defer group.decor_count = @as(u32, @intCast(g.decor.items.len)) - group.decor_start;
+ // The grips' buttons before any rule: the rules over their rows cut
+ // them, and the band round them never covers a rule.
+ if (index <= groups.tracks) {
+ const closing = if (group.track) |active| active.phase == .closing else false;
+ try gripDecor(g, gpa, if (closing) surface.previous_regions else regions, if (closing) surface.previous_cells else surface.cells, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, group.track);
+ }
if (index == 0) {
const top = for (regions) |region| {
if (region.kind == .workspace_tag) break region.rect.h;
@@ -7421,7 +8006,8 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
.grip, .tag, .column_grip, .column_tag => if (region.rect.y == top) break true,
else => {},
} else false;
- const height = topbarPaneBorderPixels(g.cell_h, g.tagline_height);
+ // As wide as the other rules, where the tagline leaves the room.
+ const height: u32 = @max(1, @min(chrome.rule_px, (g.cell_h -| g.tagline_height) * 2));
if (top != 0 and top < surface.rows and tag_under and height != 0) {
const y0: f32 = @floatFromInt(top * g.cell_h - height / 2);
try addDecor(g, gpa, 0, y0, win_w, y0 + @as(f32, @floatFromInt(height)), chrome.topbar_rule, 1, null, win_w, win_h);
@@ -7430,7 +8016,7 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
for (regions) |bar| {
if (bar.kind != .column_grip) continue;
const y = @as(f32, @floatFromInt(bar.rect.y + bar.rect.h)) * layout.h;
- try addDecor(g, gpa, 0, y - 1, win_w, y, chrome.border, 1, null, win_w, win_h);
+ try addDecor(g, gpa, 0, y - rulePx(chrome), win_w, y, chrome.border, 1, null, win_w, win_h);
break;
}
// A tag on the last row runs on through the pixels under the grid.
@@ -7446,7 +8032,14 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
// A closing pane is gone: its chrome is the last frame's.
const closing = if (track) |active| active.phase == .closing else false;
const placed = if (closing) surface.previous_regions else regions;
- const grid = if (closing) surface.previous_cells else surface.cells;
+ // Where a lift may fall: pane bodies, never tags (addLift).
+ var body_buf: [pardes.MAX_PANES][4]f32 = undefined;
+ var nbodies: usize = 0;
+ for (placed) |part| if ((part.kind == .body or part.kind == .rail) and nbodies < body_buf.len) {
+ body_buf[nbodies] = bodyPixels(part, layout);
+ nbodies += 1;
+ };
+ const bodies = body_buf[0..nbodies];
try paneDecor(g, gpa, placed, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, track);
// A single spine runs down each column, through every tag and body.
if (index == 0) for (regions) |column| {
@@ -7455,14 +8048,42 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
const y0 = @as(f32, @floatFromInt(column.rect.y)) * layout.h;
const grid_y1 = @as(f32, @floatFromInt(surface.rows)) * layout.h;
const y1 = if (win_h - grid_y1 < layout.h) @max(grid_y1, win_h) else grid_y1;
- if (y1 > y0) try addDecor(g, gpa, x, y0, x + 1, y1, chrome.border, 1, null, win_w, win_h);
+ // None at the window's edge: nothing to rule apart there.
+ if (column.rect.x == 0) continue;
+ if (y1 > y0) try addDecor(g, gpa, x, y0, x + rulePx(chrome), y1, chrome.border, 1, null, win_w, win_h);
};
- try gripDecor(g, gpa, placed, grid, groups, index, layout, surface.cols, surface.rows, win_w, win_h, track);
+ // Cast last in the group, over its cells, images and chrome: a
+ // lifted pane's shadow darkens its neighbours' rails and rules
+ // as it darkens their text, and never the pane itself.
+ for (placed) |grip| {
+ if (grip.kind != .grip or grip.lift <= 0.001 or groupOf(groups, grip.serial) != index) continue;
+ var box: [4]f32 = .{ @as(f32, @floatFromInt(grip.rect.x)) * layout.w, @as(f32, @floatFromInt(grip.rect.y)) * layout.h, 0, @as(f32, @floatFromInt(grip.rect.y + grip.rect.h)) * layout.h };
+ box[2] = box[0];
+ for (placed) |part| {
+ if (part.owner != grip.owner or (part.kind != .tag and part.kind != .rail and part.kind != .body)) continue;
+ box[1] = @min(box[1], @as(f32, @floatFromInt(part.rect.y)) * layout.h);
+ box[2] = @max(box[2], @as(f32, @floatFromInt(part.rect.x + part.rect.w)) * layout.w);
+ box[3] = @max(box[3], @as(f32, @floatFromInt(part.rect.y + part.rect.h)) * layout.h);
+ }
+ // The top edge of its tag rows, for a rim: x0, x1, y.
+ const rim_edge: ?[3]f32 = for (placed) |part| {
+ if (part.owner == grip.owner and part.kind == .tag) break .{ @as(f32, @floatFromInt(grip.rect.x)) * layout.w, @as(f32, @floatFromInt(part.rect.x + part.rect.w)) * layout.w, @as(f32, @floatFromInt(part.rect.y)) * layout.h };
+ } else null;
+ try addLift(g, gpa, lift_style, ceilings, box, rim_edge, bodies, grip.lift, track, win_w, win_h);
+ }
}
// A notice chip is tagline hung over the body's top rows, so it is
// ruled off from the text beneath it the way a tag is: the same
// colour and thickness along its bottom, running to the window's
// edge when it does, fading up and dissolving with its band.
+ // A notice floats: its shadow first, then every rule.
+ for (surface.tagLayers()) |*layer| {
+ if (layer.rows == 0 or layer.kind != .notice or noticeGroup(groups, layer) != index) continue;
+ const x = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w;
+ const top = (@as(f32, @floatFromInt(layer.viewport.y)) + layer.slide) * layout.h;
+ const track = groups.items[groupAt(groups, layer.viewport.x, layer.viewport.y)].track;
+ try addLift(g, gpa, lift_style, ceilings, .{ x, top, x + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w, top + layout.h }, null, current_bodies, notice_lift * layer.fade, track, win_w, win_h);
+ }
for (surface.tagLayers()) |*layer| {
if (layer.rows == 0 or layer.kind != .notice or noticeGroup(groups, layer) != index) continue;
const x0 = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w;
@@ -7500,47 +8121,245 @@ fn paneDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, gr
const rule_x1 = if (win_w - x1 < layout.w) @max(x1, win_w) else x1;
const tag_bottom = tag.rect.y > rail.rect.y;
const rule_y = @as(f32, @floatFromInt(if (tag_bottom) tag.rect.y else tag.rect.y + tag.rect.h)) * layout.h;
- try addDecor(g, gpa, x0, if (tag_bottom) rule_y else rule_y - 1, rule_x1, if (tag_bottom) rule_y + 1 else rule_y, chrome.border, 1, track, win_w, win_h);
+ try addDecor(g, gpa, x0, if (tag_bottom) rule_y else rule_y - 1, rule_x1, if (tag_bottom) rule_y + 1 else rule_y, chrome.tag_rule orelse mixRgb(chrome.tag_bg, page), 1, track, win_w, win_h);
+ // Stacked panes are ruled apart as columns are, where one pane's
+ // body meets the next one's tag (acme's Border).
+ {
+ const pane_top = @min(tag.rect.y, rail.rect.y);
+ const stacked = for (regions) |other| {
+ if ((other.kind == .body or other.kind == .tag) and other.owner != rail.owner and other.rect.y + other.rect.h == pane_top and
+ other.rect.x < tag.rect.x + tag.rect.w and tag.rect.x < other.rect.x + other.rect.w) break true;
+ } else false;
+ // Inside the tag band's slack, never over its glyphs: the top
+ // slack of a tag on top, the bottom slack of the one above when
+ // tags are at the bottom; a pixel at least.
+ const y = @as(f32, @floatFromInt(pane_top)) * layout.h;
+ const spare = g.cell_h -| g.tagline_height;
+ if (stacked and !tag_bottom) {
+ const room = taglineBandOffset(pane_top, win_h, g.cell_h, g.tagline_height);
+ try addDecor(g, gpa, x0, y, rule_x1, y + @as(f32, @floatFromInt(@max(1, @min(chrome.rule_px, room)))), chrome.border, 1, track, win_w, win_h);
+ } else if (stacked and pane_top > 0) {
+ const room = spare - taglineBandOffset(pane_top - 1, win_h, g.cell_h, g.tagline_height);
+ try addDecor(g, gpa, x0, y - @as(f32, @floatFromInt(@max(1, @min(chrome.rule_px, room)))), rule_x1, y, chrome.border, 1, track, win_w, win_h);
+ }
+ }
const y0 = @as(f32, @floatFromInt(rail.rect.y)) * layout.h;
const body_end = rail.rect.y + rail.rect.h;
const body_y1 = @as(f32, @floatFromInt(body_end)) * layout.h;
const y1 = if (body_end == rows and win_h - body_y1 < layout.h) @max(body_y1, win_h) else body_y1;
+ // A theme's own track fills its width, the thumb one pixel short
+ // of it so the track edges it (acme's scrollbar); else a thin rail.
+ // acme's scrollbar: the track fills the scroll column, under the
+ // grip's button; the thumb is a pixel short of its right edge, so
+ // the track edges it.
+ const column = scrollColumn(g, chrome, rail.rect.x, layout);
try addDecor(g, gpa, x0, y0, x0 + layout.w, y1, page, 1, track, win_w, win_h);
- try addDecor(g, gpa, x0, y0, x0 + @min(layout.w, 2), y1, chrome.scroll_track, 1, track, win_w, win_h);
+ try addDecor(g, gpa, column[0], y0, column[1], y1, chrome.scroll_track, 1, track, win_w, win_h);
if (rail.thumb_h != 0) {
const thumb_y0 = @as(f32, @floatFromInt(rail.rect.y + rail.thumb_y)) * layout.h;
const thumb_y1 = if (rail.thumb_y + rail.thumb_h == rail.rect.h) y1 else @as(f32, @floatFromInt(rail.rect.y + rail.thumb_y + rail.thumb_h)) * layout.h;
- try addDecor(g, gpa, x0, thumb_y0, x0 + @min(layout.w, @max(2, @floor(layout.w * 0.5))), thumb_y1, chrome.scroll_thumb, 1, track, win_w, win_h);
+ try addDecor(g, gpa, column[0], thumb_y0, column[1] - 1, thumb_y1, chrome.scroll_thumb, 1, track, win_w, win_h);
}
}
}
+/// Halfway between two colours.
+fn mixRgb(a: [3]u8, b: [3]u8) [3]u8 {
+ var out: [3]u8 = undefined;
+ for (&out, a, b) |*o, x, y| o.* = @intCast((@as(u16, x) + y) / 2);
+ return out;
+}
+
+/// A rule's width: the theme's own, or one pixel.
+fn rulePx(chrome: *const pardes.Chrome) f32 {
+ return @floatFromInt(@max(1, chrome.rule_px));
+}
+
/// The two short bars on each row of a grip with no mode glyph there,
/// centered in its anchor. Only the panes of group `index`.
-fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, cells: []const pardes.Cell, groups: *const Groups, index: usize, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void {
+fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, cells: []const pardes.Cell, groups: *const Groups, index: usize, chrome: *const pardes.Chrome, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void {
if (g.tagline_height < 6 or g.tagline_width < 3) return;
for (regions) |grip| {
- if (grip.kind != .grip or groupOf(groups, grip.serial) != index) continue;
+ if ((grip.kind != .grip and grip.kind != .column_grip) or groupOf(groups, grip.serial) != index) continue;
+ if (grip.rect.h == 0 or grip.rect.y >= rows) continue;
+ const button = gripButton(g, chrome, grip.rect.x, grip.rect.y, layout, win_h);
+ // Round the button, its first row is the tag's band: nothing of the
+ // cells under it shows there.
+ const band = if (grip.active) chrome.tag_focus_bg else chrome.tag_bg;
+ const left = @as(f32, @floatFromInt(grip.rect.x)) * layout.w;
+ const right = @as(f32, @floatFromInt(grip.rect.x + grip.rect.w)) * layout.w;
+ try addDecor(g, gpa, left, button[1], button[0], button[3], band, 1, track, win_w, win_h);
+ try addDecor(g, gpa, button[2], button[1], right, button[3], band, 1, track, win_w, win_h);
+ if (grip.kind != .grip) continue;
+ // acme's button ring, two pixels (ButtonBorder) inside the button:
+ // the focused one's in its own fill. An unfocused dirty one keeps a
+ // pixel of band inside its ring, so the four states differ in shape
+ // whatever a theme's colours (a focused dirty one is acme's
+ // modbutton, filled up to its ring).
+ {
+ // The rules drawn over the row (paneDecor, after this) cut into
+ // it: the ring runs inside them, from rule to rule.
+ const tag = for (regions) |part| {
+ if (part.kind == .tag and part.owner == grip.owner) break part;
+ } else grip;
+ const tag_bottom = for (regions) |part| {
+ if (part.kind == .rail and part.owner == grip.owner) break part.rect.y < tag.rect.y;
+ } else false;
+ const stacked = !tag_bottom and for (regions) |other| {
+ if ((other.kind == .body or other.kind == .tag) and other.owner != grip.owner and other.rect.y + other.rect.h == grip.rect.y and
+ other.rect.x < tag.rect.x + tag.rect.w and tag.rect.x < other.rect.x + other.rect.w) break true;
+ } else false;
+ const top_rule: f32 = if (tag_bottom) 1 else if (stacked) @floatFromInt(@max(1, @min(chrome.rule_px, taglineBandOffset(grip.rect.y, win_h, g.cell_h, g.tagline_height)))) else 0;
+ const bottom_rule: f32 = if (!tag_bottom and grip.rect.h == 1) 1 else 0;
+ const x0 = button[0];
+ const x1 = button[2];
+ const y0 = button[1] + top_rule;
+ const y1 = button[3] - bottom_rule;
+ const ring = if (grip.active) chrome.grip_focus_ring else chrome.grip_border;
+ try addDecor(g, gpa, x0, y0, x1, y0 + 2, ring, 1, track, win_w, win_h);
+ try addDecor(g, gpa, x0, y1 - 2, x1, y1, ring, 1, track, win_w, win_h);
+ try addDecor(g, gpa, x0, y0 + 2, x0 + 2, y1 - 2, ring, 1, track, win_w, win_h);
+ try addDecor(g, gpa, x1 - 2, y0 + 2, x1, y1 - 2, ring, 1, track, win_w, win_h);
+ if (grip.dirty and !grip.active) {
+ try addDecor(g, gpa, x0 + 2, y0 + 2, x1 - 2, y0 + 3, band, 1, track, win_w, win_h);
+ try addDecor(g, gpa, x0 + 2, y1 - 3, x1 - 2, y1 - 2, band, 1, track, win_w, win_h);
+ try addDecor(g, gpa, x0 + 2, y0 + 3, x0 + 3, y1 - 3, band, 1, track, win_w, win_h);
+ try addDecor(g, gpa, x1 - 3, y0 + 3, x1 - 2, y1 - 3, band, 1, track, win_w, win_h);
+ }
+ }
const x = grip.rect.x;
for (0..grip.rect.h) |line| {
const y = grip.rect.y + @as(u16, @intCast(line));
if (x >= cols or y >= rows) continue;
const cell = &cells[@as(usize, y) * cols + x];
if (cell.default or cell.printableAscii() != ' ') continue;
- const rgb = switch (cell.style.fg) {
- .rgb => |rgb| rgb,
- else => continue,
- };
- const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w + @as(f32, @floatFromInt(anchorInset(g, y, win_h) / 2));
+ // The marks are in the button's ink, from the region's state.
+ const rgb = inkOn(gripFill(chrome, grip.active, grip.dirty));
+ // The marks are the first row's alone, centered in the button.
+ if (line != 0) continue;
+ const center_x = @floor((button[0] + button[2]) / 2);
const center_y = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, win_h, g.cell_h, g.tagline_height))) + @as(f32, @floatFromInt(g.tagline_height)) / 2;
- const half_w = @max(1, @min(3, layout.w - 2));
+ const half_w = @max(1, @min(3, @floor((button[2] - button[0] - 6) / 2)));
try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), rgb, 1, track, win_w, win_h);
try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), rgb, 1, track, win_w, win_h);
}
}
}
+test "a theme's own rules and scrollbar are drawn at its widths: acme's 2px black rules and 12px track" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 });
+ defer core.deinit();
+ _ = try core.newShell(1, "");
+ try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false));
+ try std.testing.expect(core.executeBuiltinLine(0, "Theme acme"));
+ for (0..40) |_| core.update(.tick);
+ pardes.test_api.sync(core);
+ const s = try core.render(core.scratch.allocator());
+ try std.testing.expectEqual(@as(u8, 2), s.chrome.rule_px);
+ // acme's 12px, at GripWidth's 150% default.
+ try std.testing.expectEqual(@as(u8, 18), s.chrome.rail_px);
+ var g: Gui = undefined;
+ g.decor = .empty;
+ defer g.decor.deinit(gpa);
+ g.cell_w = 16;
+ g.cell_h = 20;
+ g.tagline_width = 10;
+ g.tagline_height = 12;
+ var groups = makeGroups(&.{}, false);
+ try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 1280, 480, true);
+ const px = struct {
+ fn w(item: CellInstance) f32 {
+ return (item.x1 - item.x0) / 2 * 1280;
+ }
+ };
+ var spines: usize = 0;
+ var tracks: usize = 0;
+ for (g.decor.items) |item| {
+ const black = item.br == 0 and item.bg == 0 and item.bb == 0;
+ // No rule at the window's left edge; one 2px spine where columns meet.
+ if (black and @abs(px.w(item) - 2) < 0.01 and item.y1 < -0.9) {
+ try std.testing.expect(item.x0 > -1);
+ spines += 1;
+ }
+ const column = scrollColumn(&g, &s.chrome, 40, fixedCellLayout(&g));
+ if (@abs(item.br - @as(f32, 0x99) / 255) < 1e-3 and @abs(item.bb - @as(f32, 0x4c) / 255) < 1e-3 and @abs(px.w(item) - (column[1] - column[0])) < 0.01) tracks += 1;
+ }
+ try std.testing.expectEqual(@as(usize, 1), spines);
+ try std.testing.expect(tracks >= 2);
+}
+
+test "the rule between stacked panes stays in the tag band's slack, at any tagline size" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 });
+ defer core.deinit();
+ _ = try core.newShell(1, "");
+ core.newScratchBelow(0);
+ pardes.test_api.sync(core);
+ const s = try core.render(core.scratch.allocator());
+ const lower = for (s.regionList()) |region| {
+ if (region.kind == .tag and region.rect.y > 2) break region;
+ } else return error.TestExpectedStackedTag;
+ for ([_]u32{ 12, 20 }) |tagline_h| {
+ var g: Gui = undefined;
+ g.decor = .empty;
+ defer g.decor.deinit(gpa);
+ g.cell_w = 10;
+ g.cell_h = 20;
+ g.tagline_width = 6;
+ g.tagline_height = tagline_h;
+ var groups = makeGroups(&.{}, false);
+ try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 800, 480, true);
+ const top = @as(f32, @floatFromInt(lower.rect.y)) * 20;
+ const slack: f32 = @floatFromInt(@max(1, pardes.taglineBandOffset(lower.rect.y, 480, 20, tagline_h)));
+ var found = false;
+ for (g.decor.items) |item| {
+ const y0 = (1 - item.y0) / 2 * 480;
+ const y1 = (1 - item.y1) / 2 * 480;
+ if (@abs(y0 - top) > 0.01 or @abs(item.br - @as(f32, @floatFromInt(s.chrome.border[0])) / 255) > 1e-3) continue;
+ found = true;
+ try std.testing.expect(y1 - y0 <= slack + 0.01);
+ }
+ try std.testing.expect(found);
+ }
+}
+
+test "an empty column is ruled on both sides, as between two filled ones" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 });
+ defer core.deinit();
+ _ = try core.newShell(1, "");
+ try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false));
+ // Newcol from the left pane: an empty column between the two filled ones.
+ core.newColumn(0);
+ pardes.test_api.sync(core);
+ try std.testing.expectEqual(@as(usize, 3), core.ncol);
+ try std.testing.expectEqual(@as(usize, 0), core.col_n[1]);
+ const s = try core.render(core.scratch.allocator());
+ var g: Gui = undefined;
+ g.decor = .empty;
+ defer g.decor.deinit(gpa);
+ g.cell_w = 10;
+ g.cell_h = 20;
+ g.tagline_width = 6;
+ g.tagline_height = 12;
+ const layout = fixedCellLayout(&g);
+ var groups = makeGroups(&.{}, false);
+ try buildDecor(&g, gpa, s, &groups, layout, 800, 480, true);
+ const border = [3]f32{ @as(f32, @floatFromInt(s.chrome.border[0])) / 255, @as(f32, @floatFromInt(s.chrome.border[1])) / 255, @as(f32, @floatFromInt(s.chrome.border[2])) / 255 };
+ // Its left edge and its right edge (the next column's left): each a
+ // rule `rule_px` wide in the border colour, down to the window's foot.
+ for ([_]usize{ 1, 2 }) |column| {
+ const px: f32 = @floatFromInt(@as(u32, core.col_x[column]) * 10);
+ const rule = for (g.decor.items) |item| {
+ if (@abs(item.x0 - (px / 800 * 2 - 1)) < 1e-4 and @abs(item.x1 - ((px + @as(f32, @floatFromInt(s.chrome.rule_px))) / 800 * 2 - 1)) < 1e-4 and
+ std.mem.eql(f32, &.{ item.br, item.bg, item.bb }, &border)) break item;
+ } else return error.TestExpectedColumnRule;
+ try std.testing.expectEqual(@as(f32, -1), rule.y1);
+ }
+}
+
test "decor is read from the regions, each pane's in its own group" {
const gpa = std.testing.allocator;
const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 });
@@ -7568,15 +8387,23 @@ test "decor is read from the regions, each pane's in its own group" {
// workspace tag; a rail two pixels wide at each pane's.
for (core.col_x[0..core.ncol]) |x| {
const left = ndc.x(@as(f32, @floatFromInt(x)) * 10);
+ // Two pixels (rule_px), and none at the window's own edge.
const spine = for (g.decor.items) |item| {
- if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 1)) < 0.0001) break item;
- } else return error.TestExpectedSpine;
- try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), spine.y0, 0.0001);
- try std.testing.expectEqual(@as(f32, -1), spine.y1);
+ if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 2)) < 0.0001 and
+ @abs(item.y0 - (1 - 20.0 / 480.0 * 2)) < 0.0001 and std.mem.eql(f32, &.{ item.br, item.bg, item.bb }, &.{ @as(f32, @floatFromInt(s.chrome.border[0])) / 255, @as(f32, @floatFromInt(s.chrome.border[1])) / 255, @as(f32, @floatFromInt(s.chrome.border[2])) / 255 })) break item;
+ } else null;
+ try std.testing.expectEqual(x == 0, spine == null);
+ if (spine) |rule| {
+ try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), rule.y0, 0.0001);
+ try std.testing.expectEqual(@as(f32, -1), rule.y1);
+ }
+ // The scrollbar fills the scroll column right of the rule.
+ const column = scrollColumn(&g, &s.chrome, x, layout);
const rail = for (g.decor.items) |item| {
- if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 2)) < 0.0001) break item;
+ if (@abs(item.x0 - ndc.x(column[0])) < 0.0001 and @abs(item.x1 - ndc.x(column[1])) < 0.0001 and
+ @abs(item.br - @as(f32, @floatFromInt(s.chrome.scroll_track[0])) / 255) < 0.0001) break item;
} else return error.TestExpectedRail;
- try std.testing.expectApproxEqAbs(@as(f32, s.chrome.scroll_track[0]) / 255, rail.br, 0.0001);
+ _ = rail;
}
try std.testing.expectEqual(@as(u32, 0), groups.items[groups.notices].decor_count);
const still = groups.items[0].decor_count;
diff --git a/src/host_io.zig b/src/host_io.zig
index 25bd61e1..df78f73c 100644
--- a/src/host_io.zig
+++ b/src/host_io.zig
@@ -792,7 +792,7 @@ pub const Shell = struct {
pub const Spawn = struct {
path: [*:0]const u8,
- argv: [4:null]?[*:0]const u8,
+ argv: [6:null]?[*:0]const u8,
};
// Resolve in the parent. The path buffer and prompt files must survive through exec.
@@ -803,7 +803,24 @@ pub const Shell = struct {
.fish => if (prompt_rcs.fishCommand()) |command| .{ "-C", command.ptr } else .{ null, null },
.none => .{ null, null },
};
- return .{ .path = path, .argv = .{ path, marks[0], marks[1], null } };
+ return .{ .path = path, .argv = .{ path, marks[0], marks[1], null, null, null } };
+ }
+
+ /// A command pane's shell runs its one line with job control on, so a
+ /// job the line puts in the background has a process group of its own.
+ /// The shell leads the terminal's session, and when it exits the kernel
+ /// hangs up the terminal's foreground group: with job control off that
+ /// is the shell's own, the job's too, and the job died with the command
+ /// (nohup'd or not; only setsid got out, and not always in time). A
+ /// shell pardes does not know runs the line as it is.
+ pub fn commandArgv(path: [*:0]const u8, line: [*:0]const u8) [6:null]?[*:0]const u8 {
+ const bin = std.mem.span(path);
+ const base = if (std.mem.lastIndexOfScalar(u8, bin, '/')) |s| bin[s + 1 ..] else bin;
+ const posix_shells = [_][]const u8{ "bash", "sh", "dash", "zsh", "ksh", "mksh", "yash" };
+ for (posix_shells) |name| if (std.mem.eql(u8, base, name))
+ return .{ path, "-m", "-c", line, null, null };
+ if (family(bin) == .fish) return .{ path, "-C", "status job-control full", "-c", line, null };
+ return .{ path, "-c", line, null, null, null };
}
/// A shell by its path or its name in the usual directories, or null.
@@ -840,6 +857,20 @@ pub const Shell = struct {
return @ptrCast(buf);
}
+ test "a command pane's line runs with job control on in the shells that have it" {
+ const want = struct {
+ fn check(path: [*:0]const u8, words: []const []const u8) !void {
+ const argv = commandArgv(path, "sleep 9 &");
+ for (words, 0..) |w, i| try std.testing.expectEqualStrings(w, std.mem.span(argv[i].?));
+ try std.testing.expect(argv[words.len] == null);
+ }
+ };
+ try want.check("/usr/bin/bash", &.{ "/usr/bin/bash", "-m", "-c", "sleep 9 &" });
+ try want.check("/bin/sh", &.{ "/bin/sh", "-m", "-c", "sleep 9 &" });
+ try want.check("/usr/bin/fish", &.{ "/usr/bin/fish", "-C", "status job-control full", "-c", "sleep 9 &" });
+ try want.check("/usr/bin/nu", &.{ "/usr/bin/nu", "-c", "sleep 9 &" });
+ }
+
test "a path is taken at its word, a name is looked up, and both pick their own marks" {
if (builtin.os.tag == .windows) return;
var buf: [std.fs.max_path_bytes]u8 = undefined;
@@ -1070,7 +1101,7 @@ pub fn forkShell(
if (line.len > exec_command_max or std.mem.indexOfScalar(u8, line, 0) != null) return error.InvalidCommand;
@memcpy(command_buf[0..line.len], line);
command_buf[line.len] = 0;
- spawn.argv = .{ spawn.path, "-c", command_buf[0..line.len :0].ptr, null };
+ spawn.argv = Shell.commandArgv(spawn.path, command_buf[0..line.len :0].ptr);
}
var helper_buf: [4096]u8 = undefined;
const mounted = one_line == null and if (core) |c| if (c.panes[pane]) |pn| pn.v9fs_on_spawn else false else false;
@@ -1639,7 +1670,11 @@ fn reapShell(pid: libc.pid_t, grace_ms: u32) void {
while (libc.waitpid(pid, null, 0) < 0 and libc.errno(-1) == .INTR) {}
}
-pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySignal) void {
+/// The tty's foreground job is signalled. A command pane's shell (`command`)
+/// is its command too: its line runs with job control on, so the job
+/// running is in a group of its own, and the shell's group goes as well, or
+/// the rest of the line would run on after the job it was on.
+pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySignal, command: bool) void {
const sig = switch (which) {
.int => libc.SIG.INT,
.term => libc.SIG.TERM,
@@ -1648,11 +1683,9 @@ pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySigna
.kill => libc.SIG.KILL,
};
const fg = tcgetpgrp(master_fd);
- if (fg > 0) {
- _ = libc.kill(-fg, sig);
- return;
- }
- if (shell_pid > 0) _ = libc.kill(shell_pid, sig);
+ if (fg > 0) _ = libc.kill(-fg, sig);
+ if (command and shell_pid > 0 and fg != shell_pid) _ = libc.kill(-shell_pid, sig);
+ if (fg <= 0 and !command and shell_pid > 0) _ = libc.kill(shell_pid, sig);
}
/// Kill: SIGTERM to the job in the tty's foreground, never to the shell.
@@ -2043,6 +2076,43 @@ test "a command's exit is its process's, told by its watcher, whether or not its
}
}
+test "a command's background job outlives it and its pty, and Kill stops its whole line" {
+ if (comptime !tty_probe_platform) return error.SkipZigTest;
+ if (!haveFile("/bin/sh")) return error.SkipZigTest;
+ const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true });
+ defer core.deinit();
+ while (core.nextEffect()) |_| {}
+ const rcs: Shell.PromptFiles = .{};
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)];
+ const Case = struct { line: []const u8, kill: bool, made: bool };
+ for ([_]Case{
+ // The shell leads the pty's session; its exit hangs up only the
+ // foreground group, which the job, in a group of its own, is not.
+ .{ .line = "(sleep 0.4; touch left) & exit 0", .kill = false, .made = true },
+ // The job running is in its own group; the shell goes too.
+ .{ .line = "sleep 0.4; touch killed", .kill = true, .made = false },
+ }) |c| {
+ const id = core.freeSlot().?;
+ _ = try core.newCommand(id, dir, c.line);
+ while (core.nextEffect()) |_| {}
+ const child = try forkShell(core, id, &rcs, "/bin/sh", dir, 24, 80, null);
+ if (c.kill) {
+ sleepMs(150);
+ signalTty(child.pid, child.file.handle, .term, true);
+ }
+ _ = exitStatus(child.pid, 2000);
+ _ = libc.close(child.file.handle); // the pane closes: the pty hangs up
+ sleepMs(800);
+ const name = if (c.kill) "killed" else "left";
+ const made = if (tmp.dir.statFile(std.testing.io, name, .{})) |_| true else |_| false;
+ try std.testing.expectEqual(c.made, made);
+ try core.removePane(id, null);
+ }
+}
+
test "a retired shell that ignores the hangup is killed and reaped, not left a zombie" {
const pid = libc.fork();
if (pid == 0) {
diff --git a/src/look.zig b/src/look.zig
index a413bf50..d90d27b8 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -10,6 +10,7 @@ const platform_has_fs = fs.platform_has_fs;
const layout = @import("layout.zig");
const animation = @import("animation.zig");
const modal = @import("modal.zig");
+const tagline = @import("tagline.zig");
const filesystem = @import("fs.zig");
const panes = @import("panes.zig");
const mouse = @import("mouse.zig");
@@ -466,6 +467,7 @@ pub fn expandedWord(line: []const u8, col: usize) ?ExpandedWord {
pub fn expandedSel(p: *Pardes, pane: *Pane, at: Pane.Sel) ?Pane.Sel {
var sl = at;
if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return sl;
+ if (sl.r0 >= 0 and sl.r0 < pane.tag_rows) return tagWordSel(p, pane, sl);
const text = edit.paneText(p, pane) catch return null;
var it = std.mem.splitAny(u8, text, "\n");
var v: i32 = 0;
@@ -481,6 +483,34 @@ pub fn expandedSel(p: *Pardes, pane: *Pane, at: Pane.Sel) ?Pane.Sel {
return null;
}
+/// A tag word is expanded in the tag's own text, so a word the wrapping
+/// broke across rows is still one word, as acme's frame wraps a tag
+/// without breaking its text.
+fn tagWordSel(p: *Pardes, pane: *Pane, at: Pane.Sel) ?Pane.Sel {
+ const arena = p.scratch.allocator();
+ const shown = tagline.shownTag(p, arena, pane) catch return null;
+ const whole = tagline.tagText(p, arena, pane) catch return null;
+ const row: usize = @as(usize, @intCast(at.r0)) + pane.tag_top;
+ const shown_line = modal.lineSlice(shown.text, row);
+ const real = shown.toReal(modal.lineStartOffset(shown.text, row) + panes.File.rawAtDisplay(shown_line, @intCast(@max(0, at.c0))));
+ const start = if (std.mem.lastIndexOfScalar(u8, whole[0..@min(real, whole.len)], '\n')) |nl| nl + 1 else 0;
+ const end = std.mem.indexOfScalarPos(u8, whole, start, '\n') orelse whole.len;
+ const b = expandedWord(whole[start..end], real - start) orelse return null;
+ const lo = shownCell(shown.text, shown.fromReal(start + b.lo), pane.tag_top);
+ const hi = shownCell(shown.text, shown.fromReal(start + b.hi - 1), pane.tag_top);
+ return .{ .state = at.state, .r0 = lo.row, .c0 = lo.col, .r1 = hi.row, .c1 = hi.col };
+}
+
+fn shownCell(text: []const u8, off: usize, top: usize) struct { row: i32, col: i32 } {
+ const before = text[0..@min(off, text.len)];
+ const row = std.mem.count(u8, before, "\n");
+ const line_start = if (std.mem.lastIndexOfScalar(u8, before, '\n')) |nl| nl + 1 else 0;
+ return .{
+ .row = @as(i32, @intCast(row)) - @as(i32, @intCast(top)),
+ .col = @intCast(panes.File.rawDisplayCol(text[line_start..], off - line_start)),
+ };
+}
+
/// the word under the modal cursor as a pane-local selection (paneText
/// coords: row 0 is the tag; file panes carry the line-number prefix)
pub fn cursorWordSel(p: *Pardes, pane: *Pane) Pane.Sel {
@@ -1143,14 +1173,49 @@ pub fn followPdfLink(p: *Pardes, id: usize, probe: *const panes.Pdf.WordProbe) v
}
}
+/// A look that found nothing: said on the message row, logged as an `err`
+/// record, and marked so that /look reads back nothing (ninep/ctl.zig run).
+fn missed(p: *Pardes, id: usize, comptime fmt: []const u8, args: anytype) void {
+ p.look_missed = true;
+ var said: [160]u8 = undefined;
+ const why = std.fmt.bufPrint(&said, fmt, args) catch "no match";
+ var msg: [176]u8 = undefined;
+ p.setMessage(id, std.fmt.bufPrint(&msg, "Look: {s}", .{why}) catch "Look: no match");
+ const serial = if (p.panes[id]) |pane| pane.serial else 0;
+ var rec: [200]u8 = undefined;
+ pardes.ctlfs.events.notePath(p, "err", std.fmt.bufPrint(&rec, "{d} look: {s}", .{ serial, why }) catch return);
+}
+
+/// After a look at `path:N` focused its pane: a line past the end focuses
+/// nothing, leaves no pane it opened, and is a miss; a bare line number
+/// selects the line, its newline included, as acme's does.
+fn settleLine(p: *Pardes, id: usize, path: []const u8, at: Spot, before: usize, opened: ?usize) void {
+ const shown = p.panes[p.active] orelse return;
+ const f = if (shown.file) |*file| file else return;
+ if (at.line > panes.File.nlines(p.gpa, f)) {
+ if (opened) |free| p.removePane(free, null) catch {};
+ if (p.panes[before] != null) p.active = before;
+ return missed(p, id, "{s} has no line {d}", .{ path, at.line });
+ }
+ if (at.line > 0 and at.col == 0 and at.end_line == 0) {
+ const start = modal.lineStartOffset(f.content, at.line - 1);
+ const next = std.mem.indexOfScalarPos(u8, f.content, start, '\n');
+ const end = if (next) |i| i + 1 else f.content.len;
+ @import("ninep/pane.zig").setDot(shown, .{ .q0 = @intCast(start), .q1 = @intCast(end) });
+ }
+}
+
/// `file:addr` or `:addr`, acme's look.c:450-543: a colon followed by an
/// address character (addr.c:28, isaddrc) that is not a line spot this
/// parser already reads (`file:12`, `file:12:5`). Not a URL.
fn addressed(tok: []const u8) ?struct { path: []const u8, expr: []const u8 } {
if (std.mem.indexOf(u8, tok, "://") != null) return null;
- if (std.mem.indexOfAny(u8, tok, " \t") != null) return null;
const colon = std.mem.indexOfScalar(u8, tok, ':') orelse return null;
const expr = tok[colon + 1 ..];
+ // A written look is a whole line: the path has no blanks, but a
+ // pattern in the address may (`calc.py:/return a/`).
+ if (std.mem.indexOfAny(u8, tok[0..colon], " \t") != null) return null;
+ if (std.mem.indexOfAny(u8, expr, " \t") != null and std.mem.indexOfAny(u8, expr, "/?") == null) return null;
if (expr.len == 0 or std.mem.indexOfScalar(u8, "0123456789+-/$.#,;", expr[0]) == null) return null;
if (std.ascii.isDigit(expr[0])) {
const pl = parsePathLine(tok);
@@ -1178,12 +1243,10 @@ fn lookAddress(p: *Pardes, id: usize, path: []const u8, expr: []const u8) void {
if (path.len > 0 and (f.output != null or !std.mem.endsWith(u8, f.path, std.fs.path.basename(path)))) return;
var a: Addr = .{ .text = f.content, .lim = null, .expr = expr };
const r = a.address(ninep_pane.dotOf(pane)) orelse {
- var said: [128]u8 = undefined;
- return p.setMessage(id, std.fmt.bufPrint(&said, "Look: {s}", .{a.err}) catch "Look: bad address");
+ return missed(p, id, "{s}", .{a.err});
};
if (a.i < expr.len or r.q0 > r.q1) {
- var said: [128]u8 = undefined;
- return p.setMessage(id, std.fmt.bufPrint(&said, "Look: {s}", .{if (r.q0 > r.q1) "addresses out of order" else "bad address syntax"}) catch "Look: bad address");
+ return missed(p, id, "{s}", .{if (r.q0 > r.q1) "addresses out of order" else "bad address syntax"});
}
clearNavigationSelection(p, pane);
ninep_pane.setDot(pane, r);
@@ -1210,6 +1273,10 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
if (comptime pdf_enabled) if (panes.Pdf.lookSection(p, id, pl.path, pl.at)) return;
var realbuf: [4096]u8 = undefined;
const found = resolveLookTarget(p, id, txt, &realbuf);
+ // A line past the end of an open pane is a miss (the target's line is
+ // already clamped, so the one written is asked).
+ if (found == .pane and found.pane.id < MAX_PANES) if (p.panes[found.pane.id]) |shown| if (shown.file) |*f| if (pl.at.line > panes.File.nlines(p.gpa, f))
+ return missed(p, id, "{s} has no line {d}", .{ trimmed, pl.at.line });
// A miss changes nothing, the selection included; anything else starts
// from a clean one.
if (found != .none) clearNavigationSelection(p, pane);
@@ -1220,10 +1287,7 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
p.reportError(id, "search", err);
return;
};
- if (rows == 0) {
- var said: [96]u8 = undefined;
- return p.setMessage(id, std.fmt.bufPrint(&said, "Look: no match for \"{s}\"", .{@import("Messages.zig").clip(trimmed, 48)}) catch "Look: no match");
- }
+ if (rows == 0) return missed(p, id, "no match for \"{s}\"", .{@import("Messages.zig").clip(trimmed, 48)});
clearNavigationSelection(p, pane);
const at = pane.search_row;
_ = searchStep(p, id, 1);
@@ -1236,8 +1300,10 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
.pane => |t| {
if (t.id >= MAX_PANES) return;
const target = p.panes[t.id] orelse return;
+ const before = p.active;
clearNavigationSelection(p, target);
focusPaneLine(p, t.id, t.at, .center);
+ settleLine(p, id, trimmed, t.at, before, null);
},
.url => |u| if (u.len <= 256) p.emit(.{ .open_link = .from(u) }),
.dir => |dir| {
@@ -1263,20 +1329,30 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
exec.placeNew(p, id, id, free, .doc);
return;
};
+ // An open pane on this path too short for the line: a miss that
+ // touches nothing.
+ if (openPaneTarget(p, target.path, target.at)) |open| if (p.panes[open.pane.id].?.file) |*f|
+ if (target.at.line > panes.File.nlines(p.gpa, f))
+ return missed(p, id, "{s} has no line {d}", .{ target.path, target.at.line });
// focus an existing pane on this path (rescrolled), else open
- if (focusPaneByPath(p, target.path, target.at)) return;
- const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
- const nt = panes.File.open(p, free, target.path, target.at.line) catch |err|
- return p.reportError(id, "look", err);
- if (target.at.col > 0) nt.body.cur_col = @intCast(target.at.col - 1);
- exec.placeNew(p, id, id, free, .doc);
- layout.compute(p);
- nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2;
- if (target.at.end_line != 0) {
- const centered = nt.file.?.scroll;
- selectSpan(p, nt, target.at);
- nt.file.?.scroll = centered;
+ const before = p.active;
+ var opened: ?usize = null;
+ if (!focusPaneByPath(p, target.path, target.at)) {
+ const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
+ const nt = panes.File.open(p, free, target.path, target.at.line) catch |err|
+ return p.reportError(id, "look", err);
+ if (target.at.col > 0) nt.body.cur_col = @intCast(target.at.col - 1);
+ exec.placeNew(p, id, id, free, .doc);
+ layout.compute(p);
+ nt.file.?.scroll -|= @max(1, p.rects[free].h -| BOX_H) / 2;
+ if (target.at.end_line != 0) {
+ const centered = nt.file.?.scroll;
+ selectSpan(p, nt, target.at);
+ nt.file.?.scroll = centered;
+ }
+ opened = free;
}
+ settleLine(p, id, target.path, target.at, before, opened);
},
.image => |target| {
if (focusPaneByPath(p, target.path, .{})) return;
@@ -1297,6 +1373,8 @@ test "Look hover waits without mutating the pane and input cancels it" {
defer p.deinit();
while (p.nextEffect()) |_| {}
const pane = try p.setTestFile("alpha beta gamma\n");
+ pane.tag_expanded = false; // a one-row tag keeps this small screen's rows
+ p.sync();
while (p.nextEffect()) |_| {}
const other_id = p.freeSlot() orelse return error.NoSparePaneForHoverTest;
_ = try p.newShell(other_id, "");
diff --git a/src/macos.zig b/src/macos.zig
index b180d7ba..678b155d 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -1313,15 +1313,15 @@ fn drainInbox(st: *State) bool {
host_io.commandEof(st.core, &st.ptys, e.pane, st, closeWatched);
continue;
}
- // Unwatched, a command's exit is read here, as its end.
- if (st.core.panes[e.pane]) |pane| if (pane.command != null) {
- const status = host_io.exitStatus(pt.pid, 100);
- if (status != null) pt.pid = 0; // reaped: no shell to retire
- reap(st, e.pane);
- st.core.update(.{ .exited = .{ .pane = e.pane, .status = status } });
- st.core.update(.{ .eof = .{ .pane = e.pane } });
- continue;
- };
+ // Unwatched, a command's exit is read here, as its end,
+ // and a shell's, for a run waiting on it and the log.
+ const command = if (st.core.panes[e.pane]) |pane| pane.command != null else false;
+ const status = host_io.exitStatus(pt.pid, 100);
+ if (status != null) pt.pid = 0; // reaped: no shell to retire
+ reap(st, e.pane);
+ if (command or status != null) st.core.update(.{ .exited = .{ .pane = e.pane, .status = status } });
+ st.core.update(.{ .eof = .{ .pane = e.pane } });
+ continue;
}
reap(st, e.pane);
st.core.update(.{ .eof = .{ .pane = e.pane } });
@@ -2469,7 +2469,7 @@ fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void {
fn ptySignal(ctx: ?*anyopaque, pane: u8, sig: pardes.PtySignal) void {
const st = hostState(ctx);
// A command whose exit is recorded is reaped: its group may be another's.
- if (st.ptys[pane]) |pt| if (!pt.cmd.exited) host_io.signalTty(pt.pid, pt.file.handle, sig);
+ if (st.ptys[pane]) |pt| if (!pt.cmd.exited) host_io.signalTty(pt.pid, pt.file.handle, sig, pt.cmd.watched);
}
fn ttyTaken(ctx: ?*anyopaque, pane: u8) bool {
@@ -2866,7 +2866,7 @@ test "pardes.h matches the Zig boundary" {
try expectEqual(@offsetOf(c.pardes_panel_box_s, field.name), @offsetOf(PanelBox, field.name));
try expectEqual(@sizeOf(c.pardes_panel_track_s), @sizeOf(PanelTrack));
inline for (@typeInfo(PanelTrack).@"struct".fields) |field| {
- const c_name = comptime if (std.mem.eql(u8, field.name, "_padding"))
+ const c_name = comptime if (std.mem.eql(u8, field.name, "motion"))
"reserved0"
else
field.name;
@@ -3000,7 +3000,7 @@ test "mac panel ABI has the documented compact byte layout" {
try std.testing.expectEqual(@as(usize, 4), @offsetOf(PanelTrack, "pane"));
try std.testing.expectEqual(@as(usize, 5), @offsetOf(PanelTrack, "phase"));
try std.testing.expectEqual(@as(usize, 6), @offsetOf(PanelTrack, "effect"));
- try std.testing.expectEqual(@as(usize, 7), @offsetOf(PanelTrack, "_padding"));
+ try std.testing.expectEqual(@as(usize, 7), @offsetOf(PanelTrack, "motion"));
try std.testing.expectEqual(@as(usize, 8), @offsetOf(PanelTrack, "frame"));
try std.testing.expectEqual(@as(usize, 10), @offsetOf(PanelTrack, "frame_count"));
try std.testing.expectEqual(@as(usize, 12), @offsetOf(PanelTrack, "from"));
diff --git a/src/mouse.zig b/src/mouse.zig
index ac72481a..36786612 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -48,6 +48,14 @@ pub const Drag = union(enum) {
button: Mouse.Button,
chorded: bool = false,
ctrl: bool = false,
+ /// A B1 body sweep held past the pane's top (-1) or bottom (1): the
+ /// body scrolls a line a tick, as acme's frselect scrolls
+ /// (libframe frselect.c, the `scroll` callback while the button is
+ /// held outside the frame).
+ edge: i2 = 0,
+ /// Where the sweep began in the text, once it has scrolled: its
+ /// press row may be off the screen.
+ anchor: ?modal.Cursor = null,
/// This gesture began over a usable native PDF raster. The payload is
/// zero-bit when PDF support is absent.
pdf: panes.Pdf.PointerDrag = .{},
@@ -625,6 +633,17 @@ pub fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16, body_hit: ?Mouse.BodyHit) vo
// from the pointer.
const body_y = p.bodyTop(pane, r);
const body_h = r.h -| pane.tag_rows;
+ s.edge = if (s.button != config.select_button or pane.sel[b].r0 < tag_rows or pane.file == null or pane.hasPdf() or body_h == 0)
+ 0
+ else if (mrow < body_y)
+ -1
+ else if (mrow >= body_y + body_h or
+ // The pointer cannot leave the screen: at a bottom pane,
+ // its last row is the edge.
+ (body_y + body_h >= p.screen_h and mrow + 1 >= body_y + body_h))
+ 1
+ else
+ 0;
pane.sel[b].r1 = if (pane.sel[b].r0 < tag_rows)
tag_line
else if (body_h == 0)
@@ -670,11 +689,12 @@ fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand:
const pane = p.panes[id] orelse return .{};
if (on_tag) {
const tag = tagline.tagText(p, p.scratch.allocator(), pane) catch return .{};
+ const shown = tagline.shownTag(p, p.scratch.allocator(), pane) catch return .{};
const sel = operand.expanded orelse operand.preview orelse return .{};
const lead = if (sel.r0 < sel.r1 or (sel.r0 == sel.r1 and sel.c0 <= sel.c1)) sel else Pane.Sel{ .r0 = sel.r1, .c0 = sel.c1 };
const row: usize = @as(usize, @intCast(@max(0, lead.r0))) + pane.tag_top;
- const start = modal.lineStartOffset(tag, row);
- const at = start + panes.File.rawAtDisplay(modal.lineSlice(tag, row), @intCast(@max(0, lead.c0)));
+ const start = modal.lineStartOffset(shown.text, row);
+ const at = shown.toReal(start + panes.File.rawAtDisplay(modal.lineSlice(shown.text, row), @intCast(@max(0, lead.c0))));
const q0: u32 = @intCast(@min(at, tag.len));
return .{ .q0 = q0, .q1 = @intCast(@min(q0 + text.len, tag.len)) };
}
@@ -695,12 +715,13 @@ fn gestureRange(p: *Pardes, id: usize, text: []const u8, on_tag: bool, operand:
const start: ?modal.Cursor = if (operand.file_word) |w|
.{ .row = @intCast(@max(0, w.row)), .col = @intCast(@max(0, w.lo)) }
else if (operand.expanded orelse operand.preview) |sel| lead: {
- // A selection's leading corner in reading order, converted the way
+ // A selection's start in reading order, converted the way
// `pointerOperand` converts the click itself.
- const top = @min(sel.r0, sel.r1) - @as(i32, pane.tag_rows);
+ const lead = edit.sweepEnds(sel)[0];
+ const top = lead[0] - @as(i32, pane.tag_rows);
if (top < 0) break :lead null;
const w = pane.wrapAt(top);
- const col = edit.paneByteAtDisplay(p, &pane.body, w.line, w.at, @min(sel.c0, sel.c1) - panes.File.gutterWidth(pane));
+ const col = edit.paneByteAtDisplay(p, &pane.body, w.line, w.at, lead[1] - panes.File.gutterWidth(pane));
break :lead .{
.row = @intCast(@max(0, w.line)),
.col = @intCast(@max(0, col)),
@@ -734,10 +755,11 @@ fn mirrorTtySelection(p: *Pardes, pane: *Pane) void {
const rows = pane.pointerSelection(gesture) orelse return;
const text = pane.rawPointerText(gesture) orelse return;
if (rows.len == 0) return;
- const endpoint = rows[if (gesture.r1 >= gesture.r0) rows.len - 1 else 0];
+ const forward = gesture.r1 > gesture.r0 or (gesture.r1 == gesture.r0 and gesture.c1 >= gesture.c0);
+ const endpoint = rows[if (forward) rows.len - 1 else 0];
const body = body_layer.bodyText(p, p.scratch.allocator(), pane) catch return;
const line = modal.lineSlice(body, @intCast(@max(0, endpoint.row - panes.Terminal.gridOffset(pane))));
- const byte = if (gesture.c1 >= gesture.c0) modal.prevGrapheme(line, @min(endpoint.hi, line.len)) else endpoint.lo;
+ const byte = if (forward) modal.prevGrapheme(line, @min(endpoint.hi, line.len)) else endpoint.lo;
pane.body.cur_row = pane.surfRow(endpoint.row);
var shown = std.mem.trimEnd(u8, line[@min(endpoint.prompt_bytes, line.len)..], " \t");
if (pane.ovl) |overlay| {
@@ -757,11 +779,70 @@ fn mirrorTtySelection(p: *Pardes, pane: *Pane) void {
edit.setYank(p, text);
}
+/// acme's double-click (text.c:881 textselect, plan9port text.c:1407
+/// textdoubleclick): a second plain B1 click at the same cell within half a
+/// second selects what edit.doubleClickRange says, from the caret the click
+/// left. A plain click still puts the caret on its cell.
+fn doubleClick(p: *Pardes, id: usize, t: *panes.Text, on_tag: bool, row: i32, col: i32) void {
+ const now = clockMs();
+ const prev = p.last_click;
+ p.last_click = .{ .id = id, .on_tag = on_tag, .row = row, .col = col, .ms = now };
+ const c = prev orelse return;
+ if (c.id != id or c.on_tag != on_tag or c.row != row or c.col != col or now - c.ms > 500) return;
+ p.last_click = null;
+ const text = edit.flatSurface(p, t) catch return;
+ const q = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) });
+ const r = edit.doubleClickRange(text, q);
+ if (r[1] <= r[0]) return;
+ const a = modal.positionAt(text, r[0]);
+ const h = modal.positionAt(text, modal.prevGrapheme(text, r[1]));
+ t.vsel = .{ .active = true, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true };
+ t.cur_row = @intCast(h.row);
+ t.cur_col = @intCast(h.col);
+ t.cur_pinned = true;
+}
+
+/// On each tick while a B1 body sweep is held past an edge: the body
+/// scrolls a line toward it, the sweep's end stays under the pointer and
+/// its start stays on its text (Drag.select.anchor).
+pub fn edgeScroll(p: *Pardes) void {
+ if (p.drag != .select) return;
+ const s = &p.drag.select;
+ if (s.edge == 0) return;
+ const pane = p.panes[s.id] orelse return;
+ const sl = &pane.sel[sel_slot];
+ if (s.anchor == null) s.anchor = sweepPoint(p, pane, sl.r0, sl.c0) orelse return;
+ const before = pane.scroll();
+ pane.scrollBy(s.edge);
+ // The start's row follows its text while it is on the screen, and
+ // stays on the body's edge row once it is off (the anchor keeps it).
+ const top: i32 = pane.tag_rows;
+ sl.r0 = std.math.clamp(sl.r0 - (pane.scroll() - before), top, top + @as(i32, pane.rows) - 1);
+}
+
+/// The text position under a body cell of the sweep's space.
+fn sweepPoint(p: *Pardes, pane: *panes.Pane, r: i32, c: i32) ?modal.Cursor {
+ const visible = r - @as(i32, pane.tag_rows);
+ if (visible < 0) return null;
+ const w = pane.wrapAt(visible);
+ if (w.line < 0) return null;
+ const col = edit.paneByteAtDisplay(p, &pane.body, w.line, w.at, c - (if (pane.file != null) @as(i32, panes.File.gutterWidth(pane)) else 0));
+ return .{ .row = @intCast(w.line), .col = @intCast(@max(0, col)) };
+}
+
+fn clockMs() i64 {
+ if (comptime !pardes.hosted) return 0;
+ var ts: std.c.timespec = undefined;
+ if (std.c.clock_gettime(.MONOTONIC, &ts) != 0) return 0;
+ return @as(i64, ts.sec) * 1000 + @divTrunc(@as(i64, ts.nsec), 1_000_000);
+}
+
fn dragRelease(p: *Pardes, button: Mouse.Button) void {
// A different button's release belongs to a chord or another host
// stream; only the button which began this gesture may end it.
if (p.drag.button() != button) return;
defer p.drag = .none;
+ defer p.chord_hold = false;
switch (p.drag) {
.border_v => |d| {
const c = d.left_col;
@@ -844,10 +925,11 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
} else if (tagline.tagOffsetAt(p, pane, row, col)) |cursor| {
pane.tag.cur_row = @intCast(cursor.row);
pane.tag.cur_col = @intCast(cursor.col);
+ if (!dragged) doubleClick(p, s.id, &pane.tag, true, sl.r1, sl.c1);
}
return;
}
- if (dragged) edit.capturePointerSelection(p, pane, sel_slot) catch {};
+ if (dragged and s.anchor == null) edit.capturePointerSelection(p, pane, sel_slot) catch {};
if (dragged and pane.body.mode == .tty) mirrorTtySelection(p, pane);
const body_vis = sl.r1 - @as(i32, pane.tag_rows);
if (body_vis >= 0 and pane.body.mode != .tty) {
@@ -869,6 +951,15 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
pane.body.select = false;
pane.body.restore_cursor = false;
pane.body.sticky_col = -1;
+ // A ctrl-click is a definition jump, not half a double-click.
+ if (!dragged and !s.ctrl) doubleClick(p, s.id, &pane.body, false, sl.r1, sl.c1);
+ if (s.ctrl) p.last_click = null;
+ // A sweep that scrolled has its start off the screen: it
+ // is kept as the text's own selection, start to end.
+ if (dragged) if (s.anchor) |a| {
+ pane.sel[sel_slot].state = .none;
+ pane.body.vsel = .{ .active = true, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true };
+ };
if (s.ctrl) p.lspRequest(s.id, .definition, "");
}
} else {
@@ -1001,6 +1092,11 @@ fn chordCutPaste(p: *Pardes, cut: bool) void {
}
if (!s.chorded) {
s.chorded = true;
+ // Everything done while B1 stays down is one undo step, as acme
+ // marks the file once per B1 hold (text.c:881 textselect, `state`
+ // and its one filemark): B1-B2 then B1-B3 is a copy.
+ edit.pushUndo(p, &pane.body);
+ p.chord_hold = true;
const sl = pane.sel[sel_slot];
pane.sel[sel_slot].state = .none;
const pfx: i32 = if (pane.file != null) panes.File.gutterWidth(pane) else 0;
diff --git a/src/ninep/addr.zig b/src/ninep/addr.zig
index f57c62bd..1e47183b 100644
--- a/src/ninep/addr.zig
+++ b/src/ninep/addr.zig
@@ -222,8 +222,8 @@ pub const Addr = struct {
a.err = "no previous regular expression";
return null;
}
- var rx = regexp_.Regex.compile(pat) catch {
- a.err = e_regexp;
+ var rx = regexp_.Regex.compile(pat) catch |err| {
+ a.err = if (err == error.Anchor) regexp_.Regex.e_anchor else e_regexp;
return null;
};
const found = if (back) found: {
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 23d966e8..b4144fd2 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -69,10 +69,13 @@ fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
const before = Serials.snapshot(p);
// An exec answers the pane it went to: a command pane it reused, or
// the terminal it typed into.
+ p.look_missed = false;
const went: ?usize = if (exec) pardes.exec.execute(p, id, text) else blk: {
look.lookAt(p, id, text);
break :blk null;
};
+ // A look that found nothing answers nothing, not the pane before it.
+ if (!exec and p.look_missed) return;
const after = Serials.snapshot(p);
var created = false;
for (after.list[0..after.len]) |serial| if (std.mem.indexOfScalar(u32, before.list[0..before.len], serial) == null) {
@@ -87,6 +90,37 @@ fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
noteResult(p, if (exec) context else (p.panes[p.active] orelse return).serial);
}
+/// A write's messages, a line each, except that an `Edit` line takes the
+/// lines after it while its `{` group or `a`/`c`/`i` text block is open
+/// (sam_edit.needsMore): a block written whole reaches Edit whole, never
+/// as lines of their own.
+const Messages = struct {
+ p: *Pardes,
+ data: []const u8,
+ at: usize = 0,
+
+ fn init(p: *Pardes, data: []const u8) Messages {
+ return .{ .p = p, .data = data };
+ }
+
+ fn next(m: *Messages) ?[]const u8 {
+ if (m.at >= m.data.len) return null;
+ const start = m.at;
+ var end = std.mem.indexOfScalarPos(u8, m.data, start, '\n') orelse m.data.len;
+ m.at = end + 1;
+ const first = std.mem.trim(u8, m.data[start..end], " \t\r");
+ const edit = "Edit";
+ if (!std.mem.startsWith(u8, first, edit) or (first.len > edit.len and first[edit.len] != ' ' and first[edit.len] != '\t'))
+ return first;
+ const sam = @import("../sam_edit.zig");
+ while (m.at < m.data.len and sam.needsMore(m.p.scratch.allocator(), std.mem.trim(u8, m.data[start..end], " \t\r")[edit.len..])) {
+ end = std.mem.indexOfScalarPos(u8, m.data, m.at, '\n') orelse m.data.len;
+ m.at = end + 1;
+ }
+ return std.mem.trim(u8, m.data[start..end], " \t\r");
+ }
+};
+
// ---- look and exec ----
/// One command per line, checked whole before any line runs: a line is the
@@ -96,11 +130,11 @@ fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void {
pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool) Reply {
for ([2]bool{ false, true }) |apply| {
if (apply) p.fs.results_len = 0;
- var it = std.mem.splitScalar(u8, req.data, '\n');
- while (it.next()) |raw| {
- const text = std.mem.trim(u8, raw, " \t\r");
+ var it = Messages.init(p, req.data);
+ while (it.next()) |text| {
if (text.len == 0) continue;
- for (text) |c| if (c < ' ' and c != '\t') return Reply.fail(req.tag, E.INVAL);
+ // Only an Edit block holds newlines (Messages).
+ for (text) |c| if (c < ' ' and c != '\t' and c != '\n') return Reply.fail(req.tag, E.INVAL);
if (!apply) continue;
const id = if (serial) |s| p.paneBySerial(s) orelse break else p.active;
if (p.panes[id] == null) return Reply.fail(req.tag, E.NOENT);
@@ -226,7 +260,8 @@ fn checkBuiltin(p: *Pardes, req: Req, line: []const u8, scope: builtins.Scope) ?
const word = line[0 .. sp orelse line.len];
const arg = if (sp) |s| std.mem.trim(u8, line[s + 1 ..], " \t") else "";
const b = std.meta.stringToEnum(Builtin, word) orelse return refuse(p, req, "unknown control message", line);
- if (builtins.registry.scope(b) != scope)
+ // Edit is the one pane word the root takes too: at the active pane.
+ if (builtins.registry.scope(b) != scope and b != .Edit)
return refuseTo(p, req, if (scope == .pane) "not a window control message" else "not a session control message", line, if (scope == .pane) "/ctl" else "pane/<n>/ctl");
if (arg.len > 0 and !builtins.registry.takesArg(b)) return refuse(p, req, "wrong #args in control message", line);
if (arg.len == 0 and builtins.registry.requiresArg(b)) return refuse(p, req, "wrong #args in control message", line);
@@ -318,6 +353,10 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void {
.scene => |effect| if (s.post.level(effect) == 0) try w.print("{s} off\n", .{word}) else try w.print("{s} {d}\n", .{ word, s.post.level(effect) }),
.shader => for (s.post.list()) |entry| if (entry.scene == null) try w.print("{s} {s}\n", .{ word, entry.path.get() }),
.shader_animation => try w.print("{s} {s}\n", .{ word, @tagName(s.shader_animation) }),
+ .lift => try w.print("{s} {s}\n", .{ word, @tagName(s.lift) }),
+ .motion => try w.print("{s} {s}\n", .{ word, @tagName(s.motion) }),
+ .inactive_dim => try w.print("{s} {d}\n", .{ word, s.inactive_dim }),
+ .grip_width => try w.print("{s} {d}\n", .{ word, s.grip_width }),
}
}
try w.writeAll("LocationsConfig ");
@@ -345,9 +384,8 @@ pub fn readRoot(p: *Pardes, req: Req) Reply {
pub fn writeRoot(p: *Pardes, req: Req) Reply {
for ([2]bool{ false, true }) |apply| {
if (apply) p.fs.results_len = 0;
- var it = std.mem.splitScalar(u8, req.data, '\n');
- while (it.next()) |raw| {
- const line = std.mem.trim(u8, raw, " \t\r");
+ var it = Messages.init(p, req.data);
+ while (it.next()) |line| {
if (line.len == 0) continue;
if (!apply) {
if (checkBuiltin(p, req, line, .session)) |refusal| return refusal;
@@ -456,9 +494,8 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
for ([2]bool{ false, true }) |apply| {
if (apply) p.fs.results_len = 0;
var held = !other and pane.fs.lock != null;
- var it = std.mem.splitScalar(u8, req.data, '\n');
- while (it.next()) |raw| {
- const line = std.mem.trim(u8, raw, " \t\r");
+ var it = Messages.init(p, req.data);
+ while (it.next()) |line| {
if (line.len == 0) continue;
// A line before this one may have closed the pane (`Del`).
if (apply and p.paneBySerial(serial) == null) return Reply.fail(req.tag, E.NOENT);
@@ -476,6 +513,14 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply {
if (!held) return tree.failText(req.tag, E.INVAL, e_not_locked);
held = false;
if (apply) pane.fs.lock = null;
+ } else if (std.mem.startsWith(u8, line, "answer ")) {
+ // The pane's question (its `ask` in the log), answered.
+ const choice = std.mem.trim(u8, line["answer ".len..], " ");
+ if (choice.len != 1) return refuse(p, req, "answer takes one choice, or -", line);
+ const id = p.paneBySerial(serial).?;
+ if (p.panes[id].?.prompt != .del_side and p.panes[id].?.prompt != .repl_choice)
+ return refuse(p, req, "no question asked", line);
+ if (apply) _ = p.answerQuestion(id, if (choice[0] == '-') null else choice[0]);
} else if (!apply) {
if (checkBuiltin(p, req, line, .pane)) |refusal| return refusal;
} else if (runBuiltin(p, req, p.paneBySerial(serial).?, line)) |refusal| {
@@ -790,6 +835,23 @@ test "Edit through a pane's ctl renames every foo in one undo step, and a failed
try testing.expectEqualStrings("foo x foo y foo\n", rd(p, body, 0, 64).bytes);
}
+test "a write of an Edit block carries it whole, to a pane's ctl, the root's ctl and exec" {
+ const p = try withFile(testing.allocator, "foo x\nfoo y\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const body = Node.of(serial, .body);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Edit ,x/foo/{\ni/[/\na/]/\n}\n").reply.status);
+ try testing.expectEqualStrings("[foo] x\n[foo] y\n", rd(p, body, 0, 64).bytes);
+ p.active = p.paneBySerial(serial).?;
+ try testing.expectEqual(Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Edit ,x/\\[/{\nc/</\n}\n").reply.status);
+ try testing.expectEqualStrings("<foo] x\n<foo] y\n", rd(p, body, 0, 64).bytes);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), "Edit $a\nend\n.\n").reply.status);
+ try testing.expectEqualStrings("<foo] x\n<foo] y\nend\n", rd(p, body, 0, 64).bytes);
+ // An unclosed group is refused, and nothing after it runs as a line.
+ try testing.expectEqual(E.IO, wr(p, Node.of(serial, .ctl), "Edit ,x/foo/{\nd\n").errno());
+ try testing.expectEqualStrings("<foo] x\n<foo] y\nend\n", rd(p, body, 0, 64).bytes);
+}
+
test "dirty goes back to 0 when the text is the saved text again" {
const p = try withFile(testing.allocator, "one\n");
defer p.deinit();
@@ -848,6 +910,18 @@ test "a look takes acme's :addr and file:addr, and a miss changes nothing" {
try testing.expectEqual(Status.ok, wr(p, look_node, "foo\n").reply.status);
var own: [16]u8 = undefined;
try testing.expectEqualStrings(try std.fmt.bufPrint(&own, "{d}\n", .{serial}), rd(p, look_node, 0, 64).bytes);
+ // A pattern with a blank in it; 0/re/ searches from the top, whatever dot is.
+ try testing.expectEqual(Status.ok, wr(p, look_node, ":/foo y/\n").reply.status);
+ try testing.expectEqualStrings(" 6 11 ", rd(p, dot, 0, 64).bytes);
+ try testing.expectEqual(Status.ok, wr(p, look_node, try std.fmt.bufPrint(&line, "{s}:0/foo/\n", .{path})).reply.status);
+ try testing.expectEqualStrings(" 0 3 ", rd(p, dot, 0, 64).bytes);
+ // file:N selects the line; a line past the end moves nothing and reads back empty.
+ try testing.expectEqual(Status.ok, wr(p, look_node, try std.fmt.bufPrint(&line, "{s}:2\n", .{path})).reply.status);
+ try testing.expectEqualStrings(" 12 16 ", rd(p, dot, 0, 64).bytes);
+ try testing.expectEqual(Status.ok, wr(p, look_node, try std.fmt.bufPrint(&line, "{s}:99\n", .{path})).reply.status);
+ try testing.expectEqualStrings(" 12 16 ", rd(p, dot, 0, 64).bytes);
+ try testing.expectEqualStrings("", rd(p, look_node, 0, 64).bytes);
+ try testing.expectStringStartsWith(p.panes[0].?.msg[0..p.panes[0].?.msg_len], "Look: ");
}
test "exec runs a builtin at the pane and records the pane it acted on" {
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index 1c66c18f..0e489be7 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -934,8 +934,13 @@ test "a Dump written and a Restore made are in the log, with their files" {
const log = try freezeLog(core);
defer core.gpa.free(log.bytes);
var want: [64]u8 = undefined;
- try testing.expect(std.mem.endsWith(u8, log.bytes, try std.fmt.bufPrint(&want, "{s} /tmp/pardes.dump.zon\n", .{what})));
+ try testing.expect(std.mem.indexOf(u8, log.bytes, try std.fmt.bufPrint(&want, "{s} /tmp/pardes.dump.zon\n", .{what})) != null);
}
+ // The replacement's last says which serial each pane had.
+ const log = try freezeLog(restored);
+ defer restored.gpa.free(log.bytes);
+ var want: [64]u8 = undefined;
+ try testing.expect(std.mem.endsWith(u8, log.bytes, try std.fmt.bufPrint(&want, "restore /tmp/pardes.dump.zon\nrestored {d} {d}\n", .{ p.panes[0].?.serial, restored.panes[0].?.serial })));
}
test "opens of the log share the open records, and a closed one frees its record" {
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index 99acf24b..13381066 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -47,6 +47,10 @@ pub const State = struct {
event_reader: bool = false,
events: events.Queue = .{},
nomark: bool = false,
+ /// The revision a truncation of `data`, `xdata` or `body` left: the
+ /// write that follows it at once joins its undo step, so a shell's
+ /// `printf x > data` is one step, as acme's replace is.
+ joined: ?u32 = null,
noscroll: bool = false,
tag_snap: std.ArrayList(u8) = .empty,
/// An open reads pty/data, so output queues for it; a second is refused.
@@ -59,6 +63,9 @@ pub const State = struct {
lock: ?u32 = null,
/// The host started a shell it could not teach to mark its prompts.
unmarked: bool = false,
+ /// The pty's size as pty/ctl's winsize last set it, until the pane's own
+ /// grid resizes and gives the pty its size again.
+ winsize: ?[2]u16 = null,
/// Installed during this update; /log hears about it once the update ends
/// and the pane has its file, name and place.
unannounced: bool = false,
@@ -190,7 +197,9 @@ pub fn spliceBody(p: *Pardes, pane: *Pane, q0: usize, q1: usize, bytes: []const
@memcpy(new[0..lo], f.content[0..lo]);
@memcpy(new[lo..][0..take], bytes[0..take]);
@memcpy(new[lo + take ..], f.content[hi..]);
- if (!pane.fs.nomark) panes.File.pushUndo(p, pane);
+ const join = pane.fs.joined == f.revision;
+ pane.fs.joined = null;
+ if (!pane.fs.nomark and !join) panes.File.pushUndo(p, pane);
panes.File.setContent(p, f, new);
return take;
}
@@ -516,9 +525,12 @@ fn writeFlag(p: *Pardes, req: Req, pane: *Pane, file: PaneFile) Reply {
const f = fileOf(pane) orelse return Reply.fail(req.tag, E.INVAL);
f.saved_revision = if (on) f.revision -% 1 else f.revision;
},
+ // acme's nomark joins the writes after it into one undo step. The
+ // step starts here: the text as it is now is what Undo brings back,
+ // and turning mark on again only ends it.
.mark => {
+ if (!on and !pf.nomark) panes.File.pushUndo(p, pane);
pf.nomark = !on;
- if (on) panes.File.pushUndo(p, pane);
},
.scroll => pf.noscroll = !on,
else => unreachable,
@@ -611,6 +623,7 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status {
switch (file) {
.body => if (fileOf(pane) != null) {
_ = spliceBody(p, pane, 0, bodyOf(pane).len, "") orelse return .err;
+ pf.joined = fileOf(pane).?.revision;
pf.addr = .{};
setDot(pane, .{});
},
@@ -620,6 +633,7 @@ pub fn truncate(p: *Pardes, pane: *Pane, file: PaneFile) tree.Status {
const q1 = @max(q0, pf.addr.q1);
const before = dotOf(pane);
_ = spliceBody(p, pane, q0, q1, "") orelse return .err;
+ pf.joined = fileOf(pane).?.revision;
setDot(pane, .{ .q0 = shiftOne(before.q0, q0, q1 - q0, 0), .q1 = shiftOne(before.q1, q0, q1 - q0, 0) });
pf.addr = .{ .q0 = q0, .q1 = q0 };
},
@@ -906,6 +920,34 @@ test "the flag files read and write the state acme kept behind ctl verbs" {
try testing.expectEqual(E.INVAL, wr(term, Node.of(serialOf(term), .dirty), "1").errno());
}
+test "a replace through data, truncate then write, is one undo step" {
+ const p = try withFile(testing.allocator, "one two\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ _ = wr(p, Node.of(serial, .addr), "/two/");
+ _ = call(p, .{ .tag = 1, .op = .setattr, .node = Node.of(serial, .data), .truncate = true });
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .data), "2").reply.status);
+ try testing.expectEqualStrings("one 2\n", rd(p, Node.of(serial, .body), 0, 64).bytes);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status);
+ try testing.expectEqualStrings("one two\n", rd(p, Node.of(serial, .body), 0, 64).bytes);
+}
+
+test "writes under mark 0 are one undo step, even with no history before them" {
+ const p = try withFile(testing.allocator, "one\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const body = Node.of(serial, .body);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .mark), "0").reply.status);
+ _ = wr(p, body, "two\n");
+ _ = wr(p, body, "three\n");
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .mark), "1").reply.status);
+ try testing.expectEqualStrings("one\ntwo\nthree\n", rd(p, body, 0, 64).bytes);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Undo").reply.status);
+ try testing.expectEqualStrings("one\n", rd(p, body, 0, 64).bytes);
+ try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .ctl), "Redo").reply.status);
+ try testing.expectEqualStrings("one\ntwo\nthree\n", rd(p, body, 0, 64).bytes);
+}
+
test "truncating the tag clears its editable tail" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig
index 3599b612..393516ce 100644
--- a/src/ninep/pty.zig
+++ b/src/ninep/pty.zig
@@ -60,6 +60,7 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool {
if (words.next() != null) return false;
if (!apply) return true;
p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } });
+ pane.fs.winsize = .{ cols, rows };
},
.sig => {
const which = signalNamed(words.next() orelse return false) orelse return false;
@@ -79,13 +80,20 @@ fn verb(p: *Pardes, id: usize, line: []const u8, apply: bool) bool {
/// ctl reads back its state in the words it takes, as a Plan 9 ctl does.
pub fn readCtl(p: *Pardes, req: Req, pane: *Pane) Reply {
- p.fs.stage(p.gpa).print(p.gpa, "winsize {d} {d}\n", .{ pane.cols, pane.rows }) catch
+ const size = winsize(pane);
+ p.fs.stage(p.gpa).print(p.gpa, "winsize {d} {d}\n", .{ size[0], size[1] }) catch
return Reply.fail(req.tag, E.NOMEM);
return tree.stagedReply(p, req);
}
-/// Three right-aligned fields: cols, rows and busy: whether a run written now
-/// would be told `busy` (a command runs, or text is typed at the prompt).
+/// The pty's size: what ctl's winsize set, else the pane's grid.
+fn winsize(pane: *const Pane) [2]u16 {
+ return pane.fs.winsize orelse .{ pane.cols, pane.rows };
+}
+
+/// Three right-aligned fields: the pty's cols and rows, and busy: whether a
+/// run written now would be told `busy` (a command runs, or text is typed at
+/// the prompt). A newline ends the line, as a Plan 9 status file's does.
pub const status_len: u64 = 36;
pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
@@ -97,7 +105,8 @@ pub fn readStatus(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
const busy = p.hostTtyTaken(id) or (!pane.fs.unmarked and (waitingRun(p, pane) != null or
(if (marks) |m| m.prompts > 0 and (m.phase != .input or !pardes.panes.Terminal.promptInputEmpty(pane)) else false)));
const out = p.fs.stage(p.gpa);
- out.print(p.gpa, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, @intFromBool(busy) }) catch {};
+ const size = winsize(pane);
+ out.print(p.gpa, "{d:>11} {d:>11} {d:>11}\n", .{ size[0], size[1], @intFromBool(busy) }) catch {};
return tree.stagedReply(p, req);
}
@@ -287,6 +296,12 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void {
const done = marks.finished orelse return;
if (done.seq != slot.want) return;
pf.run = null;
+ finish(p, slot, pane, done.status);
+}
+
+/// Answers a run whose command ended with `status`: the header and the
+/// output it printed.
+fn finish(p: *Pardes, slot: *Run, pane: *Pane, status_code: ?i32) void {
const printed = pardes.panes.Terminal.commandOutput(pane, p.gpa, output_rows) catch
return answer(p, slot, "error out of memory", .{});
defer p.gpa.free(printed.text);
@@ -305,7 +320,7 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void {
}
// A D that carries no status says nothing of how the command went.
var code: [16]u8 = undefined;
- const status = if (done.status) |s| std.fmt.bufPrint(&code, "{d}", .{s}) catch unreachable else "?";
+ const status = if (status_code) |s| std.fmt.bufPrint(&code, "{d}", .{s}) catch unreachable else "?";
// The header is the whole first line, so a count there can never be
// mistaken for output; `cut` with no count: its start is not there to
// count from.
@@ -317,6 +332,22 @@ pub fn noteMarks(p: *Pardes, id: usize, pane: *Pane) void {
answer(p, slot, "exit {s}", .{status});
}
+/// The shell itself exited (a run's `exit 3`, or one typed): a run waiting
+/// on the command it ran answers with the shell's status and what it
+/// printed, and the log says `exit` before the pane's `del`. Unknown, the
+/// status is not guessed: the run hears `error shell gone` when the pane
+/// goes.
+pub fn shellExited(p: *Pardes, pane: *Pane, status: ?u8) void {
+ const code = status orelse return;
+ if (waitingRun(p, pane)) |slot| if (slot.phase == .sent) {
+ pane.fs.run = null;
+ pardes.panes.Terminal.endOutputHere(pane);
+ finish(p, slot, pane, code);
+ };
+ var buf: [4]u8 = undefined;
+ pardes.exec.noteRun(p, pane, "exit", std.fmt.bufPrint(&buf, "{d}", .{code}) catch unreachable);
+}
+
/// The pane closed or its shell was replaced: the command's end will never
/// be reported, so the run says so instead of waiting forever. A line not
/// sent yet waits on for the replacement's first prompt.
@@ -466,6 +497,12 @@ test "every pty/ctl verb, and every refusal" {
try testing.expectEqual(@as(u16, 44), ws.winsize.?.rows);
try testing.expectEqual(cols, pane.cols);
try testing.expectEqual(rows, pane.rows);
+ // ctl and status read back the pty's size, until the pane resizes.
+ try testing.expectEqualStrings("winsize 132 44\n", rd(p, ctl, 0, 64).bytes);
+ try testing.expect(std.mem.startsWith(u8, rd(p, Node.of(serialOf(p), .pty_status), 0, 64).bytes, " 132 44 "));
+ p.update(.{ .resize = .{ .cols = 76, .rows = 24 } });
+ try testing.expect(pane.cols != 132);
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&said, "winsize {d} {d}\n", .{ pane.cols, pane.rows }), rd(p, ctl, 0, 64).bytes);
for ([_]struct { line: []const u8, want: pardes.PtySignal }{
.{ .line = "sig INT", .want = .int },
@@ -527,6 +564,8 @@ test "owned cwd pty exec rejects long paths before applying its batch" {
const p = try withTerm(testing.allocator);
defer p.deinit();
const pane = p.panes[0].?;
+ // A one-row tag: the long path would wrap it onto rows and resize the pty.
+ pane.tag_expanded = false;
const ctl = Node.of(pane.serial, .pty_ctl);
var path: [1025]u8 = @splat('d');
path[0] = '/';
@@ -554,7 +593,7 @@ test "pty/status reports the grid and who holds the tty" {
const a = rd(p, status, 0, 256);
try testing.expectEqual(Status.ok, a.reply.status);
var want: [64]u8 = undefined;
- const whole = try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 0 });
+ const whole = try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11}\n", .{ pane.cols, pane.rows, 0 });
try testing.expectEqualStrings(whole, a.bytes);
try testing.expectEqual(@as(usize, 3 * 12), a.bytes.len);
try testing.expectEqualStrings(whole[12..], rd(p, status, 12, 256).bytes);
@@ -563,7 +602,7 @@ test "pty/status reports the grid and who holds the tty" {
p.host = .{ .ctx = &probe, .vtable = &th.FakeTty.vtable };
const held = rd(p, status, 0, 256);
try testing.expectEqualStrings(
- try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11} ", .{ pane.cols, pane.rows, 1 }),
+ try std.fmt.bufPrint(&want, "{d:>11} {d:>11} {d:>11}\n", .{ pane.cols, pane.rows, 1 }),
held.bytes,
);
}
@@ -645,12 +684,12 @@ test "run answers how its command ended once the shell is back at a prompt" {
// Before its first prompt the shell cannot take a line yet: the line
// waits for it, and pty/status does not call the shell busy meanwhile.
const status = Node.of(serialOf(p), .pty_status);
- try testing.expect(std.mem.endsWith(u8, rd(p, status, 0, 64).bytes, " 0 "));
+ try testing.expect(std.mem.endsWith(u8, rd(p, status, 0, 64).bytes, " 0\n"));
const early = call(p, .{ .tag = 1, .op = .open, .node = node });
const waiting = call(p, .{ .tag = 2, .op = .write, .node = node, .handle = early.reply.handle, .data = "true\n" });
try testing.expectEqualStrings("", waiting.pty());
try testing.expectEqual(Status.again, call(p, .{ .tag = 3, .op = .read, .node = node, .handle = early.reply.handle, .size = 64 }).reply.status);
- try testing.expect(std.mem.endsWith(u8, rd(p, status, 0, 64).bytes, " 1 "));
+ try testing.expect(std.mem.endsWith(u8, rd(p, status, 0, 64).bytes, " 1\n"));
// A respawn before that prompt keeps it waiting for the new shell's.
p.acknowledgeShell(0, "/bin/sh", true);
try testing.expectEqual(Status.again, call(p, .{ .tag = 3, .op = .read, .node = node, .handle = early.reply.handle, .size = 64 }).reply.status);
@@ -746,8 +785,52 @@ test "run answers how its command ended once the shell is back at a prompt" {
_ = call(p, .{ .tag = 18, .op = .write, .node = node, .handle = gone.reply.handle, .data = "sleep 9\n" });
try p.removePane(0, null);
try testing.expectEqualStrings("error shell gone\n", call(p, .{ .tag = 19, .op = .read, .node = node, .handle = gone.reply.handle, .size = 64 }).bytes);
+ // It still stats while the run is open (a reader's fstat), on the
+ // open or on the file's name, and not once it is released.
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 19, .op = .getattr, .node = node, .handle = gone.reply.handle }).reply.status);
+ try testing.expectEqual(Status.ok, call(p, .{ .tag = 19, .op = .getattr, .node = node }).reply.status);
_ = call(p, .{ .tag = 20, .op = .release, .node = node, .handle = gone.reply.handle });
for (p.fs.opens) |o| try testing.expect(o.node == 0);
+ try testing.expectEqual(E.NOENT, call(p, .{ .tag = 21, .op = .getattr, .node = node }).errno());
+}
+
+test "a shell that exits under a run answers its status, and the log says exit before del" {
+ const gpa = testing.allocator;
+ const p = try withTerm(gpa);
+ defer p.deinit();
+ const serial = serialOf(p);
+ const node = Node.of(serial, .pty_run);
+ const prompt = "\x1b]133;A;cl=line;aid=pardes\x07$ \x1b]133;B;aid=pardes\x07";
+ p.update(.{ .output = .{ .pane = 0, .bytes = prompt } });
+ while (p.nextEffect()) |_| {}
+ const log = @intFromEnum(tree.TopFile.log);
+ const f = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ const frozen = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = f, .size = 1 << 16 }).bytes.len;
+ _ = call(p, .{ .tag = 3, .op = .write, .node = log, .handle = f, .data = "follow" });
+
+ const r = call(p, .{ .tag = 4, .op = .open, .node = node });
+ _ = call(p, .{ .tag = 5, .op = .write, .node = node, .handle = r.reply.handle, .data = "echo bye; exit 3\n" });
+ p.update(.{ .output = .{ .pane = 0, .bytes = "echo bye; exit 3\r\n\x1b]133;C;aid=pardes\x07bye\r\n" } });
+ p.update(.{ .exited = .{ .pane = 0, .status = 3 } });
+ p.update(.{ .eof = .{ .pane = 0 } });
+ while (p.nextEffect()) |_| {}
+ try testing.expect(p.paneBySerial(serial) == null);
+ try testing.expectEqualStrings("exit 3\nbye\n", call(p, .{ .tag = 6, .op = .read, .node = node, .handle = r.reply.handle, .size = 64 }).bytes);
+ var want: [128]u8 = undefined;
+ // A followed log reads a record at a time: the exit, then the del.
+ var records: [2][]const u8 = undefined;
+ var at = frozen;
+ for (&records) |*rec| {
+ rec.* = try gpa.dupe(u8, call(p, .{ .tag = 7, .op = .read, .node = log, .handle = f, .off = at, .size = 4096 }).bytes);
+ at += rec.len;
+ }
+ defer for (records) |rec| gpa.free(rec);
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "exit {d} 3\n", .{serial}), records[0]);
+ try testing.expect(std.mem.startsWith(u8, records[1], try std.fmt.bufPrint(&want, "del {d} ", .{serial})));
+ // A write to its pty/data now finds no pane.
+ try testing.expectEqual(E.NOENT, wr(p, Node.of(serial, .pty_data), "x").errno());
+ _ = call(p, .{ .tag = 8, .op = .release, .node = node, .handle = r.reply.handle });
+ _ = call(p, .{ .tag = 9, .op = .release, .node = log, .handle = f });
}
test "run on a command pane says no shell is there" {
diff --git a/src/ninep/screen.zig b/src/ninep/screen.zig
index 88947d0d..462ea40f 100644
--- a/src/ninep/screen.zig
+++ b/src/ninep/screen.zig
@@ -254,6 +254,8 @@ test "screen inspection preserves acknowledged presentation and the next real fr
var arena: std.heap.ArenaAllocator = .init(gpa);
defer arena.deinit();
for ([_]*Pardes{ control, inspected }) |p| {
+ // The mechanism, at a fixed flavour, not the default's tuning.
+ p.settings.motion = .crisp;
p.settings.panel_transition = .off;
p.update(.tick);
p.acknowledgePanelPresentation((try p.render(arena.allocator())).panelTracks());
diff --git a/src/ninep/testing.zig b/src/ninep/testing.zig
index fce77f10..927552e2 100644
--- a/src/ninep/testing.zig
+++ b/src/ninep/testing.zig
@@ -56,6 +56,15 @@ pub fn rd(p: *Pardes, node: u64, off: u64, size: u32) Answer {
return call(p, .{ .tag = 1, .op = .read, .node = node, .off = off, .size = size });
}
+/// Whether the log, read whole from a fresh open, holds `needle`.
+pub fn logHas(p: *Pardes, needle: []const u8) bool {
+ const log = @intFromEnum(tree.TopFile.log);
+ const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle;
+ defer _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
+ const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes;
+ return std.mem.indexOf(u8, text, needle) != null;
+}
+
pub fn wr(p: *Pardes, node: u64, data: []const u8) Answer {
return call(p, .{ .tag = 2, .op = .write, .node = node, .data = data });
}
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index 5ef69bb4..ad95f6bf 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -385,7 +385,7 @@ pub fn handle(p: *Pardes, req: Req) Reply {
};
return switch (req.op) {
.lookup => lookup(p, req, target),
- .getattr => .{ .tag = req.tag, .attr = attrOf(p, target) orelse return Reply.fail(req.tag, E.NOENT) },
+ .getattr => .{ .tag = req.tag, .attr = attrOf(p, target) orelse runAttr(p, req, target) orelse return Reply.fail(req.tag, E.NOENT) },
.setattr => setattr(p, req, target),
.open => open(p, req, target),
.release => release(p, req),
@@ -395,6 +395,24 @@ pub fn handle(p: *Pardes, req: Req) Reply {
};
}
+/// A run's answer outlives its pane, so its file still stats while a run
+/// is open on it (a reader's fstat, cat's), as acme's fid keeps its window.
+/// The stat names the open when it came on one; a mount's comes on the
+/// file's walked name, and serials are never reused, so any run open on
+/// this node is the reader's.
+fn runAttr(p: *Pardes, req: Req, target: Target) ?Reply.Attr {
+ const t = switch (target) {
+ .pane => |t| t,
+ .top => return null,
+ };
+ if (t.file != .pty_run) return null;
+ const run = if (req.handle != 0) openOf(p, req) else for (&p.fs.opens) |*o| {
+ if (o.node == req.node and o.what == .run) break o;
+ } else null;
+ if ((run orelse return null).what != .run) return null;
+ return .{ .name = t.file.fileName(), .node = Node.of(t.serial, t.file), .mode = t.file.mode(), .mtime = p.fs.started };
+}
+
fn attrOf(p: *Pardes, target: Target) ?Reply.Attr {
switch (target) {
.top => |f| return .{
diff --git a/src/panes.zig b/src/panes.zig
index 47582692..8ca2bb4d 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -27,8 +27,10 @@ pub const Pane = struct {
/// The words that throw unsaved text away (builtins.zig warnModified).
pub const Discarding = enum { Exit, Restore, get };
- /// One mouse selection (block-shaped), per button. c/r are text-area relative;
- /// r counts from the tag's first row (the body starts at Pane.tag_rows).
+ /// One mouse sweep, per button: the press (c0, r0) and where it is now
+ /// (c1, r1), a stream between them as acme's (edit.sweepCols). c/r are
+ /// text-area relative; r counts from the tag's first row (the body
+ /// starts at Pane.tag_rows).
pub const Sel = struct {
state: enum { none, dragging, done } = .none,
source_id: u64 = 0,
@@ -38,12 +40,15 @@ pub const Pane = struct {
r1: i32 = 0,
};
- /// A rectangle keeps one source span per rendered row. Wrapped segments
- /// remain separate rows when copied, even when they share a source line.
+ /// A captured sweep keeps one source span per rendered row. Wrapped
+ /// segments remain separate rows when copied, even when they share a
+ /// source line.
pub const PointerRow = struct {
row: i32,
lo: usize,
hi: usize,
+ /// Not the sweep's last row: its highlight runs to the right edge.
+ to_edge: bool = false,
prefix: [32]u8 = @splat(0),
prefix_len: u8 = 0,
raw_terminal: bool = false,
@@ -181,6 +186,11 @@ pub const Pane = struct {
/// scrolls to keep its caret in view while it has the keyboard; pointer
/// rows count from this line, not the tag's first.
tag_top: u16 = 0,
+ /// The tag shows all its rows (acme's `tagexpand`, on by default); off,
+ /// one row, as acme's Tagup leaves it. Down on the
+ /// tag's last line and Up on its first, typing, or Alt-Down and Alt-Up
+ /// in the tag, as acme's arrows in a tag (plan9port text.c:776).
+ tag_expanded: bool = true,
/// How long the tag's computed prefix was when its positions were last
/// placed. They count from the start of the prefix, so when it grows or
/// shrinks (a rename, the dirty marker, a PDF's page) what follows it
diff --git a/src/pardes.zig b/src/pardes.zig
index 18eedccb..6294b6cb 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1415,37 +1415,19 @@ test "pane defaults keep creation of scratch panes in column tags" {
try std.testing.expectEqualStrings(tagline.file_pane_tail, tagline.curTail(file));
}
-test "an unsaved file marker sits between its path and builtins until Save" {
+test "an unsaved file's tag is its name alone: the grip, not a mark, says it is unsaved" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 });
defer p.deinit();
while (p.nextEffect()) |_| {}
const pane = try p.setTestFile("before\n");
-
const clean = try tagline.tagText(p, p.scratch.allocator(), pane);
- try std.testing.expect(std.mem.indexOf(u8, clean, "/test.txt *") == null);
-
panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n"));
const dirty = try tagline.tagText(p, p.scratch.allocator(), pane);
- const marker_at = std.mem.indexOf(u8, dirty, "/test.txt *") orelse return error.MissingDirtyMarker;
- const save_at = std.mem.indexOf(u8, dirty, "Save") orelse return error.MissingSaveBuiltin;
- try std.testing.expect(marker_at < save_at);
-
- try std.testing.expect(p.executeBuiltinLine(0, "Save"));
- while (p.nextEffect()) |effect| p.perform(effect);
- const saved = try tagline.tagText(p, p.scratch.allocator(), pane);
- try std.testing.expect(std.mem.indexOf(u8, saved, "/test.txt *") == null);
-
- panes.File.changed(p, 0, "external\n");
- const reloaded = try tagline.tagText(p, p.scratch.allocator(), pane);
- try std.testing.expect(std.mem.indexOf(u8, reloaded, "/test.txt *") == null);
-
- // A generated output is file-shaped and Save can write it to a path, but
- // there is no file of its own for it to be dirty against.
- pane.file.?.output = .{ .from = .search };
- panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "result\n"));
- const output = try tagline.tagText(p, p.scratch.allocator(), pane);
- try std.testing.expect(std.mem.indexOf(u8, output, "/test.txt *") == null);
+ try std.testing.expectEqualStrings(clean, dirty);
+ try std.testing.expect(std.mem.indexOf(u8, dirty, " *") == null);
+ // The dirty file and index's flag still say it.
+ try std.testing.expect(ctlfs.pane.dirtyOf(pane));
}
test "unknown Exec from a document runs as a command pane in its directory, typed into no terminal" {
@@ -1656,6 +1638,11 @@ test "a REPL bound for a language takes the exec made on a body in it; tags, Exe
_ = exec.executeFrom(p, 0, "a = 1\r\nb = \x1b[201~2", true);
Sent.drain(p);
try std.testing.expectEqualStrings("a = 1\rb = [201~2\r", Sent.said(1));
+ // A block left open by its indented last line gets the blank line that
+ // ends it: a second Enter.
+ _ = exec.executeFrom(p, 0, "def f():\n return 1", true);
+ Sent.drain(p);
+ try std.testing.expectEqualStrings("def f():\r return 1\r\r", Sent.said(1));
// Several selections executed at once: each is sent, not the first.
{
const text = try edit.flatSurface(p, &file.body);
@@ -1691,6 +1678,16 @@ test "a REPL bound for a language takes the exec made on a body in it; tags, Exe
Sent.drain(p);
try std.testing.expectEqualStrings("print(2)\r", Sent.said(2));
try std.testing.expect(Sent.len[1] == 0 and file.prompt == .none);
+ // The question is in the log, and a script answers it on the ctl.
+ {
+ const th = @import("ninep/testing.zig");
+ _ = exec.executeFrom(p, 0, "print(5)", true);
+ var want: [32]u8 = undefined;
+ try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "ask {d} repl a b\n", .{file.serial})));
+ const answered = th.wr(p, ctlfs.Node.of(file.serial, .ctl), "answer a\n");
+ try std.testing.expectEqual(ctlfs.Status.ok, answered.reply.status);
+ try std.testing.expectEqualStrings("print(5)\r", answered.pty());
+ }
_ = exec.executeFrom(p, 0, "print(3)", true);
p.update(.{ .key = .{ .cp = Key.escape } });
Sent.drain(p);
@@ -3314,7 +3311,7 @@ test "header clicks beyond text append and long UTF-8 tags reveal their caret" {
try std.testing.expectEqual(@as(usize, 0), p.header_scroll);
}
-test "a column tag with a newline in it takes a row per line and pushes its panes down" {
+test "a newline typed into a column tag is a space: the tag stays one row" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 50, .rows = 12, .tty_only = true });
defer p.deinit();
_ = try p.setTestFile("body\n");
@@ -3324,26 +3321,9 @@ test "a column tag with a newline in it takes a row per line and pushes its pane
p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 40, .row = TOPBAR_H } });
p.update(.{ .key = .{ .cp = Key.enter } });
p.update(.{ .key = .{ .cp = 'S', .text = "Snarf" } });
- try std.testing.expectEqualStrings("New\nSnarf", tagline.columnTag(p, 0));
- try std.testing.expectEqual(@as(u16, 2), p.columnBarHeight());
- const frame = try p.render(p.scratch.allocator());
- try std.testing.expectEqual(TOPBAR_H + 2, p.rects[0].y);
- try std.testing.expectEqual(TOPBAR_H + 1, frame.cursor.?.y);
- try std.testing.expectEqual(@as(u8, 'S'), frame.at(TAG_TEXT_INSET, TOPBAR_H + 1).text[0]);
-
- // A click on the second row lands on the second line.
- p.update(.{ .key = .{ .cp = Key.escape } });
- p.acknowledgePanelPresentation(&.{});
- p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = TAG_TEXT_INSET + 1, .row = TOPBAR_H + 1 } });
- p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET + 1, .row = TOPBAR_H + 1 } });
- try std.testing.expectEqual(@as(i32, 1), p.col_tags[0].cur_row);
- try std.testing.expectEqual(@as(i32, 1), p.col_tags[0].cur_col);
-
- // Taking the newline back out gives the panes their row again.
- p.update(.{ .key = .{ .cp = Key.home } });
- p.update(.{ .key = .{ .cp = Key.backspace } });
- try std.testing.expectEqualStrings("NewSnarf", tagline.columnTag(p, 0));
_ = try p.render(p.scratch.allocator());
+ try std.testing.expectEqualStrings("New Snarf", tagline.columnTag(p, 0));
+ try std.testing.expectEqual(COLBAR_H, p.columnBarHeight());
try std.testing.expectEqual(TOPBAR_H + COLBAR_H, p.rects[0].y);
}
@@ -3733,6 +3713,8 @@ test "Newcol refuses an unsplittable restored weight before spawning" {
test "layout commits publish finite tracks only for changed panes" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20 });
defer p.deinit();
+ // The mechanism, at a fixed flavour, not the default\'s tuning.
+ p.settings.motion = .crisp;
_ = try p.newShell(1, "");
try std.testing.expect(layout.splitColumn(p, 0, 1, false));
@@ -3796,6 +3778,8 @@ test "vertical close samples only a canonical baseline containing that pane" {
{
const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true });
defer p.deinit();
+ // The mechanism, at a fixed flavour, not the default\'s tuning.
+ p.settings.motion = .crisp;
var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
defer frame.deinit();
p.settings.panel_transition = .vertical;
@@ -3819,6 +3803,8 @@ test "vertical close samples only a canonical baseline containing that pane" {
{
const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true });
defer p.deinit();
+ // The mechanism, at a fixed flavour, not the default\'s tuning.
+ p.settings.motion = .crisp;
var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
defer frame.deinit();
p.settings.panel_transition = .vertical;
@@ -4270,6 +4256,10 @@ pub const Pardes = struct {
theme_file_generation: u32 = 0,
theme_file_pane: u8 = 0,
chrome_animation: colors.ChromeAnimation = colors.ChromeAnimation.init(colors.initial_chrome),
+ /// Each pane's lift off the page (Lift): 1 while it has the focus, on a
+ /// spring. `lift_serials` says whose it is, so a reused slot starts flat.
+ lifts: [MAX_PANES]animation.Spring = @splat(.{}),
+ lift_serials: [MAX_PANES]u32 = @splat(0),
animate_theme_changes: bool = false,
native_images: bool = false,
/// The shell can take a changed image a few rows at a time (a texture row
@@ -4290,6 +4280,13 @@ pub const Pardes = struct {
pointer_tag_hit: ?TagHit = null,
raw_hover_intent: bool = false,
pointer_inside: bool = false,
+ /// The last plain B1 click, for acme's double-click (text.c:881,
+ /// textselect: a second click at the same point within 500 ms).
+ last_click: ?struct { id: usize, on_tag: bool, row: i32, col: i32, ms: i64 } = null,
+ /// A B1 chord's edits share the undo point it took (mouse.zig).
+ chord_hold: bool = false,
+ /// The look just run found nothing (look.zig missed).
+ look_missed: bool = false,
look_hover_wait: ?look.LookHoverWait = null,
look_hover_preview: ?look.LookHoverPreview = null,
pdf_hover_preview: if (pdf_enabled) ?look.PdfWordPreview else void = if (pdf_enabled) null else {},
@@ -4617,24 +4614,21 @@ pub const Pardes = struct {
return a;
}
- /// The workspace tag's height: a row per line of it while the setting
- /// keeps it, zero once a native shell takes its commands over. Layout and
- /// hit testing read this rather than the constant, the way they read
- /// `columnBarHeight`.
+ /// The workspace tag's height: one row while the setting keeps it, as
+ /// acme's row tag is, zero once a native shell takes its commands over.
+ /// Layout and hit testing read this rather than the constant, the way
+ /// they read `columnBarHeight`.
pub fn topBarHeight(p: *const Pardes) u16 {
if (!p.settings.workspace_tag) return 0;
- const lines = if (p.global_tag.own) |own| std.mem.count(u8, own, "\n") + 1 else TOPBAR_H;
- return @intCast(@min(lines, MAX_TAG_ROWS, @max(TOPBAR_H, p.screen_h / 3)));
+ return TOPBAR_H;
}
- /// The column tags' height: a row per line of the tallest one.
+ /// The column tags' height: one row, as acme's (cols.c:244, colresize,
+ /// gives a column tag one font height); a newline in one is a space
+ /// (tagline.flattenHeaders).
pub fn columnBarHeight(p: *const Pardes) u16 {
if (p.screen_h < 3) return 0;
- var lines: usize = COLBAR_H;
- for (p.col_tags[0..p.ncol]) |t| if (t.own) |own| {
- lines = @max(lines, std.mem.count(u8, own, "\n") + 1);
- };
- return @intCast(@min(lines, MAX_TAG_ROWS, @max(COLBAR_H, p.screen_h / 3)));
+ return COLBAR_H;
}
fn detachCwds(p: *Pardes, parents: []const *Pane) !void {
@@ -5227,7 +5221,9 @@ pub const Pardes = struct {
if (v.wait_input) |f| {
// Asleep until input or until core animation next needs a
// frame; 0 is no timeout at all.
- const timeout: u32 = if (p.nextWake()) |due| wake: {
+ // A render-time animation draws every loop: the wait only takes
+ // what input is there.
+ const timeout: u32 = if (p.continuous()) 1 else if (p.nextWake()) |due| wake: {
const now = if (v.now) |clock| clock(h.ctx) else p.now_ns;
const ms = ((due -| now) + std.time.ns_per_ms - 1) / std.time.ns_per_ms;
break :wake @intCast(std.math.clamp(ms, 1, std.math.maxInt(u32)));
@@ -5246,7 +5242,7 @@ pub const Pardes = struct {
// rendering and nothing else.
// A shell with a clock draws what `advance` stepped; one without
// steps its own `.tick`s and draws while anything animates.
- if (!p.needs_frame and (v.now != null or !p.animationActive())) return;
+ if (!p.needs_frame and !p.continuous() and (v.now != null or !p.animationActive())) return;
_ = p.frame_arena.reset(.retain_capacity);
const surface = try p.render(p.frame_arena.allocator());
if (v.present) |f| f(h.ctx, surface);
@@ -5365,8 +5361,11 @@ pub const Pardes = struct {
panes.Terminal.feedOutput(p, pane, o.bytes);
ctlfs.pty.noteMarks(p, o.pane, pane);
},
- .exited => |e| if (p.panes[e.pane]) |pane| if (pane.command != null and !pane.command_done) {
- exec.commandDone(p, e.pane, e.status);
+ .exited => |e| if (p.panes[e.pane]) |pane| {
+ if (pane.command == null)
+ ctlfs.pty.shellExited(p, pane, e.status)
+ else if (!pane.command_done)
+ exec.commandDone(p, e.pane, e.status);
},
.eof => |e| if (p.panes[e.pane]) |pane| if (pane.command != null) {
// Output is over; the command is over when its child exits.
@@ -5410,6 +5409,7 @@ pub const Pardes = struct {
p.pointer_tag_hit = null;
},
.tick => {
+ mouse.edgeScroll(p);
p.chrome_animation.advance();
p.presentation.advance();
look.advanceLookHover(p);
@@ -5494,37 +5494,66 @@ pub const Pardes = struct {
}
}
- fn handleKey(p: *Pardes, key: Key) void {
- if (p.macro.recording != 0 and p.macro.replaying == 0) Macro.append(&p.macro.recorded, p.gpa, key);
- // A question waiting on a pane takes the next key, whatever mode the
- // pane or a header edit is in: that key is its answer.
- for (p.panes, 0..) |slot, id| {
- const asked = slot orelse continue;
- if (asked.prompt == .repl_choice) {
+ /// Answers the question pane `id` asks on its notice band, logged as
+ /// `ask <serial> <what> <choices>`: Del's side (`k` above, `j` below) or
+ /// the REPL to send to (its letter). Null, or a choice not offered,
+ /// takes the question back. A key answers it, or `answer` on the pane's
+ /// ctl; false when the pane asks nothing.
+ pub fn answerQuestion(p: *Pardes, id: usize, choice: ?u8) bool {
+ const asked = p.panes[id] orelse return false;
+ switch (asked.prompt) {
+ .repl_choice => {
asked.prompt = .none;
- const ask = p.repl_ask orelse return;
+ const ask = p.repl_ask orelse return true;
p.repl_ask = null;
defer p.gpa.free(ask.text);
- // Its letter sends it there; anything else takes it back.
- if (key.ctrl or key.alt or key.cp > 'z' or key.cp < 'a') return;
- const serial = ask.offered[key.cp - 'a'];
- if (serial == 0) return;
+ const letter = choice orelse return true;
+ if (letter < 'a' or letter > 'z') return true;
+ const serial = ask.offered[letter - 'a'];
+ if (serial == 0) return true;
// The pane offered, still that REPL and still running: it
// may have closed or been rebound while the question stood.
if (p.paneBySerial(serial)) |to| if (p.panes[to].?.repl) |r|
- if (r.lang == ask.lang and r.letter == key.cp and exec.replLive(p, to))
- return exec.sendToRepl(p, id, to, ask.text);
+ if (r.lang == ask.lang and r.letter == letter and exec.replLive(p, to)) {
+ exec.sendToRepl(p, id, to, ask.text);
+ return true;
+ };
p.setMessage(id, "Repl: the REPL chosen is gone; nothing sent");
- return;
+ },
+ .del_side => {
+ asked.prompt = .none;
+ if (choice == 'k') exec.runBuiltin(p, .Del, id, "", "k");
+ if (choice == 'j') exec.runBuiltin(p, .Del, id, "", "j");
+ },
+ else => return false,
+ }
+ return true;
+ }
+
+ fn handleKey(p: *Pardes, key: Key) void {
+ // A double-click is two clicks with nothing between them.
+ p.last_click = null;
+ if (p.macro.recording != 0 and p.macro.replaying == 0) Macro.append(&p.macro.recorded, p.gpa, key);
+ // A question waiting on a pane takes the next key, whatever mode the
+ // pane or a header edit is in: that key is its answer.
+ for (p.panes, 0..) |slot, id| {
+ const asked = slot orelse continue;
+ switch (asked.prompt) {
+ // Its letter sends it there; anything else takes it back.
+ .repl_choice => _ = p.answerQuestion(id, if (key.ctrl or key.alt or key.cp > 'z' or key.cp < 'a') null else @intCast(key.cp)),
+ .del_side => _ = p.answerQuestion(id, if (hit(key, config.del_above)) 'k' else if (hit(key, config.del_below)) 'j' else null),
+ else => continue,
}
- if (asked.prompt != .del_side) continue;
- asked.prompt = .none;
- if (hit(key, config.del_above)) exec.runBuiltin(p, .Del, id, "", "k");
- if (hit(key, config.del_below)) exec.runBuiltin(p, .Del, id, "", "j");
return;
}
const pane = p.panes[p.active] orelse return;
p.noteTyped();
+ // Alt-Down and Alt-Up in a pane's tag expand and collapse it, in
+ // either mode: acme's Tagdown and Tagup (plan9port text.c:776).
+ if (pane.focus == .tag and key.alt and !key.ctrl and (key.cp == Key.down or key.cp == Key.up) and tagline.focusedHeader(p) == null) {
+ pane.tag_expanded = key.cp == Key.down;
+ return;
+ }
// An open prompt takes the keys even when it answers for a header.
const header = if (pane.prompt != .none) null else tagline.focusedHeader(p);
if (header == null and pane.isTerminal() and pane.body.mode == .tty and pane.focus == .body and pane.prompt == .none) {
@@ -6276,6 +6305,7 @@ pub const Pardes = struct {
/// Recompute geometry, push grid-size changes to each emulator + pty, fire
/// deferred greetings. The mirror of the prototype's loop epilogue.
pub fn sync(p: *Pardes) void {
+ tagline.flattenHeaders(p);
if (p.header_column != null and p.columnBarHeight() == 0) {
tagline.exitHeader(p);
}
@@ -6326,11 +6356,30 @@ pub const Pardes = struct {
}
pane.tag_lead = lead;
}
- const lines = std.mem.count(u8, tagline.curTail(pane), "\n") + 1;
- pane.tag_rows = @intCast(@min(lines, MAX_TAG_ROWS, @max(BOX_H, r.h -| 1)));
+ // A row per shown row: the tag's lines wrapped at its width.
+ var wrap_room: [32 * 1024]u8 = undefined;
+ var wrap_fba = std.heap.FixedBufferAllocator.init(&wrap_room);
+ const wa = wrap_fba.allocator();
+ var lines = std.mem.count(u8, tagline.curTail(pane), "\n") + 1;
+ var wanted = lines;
+ var caret_row: usize = @intCast(@max(0, pane.tag.cur_row));
+ if (tagline.prefixIn(wa, pane)) |prefix| {
+ if (std.mem.concat(wa, u8, &.{ prefix, tagline.curTail(pane) })) |whole| {
+ if (tagline.wrapTag(wa, whole, tagline.tagWidth(p, id))) |shown| {
+ lines = shown.rows();
+ // Collapsed, one row, as acme's Tagup sets taglines to 1
+ // (plan9port text.c:786).
+ wanted = if (pane.tag_expanded) lines else 1;
+ const cur = modal.offsetAt(whole, .{ .row = caret_row, .col = @intCast(@max(0, pane.tag.cur_col)) });
+ caret_row = std.mem.count(u8, shown.text[0..@min(shown.fromReal(cur), shown.text.len)], "\n");
+ } else |_| {}
+ } else |_| {}
+ } else |_| {}
+ if (!pane.tag_expanded) wanted = 1;
+ pane.tag_rows = @intCast(@min(wanted, MAX_TAG_ROWS, @max(BOX_H, r.h -| 1)));
if (pane.collapsed) pane.tag_rows = BOX_H;
if (pane.focus == .tag and pane.prompt != .name) {
- const cur: usize = @intCast(@max(0, pane.tag.cur_row));
+ const cur: usize = caret_row;
var top: usize = pane.tag_top;
if (cur < top) top = cur;
if (cur >= top + pane.tag_rows) top = cur + 1 - pane.tag_rows;
@@ -6345,6 +6394,7 @@ pub const Pardes = struct {
panes.Terminal.resizeGrid(pane, p.gpa, cols, rows);
p.shell_rows.markStale(pane); // reflow moved every row
p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } });
+ pane.fs.winsize = null;
}
pane.cols = cols;
pane.rows = rows;
@@ -6391,10 +6441,19 @@ pub const Pardes = struct {
return if (frames) |n| p.stepped_ns + n * animation.frame_ns else null;
}
+ /// A render-time animation is moving (a pane's lift): it is sampled at
+ /// each frame's own `now`, so while it moves the shell draws every loop
+ /// and its present (vsync) paces it, at the display's rate. `nextWake`
+ /// is for the stepped animations, which count frames of `frame_ns`.
+ pub fn continuous(p: *const Pardes) bool {
+ for (p.lifts) |spring| if (!spring.settled) return true;
+ return false;
+ }
+
/// For shells that only need a yes or no: macOS and the board, which
/// keep their own frame banks.
pub fn animationActive(p: *const Pardes) bool {
- return p.nextWake() != null;
+ return p.nextWake() != null or p.continuous();
}
/// Bring core animation up to `now_ns`, the shell's monotonic clock: one
@@ -6928,6 +6987,28 @@ test "Del from a key asks which neighbour takes the rows; nothing else asks" {
try std.testing.expectEqual(top, p.active);
try std.testing.expectEqual(h[2], p.rects[low].h);
+ // A script answers the question on the pane's ctl, as the log's `ask`
+ // names it: `answer -` takes it back, a side closes the pane toward it.
+ {
+ const th = @import("ninep/testing.zig");
+ p.newScratchBelow(top);
+ const asked = p.active;
+ p.sync();
+ const serial = p.panes[asked].?.serial;
+ const ctl = ctlfs.Node.of(serial, .ctl);
+ try std.testing.expectEqual(ctlfs.E.INVAL, th.wr(p, ctl, "answer k\n").errno());
+ p.update(.{ .key = .{ .cp = ' ' } });
+ p.update(.{ .key = .{ .cp = 'd' } });
+ var want: [32]u8 = undefined;
+ try std.testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "ask {d} del k j\n", .{serial})));
+ try std.testing.expectEqual(ctlfs.Status.ok, th.wr(p, ctl, "answer -\n").reply.status);
+ try std.testing.expect(p.panes[asked] != null and p.panes[asked].?.prompt == .none);
+ p.update(.{ .key = .{ .cp = ' ' } });
+ p.update(.{ .key = .{ .cp = 'd' } });
+ try std.testing.expectEqual(ctlfs.Status.ok, th.wr(p, ctl, "answer k\n").reply.status);
+ try std.testing.expect(p.paneBySerial(serial) == null);
+ }
+
// Bare Del from anything but a key closes at once and gives the rows up,
// as it always has.
p.newScratchBelow(top);
diff --git a/src/regexp.zig b/src/regexp.zig
index f6d540c4..249763c2 100644
--- a/src/regexp.zig
+++ b/src/regexp.zig
@@ -41,7 +41,12 @@ pub const Regex = struct {
/// patterns take 45-80 ms, and a Debug build is ten times slower.
pub const budget: u64 = if (builtin.mode == .Debug) 4_000_000 else 32_000_000;
- pub fn compile(pat: []const u8) error{Bad}!Regex {
+ pub const e_anchor = "in a pattern with \\n, ^ can only come first and $ only just before a \\n";
+
+ /// `Anchor`: a pattern that names a newline has `^` other than first,
+ /// or `$` other than just before a `\n`, which mvzr would read as the
+ /// ends of the whole text and so never match where sam would.
+ pub fn compile(pat: []const u8) error{ Bad, Anchor }!Regex {
if (pat.len == 0) return error.Bad;
var buf: [256]u8 = undefined;
var len: usize = 0;
@@ -72,6 +77,12 @@ pub const Regex = struct {
in_class = true;
} else if (c == '.' and spans) {
piece = "[^\\n]";
+ } else if (spans and c == '^' and i != 0) {
+ return error.Anchor;
+ } else if (spans and c == '$') {
+ // `x$\n` is `x\n`; any other `$` would be the text's end.
+ if (std.mem.startsWith(u8, pat[i + 1 ..], "\\n")) continue;
+ return error.Anchor;
}
if (!emit) continue;
if (len + piece.len > buf.len) return error.Bad;
@@ -95,6 +106,20 @@ pub const Regex = struct {
mvzr.steps_left = rx.steps;
mvzr.exhausted = false;
defer rx.steps = mvzr.steps_left;
+ // A `^` pattern that spans lines: mvzr's `^` is its haystack's start,
+ // so each line start from `from` on is tried as one.
+ // ponytail: a search per line start, each to `hi`; the step budget
+ // bounds it.
+ if (rx.spans and rx.bol) {
+ var s = if (from == 0 or text[from - 1] == '\n') from else (std.mem.indexOfScalarPos(u8, text[0..hi], from, '\n') orelse return null) + 1;
+ while (s <= last and s <= hi) {
+ const hit = rx.re.match(text[s..hi]);
+ if (mvzr.exhausted) return error.TooSlow;
+ if (hit) |m| if (m.start == 0) return .{ .start = s, .end = s + m.end };
+ s = (std.mem.indexOfScalarPos(u8, text[0..hi], s, '\n') orelse return null) + 1;
+ }
+ return null;
+ }
var start: usize = if (rx.spans) 0 else if (std.mem.lastIndexOfScalar(u8, text[0..from], '\n')) |nl| nl + 1 else 0;
var at = from - start;
// `^` cannot match in the middle of a line.
@@ -140,6 +165,20 @@ test "lines are haystacks: ^ and $ at each line, . never a newline, \\n spans li
try std.testing.expectEqual(c.end, m.end);
}
_ = try Regex.compile("a.*a\\nq");
+ // `^` at every line start and `$` before a newline, when a pattern
+ // spans lines; anywhere else they are refused, never silently wrong.
+ const defs = "x = 1\ndef a\n\ndef b\n";
+ var def = try Regex.compile("^def .*\\n");
+ const d = (try def.find(defs, 0, defs.len, defs.len)).?;
+ try std.testing.expectEqual(@as(usize, 6), d.start);
+ try std.testing.expectEqual(@as(usize, 12), d.end);
+ try std.testing.expectEqual(@as(usize, 13), (try def.find(defs, 7, defs.len, defs.len)).?.start);
+ var blank = try Regex.compile("^\\n");
+ try std.testing.expectEqual(@as(usize, 12), (try blank.find(defs, 0, defs.len, defs.len)).?.start);
+ var dollar = try Regex.compile("1$\\n");
+ try std.testing.expectEqual(@as(usize, 4), (try dollar.find(defs, 0, defs.len, defs.len)).?.start);
+ try std.testing.expectError(error.Anchor, Regex.compile("(^|\\n)def"));
+ try std.testing.expectError(error.Anchor, Regex.compile("a$\\nb$"));
var none = try Regex.compile("zzz");
try std.testing.expect(try none.find(text, 0, text.len, text.len) == null);
try std.testing.expectError(error.Bad, Regex.compile(""));
diff --git a/src/sam_edit.zig b/src/sam_edit.zig
index c08c6cdd..679823da 100644
--- a/src/sam_edit.zig
+++ b/src/sam_edit.zig
@@ -82,6 +82,19 @@ pub fn run(arena: std.mem.Allocator, text: []const u8, dot: Range, name: []const
return .{ .ops = ex.ops.items, .dot = d, .printed = ex.out.items };
}
+/// Whether `command` stops inside a `{` group or an `a`, `c` or `i` text
+/// block still waiting for its `.` line: a writer of lines (a ctl, exec)
+/// hands an Edit the lines after it until it does not.
+pub fn needsMore(arena: std.mem.Allocator, command: []const u8) bool {
+ var why: Why = .{};
+ const src = std.fmt.allocPrint(arena, "{s}\n", .{command}) catch return false;
+ var ps: Parser = .{ .arena = arena, .src = src, .why = &why };
+ while (true) {
+ const c = ps.parse(0) catch return std.mem.eql(u8, why.text(), "unmatched `{'") or ps.open_text;
+ if (c == null) return ps.open_text;
+ }
+}
+
fn fail(why: *Why, comptime fmt: []const u8, args: anytype) Failure {
why.len = if (std.fmt.bufPrint(&why.buf, fmt, args)) |s| s.len else |_| 0;
return error.Edit;
@@ -95,6 +108,10 @@ const Parser = struct {
i: usize = 0,
/// The last pattern given, for an empty one (edit.c:562, getregexp).
lastpat: []const u8 = "",
+ /// The last `parse` that answered null met a `}`, not the end.
+ braced: bool = false,
+ /// A text block of `a`, `c` or `i` ran out before its `.` line.
+ open_text: bool = false,
why: *Why,
fn getch(ps: *Parser) ?u8 {
@@ -248,6 +265,7 @@ const Parser = struct {
try s.appendSlice(ps.arena, line);
try s.append(ps.arena, '\n');
}
+ ps.open_text = true;
return s.items;
}
const delim = ps.getch() orelse return "";
@@ -290,7 +308,11 @@ const Parser = struct {
if (ps.nextc() == 'g') {
ps.i += 1;
cmd.global = true;
- }
+ } else if (ps.nextc()) |d| if (d >= '1' and d <= '9') {
+ // `s/a/b/2`, the count after, as some write it for
+ // sam's `s2/a/b/`.
+ cmd.num = ps.getnum(1);
+ };
}
try ps.atnl();
},
@@ -326,12 +348,18 @@ const Parser = struct {
var last: ?*Cmd = null;
while (true) {
if (ps.skipbl() == '\n') ps.i += 1;
- const next = (try ps.parse(nest + 1)) orelse break;
+ ps.braced = false;
+ const next = (try ps.parse(nest + 1)) orelse {
+ // sam reads on for the `}`; an Edit has no more to read.
+ if (!ps.braced) return fail(ps.why, "unmatched `{{'", .{});
+ break;
+ };
if (last) |l| l.next = next else cmd.sub = next;
last = next;
}
},
'}' => {
+ ps.braced = true;
try ps.atnl();
if (nest == 0) return fail(ps.why, "right brace with no left brace", .{});
return null;
@@ -380,7 +408,10 @@ const Exec = struct {
}
fn compile(ex: *Exec, pat: []const u8, c: u8) Failure!regexp.Regex {
- return regexp.Regex.compile(pat) catch fail(ex.why, "bad regexp in {c} command", .{c});
+ return regexp.Regex.compile(pat) catch |err| switch (err) {
+ error.Anchor => fail(ex.why, "{s}", .{regexp.Regex.e_anchor}),
+ error.Bad => fail(ex.why, "bad regexp in {c} command", .{c}),
+ };
}
fn find(ex: *Exec, rx: *regexp.Regex, from: usize, hi: usize) Failure!?regexp.Regex.Match {
@@ -625,6 +656,7 @@ test "sam's classic commands" {
try expectEdit("aaa\n", ",s/a/b/g", "bbb\n");
try expectEdit("aaa\n", ",s/a/b/", "baa\n");
try expectEdit("aaa\n", ",s2/a/b/", "aba\n");
+ try expectEdit("aaa\n", ",s/a/b/2", "aba\n");
try expectEdit("cat dog\n", ",s/[a-z]+/<&>/g", "<cat> <dog>\n");
// x within x, g within x.
try expectEdit("int a;\nfloat b;\nint c;\n", ",x/.*\\n/g/^int/x/[a-z];/c/X;/", "int X;\nfloat b;\nint X;\n");
@@ -637,6 +669,8 @@ test "sam's classic commands" {
try expectEdit("one\ntwo\n", "1 m $", "two\none\n");
try expectEdit("one\ntwo\n", "1 t $", "one\ntwo\none\n");
try expectEdit("one\ntwo\nthree\n", "2 d", "one\nthree\n");
+ // ^ at every line start in a pattern that spans lines (dogfood round 5).
+ try expectEdit("x = 1\ndef a\ndef b\n", ",x/^def .*\\n/a/X\\n/", "x = 1\ndef a\nX\ndef b\nX\n");
// pardes's line:col.
try expectEdit("one\ntwo\n", "2:2 i/-/", "one\nt-wo\n");
}
@@ -650,9 +684,12 @@ test "an Edit that fails halfway changes nothing, and says why in acme's words"
.{ "k", "unknown command k" },
.{ ",x/a/{\na/1/\ni/2/\n}", "changes out of sequence" },
.{ "}", "right brace with no left brace" },
+ .{ ",x/foo/{", "unmatched `{'" },
+ .{ ",x/foo/{\nd", "unmatched `{'" },
.{ "w /tmp/x", "w is not supported in pardes" },
.{ ",s/(a)/\\1/", "no \\1: mvzr keeps no submatches" },
.{ "1 m 1,2", "move overlaps itself" },
+ .{ ",x/(^|\\n)foo/d", "in a pattern with \\n, ^ can only come first and $ only just before a \\n" },
}) |c| {
var why: Why = .{};
try std.testing.expectError(error.Edit, run(arena_state.allocator(), "foo a\nfoo\n", .{}, "t", c[0], &why));
@@ -660,6 +697,19 @@ test "an Edit that fails halfway changes nothing, and says why in acme's words"
}
}
+test "an Edit wants the lines after it until its braces and text blocks close" {
+ var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena_state.deinit();
+ const a = arena_state.allocator();
+ try std.testing.expect(needsMore(a, ",x/foo/{"));
+ try std.testing.expect(needsMore(a, ",x/foo/{\ni/[/"));
+ try std.testing.expect(!needsMore(a, ",x/foo/{\ni/[/\n}"));
+ try std.testing.expect(needsMore(a, "$a\nline one"));
+ try std.testing.expect(!needsMore(a, "$a\nline one\n."));
+ try std.testing.expect(!needsMore(a, ",s/{/x/"));
+ try std.testing.expect(!needsMore(a, ",x/foo/c/bar/"));
+}
+
test "the dot an Edit leaves selects what a change put where it stood" {
const ops = [_]Op{.{ .q0 = 2, .q1 = 4, .text = "XYZ" }};
try std.testing.expectEqual(Range{ .q0 = 2, .q1 = 5 }, moveDot(.{ .q0 = 2, .q1 = 2 }, &ops));
diff --git a/src/surface.zig b/src/surface.zig
index a0471e51..15de128f 100644
--- a/src/surface.zig
+++ b/src/surface.zig
@@ -356,6 +356,8 @@ pub const Region = struct {
rect: layout.Rect,
/// The pane or column has the keyboard's focus.
active: bool = false,
+ /// A grip's pane has changes it has not saved.
+ dirty: bool = false,
/// A rail's thumb: rows from the rail's top, and how many. Zero rows is
/// a rail with no thumb (an image). A native PDF's thumb is measured in
/// document pixels and filled in by Pdf.draw while it paints, so read
@@ -364,6 +366,10 @@ pub const Region = struct {
thumb_h: u16 = 0,
/// A notice's index in its pane's `notices`.
notice: u8 = 0,
+ /// How far it stands off the page, 0 to 1: a pane's while it has the
+ /// focus (Lift, on a spring), a notice's while Lift is on. A pixel shell
+ /// casts a soft shadow from it; the tier it is drawn in never changes.
+ lift: f32 = 0,
/// Cells the column-move rail takes off the right edge while it runs
/// there: layers and pixel shells stop short of them, the grid does not.
clip: u16 = 0,
@@ -392,6 +398,26 @@ pub const Chrome = struct {
tag_fg: [3]u8 = .{ 0, 0, 0 },
scroll_track: [3]u8 = .{ 0, 0, 0 },
scroll_thumb: [3]u8 = .{ 0, 0, 0 },
+ /// The theme's accent (a glow's tint).
+ accent: [3]u8 = .{ 0, 0, 0 },
+ /// A selection's text and ground: a lift keeps their contrast too.
+ sel_fg: [3]u8 = .{ 0, 0, 0 },
+ sel_bg: [3]u8 = .{ 0, 0, 0 },
+ /// How a lifted part stands off the page (config.Runtime.LiftStyle).
+ lift_style: u8 = 0,
+ /// Rule width in pixels (colors.Theme.rule_px).
+ rule_px: u8 = 2,
+ /// The ring round a grip, acme's button: unfocused, and focused.
+ grip_border: [3]u8 = .{ 0, 0, 0 },
+ grip_focus_ring: [3]u8 = .{ 0, 0, 0 },
+ /// A dirty pane's button fill.
+ grip_dirty: [3]u8 = .{ 0, 0, 0 },
+ /// The rule between a pane's tag and its body; null is halfway between
+ /// the tag's ground and the page the shell actually draws.
+ tag_rule: ?[3]u8 = null,
+ /// The scroll column's width, button and scrollbar alike, in pixels at
+ /// a 17px tagline (acme's 12 at its font); a pixel shell scales it.
+ rail_px: u8 = 12,
};
/// The canonical interface: what a frame of pardes IS. The tty shell writes
diff --git a/src/tagline.zig b/src/tagline.zig
index c04cbc66..68fa2645 100644
--- a/src/tagline.zig
+++ b/src/tagline.zig
@@ -23,7 +23,15 @@ pub const context_pane_tail = file_pane_tail ++ " TreeContext";
pub const locations_pane_tail = file_pane_tail ++ " LocationsConfig";
pub const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str;
pub const command_pane_tail = " " ++ config.command_pane_builtins_str;
+/// The mark a tag carried after an unsaved file's name before the grip
+/// showed it; a dump from then still has it in its saved tags.
const dirty_marker = " *";
+
+/// A file with changes it has not saved.
+pub fn dirty(pane: *const panes.Pane) bool {
+ const f = pane.file orelse return false;
+ return panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision;
+}
pub const legacy_pane_tail = " Del";
pub const legacy_file_pane_tail = " Save Del";
// The defaults from the release before Newtty joined every tagline. Recognized
@@ -134,12 +142,9 @@ pub fn pathPrefix(arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
var idbuf: [48]u8 = undefined;
return std.fmt.allocPrint(arena, "{s} {s}", .{ pane.cwdSlice(), exec.replId(&idbuf, pane) });
}
- if (pane.file) |f| {
- const name = if (pane.prompt == .name) pane.input.own orelse f.path else f.path;
- if (panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision)
- return std.fmt.allocPrint(arena, "{s}{s}", .{ name, dirty_marker });
- return name;
- }
+ // Unsaved text shows on the grip, as acme's modbutton does, not as a
+ // mark in the text: the prefix is the name alone.
+ if (pane.file) |f| return if (pane.prompt == .name) pane.input.own orelse f.path else f.path;
return pane.cwdSlice();
}
@@ -207,13 +212,6 @@ pub fn enterTag(p: *Pardes, pane: *Pane) void {
t.cur_row = @intCast(cursor.row);
t.cur_col = @intCast(cursor.col);
t.cur_pinned = true;
- // Reveal the suffix together with its commands, not merely the
- // first command letter at the viewport's far right edge.
- if (p.paneIdOf(pane)) |id| {
- const line = whole[0 .. std.mem.indexOfScalar(u8, whole, '\n') orelse whole.len];
- const width = p.tagCapacity(p.rects[id].w -| TAG_TEXT_INSET);
- pane.tag_scroll = @intCast(panes.File.displayWidth(line) + 1 -| width);
- }
}
// The text may have changed since (a 9P write, a rename), so the
// remembered cursor is pulled back inside it. It is the cursor that
@@ -233,12 +231,83 @@ pub fn enterTag(p: *Pardes, pane: *Pane) void {
pane.focus = .tag;
}
-/// Where display column `col` of the tag's shown row `row` (tag scroll
-/// included) puts its cursor: the tag as shown, prefix and all.
+/// A pane's tag as it is shown: its lines, each wrapped onto more rows at
+/// the tag's width as acme's frame wraps a tag at its right edge (plan9port
+/// acme wind.c:140, wintaglines, counts the frame's lines). `soft` are the
+/// offsets in `text` of the newlines the wrapping put in.
+pub const Shown = struct {
+ text: []u8,
+ soft: []const usize,
+
+ /// The tag's own offset of a shown one.
+ pub fn toReal(s: Shown, off: usize) usize {
+ var n: usize = 0;
+ for (s.soft) |b| {
+ if (b < off) n += 1 else break;
+ }
+ return off - n;
+ }
+
+ /// The shown offset of the tag's own one.
+ pub fn fromReal(s: Shown, real: usize) usize {
+ var off = real;
+ for (s.soft) |b| {
+ if (b <= off) off += 1 else break;
+ }
+ return off;
+ }
+
+ pub fn rows(s: Shown) usize {
+ return modal.cursorLineCount(s.text);
+ }
+};
+
+/// `whole` wrapped at `width` display columns.
+pub fn wrapTag(arena: std.mem.Allocator, whole: []const u8, width: usize) !Shown {
+ var out: std.ArrayList(u8) = .empty;
+ var soft: std.ArrayList(usize) = .empty;
+ const w = @max(1, width);
+ var lines = std.mem.splitScalar(u8, whole, '\n');
+ var first = true;
+ while (lines.next()) |line| {
+ if (!first) try out.append(arena, '\n');
+ first = false;
+ var rest = line;
+ while (panes.File.displayWidth(rest) > w) {
+ var cut = modal.graphemeStart(rest, panes.File.rawAtDisplay(rest, w));
+ if (cut == 0) cut = modal.nextGrapheme(rest, 0);
+ try out.appendSlice(arena, rest[0..cut]);
+ try soft.append(arena, out.items.len);
+ try out.append(arena, '\n');
+ rest = rest[cut..];
+ }
+ try out.appendSlice(arena, rest);
+ }
+ return .{ .text = out.items, .soft = soft.items };
+}
+
+/// The tag's width for wrapping: its columns, one kept for the caret past
+/// a full row's end.
+pub fn tagWidth(p: *const Pardes, id: usize) usize {
+ return @max(1, @as(usize, p.tagCapacity(p.rects[id].w -| TAG_TEXT_INSET)) -| 1);
+}
+
+pub fn shownTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) !Shown {
+ const whole = try tagText(p, arena, pane);
+ const id = p.paneIdOf(pane) orelse return .{ .text = whole, .soft = &.{} };
+ return wrapTag(arena, whole, tagWidth(p, id));
+}
+
+/// Where display column `col` of the tag's shown row `row` puts its
+/// cursor: the tag as shown, prefix and all, wrapped rows included.
pub fn tagOffsetAt(p: *Pardes, pane: *Pane, row: usize, col: usize) ?modal.Cursor {
+ const arena = p.scratch.allocator();
+ const shown = shownTag(p, arena, pane) catch return null;
+ const whole = tagText(p, arena, pane) catch return null;
const at = row + pane.tag_top;
- const line = modal.lineSlice(tagText(p, p.scratch.allocator(), pane) catch return null, at);
- return .{ .row = at, .col = modal.graphemeStart(line, panes.File.rawAtDisplay(line, col)) };
+ const line = modal.lineSlice(shown.text, at);
+ const off = modal.lineStartOffset(shown.text, at) + modal.graphemeStart(line, panes.File.rawAtDisplay(line, col));
+ return modal.positionAt(whole, @min(shown.toReal(off), whole.len));
}
/// Whether display column `col` of the tag's shown row `row` is on a name
@@ -454,3 +523,51 @@ pub fn headerSelection(t: *const Text, text: []const u8, row: usize) ?struct { l
if (hi <= start or lo >= end) return null;
return .{ .lo = @max(lo, start) - start, .hi = @min(hi, end) - start };
}
+
+/// Column and workspace tags are one line, as acme's: a newline typed,
+/// pasted or restored from a dump into one becomes a space, the caret and
+/// the selection kept on the same characters.
+pub fn flattenHeaders(p: *Pardes) void {
+ for (p.col_tags[0..p.ncol]) |*t| flatten(t);
+ flatten(&p.global_tag);
+}
+
+fn flatten(t: *Text) void {
+ const own = t.own orelse return;
+ if (std.mem.indexOfScalar(u8, own, '\n') == null) return;
+ const cur = modal.offsetAt(own, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) });
+ const anchor = modal.offsetAt(own, .{ .row = @intCast(@max(0, t.vsel.row)), .col = @intCast(@max(0, t.vsel.col)) });
+ std.mem.replaceScalar(u8, own, '\n', ' ');
+ t.cur_row = 0;
+ t.cur_col = @intCast(cur);
+ t.vsel.row = 0;
+ t.vsel.col = @intCast(anchor);
+ t.nsel = 0;
+}
+
+test "a column or workspace tag is one line: a newline in it is a space" {
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 50, .rows = 12, .tty_only = true });
+ defer p.deinit();
+ p.col_tags[0].own = try p.gpa.dupe(u8, "New\nSnarf");
+ p.col_tags[0].cur_row = 1;
+ p.col_tags[0].cur_col = 2;
+ p.global_tag.own = try p.gpa.dupe(u8, "Newcol\nExit");
+ p.sync();
+ try std.testing.expectEqualStrings("New Snarf", columnTag(p, 0));
+ try std.testing.expectEqualStrings("Newcol Exit", p.global_tag.own.?);
+ try std.testing.expectEqual(@as(i32, 0), p.col_tags[0].cur_row);
+ try std.testing.expectEqual(@as(i32, 6), p.col_tags[0].cur_col);
+ try std.testing.expectEqual(pardes.COLBAR_H, p.columnBarHeight());
+ try std.testing.expectEqual(pardes.TOPBAR_H, p.topBarHeight());
+}
+
+test "a pane tag wraps a long line onto rows, and offsets map both ways" {
+ var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
+ defer arena_state.deinit();
+ const shown = try wrapTag(arena_state.allocator(), "/a/b.txt Del Snarf Undo Put\nMore", 10);
+ try std.testing.expectEqualStrings("/a/b.txt D\nel Snarf U\nndo Put\nMore", shown.text);
+ try std.testing.expectEqual(@as(usize, 4), shown.rows());
+ try std.testing.expectEqual(@as(usize, 10), shown.toReal(11)); // the 'e' of Del
+ try std.testing.expectEqual(@as(usize, 11), shown.fromReal(10));
+}
+
diff --git a/src/themes/acme.zig b/src/themes/acme.zig
index 693055d6..621ffa23 100644
--- a/src/themes/acme.zig
+++ b/src/themes/acme.zig
@@ -6,20 +6,28 @@
//! in) is a mid grey, and 15 (bright white) is BLACK, because a program asking
//! for the brightest color on a light page means "make this stand out".
//!
-//! The selection is acme's own: plan9's DDarkyellow #eeee9e, the colour acme
-//! sweeps text with, and black ink over it — acme changes the paper under a
-//! selection and never the letters. Out of the same draw.h palette the
-//! scrollbar's #99994c (DYellowgreen) is already taken from.
+//! Every colour below that acme has is acme's own, from plan9port's acme.c
+//! iconinit and draw.h: the tag #eaffff and body #ffffea (each 25% of
+//! DPalebluegreen or DPaleyellow over white), their selections #9eeeee and
+//! #eeee9e with black ink (acme changes the paper under a selection, never
+//! the letters), the exec and look sweeps #aa0000 and #006600 with white ink,
+//! the scrollbar's #99994c track under a #ffffea thumb, 12px wide, the
+//! #8888cc (DPurpleblue) line between a tag and its body and the column
+//! button, and 2px black rules between columns, windows and tags.
+//!
+//! acme has no focus colour; pardes needs one, so the focused pane's button
+//! is filled #8888cc (the column button's look) and every other pane's is
+//! acme's own: #eaffff in a 2px #8888cc ring, #000099 inside it when dirty.
pub const theme = .{
.name = "acme",
.bg = .{ 0xff, 0xff, 0xea },
.fg = .{ 0x00, 0x00, 0x00 },
- .sel_bg = .{ 0xfa, 0xfa, 0x92 },
+ .sel_bg = .{ 0xee, 0xee, 0x9e },
.sel_fg = .{ 0x00, 0x00, 0x00 },
.tag_bg = .{ 0xea, 0xff, 0xff },
.tag_fg = .{ 0x00, 0x00, 0x00 },
- .box = .{ 0x66, 0x3f, 0xe0 },
- .box_dim = .{ 0x35, 0x3a, 0x58 },
+ .box = .{ 0x88, 0x88, 0xcc },
+ .box_dim = .{ 0xea, 0xff, 0xff },
.kw = .{ 0x97, 0x0d, 0xae },
.str = .{ 0x00, 0x77, 0x33 },
.num = .{ 0xa0, 0x52, 0x00 },
@@ -29,6 +37,17 @@ pub const theme = .{
.scroll_thumb = .{ 0xff, 0xff, 0xea },
// acme paints a column with no window white (cols.c:187).
.empty_col = .{ 0xff, 0xff, 0xff },
+ .border = .{ 0x00, 0x00, 0x00 },
+ .tag_rule = .{ 0x88, 0x88, 0xcc },
+ .rule_px = 2,
+ .box_border = .{ 0x88, 0x88, 0xcc },
+ .box_dirty = .{ 0x00, 0x00, 0x99 },
+ .rail_px = 12,
+ .tag_sel_bg = .{ 0x9e, 0xee, 0xee },
+ .sweep_bg = .{ .{ 0xee, 0xee, 0x9e }, .{ 0xaa, 0x00, 0x00 }, .{ 0x00, 0x66, 0x00 } },
+ .sweep_fg = .{ .{ 0x00, 0x00, 0x00 }, .{ 0xff, 0xff, 0xff }, .{ 0xff, 0xff, 0xff } },
+ .column_box = .{ 0x88, 0x88, 0xcc },
+ .column_box_dim = .{ 0x88, 0x88, 0xcc },
.palette = .{
.{ 0x00, 0x00, 0x00 }, .{ 0xaa, 0x22, 0x22 }, .{ 0x22, 0x80, 0x22 }, .{ 0x88, 0x66, 0x00 },
.{ 0x22, 0x44, 0xbb }, .{ 0x99, 0x33, 0x99 }, .{ 0x11, 0x88, 0x88 }, .{ 0x55, 0x55, 0x55 },
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index d17316e8..96aec9a9 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -1050,12 +1050,13 @@ const Shell = struct {
return true;
}
_ = libc.close(pt.file.handle);
- // Unwatched, a command's exit is read here, as its end.
+ // Unwatched, a command's exit is read here, as its end,
+ // and a shell's, for a run waiting on it and the log.
const command = if (core.panes[e.id]) |pn| pn.command != null else false;
- const status = if (command) host_io.exitStatus(pt.pid, 100) else null;
+ const status = host_io.exitStatus(pt.pid, 100);
if (status == null) host_io.retireShell(pt.pid);
s.ptys[e.id] = null;
- if (command) core.update(.{ .exited = .{ .pane = @intCast(e.id), .status = status } });
+ if (command or status != null) core.update(.{ .exited = .{ .pane = @intCast(e.id), .status = status } });
}
core.update(.{ .eof = .{ .pane = @intCast(e.id) } });
},
@@ -1272,7 +1273,7 @@ const Shell = struct {
const s = of(ctx);
// A command whose exit is recorded is reaped: its group may be
// another's by now.
- if (s.ptys[pane]) |pt| if (!pt.cmd.exited) host_io.signalTty(pt.pid, pt.file.handle, sig);
+ if (s.ptys[pane]) |pt| if (!pt.cmd.exited) host_io.signalTty(pt.pid, pt.file.handle, sig, pt.cmd.watched);
}
/// The pane is gone: hang its pty up, which the kernel passes on to the