summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/CHANGELOG.md18
-rw-r--r--src/Messages.zig10
-rw-r--r--src/Mini.zig2
-rw-r--r--src/Output.zig10
-rw-r--r--src/Presentation.zig59
-rw-r--r--src/animation.zig288
-rw-r--r--src/body_layer.zig8
-rw-r--r--src/builtins.zig29
-rw-r--r--src/colors.zig29
-rw-r--r--src/config.zig148
-rw-r--r--src/detached/wire.zig34
-rw-r--r--src/draw.zig572
-rw-r--r--src/dump.zig17
-rw-r--r--src/exec.zig166
-rw-r--r--src/fs-help.txt8
-rw-r--r--src/gui/gui.zig934
-rw-r--r--src/layout.zig238
-rw-r--r--src/look.zig20
-rw-r--r--src/macos.zig4
-rw-r--r--src/mouse.zig14
-rw-r--r--src/ninep/ctl.zig25
-rw-r--r--src/ninep/pane.zig4
-rw-r--r--src/ninep/screen.zig2
-rw-r--r--src/ninep/tree.zig2
-rw-r--r--src/panes.zig18
-rw-r--r--src/pardes.zig539
-rw-r--r--src/pdf_view.zig2
-rw-r--r--src/regexp.zig11
-rw-r--r--src/surface.zig30
-rw-r--r--src/tagline.zig7
-rw-r--r--src/themes/acme.zig35
-rw-r--r--src/web/app.mjs3
32 files changed, 2858 insertions, 428 deletions
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index f8451baf..142287b2 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -2,6 +2,24 @@
## 0.0.3
+- A column can be empty, as in acme. `Newcol` makes an empty column and
+ gives its tag the keyboard; closing a column's last pane, or dragging it
+ away, leaves the column where it was with nothing in it, drawn as its tag
+ over blank space. The `+New` placeholder that used to stand in an emptied
+ column, and the machinery that swapped it out when something else arrived,
+ are gone. Closing the session's last pane now quits.
+- New panes go where acme's makenewwindow puts them: into the active column
+ (the one last typed or clicked in), filling it when it is empty, else under
+ the text of a pane with room to spare, else halving the biggest pane; never
+ into a new column. `Placement pardes` brings back the old rules, which open a
+ first document in a column of its own.
+- A document dragged into the left column no longer closes the untouched
+ boot shell there; `BootShell replace` brings that back.
+- Column tags are always shown: the `ColumnTags` setting is gone, and an
+ init file's `ColumnTags` line is ignored with a message.
+- Carrying a pane or a column by its grip shows acme's box cursor in the SDL
+ shell and a move cursor on the web.
+
- `Del` takes a side. `Del k` and `DelAbove` give the closed pane's rows to
the expanded pane above it, `Del j` and `DelBelow` to the one below. A bare
`Del` from the keyboard on a pane with panes both above and below asks on
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/Mini.zig b/src/Mini.zig
index e118e738..ac9a8e85 100644
--- a/src/Mini.zig
+++ b/src/Mini.zig
@@ -188,7 +188,7 @@ pub fn open(p: *Pardes, id: usize, argument: []const u8) !void {
.syntax_dirty = false,
};
pane.body.cur_pinned = true;
- exec.placeDoc(p, id, free, pane);
+ exec.placeNew(p, id, id, free, .doc);
p.active = free;
}
diff --git a/src/Output.zig b/src/Output.zig
index 201b5491..2f462fd1 100644
--- a/src/Output.zig
+++ b/src/Output.zig
@@ -479,7 +479,7 @@ pub fn fillResults(p: *Pardes, id: usize, dir: []const u8, from: Origin, arg: []
const free = p.freeSlot() orelse return error.NoPaneSlots;
const np = try open(p, free, dir, from, arg, result_content);
np.file.?.location_rows = result_rows;
- exec.placeDoc(p, id, free, np);
+ exec.placeNew(p, id, id, free, .doc);
p.active = id;
if (traits(from).steps) {
pane.search_pane = free;
@@ -558,8 +558,8 @@ fn openStepped(p: *Pardes, id: usize, from: Origin, content: []u8) !void {
const pane = p.panes[id] orelse return error.MissingPane;
const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
const free = p.freeSlot() orelse return error.NoPaneSlots;
- const np = try open(p, free, dir, from, "", content);
- exec.placeDoc(p, id, free, np);
+ _ = try open(p, free, dir, from, "", content);
+ exec.placeNew(p, id, id, free, .doc);
p.active = id;
pane.search_pane = free;
pane.search_row = null;
@@ -777,7 +777,7 @@ fn openRead(p: *Pardes, id: usize, from: Origin, arg: []const u8, content: []u8)
}
const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice();
const free = p.freeSlot() orelse return error.NoPaneSlots;
- const np = try open(p, free, dir, from, arg, content);
- exec.placeDoc(p, id, free, np);
+ _ = try open(p, free, dir, from, arg, content);
+ exec.placeNew(p, id, id, free, if (from == .errors) .errors else .doc);
p.active = free;
}
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/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 413ab7de..6e1fe8f1 100644
--- a/src/body_layer.zig
+++ b/src/body_layer.zig
@@ -155,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)),
@@ -163,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
@@ -311,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 1db010af..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 => 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;
}
@@ -818,11 +820,11 @@ pub const New = struct {
}
};
-/// The same empty scratch, opened in a fresh column beside the calling pane.
+/// An empty column right of the keyboard's, its tag taking the keyboard.
pub const Newcol = struct {
pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
- c.p.newScratchColumn(c.id);
+ c.p.newColumn(c.id);
}
};
@@ -939,7 +941,10 @@ pub const Collapse = struct {
pub const Delcol = struct {
pub fn run(c: Ctx) void {
- c.p.removeColumn(c.id) catch |err| c.p.reportError(c.id, "close column", err);
+ // The column of the tag it came from, else the pane's: a pane ctl's
+ // Delcol closes that pane's column wherever the keyboard is.
+ const col = c.p.exec_column orelse (layout.findPane(c.p, c.id) orelse return).col;
+ c.p.removeColumn(col) catch |err| c.p.reportError(c.id, "close column", err);
}
};
@@ -1051,9 +1056,9 @@ pub const Joincol = struct {
pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
// The keyboard's column joins the one to its right; the last has none.
- const at = layout.findPane(c.p, c.p.active) orelse return;
- if (at.col + 1 >= c.p.ncol) return c.p.reportFailure(c.id, "Joincol: no column to the right");
- layout.joinCol(c.p);
+ const col = c.p.keyboardColumn(c.p.active) orelse return;
+ if (col + 1 >= c.p.ncol) return c.p.reportFailure(c.id, "Joincol: no column to the right");
+ layout.joinCol(c.p, col);
}
};
@@ -1061,8 +1066,8 @@ pub const Tutor = struct {
pub const scope: Scope = .session;
pub fn run(c: Ctx) void {
const free = c.p.freeSlot() orelse return;
- const nt = c.p.openTutorView(free) catch return;
- exec.placeDoc(c.p, c.id, free, nt); // a doc like any other
+ _ = c.p.openTutorView(free) catch return;
+ exec.placeNew(c.p, c.id, c.id, free, .doc); // a doc like any other
}
};
@@ -1223,8 +1228,10 @@ pub const Down = struct {
return tagline.enterHeader(c.p, if (column) |at| at.col else 0);
// Onto the top pane of the column the header stands over.
const col = c.p.header_column orelse if (column) |at| at.col else return;
+ // An empty column's tag has nothing below it.
+ if (col < c.p.ncol and c.p.col_n[col] == 0) return;
tagline.exitHeader(c.p);
- if (col < c.p.ncol and c.p.col_n[col] > 0) c.p.active = c.p.col_panes[col][0];
+ if (col < c.p.ncol) c.p.active = c.p.col_panes[col][0];
return;
}
_ = layout.focusDir(c.p, c.id, .down);
diff --git a/src/colors.zig b/src/colors.zig
index aeff7b00..224591d3 100644
--- a/src/colors.zig
+++ b/src/colors.zig
@@ -54,6 +54,8 @@ pub const Theme = struct {
column_box: ?[3]u8 = null,
column_box_dim: ?[3]u8 = null,
border: ?[3]u8 = null,
+ /// What an empty column shows under its tag; the border fill without it.
+ empty_col: ?[3]u8 = null,
search_bg: ?[3]u8 = null,
search_fg: ?[3]u8 = null,
diagnostic_error: ?[3]u8 = null,
@@ -63,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 {
@@ -108,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;
@@ -144,6 +171,7 @@ pub const ChromeTheme = struct {
tag_name_fg: [3]u8,
tag_active_name_fg: [3]u8,
border: [3]u8,
+ empty_col: [3]u8,
search_bg: [3]u8,
search_fg: [3]u8,
diagnostic_error: [3]u8,
@@ -167,6 +195,7 @@ pub const ChromeTheme = struct {
.tag_name_fg = th.tag_name_fg orelse th.tag_fg,
.tag_active_name_fg = th.tag_active_name_fg orelse (th.tag_name_fg orelse (th.tag_active_fg orelse th.tag_fg)),
.border = th.border orelse th.scroll_track,
+ .empty_col = th.empty_col orelse (th.border orelse th.scroll_track),
.search_bg = th.search_bg orelse th.sel_bg,
.search_fg = th.search_fg orelse th.sel_fg,
.diagnostic_error = th.diagnostic_error orelse (th.fg orelse th.tag_fg),
diff --git a/src/config.zig b/src/config.zig
index 2eaaa505..24c48770 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -73,11 +73,12 @@ pub const leader_path = paths: {
.Newcol = "cn",
.Delcol = "cd",
.Joincol = "cj",
+ .Placement = "cp",
+ .BootShell = null,
.Debug = "td",
.Msg = null,
.Colors = "tc",
.FocusTint = null,
- .ColumnTags = null,
.SyntaxBold = null,
.Verbose = null,
.TreeContextTagStyle = null,
@@ -118,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");
@@ -616,11 +621,28 @@ pub const Runtime = struct {
/// menu bar) turn it off and carry its commands there instead; the core
/// keeps the row as the acme tradition everywhere else.
workspace_tag: bool = true,
- column_tags: bool = true,
syntax_bold: bool = false,
/// 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.
@@ -664,8 +686,29 @@ pub const Runtime = struct {
/// never, while the window has the focus, or always (ghostty's
/// custom-shader-animation).
shader_animation: ShaderAnimation = .on,
+ /// Where a new pane goes (exec.placeNew): `acme`, acme's makenewwindow,
+ /// or `pardes`, the rules pardes had before it.
+ placement: Placement = .acme,
+ /// A document dragged into the left column where a lone shell nobody
+ /// has typed into stands: `keep` leaves the shell, `replace` closes it
+ /// (exec.evictLonePristineTty).
+ boot_shell: BootShell = .keep,
pub const ShaderAnimation = enum { off, on, always };
+ pub const Placement = enum { acme, pardes };
+ pub const BootShell = enum { keep, replace };
+ /// 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 {
@@ -811,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);
@@ -818,7 +863,7 @@ pub const Runtime = struct {
/// The message timings a setting word sets, each in milliseconds.
pub const MessageTime = enum { linger, fall, dissolve };
- pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose, message_animation, ligatures };
+ pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, syntax_bold, verbose, message_animation, ligatures };
/// The bundled post pass, a Shadertoy file in shaders/post/.
pub const Scene = enum { crt };
@@ -832,12 +877,20 @@ 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
/// it of files.
shader,
shader_animation,
+ choice: Choice,
};
pub const Setting = struct {
@@ -861,11 +914,14 @@ pub const Runtime = struct {
.{ .word = "Tagbottom", .action = .{ .toggle = .tag_bottom } },
.{ .word = "Debug", .action = .{ .toggle = .debug } },
.{ .word = "FocusTint", .action = .{ .toggle = .focus_tint } },
- .{ .word = "ColumnTags", .action = .{ .toggle = .column_tags } },
.{ .word = "SyntaxBold", .action = .{ .toggle = .syntax_bold } },
.{ .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 } },
@@ -890,6 +946,8 @@ pub const Runtime = struct {
.{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders },
.{ .word = "Shader", .action = .shader, .availability = .scene_shaders },
.{ .word = "ShaderAnimation", .action = .shader_animation, .availability = .scene_shaders },
+ .{ .word = "Placement", .action = .{ .choice = .placement } },
+ .{ .word = "BootShell", .action = .{ .choice = .boot_shell } },
};
pub fn find(name: []const u8) ?Setting {
@@ -927,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");
@@ -1048,6 +1108,43 @@ pub const Runtime = struct {
const text = std.mem.trim(u8, argument orelse return false, " \t\r\n");
state.shader_animation = std.meta.stringToEnum(ShaderAnimation, text) orelse return false;
},
+ // Bare, it steps to the next word, as a tag word should.
+ .choice => |which| switch (which) {
+ inline else => |field| {
+ const now = &@field(state, @tagName(field));
+ const T = @TypeOf(now.*);
+ const text = std.mem.trim(u8, argument orelse "", " \t\r\n");
+ now.* = if (text.len == 0)
+ @enumFromInt((@as(usize, @intFromEnum(now.*)) + 1) % std.enums.values(T).len)
+ else
+ 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;
@@ -1217,6 +1314,16 @@ pub const Runtime = struct {
try out.print("{s} {s}\n", .{ setting.word, @tagName(state.shader_animation) })
else
try out.print("{s} unsupported\n", .{setting.word}),
+ .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|
@@ -1348,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 = .{};
@@ -1450,6 +1572,7 @@ pub const Runtime = struct {
.window_opacity = true,
.window_blur = true,
.ligatures = true,
+ .lift = true,
},
.state = &state,
};
@@ -1468,6 +1591,14 @@ pub const Runtime = struct {
.dump_dir => "~/dumps",
.shader => "~/crt.glsl",
.shader_animation => "always",
+ .choice => |which| switch (which) {
+ .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));
@@ -1483,7 +1614,6 @@ pub const Runtime = struct {
.tag_bottom => "Tagbottom on\n",
.debug => "Debug on\n",
.focus_tint => "FocusTint off\n",
- .column_tags => "ColumnTags off\n",
.syntax_bold => "SyntaxBold on\n",
.verbose => "Verbose off\n",
.message_animation => "MessageAnimation off\n",
@@ -1518,6 +1648,14 @@ pub const Runtime = struct {
},
.shader => "Shader ~/crt.glsl\n",
.shader_animation => "ShaderAnimation always\n",
+ .choice => |which| switch (which) {
+ .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/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 280b5e90..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;
@@ -163,6 +366,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
// the pointer per-text (the macOS I-beam) read this as "target" and
// afford it the arrow instead.
if (p.look_hover_preview != null) s.pointer_shape = .target;
+ if (p.drag == .move or p.drag == .column_move) s.pointer_shape = .box;
for (&s.body_layers) |*layer| layer.rows = 0;
for (&s.tag_layers) |*layer| layer.rows = 0;
s.nimages = 0;
@@ -183,11 +387,22 @@ 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.
const whole = s.regions[0].rect;
s.fill(whole.x, whole.y, whole.w, whole.h, .{ .bg = .{ .rgb = chrome.border } });
+ for (0..p.ncol) |c| if (p.col_n[c] == 0) s.fill(p.col_x[c], p.topBarHeight() + p.columnBarHeight(), p.col_w[c], s.rows -| (p.topBarHeight() + p.columnBarHeight()), .{ .bg = .{ .rgb = chrome.empty_col } });
for (&p.panes, 0..) |*slot, id| {
const pane = slot.* orelse continue;
@@ -202,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) {
@@ -477,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,
@@ -550,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;
@@ -683,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 };
}
};
@@ -693,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 };
}
}
@@ -939,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 };
}
}
@@ -959,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) {
@@ -968,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);
}
}
@@ -979,14 +1306,14 @@ test "place puts every part where the painters and layers put it, in every layou
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 });
defer p.deinit();
_ = try p.setTestFile("placed\n" ** 80);
- p.newScratchColumn(0);
+ p.newColumn(0);
+ p.newScratchIn(0, p.activeColumn().?);
p.newScratchBelow(0);
p.sync();
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
for (0..16) |step| {
p.settings.tag_bottom = step & 1 != 0;
- p.settings.column_tags = step & 2 != 0;
if (step & 4 != 0) try std.testing.expect(p.executeBuiltinLine(p.active, "Msg a placed notice"));
if (step == 8) try std.testing.expect(p.executeBuiltinLine(1, "Collapse"));
if (step == 12) try std.testing.expect(p.executeBuiltinLine(1, "Collapse"));
@@ -1111,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:
@@ -1126,7 +1668,7 @@ test "a monkey over notices, prompts, tags and tiny panes at a gui's metrics nev
var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
defer arena.deinit();
const words = [_][]const u8{ "error:", "\u{754c}", "x", "Shader /tmp/a.glsl:", "e\u{301}", "\t", "long-" ** 20, " " };
- const commands = [_][]const u8{ "New", "Newcol", "Collapse", "Tagbottom", "ColumnTags", "TreeContext", "Wrap", "Find", "Del", "FocusTint", "Debug" };
+ const commands = [_][]const u8{ "New", "Newcol", "Collapse", "Tagbottom", "TreeContext", "Wrap", "Find", "Del", "FocusTint", "Debug" };
var text: std.ArrayListUnmanaged(u8) = .empty;
defer text.deinit(std.testing.allocator);
for (0..steps) |_| {
diff --git a/src/dump.zig b/src/dump.zig
index 0aaea39d..ef442ddb 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -227,7 +227,8 @@ pub fn validate(state: State) !void {
}
for (state.columns) |col| {
if (!std.math.isFinite(col.weight) or col.weight <= 0) return error.BadDumpColumns;
- if (col.panes.len == 0 or col.panes.len > max_panes) return error.BadDumpColumns;
+ // An empty column is a column, as in acme's dump (rows.c:639).
+ if (col.panes.len > max_panes) return error.BadDumpColumns;
for (col.panes) |pane| {
if (pane >= state.panes.len) return error.BadDumpPaneRef;
}
@@ -649,14 +650,12 @@ pub fn dumpState(p: *Pardes) !void {
column_ids[c][ids_len] = compact;
ids_len += 1;
};
- if (ids_len > 0) {
- columns[columns_len] = .{
- .tag = p.col_tags[c].own,
- .weight = @as(f32, @floatFromInt(p.col_weight[c])) / @as(f32, @floatFromInt(column_weight_unit)),
- .panes = column_ids[c][0..ids_len],
- };
- columns_len += 1;
- }
+ columns[columns_len] = .{
+ .tag = p.col_tags[c].own,
+ .weight = @as(f32, @floatFromInt(p.col_weight[c])) / @as(f32, @floatFromInt(column_weight_unit)),
+ .panes = column_ids[c][0..ids_len],
+ };
+ columns_len += 1;
}
var mounts: [filesystem.max_mounts]Mount = undefined;
diff --git a/src/exec.zig b/src/exec.zig
index 2611162b..56e6a2a9 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -322,18 +322,15 @@ fn spawnTtyWithMount(p: *Pardes, from: usize, v9fs: bool) ?*Pane {
nt.greet = !v9fs;
nt.v9fs_on_spawn = v9fs;
nt.body.mode = .tty;
- const parent = layout.splitParent(p, from);
- const f = layout.findPane(p, parent).?;
- layout.insert(p, f.col, f.idx + 1, free);
- layout.splitBelow(p, parent, nt);
- p.active = free;
- p.stillborn_joiner = nt.serial;
+ placeNew(p, from, from, free, .shell);
return nt;
}
-/// when a doc lands in `col`, a lone pristine shell there is clutter — drop
-/// it; absorbVWeight hands its space to the doc
+/// With `BootShell replace`, a doc landing in `col` closes a lone pristine
+/// shell there, the boot's placeholder, and absorbVWeight hands its space to
+/// the doc. With `keep`, the default, the shell stays like any other pane.
pub fn evictLonePristineTty(p: *Pardes, col: usize, keep_id: usize) void {
+ if (p.settings.boot_shell == .keep) return;
var n_tty: usize = 0;
var tty_id: usize = 0;
for (0..p.col_n[col]) |k| {
@@ -357,29 +354,6 @@ pub fn evictLonePristineTty(p: *Pardes, col: usize, keep_id: usize) void {
if (p.active == tty_id) p.active = keep_id;
}
-/// A pane that just joined a column whose only other pane is stillborn —
-/// an empty scratch nobody typed into, body or tag (what Newcol makes and
-/// what a closed column's last pane leaves behind) — takes the column
-/// whole: the placeholder was only holding the space for it. Run at the
-/// end of a step: the pane that asked for the joiner is often the
-/// placeholder itself, and its caller is still holding it.
-pub fn replaceStillborn(p: *Pardes) void {
- const serial = p.stillborn_joiner orelse return;
- p.stillborn_joiner = null;
- const new_id = p.paneBySerial(serial) orelse return;
- const f = layout.findPane(p, new_id) orelse return;
- if (p.col_n[f.col] != 2) return;
- const old_id = p.col_panes[f.col][1 - f.idx];
- const old = p.panes[old_id] orelse return;
- const file = old.file orelse return;
- const out = file.output orelse return;
- if (out.from != .cmd or out.from.cmd != .New) return;
- if (file.revision != 0 or file.content.len != 0) return;
- if (old.tag.own) |own| if (!std.mem.eql(u8, own, tagline.defaultTail(old))) return;
- layout.compute(p); // the joiner may have no rect yet; absorb snaps to rows
- p.removePane(old_id, new_id) catch |err| return p.reportError(old_id, "close", err);
-}
-
// ---- REPLs ----
//
// A terminal is bound as a language's REPL by `Repl python` (builtins.zig).
@@ -569,7 +543,7 @@ pub fn executeFrom(p: *Pardes, id: usize, txt: []const u8, from_body: bool) ?usi
/// Runs `line` in a command pane for `from`'s directory: `from` itself if
/// it is one whose command is done, else the directory's command pane that
-/// is done, else a new one below the last pane of the rightmost column.
+/// is done, else a new one, placed as placeNew places a command pane.
/// A pane reused keeps what it showed: the new command runs below a line
/// naming it, as acme appends to +Errors and never clears it (util.c:213).
fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize {
@@ -624,13 +598,7 @@ fn runCommand(p: *Pardes, from: usize, line: []const u8) ?usize {
return null;
};
echoCommand(p, pane, line);
- const rc = if (p.ncol > 0) p.ncol - 1 else 0;
- if (p.col_n[rc] > 0) {
- const parent = layout.splitParent(p, p.col_panes[rc][p.col_n[rc] - 1]);
- const f = layout.findPane(p, parent).?;
- layout.splitBelow(p, parent, pane);
- layout.insert(p, f.col, f.idx + 1, free);
- } else layout.insert(p, rc, p.col_n[rc], free);
+ placeNew(p, from, from, free, .command);
noteRun(p, pane, "run", line);
return free;
}
@@ -708,7 +676,24 @@ pub fn applyStartupConfig(p: *Pardes) void {
p.announce = false;
defer p.announce = true;
var lines = std.mem.splitScalar(u8, p.opts.startup_config orelse return, '\n');
- while (lines.next()) |line| _ = executeBuiltinLine(p, p.active, line);
+ while (lines.next()) |line| {
+ if (executeBuiltinLine(p, p.active, line)) continue;
+ // A setting pardes no longer has is said, once, and changes nothing.
+ const word = commandText(line);
+ if (std.mem.eql(u8, word[0 .. std.mem.indexOfAny(u8, word, " \t") orelse word.len], "ColumnTags"))
+ p.setMessage(p.active, "ColumnTags: column tags are always shown now; the init line is ignored");
+ }
+}
+
+test "an init file's retired ColumnTags line is said and ignored" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 });
+ defer p.deinit();
+ p.opts.startup_config = "ColumnTags\nWrap off\n";
+ applyStartupConfig(p);
+ try std.testing.expect(!p.settings.wrap);
+ try std.testing.expect(p.columnBarHeight() > 0);
+ const pane = p.panes[p.active].?;
+ try std.testing.expectEqualStrings("ColumnTags: column tags are always shown now; the init line is ignored", pane.msg[0..pane.msg_len]);
}
pub fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void {
@@ -735,7 +720,6 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]
const previous_context_style = p.settings.tree_context_tag_style;
const previous_transition = p.settings.panel_transition;
const previous_tagline_percent = p.settings.font.tagline_percent;
- const previous_column_tags = p.settings.column_tags;
switch (setting.action) {
.theme => {
const want = std.mem.trim(u8, arg orelse return, " \t\r\n");
@@ -785,20 +769,98 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[]
p.settings.panel_transition != previous_transition;
const tagline_metrics_changed = setting.action == .tagline_size and
p.settings.font.tagline_percent != previous_tagline_percent;
- if (p.settings.column_tags != previous_column_tags) {
- if (p.header_column != null) tagline.exitHeader(p);
- }
// Idle frames keep no frozen grid while transitions are off; the one
// just drawn is the baseline the first transition starts from.
if (transition_changed and previous_transition == .off)
p.presentation.capturePrevious(p.gpa, &p.surface);
- if (transition_changed or tagline_metrics_changed or p.settings.column_tags != previous_column_tags) {
+ if (transition_changed or tagline_metrics_changed) {
p.abandonPanelAnimations();
}
}
-pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void {
- p.stillborn_joiner = nt.serial;
+/// What a new pane is, and who asked: each rule places them differently.
+/// `scratch` is New's, `fs` 9P's pane/new, `errors` a +Errors pane.
+pub const NewPane = enum { scratch, fs, shell, doc, command, errors };
+
+/// Put new pane `id` in the layout: every new pane comes through here, by
+/// the `Placement` setting, and every one but a command pane takes the
+/// keyboard. `from` is the pane that asked, `origin` the text it asked
+/// from: null when a column tag, the workspace tag or 9P asked (acme's
+/// t->w == nil).
+pub fn placeNew(p: *Pardes, from: usize, origin: ?usize, id: usize, kind: NewPane) void {
+ const asked = if (p.exec_column != null) null else origin;
+ switch (p.settings.placement) {
+ .acme => switch (kind) {
+ // New: the bottom half of the last pane in its own column
+ // (look.c:921-923, et->col).
+ .scratch => layout.acmePlace(p, p.exec_column orelse columnOf(p, from), null, id),
+ // +Errors, a command's output: the last column (util.c:94-98).
+ .command, .errors => layout.acmePlace(p, p.ncol - 1, null, id),
+ // makenewwindow(nil) for 9P's new (acme.c:877), else (t).
+ .fs => layout.acmePlace(p, acmeColumn(p, from), null, id),
+ .shell, .doc => layout.acmePlace(p, acmeColumn(p, from), asked, id),
+ },
+ .pardes => pardesPlace(p, from, id, kind),
+ }
+ if (kind == .command) return;
+ tagline.exitHeader(p);
+ p.active = id;
+ if (layout.findPane(p, id)) |f| p.setActiveColumn(f.col);
+}
+
+/// acme's makenewwindow column (util.c:456-467): the column a command came
+/// from (its tag's), else the active column, else the keyboard's pane's,
+/// else the asking pane's.
+fn acmeColumn(p: *Pardes, from: usize) usize {
+ if (p.exec_column) |c| return c;
+ if (p.activeColumn()) |c| return c;
+ if (layout.findPane(p, p.active)) |f| return f.col;
+ return columnOf(p, from);
+}
+
+fn columnOf(p: *Pardes, id: usize) usize {
+ return if (layout.findPane(p, id)) |f| f.col else p.ncol - 1;
+}
+
+/// The placements pardes had before it took acme's: an empty column whose
+/// tag asked, or has the keyboard, is filled; a scratch goes under the
+/// asking pane, a shell under it or the nearest pane with room, a document
+/// beside the last one read or in a column of its own, and a command pane
+/// at the foot of the last column.
+fn pardesPlace(p: *Pardes, from: usize, id: usize, kind: NewPane) void {
+ const empty = if (p.exec_column orelse if (p.header_focus) p.header_column else null) |c|
+ (if (c < p.ncol and p.col_n[c] == 0) c else null)
+ else
+ null;
+ if (empty) |c| {
+ layout.insert(p, c, 0, id);
+ p.panes[id].?.vweight = 1;
+ return;
+ }
+ const nt = p.panes[id].?;
+ switch (kind) {
+ .scratch, .fs => if (layout.findPane(p, from)) |sf| {
+ layout.insert(p, sf.col, sf.idx + 1, id);
+ layout.splitBelow(p, from, nt);
+ },
+ .shell => belowParent(p, from, id),
+ .doc, .errors => pardesDoc(p, from, id),
+ .command => {
+ const rc = p.ncol - 1;
+ if (p.col_n[rc] > 0) belowParent(p, p.col_panes[rc][p.col_n[rc] - 1], id) else layout.insert(p, rc, 0, id);
+ },
+ }
+}
+
+fn belowParent(p: *Pardes, from: usize, id: usize) void {
+ const parent = layout.splitParent(p, from);
+ const f = layout.findPane(p, parent).?;
+ layout.insert(p, f.col, f.idx + 1, id);
+ layout.splitBelow(p, parent, p.panes[id].?);
+}
+
+fn pardesDoc(p: *Pardes, from_id: usize, free: usize) void {
+ const nt = p.panes[free].?;
const doc = if (nt.file) |f| panes.Output.fileTraits(f.output).doc else true; // an image is a doc
var src_id: ?usize = null;
if (p.panes[from_id]) |src| if (if (src.file) |f| panes.Output.fileTraits(f.output).doc else src.image != null or src.hasPdf()) {
@@ -825,22 +887,14 @@ pub fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void {
if (!doc) if (layout.findPane(p, from_id)) |sf| {
layout.insert(p, sf.col, sf.idx + 1, free);
layout.splitBelow(p, from_id, nt); // NOT splitParent: no bystander pays
- p.active = free;
return;
};
if (src_id == null and doc and p.ncol < MAX_COLS and
layout.columnFitsHalves(p, from_id, 100) and layout.splitColumn(p, from_id, free, true))
- {
- p.active = free;
return;
- }
// Without room for a first-document column, split the Look origin
// just like Tty. Never displace (or evict) a top-left bystander.
- const src = layout.splitParent(p, src_id orelse from_id);
- const sf = layout.findPane(p, src).?;
- layout.insert(p, sf.col, sf.idx + 1, free);
- layout.splitBelow(p, src, nt);
- p.active = free;
+ belowParent(p, src_id orelse from_id, free);
}
test "the acme chords act once per selection, not once on the primary" {
diff --git a/src/fs-help.txt b/src/fs-help.txt
index 2b42be1f..0121a831 100644
--- a/src/fs-help.txt
+++ b/src/fs-help.txt
@@ -8,10 +8,10 @@ exec write a line: a middle click, an editor command word or a shell line
log events: new/del/rename/save <serial> <name>, run/exit/send <serial> ..., dump/restore <path>, msg, err
screen rendered screen as JSON, frozen from open to close
listeners the session's dial addresses
-focus the serial of the pane with the keyboard; write a serial to give it the keyboard
-ctl settings, one a line; or Newcol, Dump, Exit (quits, asks once), Kill [w] (stops what pardes ran, w: by first word)
+focus the serial of the pane with the keyboard (empty on a column tag); write a serial to give it the keyboard
+ctl settings, one a line; or Newcol (an empty column), Dump, Exit (quits, asks once), Kill [w] (stops what pardes ran, w: by first word)
commands every builtin: its word, arg if it takes one, and root or pane: which ctl takes it
-pane/new open it to make a pane (named +New); the read answers that pane's serial
+pane/new open it to make a pane (named +New) in the active column, filling it if empty; the read answers its serial
pane/<n>/ name body tag ctl addr dot limit data xdata sel dirty mark scroll errors (write-only:
appended to its directory's +Errors pane) event look exec, pty/ on terminals; rmdir closes
os/, src/ the host filesystem; the editor's own sources (-Dembed-sources=true builds only)
@@ -41,5 +41,5 @@ Pitfalls, one each:
A terminal's body is a history snapshot frozen per open; pty/data is the live stream.
exec: a non-builtin line runs as a command pane (ctl's Shell -c; exit N shown, logged), a shell's typed in.
Through a mount a malformed write is EINVAL, a well-formed one that fails EIO: log's err says why.
- lock/unlock need a held ctl fd (exec 3>ctl), a held lock fails: retry. A column's last pane -> +New.
+ lock/unlock need a held ctl fd (exec 3>ctl), a held lock fails: retry. A column's last pane leaves it empty; the last pane of all quits.
Repl python on a terminal's ctl: a .py body's clicks go to it (Repl - unbinds, Repl says it); tags stay commands.
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 02f7bd9e..5dfacee9 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -73,6 +73,24 @@ const p9_arrow_set = [32]u8{
0x7F, 0xFC, 0x7F, 0xFE, 0x7F, 0xFC, 0x73, 0xF8,
0x61, 0xF0, 0x60, 0xE0, 0x40, 0x40, 0x00, 0x00,
};
+/// acme's boxcursor (acme.c:956), while a grip carries a pane or column.
+const p9_box_clr = [32]u8{
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xF8, 0x1F, 0xF8, 0x1F, 0xF8, 0x1F,
+ 0xF8, 0x1F, 0xF8, 0x1F, 0xF8, 0x1F, 0xFF, 0xFF,
+ 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
+};
+const p9_box_set = [32]u8{
+ 0x00, 0x00, 0x7F, 0xFE, 0x7F, 0xFE, 0x7F, 0xFE,
+ 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E,
+ 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E, 0x70, 0x0E,
+ 0x7F, 0xFE, 0x7F, 0xFE, 0x7F, 0xFE, 0x00, 0x00,
+};
+const p9_box_mask = blk: {
+ var m: [32]u8 = undefined;
+ for (&m, p9_box_set, p9_box_clr) |*b, s, cl| b.* = s | cl;
+ break :blk m;
+};
const p9_arrow_mask = blk: {
var m: [32]u8 = undefined;
for (&m, p9_arrow_set, p9_arrow_clr) |*b, s, cl| b.* = s | cl;
@@ -487,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,
@@ -1847,6 +1867,7 @@ const Gui = struct {
pointer_present: bool = false,
arrow_cursor: ?*c.SDL_Cursor = null,
link_cursor: ?*c.SDL_Cursor = null,
+ box_cursor: ?*c.SDL_Cursor = null,
pointer_shape: pardes.Surface.PointerShape = .arrow,
pointer_mapped: bool = false,
pointer_cell: ?MouseCell = null,
@@ -1855,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,
@@ -2105,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
@@ -2137,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 => {
@@ -2292,6 +2326,9 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
defer if (arrow_cursor) |cursor| c.SDL_DestroyCursor(cursor);
const link_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_POINTER);
defer if (link_cursor) |cursor| c.SDL_DestroyCursor(cursor);
+ // Its hot spot the middle, as acme's {-7, -7} offset puts it.
+ const box_cursor = c.SDL_CreateCursor(&p9_box_set, &p9_box_mask, 16, 16, 7, 7);
+ defer if (box_cursor) |cursor| c.SDL_DestroyCursor(cursor);
if (arrow_cursor) |cur| {
_ = c.SDL_SetCursor(cur);
} else log.err("SDL_CreateCursor: {s}", .{c.SDL_GetError()});
@@ -2427,6 +2464,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
.window = window,
.arrow_cursor = arrow_cursor,
.link_cursor = link_cursor,
+ .box_cursor = box_cursor,
.device = device,
.swapchain_format = swapchain_format,
.pipeline = pipeline,
@@ -2457,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,
@@ -2606,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();
@@ -3892,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();
@@ -4142,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
@@ -5269,7 +5318,11 @@ fn renderFrame(
) !bool {
if (surface.pointer_shape != g.pointer_shape) {
g.pointer_shape = surface.pointer_shape;
- const cursor = if (g.pointer_shape == .link) g.link_cursor else g.arrow_cursor orelse c.SDL_GetDefaultCursor();
+ const cursor = switch (g.pointer_shape) {
+ .link => g.link_cursor,
+ .box => g.box_cursor orelse g.arrow_cursor,
+ else => g.arrow_cursor orelse c.SDL_GetDefaultCursor(),
+ };
if (cursor) |value| _ = c.SDL_SetCursor(value);
}
g.scene_target_failed = false;
@@ -5498,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.?;
@@ -5809,7 +5868,6 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand
var notices = false;
for (0..32) |step| {
core.settings.tag_bottom = step & 1 != 0;
- core.settings.column_tags = step & 2 != 0;
if (step & 3 == 0) try std.testing.expect(core.executeBuiltinLine(2, "Collapse"));
core.leader_on = step & 4 != 0;
if (step & 8 != 0) {
@@ -5867,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);
@@ -6004,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,
@@ -6036,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;
}
@@ -6073,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;
@@ -6760,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 = .{};
@@ -7300,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 {
@@ -7374,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
@@ -7382,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;
@@ -7394,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);
@@ -7403,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.
@@ -7419,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| {
@@ -7428,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;
@@ -7473,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 });
@@ -7541,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/layout.zig b/src/layout.zig
index f0e6fd16..c58f3bcf 100644
--- a/src/layout.zig
+++ b/src/layout.zig
@@ -19,7 +19,9 @@ pub const max_column_weight: u64 = std.math.maxInt(u64) / MAX_COLS;
pub const MovePlacement = struct {
preview_col: usize,
- above_id: usize,
+ /// The pane the moved one lands below; null when the column is empty
+ /// and the moved pane takes it whole.
+ above_id: ?usize,
row: u16,
above_y: u16,
above_h: u16,
@@ -60,6 +62,16 @@ pub fn focusDir(p: *Pardes, from: usize, dir: enum { left, right, up, down }) bo
return true;
}
+/// Column `c`'s serial, handed out the first time it is asked for.
+pub fn columnSerial(p: *Pardes, c: usize) u32 {
+ if (p.col_serial[c] == 0) {
+ p.col_serial[c] = p.next_column_serial;
+ p.next_column_serial +%= 1;
+ if (p.next_column_serial == 0) p.next_column_serial = 1;
+ }
+ return p.col_serial[c];
+}
+
pub fn columnBySerial(p: *const Pardes, serial: u32) ?usize {
if (serial == 0) return null;
for (0..p.ncol) |column| if (p.col_serial[column] == serial) return column;
@@ -178,6 +190,7 @@ pub fn movePlacement(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) ?MovePlaceme
heights[sib] +|= p.rects[id].h;
}
var y: u16 = p.topBarHeight() + p.columnBarHeight();
+ if (p.col_n[tc] == 0) return .{ .preview_col = tc, .above_id = null, .row = y, .above_y = y, .above_h = 0 };
var last: ?MovePlacement = null;
for (0..p.col_n[tc]) |k| {
const pid = p.col_panes[tc][k];
@@ -213,18 +226,23 @@ pub fn movePane(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) void {
}
}
removePane(p, id);
- if (source_multi and src.col < p.ncol and p.col_n[src.col] > 0) {
+ if (source_multi) {
const sib = if (src.idx > 0) p.col_panes[src.col][src.idx - 1] else p.col_panes[src.col][src.idx];
heights[sib] +|= p.rects[id].h;
}
- const af = findPane(p, placement.above_id) orelse return;
+ const above = placement.above_id orelse {
+ insert(p, placement.preview_col, 0, id);
+ if (source_multi) setColumnWeights(p, src.col, &heights);
+ return;
+ };
+ const af = findPane(p, above) orelse return;
const upper_h = @max(1, placement.row -| placement.above_y);
const lower_h = @max(1, placement.above_h -| upper_h);
- heights[placement.above_id] = upper_h;
+ heights[above] = upper_h;
heights[id] = lower_h;
insert(p, af.col, af.idx + 1, id);
setColumnWeights(p, af.col, &heights);
- if (source_multi and src.col < p.ncol and src.col != af.col) setColumnWeights(p, src.col, &heights);
+ if (source_multi and src.col != af.col) setColumnWeights(p, src.col, &heights);
}
pub fn setColumnWeights(p: *Pardes, col: usize, heights: *const [MAX_PANES]u16) void {
@@ -278,39 +296,23 @@ pub fn insert(p: *Pardes, c: usize, idx: usize, id: usize) void {
p.col_n[c] += 1;
}
+/// Take a pane out of its column. The column stays, empty if this was its
+/// last pane, as acme's colclose leaves it (cols.c:186): only Delcol and
+/// Joincol take a column away.
pub fn removePane(p: *Pardes, id: usize) void {
const f = findPane(p, id) orelse return;
const c = f.col;
var k = f.idx;
while (k + 1 < p.col_n[c]) : (k += 1) p.col_panes[c][k] = p.col_panes[c][k + 1];
p.col_n[c] -= 1;
- if (p.col_n[c] == 0) {
- tagline.exitHeader(p);
- if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] +|= p.col_weight[c];
- p.col_tags[c].deinit(p.gpa);
- var j = c;
- while (j + 1 < p.ncol) : (j += 1) {
- p.col_panes[j] = p.col_panes[j + 1];
- p.col_n[j] = p.col_n[j + 1];
- p.col_weight[j] = p.col_weight[j + 1];
- p.col_tags[j] = p.col_tags[j + 1];
- p.col_serial[j] = p.col_serial[j + 1];
- }
- p.ncol -= 1;
- p.col_tags[p.ncol] = .{ .what = .column, .gpa = p.gpa };
- p.col_serial[p.ncol] = 0;
- }
}
-pub fn joinCol(p: *Pardes) void {
- const f = findPane(p, p.active) orelse return;
- const c = f.col;
- if (c + 1 >= p.ncol) return;
+/// Close column `c` once its panes are gone, its width to the one left of
+/// it (the first column's to its right), as acme's rowclose gives it.
+pub fn dropColumn(p: *Pardes, c: usize) void {
+ std.debug.assert(p.col_n[c] == 0);
tagline.exitHeader(p);
- const dst = c + 1;
- p.col_weight[dst] +|= p.col_weight[c];
- for (0..p.col_n[c]) |k| p.col_panes[dst][p.col_n[dst] + k] = p.col_panes[c][k];
- p.col_n[dst] += p.col_n[c];
+ if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] +|= p.col_weight[c];
p.col_tags[c].deinit(p.gpa);
var j = c;
while (j + 1 < p.ncol) : (j += 1) {
@@ -325,42 +327,145 @@ pub fn joinCol(p: *Pardes) void {
p.col_serial[p.ncol] = 0;
}
+/// Fold column `c` into the one on its right, its panes below theirs.
+pub fn joinCol(p: *Pardes, c: usize) void {
+ if (c + 1 >= p.ncol) return;
+ const dst = c + 1;
+ for (0..p.col_n[c]) |k| p.col_panes[dst][p.col_n[dst] + k] = p.col_panes[c][k];
+ p.col_n[dst] += p.col_n[c];
+ p.col_n[c] = 0;
+ // The width goes right, where the panes went.
+ p.col_weight[dst] +|= p.col_weight[c];
+ p.col_weight[c] = 0;
+ dropColumn(p, c);
+}
+
+/// Put new pane `id` in column `c` as acme's makenewwindow does
+/// (util.c:449-495): an empty column it takes whole; asked from a tag or
+/// from 9P (`origin` null, acme's t->w == nil) it takes the bottom half of
+/// the column's last pane (coladd with y < 0, cols.c:62-65). Asked from a
+/// pane's text, it goes under the text of the pane with the most blank rows
+/// when there are many, else it halves the biggest pane, or `origin` when
+/// that is in the column and not much smaller.
+pub fn acmePlace(p: *Pardes, c: usize, origin: ?usize, id: usize) void {
+ compute(p);
+ const n = p.col_n[c];
+ if (n == 0) {
+ insert(p, c, 0, id);
+ p.panes[id].?.vweight = 1;
+ return;
+ }
+ var big: ?usize = null;
+ var empty: ?usize = null;
+ for (p.col_panes[c][0..n]) |k| {
+ const pane = p.panes[k] orelse continue;
+ if (pane.collapsed) continue;
+ // `>=`: at a tie the one nearer the bottom, as acme picks.
+ if (big == null or bodyRows(p, k) >= bodyRows(p, big.?)) big = k;
+ if (empty == null or blankRows(p, k) >= blankRows(p, empty.?)) empty = k;
+ }
+ const v0 = big orelse {
+ // Every pane folded: the new one goes last.
+ insert(p, c, n, id);
+ return;
+ };
+ const y: u16 = y: {
+ const o = origin orelse {
+ var last = v0;
+ for (p.col_panes[c][0..n]) |k| if (!p.panes[k].?.collapsed) {
+ last = k;
+ };
+ break :y bodyTop(p, last) + bodyRows(p, last) / 2;
+ };
+ const e = empty.?;
+ const el = blankRows(p, e);
+ if (el > 15 or (el > 3 and el > (bodyRows(p, v0) -| 1) / 2))
+ break :y bodyTop(p, e) + bodyRows(p, e) - el;
+ const mine = if (findPane(p, o)) |f| f.col == c and !p.panes[o].?.collapsed else false;
+ const v = if (mine and @as(u32, p.rects[o].h) * 3 > @as(u32, p.rects[v0].h) * 2) o else v0;
+ break :y p.rects[v].y + p.rects[v].h / 2;
+ };
+ splitAt(p, c, id, y);
+}
+
+/// acme's coladd at row `y` (cols.c:66-126): the new pane goes under the
+/// pane that `y` falls in, which keeps its rows above `y`.
+fn splitAt(p: *Pardes, c: usize, id: usize, y: u16) void {
+ const n = p.col_n[c];
+ var k: usize = 0;
+ while (k + 1 < n and y >= p.rects[p.col_panes[c][k]].y + p.rects[p.col_panes[c][k]].h) k += 1;
+ const v = p.col_panes[c][k];
+ const r = p.rects[v];
+ snapColWeights(p, c);
+ insert(p, c, k + 1, id);
+ const pane = p.panes[id].?;
+ // Each keeps a tag and a body row, as acme's minht does.
+ const lo = r.y +| BOX_H + 1;
+ const hi = (r.y + r.h) -| (BOX_H + 1);
+ const row = splitRowForExtent(r.y, r.h, if (lo <= hi) std.math.clamp(y, lo, hi) else r.y + r.h / 2) orelse {
+ pane.vweight = BOX_H + 1;
+ return;
+ };
+ // `y` fell in an unfolded pane: acmePlace picks from those.
+ p.panes[v].?.vweight = @floatFromInt(@max(1, row - r.y));
+ pane.vweight = @floatFromInt(@max(1, (r.y + r.h) - row));
+}
+
+fn bodyTop(p: *const Pardes, id: usize) u16 {
+ return p.rects[id].y + p.panes[id].?.tag_rows;
+}
+
+fn bodyRows(p: *const Pardes, id: usize) u16 {
+ return p.rects[id].h -| p.panes[id].?.tag_rows;
+}
+
+/// The rows at the bottom of a pane's body that show nothing: acme's
+/// maxlines - nlines. A picture or a PDF page fills its body.
+fn blankRows(p: *const Pardes, id: usize) u16 {
+ const pane = p.panes[id].?;
+ const rows = bodyRows(p, id);
+ const used: usize = if (pane.file) |*f|
+ // The empty line after a final newline shows nothing.
+ (panes.File.nlines(p.gpa, f) -| @intFromBool(std.mem.endsWith(u8, f.content, "\n"))) -| f.scroll
+ else if (pane.isTerminal())
+ @as(usize, panes.Terminal.gridCursor(pane).y) + 1
+ else
+ rows;
+ return rows -| @as(u16, @intCast(@min(used, rows)));
+}
+
pub fn canSplitColumn(p: *Pardes, source_id: usize) bool {
- if (p.ncol >= MAX_COLS or source_id >= MAX_PANES or p.panes[source_id] == null) return false;
+ if (source_id >= MAX_PANES or p.panes[source_id] == null) return false;
const source = findPane(p, source_id) orelse return false;
+ return canSplitCol(p, source.col);
+}
+
+fn canSplitCol(p: *Pardes, col: usize) bool {
+ if (p.ncol >= MAX_COLS or col >= p.ncol) return false;
// Refresh derived widths: public layout surgery may be chained between
// syncs, and a cached width must never admit a now-too-narrow split.
compute(p);
- if (p.col_w[source.col] < config.MINW * 2) return false;
+ if (p.col_w[col] < config.MINW * 2) return false;
- const weight = p.col_weight[source.col];
+ const weight = p.col_weight[col];
if (weight >= 2 and weight % 2 == 0) return true;
for (0..p.ncol) |column| if (p.col_weight[column] > std.math.maxInt(u64) / 2)
return false;
return weight > 0;
}
-pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool {
- if (id >= MAX_PANES or p.panes[id] == null) return false;
- if (!canSplitColumn(p, source_id)) return false;
- const source = findPane(p, source_id) orelse return false;
- const source_col = source.col;
- var old_weight = p.col_weight[source_col];
- const needs_rebase = old_weight < 2 or old_weight % 2 != 0;
- if (needs_rebase) old_weight *= 2;
- if (id == source_id) {
- if (p.col_n[source_col] <= 1) return false;
- _ = absorbVWeight(p, id, null);
- removePane(p, id);
- } else if (findPane(p, id) != null) return false;
-
- if (needs_rebase) {
+/// An empty column beside column `col`, with half its width: acme's Newcol
+/// (rowadd, rows.c:51). The new column's index, or null with no room.
+pub fn insertColumn(p: *Pardes, col: usize, before: bool) ?usize {
+ if (!canSplitCol(p, col)) return null;
+ var old_weight = p.col_weight[col];
+ if (old_weight < 2 or old_weight % 2 != 0) {
+ old_weight *= 2;
for (0..p.ncol) |column| p.col_weight[column] *= 2;
}
const source_weight = old_weight / 2;
- const new_weight = old_weight - source_weight;
- p.col_weight[source_col] = source_weight;
- const c = source_col + @intFromBool(!before);
+ p.col_weight[col] = source_weight;
+ const c = col + @intFromBool(!before);
tagline.exitHeader(p);
var j = p.ncol;
while (j > c) : (j -= 1) {
@@ -370,12 +475,27 @@ pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool {
p.col_tags[j] = p.col_tags[j - 1];
p.col_serial[j] = p.col_serial[j - 1];
}
- p.col_weight[c] = new_weight;
+ p.col_weight[c] = old_weight - source_weight;
p.col_tags[c] = .{ .what = .column, .gpa = p.gpa };
p.col_serial[c] = 0;
- p.col_panes[c][0] = id;
- p.col_n[c] = 1;
+ p.col_n[c] = 0;
p.ncol += 1;
+ return c;
+}
+
+/// Pane `id` alone in a new column beside `source_id`'s; `id` may be the
+/// source itself when its column keeps another pane.
+pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool {
+ if (id >= MAX_PANES or p.panes[id] == null) return false;
+ if (!canSplitColumn(p, source_id)) return false;
+ const source = findPane(p, source_id) orelse return false;
+ if (id == source_id) {
+ if (p.col_n[source.col] <= 1) return false;
+ _ = absorbVWeight(p, id, null);
+ removePane(p, id);
+ } else if (findPane(p, id) != null) return false;
+ const c = insertColumn(p, source.col, before).?;
+ insert(p, c, 0, id);
return true;
}
@@ -551,11 +671,7 @@ fn computeCollapsedColumn(p: *Pardes, c: usize, x: u16, width: u16, folded: usiz
pub fn compute(p: *Pardes) void {
p.rects = @splat(.{});
if (p.ncol == 0) return;
- for (0..p.ncol) |column| if (p.col_serial[column] == 0) {
- p.col_serial[column] = p.next_column_serial;
- p.next_column_serial +%= 1;
- if (p.next_column_serial == 0) p.next_column_serial = 1;
- };
+ for (0..p.ncol) |column| _ = columnSerial(p, column);
var wsum: u128 = 0;
for (0..p.ncol) |c| wsum += p.col_weight[c];
if (wsum == 0) wsum = 1;
@@ -688,7 +804,6 @@ test "collapse local transfers remain bounded through repeated tiny window resiz
}
for (0..180) |step| {
p.screen_h = @intCast((step * 17) % 81);
- p.settings.column_tags = step % 2 == 0;
p.settings.tag_bottom = step % 3 == 0;
compute(p);
toggleCollapse(p, step % 5);
@@ -743,7 +858,7 @@ test "collapse single and all panes leave unused column space" {
try std.testing.expectEqual(p.screen_h - 1, p.rects[1].y);
}
-test "collapse survives tiny resizes column tags and TagBottom" {
+test "collapse survives tiny resizes and TagBottom" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 });
defer p.deinit();
_ = try p.newShell(1, "");
@@ -753,8 +868,7 @@ test "collapse survives tiny resizes column tags and TagBottom" {
p.panes[1].?.collapsed = true;
for ([_]bool{ false, true }) |bottom| {
p.settings.tag_bottom = bottom;
- for ([_]bool{ false, true }) |column_tags| {
- p.settings.column_tags = column_tags;
+ {
for (0..50) |height| {
p.screen_h = @intCast(height);
compute(p);
diff --git a/src/look.zig b/src/look.zig
index 537a77fa..d90d27b8 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -998,7 +998,8 @@ pub fn focusPaneLine(p: *Pardes, id: usize, at: Spot, landing: enum { center, ke
if (pane.file) |*f| {
if (at.line > panes.File.nlines(p.gpa, f)) return;
if (landing == .center) {
- const next = (at.line - 1) -| pane.rows / 2; // center, clamp at top
+ // center, clamp at top; a text that fits is shown whole
+ const next = if (pane.textFits()) 0 else (at.line - 1) -| pane.rows / 2;
if (next != f.scroll) {
f.scroll = next;
f.syntax_dirty = true;
@@ -1317,20 +1318,15 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
const nt = p.newShell(free, dir) catch |err| return p.reportError(id, "look", err);
nt.greet = true;
- const src = layout.splitParent(p, id);
- const f = layout.findPane(p, src).?;
- layout.insert(p, f.col, f.idx + 1, free);
- layout.splitBelow(p, src, nt);
- p.active = free;
- p.stillborn_joiner = nt.serial;
+ exec.placeNew(p, id, id, free, .shell);
},
.file => |target| {
if (comptime pdf_enabled) if (target.kind == .pdf) {
if (focusPaneByPath(p, target.path, target.at)) return;
const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
- const nt = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err|
+ _ = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err|
return p.reportError(id, "look", err);
- exec.placeDoc(p, id, free, nt);
+ exec.placeNew(p, id, id, free, .doc);
return;
};
// An open pane on this path too short for the line: a miss that
@@ -1346,7 +1342,7 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
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.placeDoc(p, id, free, nt);
+ 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) {
@@ -1361,9 +1357,9 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void {
.image => |target| {
if (focusPaneByPath(p, target.path, .{})) return;
const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots);
- const nt = panes.Image.create(p, free, target.path, &.{}) catch |err|
+ _ = panes.Image.create(p, free, target.path, &.{}) catch |err|
return p.reportError(id, "look", err);
- exec.placeDoc(p, id, free, nt);
+ exec.placeNew(p, id, id, free, .doc);
},
}
}
diff --git a/src/macos.zig b/src/macos.zig
index 2b64784f..678b155d 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -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 beea68df..36786612 100644
--- a/src/mouse.zig
+++ b/src/mouse.zig
@@ -345,6 +345,9 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
wordAtCol(bar, at);
const word = p.scratch.allocator().dupe(u8, selected) catch return;
tagline.exitHeader(p);
+ // A column tag's command acts on that column, empty or not.
+ p.exec_column = column;
+ defer p.exec_column = null;
if (word.len > 0) {
if (m.button == config.exec_button) {
const named = std.meta.stringToEnum(Builtin, word);
@@ -458,6 +461,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void {
const on_tag = mrow >= tag_y and mrow < tag_y + pane.tag_rows;
if (on_tag and mcol < r.x + TAG_TEXT_INSET) return;
p.active = id;
+ p.noteTyped(); // acme's button 1 sets activecol (acme.c:659)
pane.closePrompt();
const pointed = edit.pointerTextSelection(p, id, mcol, mrow, m.body_hit, p.pointer_tag_hit) orelse return;
pane.clearPointerSelection(sel_slot);
@@ -856,13 +860,17 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
layout.applyRowSplit(p, d.col, d.top_idx, d.cur_y),
.column_move => |d| if (layout.columnBySerial(p, d.serial)) |column| switch (layout.columnDrop(p, column, d.cur_x, d.grab)) {
.stay => {},
- .reorder => |target| layout.reorderColumn(p, column, target),
+ .reorder => |target| {
+ layout.reorderColumn(p, column, target);
+ p.setActiveColumn(target); // a grip drag sets activecol (acme.c:640)
+ },
.edge => |x| layout.setColumnPairWidth(p, column - 1, x - p.col_x[column - 1]),
},
.move => |d| {
layout.movePane(p, d.id, d.cur_x, d.cur_y);
- if (p.panes[d.id]) |mp| p.stillborn_joiner = mp.serial;
- // a file moved into the left column evicts a lone unused shell
+ if (layout.findPane(p, d.id)) |f| p.setActiveColumn(f.col);
+ // BootShell replace: a file moved into the left column closes a
+ // lone unused shell there
if (p.panes[d.id]) |mt| if (mt.file != null) {
if (layout.findPane(p, d.id)) |f| if (f.col == 0)
exec.evictLonePristineTty(p, 0, d.id);
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 8ac60ec5..b4144fd2 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -343,6 +343,9 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void {
.tagline_size => try w.print("{s} {d}\n", .{ word, s.font.tagline_percent }),
.window_opacity => try w.print("{s} {d}\n", .{ word, s.window_opacity }),
.window_blur => try w.print("{s} {d}\n", .{ word, s.window_blur }),
+ .choice => |which| switch (which) {
+ inline else => |field| try w.print("{s} {s}\n", .{ word, @tagName(@field(s, @tagName(field))) }),
+ },
.message_ms => |which| try w.print("{s} {d}\n", .{ word, @field(s, "message_" ++ @tagName(which) ++ "_ms") }),
// Bare, it names the default directory.
.dump_dir => if (s.dump_dir.len > 0) try w.print("{s} {s}\n", .{ word, s.dump_dir.get() }) else try w.print("{s}\n", .{word}),
@@ -350,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 ");
@@ -1190,7 +1197,7 @@ test "Joincol with no column to the right and Theme with no such theme say so" {
try testing.expect(std.mem.indexOf(u8, pane.msg[0..pane.msg_len], "no theme \"drak\"") != null);
}
-test "a look that fills a column's stillborn scratch is answered with the scratch already gone" {
+test "a look after Newcol fills the empty column the keyboard is on" {
const gpa = testing.allocator;
const p = try withFile(gpa, "x\n");
defer p.deinit();
@@ -1200,14 +1207,12 @@ test "a look that fills a column's stillborn scratch is answered with the scratc
var dir: [4096]u8 = undefined;
const at = dir[0..try tmp.dir.realPath(testing.io, &dir)];
var line: [4200]u8 = undefined;
- // Newcol makes a column of one empty +New; a file looked from it
- // joins that column and takes the scratch's place.
+ // Newcol makes an empty column and gives its tag the keyboard; no pane
+ // has it, so focus reads empty.
try testing.expectEqual(tree.Status.ok, wr(p, @intFromEnum(tree.TopFile.ctl), "Newcol\n").reply.status);
- const scratch = p.panes[p.active].?.serial;
- try testing.expectEqual(tree.Status.ok, wr(p, Node.of(scratch, .look), try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{at})).reply.status);
- try testing.expect(p.paneBySerial(scratch) == null);
- var want: [32]u8 = undefined;
- const gone = try std.fmt.bufPrint(&want, "{d} ", .{scratch});
- var rows = std.mem.splitScalar(u8, rd(p, @intFromEnum(tree.TopFile.index), 0, 4096).bytes, '\n');
- while (rows.next()) |row| try testing.expect(!std.mem.startsWith(u8, row, gone));
+ try testing.expectEqual(@as(usize, 0), p.col_n[p.ncol - 1]);
+ try testing.expectEqualStrings("", rd(p, @intFromEnum(tree.TopFile.focus), 0, 64).bytes);
+ try testing.expectEqual(tree.Status.ok, wr(p, @intFromEnum(tree.TopFile.look), try std.fmt.bufPrint(&line, "{s}/f.txt\n", .{at})).reply.status);
+ try testing.expectEqual(@as(usize, 1), p.col_n[p.ncol - 1]);
+ try testing.expectEqual(p.active, p.col_panes[p.ncol - 1][0]);
}
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index ad6a6a28..13381066 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -605,11 +605,11 @@ pub fn appendErrors(p: *Pardes, id: usize, text: []const u8) ?usize {
}
const free = p.freeSlot() orelse return null;
const content = p.gpa.dupe(u8, text) catch return null;
- const np = panes.Output.open(p, free, dir, .errors, "", content) catch {
+ _ = panes.Output.open(p, free, dir, .errors, "", content) catch {
p.gpa.free(content);
return null;
};
- exec.placeDoc(p, id, free, np);
+ exec.placeNew(p, id, id, free, .errors);
return text.len;
}
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/tree.zig b/src/ninep/tree.zig
index 779ecff3..ad95f6bf 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -595,7 +595,7 @@ fn open(p: *Pardes, req: Req, target: Target) Reply {
// closing it leaves the pane alone.
.new => {
const slot = p.freeSlot() orelse return Reply.fail(req.tag, E.NFILE);
- p.newScratchBelow(p.active);
+ p.newScratchFor(p.active, .fs);
const made = p.panes[slot] orelse return Reply.fail(req.tag, E.NFILE);
return .{ .tag = req.tag, .handle = made.serial };
},
diff --git a/src/panes.zig b/src/panes.zig
index b812b310..8ca2bb4d 100644
--- a/src/panes.zig
+++ b/src/panes.zig
@@ -522,6 +522,16 @@ pub const Pane = struct {
}
}
+ /// Every line of the file's text fits in the body's rows; the empty
+ /// line after a final newline is no text.
+ pub fn textFits(pane: *Pane) bool {
+ if (pane.file) |*f| {
+ const lines = File.nlines(pane.gpa, f) - @intFromBool(std.mem.endsWith(u8, f.content, "\n"));
+ return lines <= pane.rows;
+ }
+ return false;
+ }
+
pub fn ensureCursorVisible(pane: *Pane) void {
const off = pane.scroll();
var in_context = false;
@@ -549,7 +559,13 @@ pub const Pane = struct {
pane.scrollBy(pane.body.cur_row - margin - off);
} else if (pane.body.cur_row > last - margin) {
var to = pane.body.cur_row + margin;
- if (pane.file) |*f| to = @min(to, @as(i32, @intCast(File.nlines(pane.gpa, f) -| 1)));
+ if (pane.file) |*f| {
+ // The margin only reveals text: a pane its whole text
+ // fits in never scrolls for it. A longer one keeps
+ // helix's clamp at the buffer's last line.
+ const lines: i32 = @intCast(File.nlines(pane.gpa, f));
+ to = @min(to, if (off == 0 and pane.textFits()) @max(pane.body.cur_row, lines - 1 - @intFromBool(std.mem.endsWith(u8, f.content, "\n"))) else lines - 1);
+ }
if (comptime Pdf.enabled) {
if (pane.pdf) |pv|
to = @min(to, @as(i32, @intCast(modal.lineCount(pv.text) -| 1)));
diff --git a/src/pardes.zig b/src/pardes.zig
index 8a6fa589..6294b6cb 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1092,13 +1092,13 @@ test "Joincol folds the active column into its right neighbor, keeping its panes
try std.testing.expectEqual(@as(usize, 2), p.ncol);
p.active = 0; // the left column is current
- layout.joinCol(p);
+ layout.joinCol(p, 0);
try std.testing.expectEqual(@as(usize, 1), p.ncol);
const lf = layout.findPane(p, 0) orelse return error.LostLeftPane;
const rf = layout.findPane(p, right) orelse return error.LostRightPane;
try std.testing.expectEqual(lf.col, rf.col);
- layout.joinCol(p); // no right neighbor left: inert
+ layout.joinCol(p, 0); // no right neighbor left: inert
try std.testing.expectEqual(@as(usize, 1), p.ncol);
}
@@ -1179,6 +1179,8 @@ test "first document fallback splits like Tty from the originating pane" {
defer p.deinit();
const tty = try Pardes.init(std.testing.allocator, .{ .cols = width, .rows = 62, .shells = 3 });
defer tty.deinit();
+ p.settings.placement = .pardes;
+ tty.settings.placement = .pardes;
if (collapsed) {
layout.toggleCollapse(p, from);
layout.toggleCollapse(tty, from);
@@ -1191,8 +1193,8 @@ test "first document fallback splits like Tty from the originating pane" {
// The Look origin need not be the currently active pane.
p.active = (from + 1) % 3;
const free = p.freeSlot().?;
- const doc = try p.openTutorView(free);
- exec.placeDoc(p, from, free, doc);
+ _ = try p.openTutorView(free);
+ exec.placeNew(p, from, from, free, .doc);
_ = exec.spawnTty(tty, from);
p.sync();
tty.sync();
@@ -1215,9 +1217,10 @@ test "first document keeps its originating shell and only opens a column when it
for ([_]u16{ 199, 200 }) |width| {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = width, .rows = 32 });
defer p.deinit();
+ p.settings.placement = .pardes;
const shell = p.panes[0].?;
- const doc = try p.openTutorView(1);
- exec.placeDoc(p, 0, 1, doc);
+ _ = try p.openTutorView(1);
+ exec.placeNew(p, 0, 0, 1, .doc);
p.sync();
try std.testing.expectEqual(shell, p.panes[0].?);
try std.testing.expectEqual(@as(usize, 1), p.active);
@@ -1233,6 +1236,167 @@ test "first document keeps its originating shell and only opens a column when it
}
}
+test "acme placement: an empty active column takes the new pane whole" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 32 });
+ defer p.deinit();
+ try std.testing.expectEqual(config.Runtime.Placement.acme, p.settings.placement);
+ p.newColumn(0); // its tag has the keyboard: it is the active column
+ tagline.exitHeader(p);
+ p.sync();
+ // A Look from the shell's text still lands in the active column, as
+ // acme's makenewwindow puts it in activecol first (util.c:456).
+ _ = try p.openTutorView(1);
+ exec.placeNew(p, 0, 0, 1, .doc);
+ try std.testing.expectEqualSlices(usize, &.{1}, p.col_panes[1][0..p.col_n[1]]);
+ try std.testing.expectEqual(@as(usize, 1), p.active);
+ try std.testing.expectEqual(@as(?usize, 1), p.activeColumn());
+ // Never a new column.
+ try std.testing.expectEqual(@as(usize, 2), p.ncol);
+}
+
+test "acme placement: under a pane's text when it has many blank rows, else halving the biggest" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 60 });
+ defer p.deinit();
+ const top = p.active;
+ _ = try p.setTestFile("one\ntwo\nthree\n");
+ p.sync();
+ p.setActiveColumn(0);
+ // Most of the pane is blank: a document looked from it starts under its
+ // three lines (makenewwindow, util.c:482-486).
+ const second = p.freeSlot().?;
+ _ = try p.openTutorView(second);
+ exec.placeNew(p, top, top, second, .doc);
+ p.sync();
+ const text_end = p.bodyTop(p.panes[top].?, p.rects[top]) + 3;
+ try std.testing.expectEqual(text_end, p.rects[second].y);
+ // Fill both with text: no blank space is big, so the biggest pane is
+ // halved.
+ const full = "x\n" ** 80;
+ for ([_]usize{ top, second }) |id| panes.File.setContent(p, &p.panes[id].?.file.?, try p.gpa.dupe(u8, full));
+ p.sync();
+ const big = if (p.rects[second].h >= p.rects[top].h) second else top;
+ const was = p.rects[big];
+ const third = p.freeSlot().?;
+ _ = try p.openTutorView(third);
+ exec.placeNew(p, second, second, third, .doc);
+ p.sync();
+ try std.testing.expectEqual(big, p.col_panes[0][layout.findPane(p, third).?.idx - 1]);
+ try std.testing.expectEqual(was.y + was.h / 2, p.rects[third].y);
+ try std.testing.expectEqual(was.y + was.h, p.rects[third].y + p.rects[third].h);
+}
+
+test "acme placement: New and 9P's new take the bottom half of the last pane" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 40 });
+ defer p.deinit();
+ p.newScratchBelow(p.active);
+ const last = p.active;
+ p.sync();
+ const was = p.rects[last];
+ p.newScratchFor(last, .fs);
+ const made = p.active;
+ p.sync();
+ try std.testing.expectEqual(layout.findPane(p, last).?.idx + 1, layout.findPane(p, made).?.idx);
+ try std.testing.expectEqual(was.y + was.h, p.rects[made].y + p.rects[made].h);
+ try std.testing.expect(p.rects[made].y > was.y + BOX_H);
+}
+
+test "Placement pardes brings back the old rules, and says so in Config" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 40 });
+ defer p.deinit();
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Placement pardes"));
+ try std.testing.expectEqual(config.Runtime.Placement.pardes, p.settings.placement);
+ const top = p.active;
+ p.sync();
+ const was = p.rects[top];
+ p.newScratchBelow(top);
+ p.sync();
+ // Directly under the asking pane, as New always put it.
+ try std.testing.expectEqual(@as(usize, 1), layout.findPane(p, p.active).?.idx);
+ try std.testing.expectEqual(was.y + was.h, p.rects[p.active].y + p.rects[p.active].h);
+ try std.testing.expect(p.executeBuiltinLine(p.active, "Placement"));
+ try std.testing.expectEqual(config.Runtime.Placement.acme, p.settings.placement);
+}
+
+test "an empty column shows its theme's empty_col under its tag: acme's white, else the border" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 20 });
+ defer p.deinit();
+ p.newColumn(0);
+ tagline.exitHeader(p);
+ inline for (.{ "acme", "dark" }) |name| {
+ try std.testing.expect(p.executeBuiltinLine(0, "Theme " ++ name));
+ p.chrome_animation.displayed = colors.ChromeTheme.fromTheme(p.theme());
+ p.sync();
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const s = try p.render(arena.allocator());
+ const cell = s.at(p.col_x[1] + 3, p.screen_h - 2);
+ const want = p.chromeTheme().empty_col;
+ try std.testing.expectEqual(want, cell.style.bg.rgb);
+ if (std.mem.eql(u8, name, "acme")) try std.testing.expectEqual([3]u8{ 0xff, 0xff, 0xff }, want) else try std.testing.expectEqual(p.chromeTheme().border, want);
+ }
+}
+
+test "BootShell keep leaves the untouched boot shell a dragged document joins; replace closes it" {
+ inline for (.{ "keep", "replace" }) |word| {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ const shell = p.active;
+ try std.testing.expect(p.panes[shell].?.isTerminal());
+ try std.testing.expect(p.executeBuiltinLine(shell, "BootShell " ++ word));
+ _ = try p.openTutorView(1);
+ layout.insert(p, 0, 1, 1);
+ p.sync();
+ exec.evictLonePristineTty(p, 0, 1);
+ p.sync();
+ const kept = std.mem.eql(u8, word, "keep");
+ try std.testing.expectEqual(kept, p.panes[shell] != null);
+ try std.testing.expectEqual(@as(usize, if (kept) 2 else 1), p.col_n[0]);
+ }
+}
+
+test "a pane its whole text fits in never scrolls to keep a margin" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 30 });
+ defer p.deinit();
+ const pane = try p.setTestFile("one\ntwo\nthree\n");
+ // A second pane under it, as acme's placement puts one: right under the
+ // three lines, which leaves the file exactly three body rows.
+ const below = p.freeSlot().?;
+ _ = try p.openTutorView(below);
+ exec.placeNew(p, 0, 0, below, .doc);
+ p.active = 0;
+ p.sync();
+ try std.testing.expectEqual(@as(u16, 3), p.rects[0].h - pane.tag_rows);
+ for ("jj") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } });
+ p.sync();
+ try std.testing.expectEqual(@as(i32, 2), pane.body.cur_row);
+ try std.testing.expectEqual(@as(i32, 0), pane.scroll());
+}
+
+test "dragging a column's only pane to another leaves that column in place, empty" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 32 });
+ defer p.deinit();
+ p.newColumn(0);
+ p.newScratchIn(0, p.activeColumn().?);
+ const moved = p.active;
+ p.sync();
+ const weights = p.col_weight;
+ const tag_serial = p.col_serial[1];
+ layout.movePane(p, moved, p.col_x[0] + 4, p.rects[0].y + p.rects[0].h / 2);
+ p.sync();
+ // acme's coldragwin: colclose then coladd (cols.c:516-517); the column
+ // left behind keeps its place, width and tag.
+ try std.testing.expectEqual(@as(usize, 2), p.ncol);
+ try std.testing.expectEqual(@as(usize, 0), p.col_n[1]);
+ try std.testing.expectEqual(tag_serial, p.col_serial[1]);
+ try std.testing.expectEqualDeep(weights, p.col_weight);
+ try std.testing.expectEqual(@as(usize, 2), p.col_n[0]);
+ // ...and a pane dragged into the empty column takes it whole.
+ layout.movePane(p, moved, p.col_x[1] + 4, p.screen_h / 2);
+ p.sync();
+ try std.testing.expectEqualSlices(usize, &.{moved}, p.col_panes[1][0..p.col_n[1]]);
+ try std.testing.expectEqual(@as(usize, 1), p.col_n[0]);
+}
+
test "pane defaults keep creation of scratch panes in column tags" {
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true });
defer p.deinit();
@@ -3044,7 +3208,7 @@ test "column tags follow their column through split and close" {
try std.testing.expectEqualStrings("Left Find", tagline.columnTag(p, 0));
p.col_tags[1].own = try p.gpa.dupe(u8, "Right Grep");
p.active = p.col_panes[0][0];
- layout.joinCol(p);
+ layout.joinCol(p, 0);
try std.testing.expectEqual(@as(usize, 1), p.ncol);
try std.testing.expectEqualStrings("Right Grep", tagline.columnTag(p, 0));
}
@@ -3053,7 +3217,8 @@ test "column tags render edit hover and scroll from the column edge" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true });
defer p.deinit();
_ = try p.setTestFile("untouched\n");
- p.newScratchColumn(0);
+ p.newColumn(0);
+ p.newScratchIn(0, p.activeColumn().?);
p.sync();
p.acknowledgePanelPresentation(&.{});
var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
@@ -3109,7 +3274,8 @@ test "column tag first-cell execution keeps New local to the clicked column" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true });
defer p.deinit();
_ = try p.setTestFile("untouched\n");
- p.newScratchColumn(0);
+ p.newColumn(0);
+ p.newScratchIn(0, p.activeColumn().?);
p.sync();
for (0..2) |column| {
p.active = p.col_panes[1 - column][0];
@@ -3169,7 +3335,7 @@ test "tiny screens keep pane space before adding a column tag" {
_ = try p.render(p.scratch.allocator());
}
-test "ColumnTags reclaims the row and releases hidden header focus without losing commands" {
+test "a screen too short for the column row releases a column tag's focus without losing its commands" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12, .tty_only = true });
defer p.deinit();
const pane = try p.setTestFile("unchanged body\n");
@@ -3181,24 +3347,7 @@ test "ColumnTags reclaims the row and releases hidden header focus without losin
p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET, .row = TOPBAR_H } });
try std.testing.expect(p.header_focus and p.header_column != null);
- p.update(.{ .command = "ColumnTags" });
- try std.testing.expect(!p.settings.column_tags);
- try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight());
- try std.testing.expectEqual(TOPBAR_H, p.rects[0].y);
- try std.testing.expectEqual(shown.h + COLBAR_H, p.rects[0].h);
- try std.testing.expect(!p.header_focus and p.header_column == null);
- try std.testing.expect(!p.header_drag);
- try std.testing.expectEqualStrings("New Help", tagline.columnTag(p, 0));
-
- p.update(.{ .command = "ColumnTags" });
- try std.testing.expect(p.settings.column_tags);
- try std.testing.expectEqual(shown, p.rects[0]);
- p.acknowledgePanelPresentation(&.{});
- p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = TAG_TEXT_INSET, .row = TOPBAR_H } });
- p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET, .row = TOPBAR_H } });
- try std.testing.expect(p.header_column != null);
p.update(.{ .resize = .{ .cols = 60, .rows = 2 } });
- try std.testing.expect(p.settings.column_tags);
try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight());
try std.testing.expectEqual(TOPBAR_H, p.rects[0].y);
try std.testing.expect(!p.header_focus and p.header_column == null);
@@ -3248,15 +3397,17 @@ test "Enter in a header runs the word under its cursor, as the execute key does"
p.global_tag.cur_col = @intCast(std.mem.indexOf(u8, config.topbar_str, "Newcol").?);
p.update(.{ .key = .{ .cp = Key.enter } });
try std.testing.expectEqual(@as(usize, 2), p.ncol);
- // and the header lets go of the keyboard, as a pane tag does
- try std.testing.expect(!p.header_focus);
+ // and the workspace tag lets go of the keyboard: the new, empty
+ // column's tag has it
+ try std.testing.expect(p.header_focus and p.header_column.? == 1);
}
test "window keys walk from a pane up through the column and workspace tags and back" {
const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 20, .tty_only = true });
defer p.deinit();
_ = try p.setTestFile("body\n");
- p.newScratchColumn(0);
+ p.newColumn(0);
+ p.newScratchIn(0, p.activeColumn().?);
p.sync();
p.active = p.col_panes[0][0];
const window = struct {
@@ -3543,6 +3694,7 @@ test "Newcol refuses an unsplittable restored weight before spawning" {
const pane = p.panes[id].?;
try p.deinitPane(pane);
layout.removePane(p, id);
+ layout.dropColumn(p, id);
p.panes[id] = null;
}
while (p.nextEffect()) |_| {}
@@ -3561,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));
@@ -3624,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;
@@ -3647,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;
@@ -4079,9 +4237,15 @@ pub const Pardes = struct {
look_walk_owner: ?u32 = null,
/// hands out Pane.serial; monotonic, never reused
next_serial: u32 = 0,
- /// The pane (by serial) that joined a column this step; `replaceStillborn`
- /// checks it once the step is over and nothing holds the column's panes.
- stillborn_joiner: ?u32 = null,
+ /// The column whose tag the running command came from, while it runs:
+ /// the column acme's commands take from their Text's col.
+ exec_column: ?usize = null,
+ /// acme's activecol (dat.c:37), by column serial, 0 for none: the
+ /// column last typed in (acme.c:487), clicked in with the select button
+ /// (acme.c:659), dropped into by a grip (acme.c:640), or given a new
+ /// pane (util.c:467). Where the acme placement puts new panes
+ /// (exec.placeNew). A Look click moves the keyboard but not this.
+ active_column: u32 = 0,
settings: config.Runtime = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } },
locations_config: locations_config.Config = .{},
locations_cache: @import("locations_cache.zig").Cache = .{},
@@ -4092,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
@@ -4334,10 +4502,12 @@ pub const Pardes = struct {
p.panes[0].?.body.mode = .tty;
p.oneColumn();
// A bare tty is still an editor: it opens with somewhere to put
- // text under the shell. The shell keeps the keyboard.
+ // text under the shell. The shell keeps the keyboard. A boot
+ // layout, like .classic's, not a placement.
if (p.boot == .tty) {
- p.newScratchBelow(0);
- p.active = 0;
+ const np = try p.newScratch(0, 1);
+ layout.insert(p, 0, 1, 1);
+ layout.splitBelow(p, 0, np);
}
},
.classic => {
@@ -4457,7 +4627,7 @@ pub const Pardes = struct {
/// 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.settings.column_tags or p.screen_h < 3) return 0;
+ if (p.screen_h < 3) return 0;
return COLBAR_H;
}
@@ -4593,34 +4763,56 @@ pub const Pardes = struct {
return np;
}
- /// The empty pane that stands in for one that closed, keeping its column
- /// alive and its directory. Unlike `newScratch` it inherits nothing: the
- /// pane it replaces is on its way out.
- fn newScratchIn(p: *Pardes, free: usize, dir: []const u8) !*Pane {
- const content = try p.gpa.dupe(u8, "");
- errdefer p.gpa.free(content);
- const np = try panes.Output.open(p, free, dir, .{ .cmd = .New }, "", content);
- np.body.cur_pinned = true;
- return np;
- }
-
- /// New: a scratch below the calling pane, in its column.
+ /// New: an empty scratch in `from_id`'s directory, placed as exec.placeNew
+ /// places it.
pub fn newScratchBelow(p: *Pardes, from_id: usize) void {
- const free = p.freeSlot() orelse return;
- const sf = layout.findPane(p, from_id) orelse return;
- const np = p.newScratch(from_id, free) catch return;
- layout.insert(p, sf.col, sf.idx + 1, free);
- layout.splitBelow(p, from_id, np);
- p.active = free;
+ p.newScratchFor(from_id, .scratch);
}
- /// Newcol: a scratch in a fresh column beside the calling pane.
- pub fn newScratchColumn(p: *Pardes, from_id: usize) void {
+ /// A scratch for New (`.scratch`) or 9P's pane/new (`.fs`).
+ pub fn newScratchFor(p: *Pardes, from_id: usize, kind: exec.NewPane) void {
const free = p.freeSlot() orelse return;
- if (!layout.canSplitColumn(p, from_id)) return;
_ = p.newScratch(from_id, free) catch return;
- std.debug.assert(layout.splitColumn(p, from_id, free, false));
- p.active = free;
+ exec.placeNew(p, from_id, from_id, free, kind);
+ }
+
+ /// Newcol: an empty column right of the calling pane's, the keyboard
+ /// on its tag, for the next pane made to fill.
+ pub fn newColumn(p: *Pardes, from_id: usize) void {
+ const col = p.keyboardColumn(from_id) orelse return;
+ const c = layout.insertColumn(p, col, false) orelse return;
+ tagline.enterHeader(p, c);
+ }
+
+ pub fn setActiveColumn(p: *Pardes, c: usize) void {
+ if (c < p.ncol) p.active_column = layout.columnSerial(p, c);
+ }
+
+ /// The active column, while it is still there.
+ pub fn activeColumn(p: *const Pardes) ?usize {
+ return layout.columnBySerial(p, p.active_column);
+ }
+
+ /// The column the keyboard is in: its tag's, or its pane's.
+ pub fn noteTyped(p: *Pardes) void {
+ if (p.header_focus) {
+ if (p.header_column) |c| p.setActiveColumn(c);
+ } else if (layout.findPane(p, p.active)) |f| p.setActiveColumn(f.col);
+ }
+
+ /// New, run from column `c`'s tag.
+ pub fn newScratchIn(p: *Pardes, from_id: usize, c: usize) void {
+ p.exec_column = c;
+ defer p.exec_column = null;
+ p.newScratchBelow(from_id);
+ }
+
+ /// The column a command acts on: the one whose tag it came from, else
+ /// the one whose tag has the keyboard, else `id`'s.
+ pub fn keyboardColumn(p: *Pardes, id: usize) ?usize {
+ if (p.exec_column) |c| return c;
+ if (p.header_focus) if (p.header_column) |c| return c;
+ return if (layout.findPane(p, id)) |f| f.col else null;
}
pub fn freeSlot(p: *Pardes) ?usize {
@@ -5029,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)));
@@ -5048,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);
@@ -5072,10 +5266,6 @@ pub const Pardes = struct {
look.cancelLookHover(p);
const reply = ctlfs.handle(p, req);
if (reply.status == .err and (req.op == .write or req.op == .setattr)) ctlfs.events.noteError(p, req, reply);
- // A pane that joined a column holding only a stillborn scratch takes
- // it now, before the answer: one read of index after the look shows
- // the replacement done, and the log has its del with the new.
- exec.replaceStillborn(p);
// The request was a whole step of its own, so it settles the way a
// step does: the cursor and scroll reconciled, the scripted panes
// told, and the panes it made announced to /log now rather than at
@@ -5087,7 +5277,6 @@ pub const Pardes = struct {
}
pub fn update(p: *Pardes, ev: Event) void {
- defer exec.replaceStillborn(p);
// A tick with nothing animating is the one event that cannot change
// the screen.
p.needs_frame = p.needs_frame or ev != .tick;
@@ -5358,6 +5547,7 @@ pub const Pardes = struct {
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) {
@@ -5412,16 +5602,11 @@ pub const Pardes = struct {
// global window ops (any mode): Alt-n new terminal below, Alt-c move
// the active terminal into a fresh column
if (hit(key, config.new_shell_below)) {
- tagline.exitHeader(p);
const free = p.freeSlot() orelse return;
const nt = p.newShell(free, "") catch return;
nt.greet = true;
- const src = layout.splitParent(p, p.active);
- const f = layout.findPane(p, src).?;
- layout.insert(p, f.col, f.idx + 1, free);
- layout.splitBelow(p, src, nt);
- p.active = free;
- p.stillborn_joiner = nt.serial;
+ // From a tag, as a 9P new is: acme's t->w == nil.
+ exec.placeNew(p, p.active, if (p.header_focus) null else p.active, free, .shell);
return;
}
for (config.jump_keys) |jk| {
@@ -5500,6 +5685,9 @@ pub const Pardes = struct {
const b = config.wordBounds(shown, modal.selectionCursor(shown, range));
break :word shown[b.lo..b.hi];
};
+ // A column tag's command acts on that column, empty or not.
+ p.exec_column = if (header != null) p.header_column else null;
+ defer p.exec_column = null;
// Out of the tag or header FIRST: Del frees the pane, Kill
// and Restore the whole session.
if (header != null) tagline.exitHeader(p) else pane.focus = .body;
@@ -5964,59 +6152,38 @@ pub const Pardes = struct {
// ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ----
/// Close a pane; its rows go to `to`, or where layout.absorbVWeight
- /// sends them when that is null. Focus on it follows its rows: to the
- /// pane that grew into them, or to the scratch left standing in an
- /// emptied column. Every route that closes a pane comes through here or
- /// removeColumn, so none picks focus for itself, and none by recency.
+ /// sends them when that is null. Focus on it follows its rows to the
+ /// pane that grew into them. A column outlives its panes, as acme's
+ /// does (cols.c:186): closing its last one leaves it empty, the keyboard
+ /// on its tag; only Delcol takes a column away. The session's last pane
+ /// closing ends the session. Every route that closes a pane comes
+ /// through here or removeColumn, so none picks focus for itself, and
+ /// none by recency.
pub fn removePane(p: *Pardes, id: usize, to: ?usize) !void {
const pane = p.panes[id] orelse return;
- // A column outlives its panes. Closing the last one leaves an empty
- // pane standing in its place and in its directory; only Delcol ever
- // takes a column away.
const place = layout.findPane(p, id);
- const last_in_column = if (place) |f| p.col_n[f.col] == 1 else false;
- var dir_buf: [limits.host_path_cap]u8 = undefined;
- var dir: []const u8 = "";
- if (last_in_column) {
- const had = paneDir(pane);
- const n = @min(had.len, dir_buf.len);
- @memcpy(dir_buf[0..n], had[0..n]);
- dir = dir_buf[0..n];
- }
+ const emptied = if (place) |f| (if (p.col_n[f.col] == 1) f.col else null) else null;
// Before anything is torn down, so a failure here changes nothing.
try p.deinitPane(pane);
- // ...and after it, so the stand-in inherits none of the closing pane.
- const heir: ?usize = heir: {
- if (!last_in_column) break :heir null;
- const f = place.?;
- const free = p.freeSlot() orelse break :heir null;
- _ = p.newScratchIn(free, dir) catch break :heir null;
- layout.insert(p, f.col, f.idx + 1, free);
- break :heir free;
- };
const gainer = layout.absorbVWeight(p, id, to);
- const beside = if (last_in_column) p.besideColumn(place.?.col, id) else null;
+ const beside = if (emptied) |c| p.besideColumn(c, p.rects[id]) else null;
layout.removePane(p, id);
p.panes[id] = null;
- if (p.active == id) p.active = heir orelse gainer orelse beside orelse p.anyPane() orelse {
- p.quit = true;
- p.emit(.quit);
- return;
- };
+ if (p.active != id) return;
+ p.active = gainer orelse beside orelse p.anyPane() orelse return p.quitEmpty();
+ if (emptied) |c| if (!p.header_focus) tagline.enterHeader(p, c);
}
- pub fn removeColumn(p: *Pardes, id: usize) !void {
- const place = layout.findPane(p, id) orelse return;
- const had_focus = layout.findPane(p, p.active);
- const beside = if (had_focus != null and had_focus.?.col == place.col)
- p.besideColumn(place.col, p.active)
- else
- null;
+ /// Delcol: close column `col` and every pane in it.
+ pub fn removeColumn(p: *Pardes, col: usize) !void {
+ if (col >= p.ncol) return;
+ const had_focus = p.keyboardColumn(p.active) == col;
+ const beside = if (had_focus) p.besideColumn(col, if (p.header_focus) null else p.rects[p.active]) else null;
var ids: [MAX_PANES]usize = undefined;
var parents: [MAX_PANES]*Pane = undefined;
- const count = p.col_n[place.col];
+ const count = p.col_n[col];
for (0..count) |i| {
- ids[i] = p.col_panes[place.col][i];
+ ids[i] = p.col_panes[col][i];
parents[i] = p.panes[ids[i]].?;
}
try p.detachCwds(parents[0..count]);
@@ -6025,11 +6192,14 @@ pub const Pardes = struct {
layout.removePane(p, closed);
p.panes[closed] = null;
}
- if (p.panes[p.active] == null) p.active = beside orelse p.anyPane() orelse {
- p.quit = true;
- p.emit(.quit);
- return;
- };
+ layout.dropColumn(p, col);
+ if (p.panes[p.active] == null) p.active = beside orelse p.anyPane() orelse return p.quitEmpty();
+ }
+
+ /// No pane is left: the session is over.
+ fn quitEmpty(p: *Pardes) void {
+ p.quit = true;
+ p.emit(.quit);
}
/// The pane Last should fall back to when the jumplist has nothing: the
@@ -6048,14 +6218,13 @@ pub const Pardes = struct {
return null;
}
- /// The pane that takes over `id`'s place when column `col` goes: in the
- /// column layout.removePane gives the width to, the one level with the
- /// middle of `id`. Asked before the column goes, while rects still hold.
- fn besideColumn(p: *const Pardes, col: usize, id: usize) ?usize {
+ /// The pane that takes over the keyboard from column `col`: in the
+ /// column layout.dropColumn gives the width to, the one level with the
+ /// middle of `r` (of the screen without one). Asked while rects hold.
+ fn besideColumn(p: *const Pardes, col: usize, r: ?Rect) ?usize {
if (p.ncol < 2) return null;
const next = if (col > 0) col - 1 else col + 1;
- const r = p.rects[id];
- const mid = r.y + r.h / 2;
+ const mid = if (r) |at| at.y + at.h / 2 else p.screen_h / 2;
var pick: ?usize = null;
for (p.col_panes[next][0..p.col_n[next]]) |k| {
if (p.panes[k] == null) continue;
@@ -6272,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
@@ -6686,43 +6864,67 @@ test "a prompt is typed on its own line in the notice band and leaves the tag ro
try std.testing.expect(pane.prompt == .none and pane.input.own == null);
}
-test "a stillborn pane gives its column to the first pane that joins it" {
+test "Newcol makes an empty column, and the next pane made from its tag fills it" {
if (platform == .web) return;
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 40 });
defer p.deinit();
- try std.testing.expect(p.executeBuiltinLine(0, "Newcol"));
- const blank = p.active;
- p.sync(); // Tty splits by the rects a frame has laid out
- try std.testing.expect(p.executeBuiltinLine(blank, "Tty"));
+ const first = p.active;
+ const panes_before = p.col_n[0];
+ try std.testing.expect(p.executeBuiltinLine(first, "Newcol"));
+ try std.testing.expectEqual(@as(usize, 2), p.ncol);
+ try std.testing.expectEqual(@as(usize, 0), p.col_n[1]);
+ try std.testing.expectEqual(panes_before, p.col_n[0]);
+ // The keyboard is on the new column's tag; no pane has it.
+ try std.testing.expect(p.header_focus and p.header_column.? == 1);
+ try std.testing.expectEqual(first, p.active);
+ p.sync();
+ try std.testing.expect(p.executeBuiltinLine(first, "Tty"));
const shell = p.active;
- p.update(.tick); // the step ends: the placeholder goes
- try std.testing.expect(p.panes[blank] == null);
- try std.testing.expectEqual(@as(usize, 1), p.col_n[layout.findPane(p, shell).?.col]);
- try std.testing.expectEqual(shell, p.active);
+ try std.testing.expect(shell != first);
+ try std.testing.expectEqual(@as(usize, 1), p.col_n[1]);
+ try std.testing.expectEqual(shell, p.col_panes[1][0]);
+ try std.testing.expect(!p.header_focus);
- // Typed into, body or tag, it is a pane like any other.
- try std.testing.expect(p.executeBuiltinLine(shell, "Newcol"));
- const typed = p.active;
+ // The next Tty splits as it always has: nothing is empty any more.
p.sync();
- for ("ix") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } });
- try std.testing.expect(p.executeBuiltinLine(typed, "Tty"));
- p.update(.tick);
- try std.testing.expect(p.panes[typed] != null);
- try std.testing.expectEqual(@as(usize, 2), p.col_n[layout.findPane(p, typed).?.col]);
+ try std.testing.expect(p.executeBuiltinLine(shell, "Tty"));
+ try std.testing.expectEqual(@as(usize, 2), p.col_n[1]);
+}
- try std.testing.expect(p.executeBuiltinLine(shell, "Newcol"));
- const tagged = p.active;
+test "a column tag's command acts on that column" {
+ if (platform == .web) return;
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 40 });
+ defer p.deinit();
+ const first = p.active;
+ try std.testing.expect(p.executeBuiltinLine(first, "Newcol"));
+ try std.testing.expect(p.executeBuiltinLine(first, "Newcol"));
+ try std.testing.expectEqual(@as(usize, 3), p.ncol);
p.sync();
- try p.panes[tagged].?.appendTag(" make");
- try std.testing.expect(p.executeBuiltinLine(tagged, "Tty"));
- p.update(.tick);
- try std.testing.expect(p.panes[tagged] != null);
+ // New from the middle column's tag lands in it, not below `first`.
+ p.exec_column = 1;
+ try std.testing.expect(p.executeBuiltinLine(first, "New"));
+ p.exec_column = null;
+ try std.testing.expectEqual(@as(usize, 1), p.col_n[1]);
+ try std.testing.expectEqual(p.active, p.col_panes[1][0]);
+ // Delcol from the empty right column's tag closes that one.
+ p.exec_column = 2;
+ try std.testing.expect(p.executeBuiltinLine(first, "Delcol"));
+ p.exec_column = null;
+ try std.testing.expectEqual(@as(usize, 2), p.ncol);
+ try std.testing.expectEqual(@as(usize, 1), p.col_n[1]);
+ // A pane's own Delcol closes its column whatever tag has the keyboard.
+ try std.testing.expect(p.executeBuiltinLine(first, "Newcol"));
+ try std.testing.expect(p.header_focus);
+ try std.testing.expect(p.executeBuiltinLine(p.col_panes[1][0], "Delcol"));
+ try std.testing.expectEqual(@as(usize, 2), p.ncol);
+ try std.testing.expectEqual(@as(usize, 0), p.col_n[1]);
}
test "Del from a key asks which neighbour takes the rows; nothing else asks" {
if (platform == .web) return;
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 40 });
defer p.deinit();
+ p.settings.placement = .pardes; // the neighbours below are what is under test
var frame = std.heap.ArenaAllocator.init(std.testing.allocator);
defer frame.deinit();
const top = p.active;
@@ -6841,7 +7043,8 @@ test "focus on a closed pane goes to the pane that takes its place, not the one
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 40 });
defer p.deinit();
const shell = p.active;
- p.newScratchColumn(shell);
+ p.newColumn(shell);
+ p.newScratchIn(shell, p.activeColumn().?);
const right = p.active;
p.sync();
p.newScratchBelow(shell);
@@ -6873,45 +7076,50 @@ test "focus on a closed pane goes to the pane that takes its place, not the one
p.sync();
p.active = left_low;
p.sync();
- try p.removeColumn(left_low);
+ try p.removeColumn(layout.findPane(p, left_low).?.col);
p.sync();
try std.testing.expectEqual(right_low, p.active);
}
-test "closing the last pane in a column leaves an empty one in its place" {
+test "closing the last pane in a column leaves the column empty, its tag focused" {
if (platform == .web) return;
const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 24 });
defer p.deinit();
const shell = p.active;
- p.newScratchColumn(shell);
+ p.newColumn(shell);
+ p.newScratchIn(shell, p.activeColumn().?);
const lone = p.active;
try std.testing.expect(lone != shell);
try std.testing.expectEqual(@as(usize, 2), p.ncol);
const at = layout.findPane(p, lone).?;
try std.testing.expectEqual(@as(usize, 1), p.col_n[at.col]);
- const had = try std.testing.allocator.dupe(u8, p.panes[lone].?.cwdSlice());
- defer std.testing.allocator.free(had);
+ p.sync();
try p.removePane(lone, null);
p.sync();
- // The column outlives the pane: still two columns, and the one that was
- // emptied holds a fresh empty pane in the same directory.
+ // The column outlives the pane, as acme's does: still two columns, the
+ // emptied one with no pane and the keyboard on its tag.
try std.testing.expectEqual(@as(usize, 2), p.ncol);
- try std.testing.expectEqual(@as(usize, 1), p.col_n[at.col]);
- const heir = p.col_panes[at.col][0];
- try std.testing.expect(heir != lone);
+ try std.testing.expectEqual(@as(usize, 0), p.col_n[at.col]);
try std.testing.expect(p.panes[lone] == null);
- const hp = p.panes[heir].?;
- try std.testing.expectEqualStrings("", hp.file.?.content);
- try std.testing.expectEqualStrings(had, hp.cwdSlice());
- try std.testing.expectEqual(heir, p.active);
+ try std.testing.expect(p.header_focus and p.header_column.? == at.col);
+ try std.testing.expectEqual(shell, p.active);
try std.testing.expect(!p.quit);
+ // Down from it goes nowhere: nothing is below.
+ try std.testing.expect(p.executeBuiltinLine(shell, "Down"));
+ try std.testing.expect(p.header_focus and p.header_column.? == at.col);
+
// ...and Delcol is what actually takes the column away.
- try p.removeColumn(heir);
+ try p.removeColumn(at.col);
p.sync();
try std.testing.expectEqual(@as(usize, 1), p.ncol);
+ try std.testing.expectEqual(shell, p.active);
+
+ // The session's last pane closing ends it.
+ try p.removePane(shell, null);
+ try std.testing.expect(p.quit);
}
test "the bare tty startup opens a text pane under the shell, unfocused" {
@@ -7077,6 +7285,7 @@ test "hopping between two panes does not grow the jump stack" {
if (platform == .web) return;
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" });
+ p.settings.placement = .pardes; // two panes of room, not acme's fit to the doc's two lines
defer p.deinit();
p.update(.{ .resize = .{ .cols = 80, .rows = 24 } });
p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc
diff --git a/src/pdf_view.zig b/src/pdf_view.zig
index 13c02231..58dd2c3c 100644
--- a/src/pdf_view.zig
+++ b/src/pdf_view.zig
@@ -2045,7 +2045,7 @@ pub fn openSections(core: *pardes.Pardes, id: usize) void {
.serial = result.serial,
.revision = result.file.?.revision,
};
- exec.placeDoc(core, id, free, result);
+ exec.placeNew(core, id, id, free, .doc);
layout.compute(core);
core.active = id;
pane.search_pane = free;
diff --git a/src/regexp.zig b/src/regexp.zig
index 9c34ae2c..249763c2 100644
--- a/src/regexp.zig
+++ b/src/regexp.zig
@@ -67,6 +67,10 @@ pub const Regex = struct {
i += 1;
piece = pat[i - 1 .. i + 1];
if (pat[i] == 'n') spans = true;
+ // mvzr slices two hex digits after `\x` without looking,
+ // so a short one panics it: refuse it here.
+ if (pat[i] == 'x' and (i + 2 >= pat.len or
+ !std.ascii.isHex(pat[i + 1]) or !std.ascii.isHex(pat[i + 2]))) return error.Bad;
} else if (in_class) {
in_class = c != ']';
} else if (c == '[') {
@@ -224,3 +228,10 @@ test "the match is the leftmost, however long the line" {
try std.testing.expectEqual(@as(usize, 2), f.start);
try std.testing.expectEqual(@as(usize, 7), f.end);
}
+
+test "a \\x without two hex digits is refused, not handed to mvzr to panic on" {
+ for ([_][]const u8{ "\\x", "a\\x1", "\\x1b[\\x", "[\\x]", "\\xg1" }) |pat|
+ try std.testing.expectError(error.Bad, Regex.compile(pat));
+ var rx = try Regex.compile("\\x41");
+ try std.testing.expectEqual(@as(usize, 1), (try rx.find("xA", 0, 1, 2)).?.start);
+}
diff --git a/src/surface.zig b/src/surface.zig
index b6f41853..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,12 +398,34 @@ 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
/// these cells to vaxis one-to-one; the SDL shells rasterize them.
pub const Surface = struct {
- pub const PointerShape = enum(u8) { arrow, link, target };
+ /// `box` is acme's boxcursor, shown while a pane or a column is carried
+ /// by its grip (cols.c:485, rows.c:151).
+ pub const PointerShape = enum(u8) { arrow, link, target, box };
pointer_shape: PointerShape = .arrow,
body_layers: [MAX_PANES]Layer = @splat(.{}),
tag_layers: [MAX_TAG_LAYERS]Layer = @splat(.{}),
diff --git a/src/tagline.zig b/src/tagline.zig
index 5ed65c26..68fa2645 100644
--- a/src/tagline.zig
+++ b/src/tagline.zig
@@ -26,6 +26,12 @@ 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
@@ -384,6 +390,7 @@ pub fn enterHeader(p: *Pardes, column: ?usize) void {
if (p.panes[p.active]) |pane| pane.focus = .body;
p.header_focus = true;
p.header_column = column;
+ if (column) |c| p.setActiveColumn(c);
const t = focusedHeader(p) orelse return exitHeader(p);
const text = edit.flatSurface(p, t) catch "";
const rows = modal.cursorLineCount(text);
diff --git a/src/themes/acme.zig b/src/themes/acme.zig
index d91e626b..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 },
@@ -27,6 +35,19 @@ pub const theme = .{
.lineno = .{ 0x99, 0x99, 0x80 },
.scroll_track = .{ 0x99, 0x99, 0x4c },
.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/web/app.mjs b/src/web/app.mjs
index 03829178..c053d663 100644
--- a/src/web/app.mjs
+++ b/src/web/app.mjs
@@ -42,7 +42,8 @@ export class DomRenderer {
render() {
const count = this.wasm.pardes_frame();
- this.root.style.cursor = this.wasm.pardes_pointer_shape() === 1 ? "pointer" : "text";
+ const shape = this.wasm.pardes_pointer_shape();
+ this.root.style.cursor = shape === 1 ? "pointer" : shape === 3 ? "move" : "text";
const cols = this.wasm.pardes_frame_cols();
const rows = this.wasm.pardes_frame_rows();
if (count !== cols * rows) throw new Error(`bad frame: ${count} cells for ${cols}x${rows}`);