summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Messages.zig9
-rw-r--r--src/Presentation.zig39
-rw-r--r--src/animation.zig70
-rw-r--r--src/colors.zig238
-rw-r--r--src/config.zig34
-rw-r--r--src/detached/wire.zig2
-rw-r--r--src/draw.zig73
-rw-r--r--src/effect_sources.zig13
-rw-r--r--src/gui/Post.zig294
-rw-r--r--src/gui/gui.zig1223
-rw-r--r--src/macos.zig2
-rw-r--r--src/macos/pardes.h3
-rw-r--r--src/pardes.zig8
-rw-r--r--src/surface.zig22
-rw-r--r--src/themes/dark.zig4
-rw-r--r--src/themes/helix.zig4
-rw-r--r--src/themes/lapis.zig92
17 files changed, 2016 insertions, 114 deletions
diff --git a/src/Messages.zig b/src/Messages.zig
index 482ad64d..ce7a2a8c 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -805,6 +805,8 @@ test "messages stack, oldest on top, each on its own timing" {
const pane = p.panes[p.active].?;
try std.testing.expect(p.executeBuiltinLine(p.active, "Verbose off"));
try std.testing.expect(p.executeBuiltinLine(p.active, "MessageLinger 160"));
+ // A dissolve longer than a fall, so the second is caught mid-way.
+ try std.testing.expect(p.executeBuiltinLine(p.active, "MessageDissolve 300"));
// `Verbose off` announced itself before it took effect.
pane.msg_len = 0;
pane.msg_life = .{};
@@ -1005,3 +1007,10 @@ test "an idle core wants no wake and a lingering message sleeps to its linger's
p.advance(long_due);
try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase);
}
+
+test "a notice leaves quicker than it arrives, each within its §8.1 band" {
+ const settings: config.Runtime = .{};
+ try std.testing.expect(settings.message_dissolve_ms < settings.message_fall_ms);
+ try std.testing.expect(settings.message_fall_ms >= 150 and settings.message_fall_ms <= 220);
+ try std.testing.expect(settings.message_dissolve_ms >= 100 and settings.message_dissolve_ms <= 160);
+}
diff --git a/src/Presentation.zig b/src/Presentation.zig
index 15ed9ea6..5cec73e0 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 = stamp(p, .{
+ var next = stamp(p, .{
.serial = pane.serial,
.pane = @intCast(id),
.phase = if (!same_lifetime or
@@ -192,6 +192,10 @@ pub fn sync(self: *Presentation, p: *Pardes) void {
.from = from,
.to = target,
});
+ // Retargeted mid-way from where it shows: it keeps its speed.
+ if (same_lifetime and shown != null) if (prior) |old_move| {
+ next.launch = carriedLaunch(old_move, next);
+ };
self.tracks[id] = next;
changed = true;
animated_change = true;
@@ -230,6 +234,23 @@ fn stamp(p: *const Pardes, track: Track) Track {
return stamped;
}
+/// A retargeted move's starting speed, in its own lengths per unit of
+/// progress, from the old move's speed now (cells per frame), along the
+/// new move's longest side; within four lengths per unit either way.
+fn carriedLaunch(old_move: Track, next: Track) f32 {
+ const v = old_move.velocity();
+ if (next.frames() <= 1) return 0;
+ const steps: f32 = @floatFromInt(next.frames() - 1);
+ const d: [4]f32 = .{ next.to.x - next.from.x, next.to.y - next.from.y, next.to.w - next.from.w, next.to.h - next.from.h };
+ const vs: [4]f32 = .{ v.x, v.y, v.w, v.h };
+ var axis: usize = 0;
+ for (d, 0..) |value, i| if (@abs(value) > @abs(d[axis])) {
+ axis = i;
+ };
+ if (@abs(d[axis]) < 1e-3) return 0;
+ return std.math.clamp(vs[axis] * steps / d[axis], -4, 4);
+}
+
fn appendClosing(self: *Presentation, track: Track) bool {
std.debug.assert(track.phase == .closing);
const baseline = self.previous_layout[track.pane] orelse return false;
@@ -784,3 +805,19 @@ test "presentation snapshots own compact body and tag rows across later edits" {
try std.testing.expectEqual(@as(u8, 'B'), state.previous_body_layers[0].cells[0].text[0]);
try std.testing.expectEqual(@as(u8, 'U'), state.previous_tag_layers[0].cells[0].text[0]);
}
+
+test "a move retargeted mid-way carries its speed into the new one" {
+ const smooth = @intFromEnum(animation.Motion.Flavour.smooth) + 1;
+ const first: Track = .{ .serial = 1, .phase = .moving, .effect = .slide, .motion = smooth, .frame = 4, .frame_count = 14, .from = .{ .x = 0, .y = 1, .w = 40, .h = 20 }, .to = .{ .x = 40, .y = 1, .w = 40, .h = 20 } };
+ const was = first.velocity();
+ try std.testing.expect(was.x > 0.5);
+ var next: Track = .{ .serial = 1, .phase = .moving, .effect = .slide, .motion = smooth, .frame_count = 14, .from = first.presented(), .to = .{ .x = 60, .y = 1, .w = 40, .h = 20 } };
+ next.launch = carriedLaunch(first, next);
+ try std.testing.expect(next.launch > 0);
+ // It sets off at the speed the old one had, not from a stop.
+ const now = next.velocity();
+ try std.testing.expectApproxEqRel(was.x, now.x, 0.15);
+ // And still lands exactly.
+ next.frame = 13;
+ try std.testing.expectEqual(next.to.x, next.presented().x);
+}
diff --git a/src/animation.zig b/src/animation.zig
index 9002a2c6..713a8a30 100644
--- a/src/animation.zig
+++ b/src/animation.zig
@@ -136,6 +136,10 @@ pub const Track = extern struct {
frame_count: u16 = 0,
from: Box = .{},
to: Box = .{},
+ /// The move's speed as it starts, in its lengths per unit of progress:
+ /// 0 sets off as the flavour does; a move retargeted mid-way carries
+ /// the old one's speed on (Presentation.sync), so it never stops dead.
+ launch: f32 = 0,
pub fn active(track: Track) bool {
return track.effect != .off and track.frame < track.frames();
@@ -151,7 +155,7 @@ pub const Track = extern struct {
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 motion.easeFrom(track.progress(), track.launch);
return progressEased(track.effect.easing(), track.frames(), track.frame);
}
@@ -163,6 +167,16 @@ pub const Track = extern struct {
return Motion.of(which);
}
+ /// How fast its presented box moves now, in cells per frame of its
+ /// own (x, y, w, h); zero when it follows no flavour or has landed.
+ pub fn velocity(track: Track) Box {
+ const motion = track.flavour() orelse return .{};
+ const u = track.progress();
+ if (u >= 1 or track.frames() <= 1) return .{};
+ const rate = motion.speedFrom(u, track.launch) / @as(f32, @floatFromInt(track.frames() - 1));
+ return .{ .x = (track.to.x - track.from.x) * rate, .y = (track.to.y - track.from.y) * rate, .w = (track.to.w - track.from.w) * rate, .h = (track.to.h - track.from.h) * rate };
+ }
+
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));
@@ -180,7 +194,7 @@ pub const Track = extern struct {
// 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);
+ const factor = 1 + motion.stretch * std.math.clamp(motion.speedFrom(track.progress(), track.launch) / 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;
@@ -685,16 +699,27 @@ pub const Motion = struct {
/// 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 {
+ return motion.easeFrom(u, 0);
+ }
+
+ /// `ease` for a move that starts already moving at `launch` lengths
+ /// per unit of `u` (a retarget): no wind-up then, the speed carried on.
+ pub fn easeFrom(motion: Motion, u: f32, launch: 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 };
+ const kick = if (launch != 0) launch else (motion.launch - 2 * motion.anticipate) * motion.norm;
+ const spring: Spring = .{ .omega = motion.norm, .zeta = motion.zeta, .target = 1, .from = 0, .velocity = kick, .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 {
+ return motion.speedFrom(u, 0);
+ }
+
+ pub fn speedFrom(motion: Motion, u: f32, launch: 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));
+ return (motion.easeFrom(@min(1, u + h), launch) - motion.easeFrom(@max(0, u - h), launch)) / (@min(1, u + h) - @max(0, u - h));
}
};
@@ -744,6 +769,14 @@ pub const Spring = struct {
return spring.at(now_ns).value;
}
+ /// Moves what it shows by `by` at `now_ns`, its speed kept, toward the
+ /// same target (a scroll's picture falling behind the text it follows),
+ /// at `motion`'s pace, never past its mark.
+ pub fn carry(spring: *Spring, by: f32, now_ns: u64, motion: Motion) void {
+ const state = spring.at(now_ns);
+ spring.* = .{ .omega = motion.omega, .zeta = 1, .target = spring.target, .from = state.value + by, .velocity = state.velocity, .from_ns = now_ns, .settled = false };
+ }
+
/// 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 {
@@ -769,6 +802,24 @@ pub const Spring = struct {
}
};
+test "a carried spring keeps its speed, heads back to its mark and never passes it" {
+ const ms = std.time.ns_per_ms;
+ var spring: Spring = .{};
+ const smooth = Motion.of(.smooth);
+ spring.carry(-3, 0, smooth);
+ try std.testing.expectEqual(@as(f32, -3), spring.value(0));
+ const mid = spring.at(40 * ms);
+ try std.testing.expect(mid.value > -3 and mid.value < 0 and mid.velocity > 0);
+ // Another notch mid-way: one more row behind, still moving back.
+ spring.carry(-1, 40 * ms, smooth);
+ const after = spring.at(40 * ms);
+ try std.testing.expectApproxEqAbs(mid.value - 1, after.value, 1e-5);
+ try std.testing.expectApproxEqAbs(mid.velocity, after.velocity, 1e-4);
+ var t: u64 = 40 * ms;
+ while (spring.step(t)) : (t += frame_ns) try std.testing.expect(spring.value(t) <= 1e-4);
+ try std.testing.expect(t < 400 * ms);
+}
+
test "a spring settles without overshoot and keeps its velocity when retargeted" {
var spring: Spring = .{};
const ms = std.time.ns_per_ms;
@@ -1081,6 +1132,9 @@ pub fn Fade(comptime Value: type) type {
to: Value,
displayed: Value,
step: u16 = transition_steps,
+ /// Set off from partway through another fade: it eases out (moving
+ /// at once) instead of in and out, so it never stalls (§7.2).
+ retargeted: bool = false,
pub fn init(value: Value) Self {
return .{ .from = value, .to = value, .displayed = value };
@@ -1091,19 +1145,25 @@ pub fn Fade(comptime Value: type) type {
}
pub fn retarget(a: *Self, target: Value) void {
+ a.retargeted = a.isActive();
a.from = a.displayed;
a.to = target;
a.step = if (std.meta.eql(a.from, target)) transition_steps else 0;
if (a.step == transition_steps) a.displayed = target;
}
+ /// A state change eases in and out (§8.1); a retargeted one out.
+ const resolution: u16 = 1000;
+
pub fn advance(a: *Self) void {
if (!a.isActive()) return;
a.step += 1;
+ const t = @as(f32, @floatFromInt(a.step)) / @as(f32, @floatFromInt(transition_steps));
+ const eased = sample(if (a.retargeted) .out_cubic else .smooth, t);
a.displayed = if (a.step == transition_steps)
a.to
else
- Value.interpolate(a.from, a.to, a.step, transition_steps);
+ Value.interpolate(a.from, a.to, @intFromFloat(@round(eased * resolution)), resolution);
}
pub fn snap(a: *Self, value: Value) void {
diff --git a/src/colors.zig b/src/colors.zig
index 224591d3..8319a112 100644
--- a/src/colors.zig
+++ b/src/colors.zig
@@ -17,12 +17,207 @@ const MAX_PANES = pardes.MAX_PANES;
const BOX_H = pardes.BOX_H;
const Pardes = pardes.Pardes;
+/// WCAG relative luminance, and the contrast of two colours.
+pub fn relativeLuminance(rgb: [3]u8) f32 {
+ var l: [3]f32 = undefined;
+ for (&l, rgb) |*out, v| {
+ const x = @as(f32, @floatFromInt(v)) / 255;
+ out.* = if (x <= 0.04045) x / 12.92 else std.math.pow(f32, (x + 0.055) / 1.055, 2.4);
+ }
+ return 0.2126 * l[0] + 0.7152 * l[1] + 0.0722 * l[2];
+}
+
+pub fn contrast(a: [3]u8, b: [3]u8) f32 {
+ const x = relativeLuminance(a);
+ const y = relativeLuminance(b);
+ return (@max(x, y) + 0.05) / (@min(x, y) + 0.05);
+}
+
+fn lerp(a: [3]u8, b: [3]u8, t: f32) [3]u8 {
+ var out: [3]u8 = undefined;
+ for (&out, a, b) |*o, x, y| o.* = @intFromFloat(std.math.clamp(@round(@as(f32, @floatFromInt(x)) + (@as(f32, @floatFromInt(y)) - @as(f32, @floatFromInt(x))) * t), 0, 255));
+ return out;
+}
+
+/// The rules between panes and columns: acme's contrast, black on white.
+/// A theme's own border, or its scroll track, as long as it stands off the
+/// page by 1.5:1; one that does not (on a near-black page, a dark rule all
+/// but vanishes) is lifted toward the text until it does, to 1.6:1.
+pub const separator_floor: f32 = 1.5;
+/// The page a theme with none (`dark`: the host's own) is judged against:
+/// the GUI's default ground, and a dark terminal's.
+pub const null_page: [3]u8 = .{ 18, 18, 18 };
+fn separatorOf(th: *const Theme) [3]u8 {
+ const base = th.border orelse th.scroll_track;
+ const page = th.bg orelse null_page;
+ if (contrast(base, page) >= separator_floor) return base;
+ const ink = th.fg orelse th.tag_fg;
+ var t: f32 = 0.05;
+ while (t <= 1) : (t += 0.05) {
+ const lifted = lerp(base, ink, t);
+ if (contrast(lifted, page) >= 1.6) return lifted;
+ }
+ return ink;
+}
+
+/// The focused tag's tint, the main focus indicator: a theme's own, pushed
+/// further the way it already leans from the plain tag where the two are
+/// closer than the floor (1.5:1 on a dark page, where a tint is all that
+/// tells the focused tag at a glance; 1.25:1 on a light one), but never so
+/// far its text drops below its own contrast or 4.5. A theme with no tint
+/// (acme: the grip's button says it) keeps none.
+pub const focus_tint_floor: f32 = 1.25;
+pub const focus_tint_floor_dark: f32 = 1.5;
+pub fn focusTintFloor(th: *const Theme) f32 {
+ return if (relativeLuminance(th.bg orelse null_page) < 0.18) focus_tint_floor_dark else focus_tint_floor;
+}
+fn focusTint(th: *const Theme) [3]u8 {
+ const tint = th.tag_active_bg orelse return th.tag_bg;
+ const floor = focusTintFloor(th);
+ if (contrast(tint, th.tag_bg) >= floor) return tint;
+ const ink = th.tag_active_fg orelse th.tag_fg;
+ const text_floor = @min(contrast(ink, tint), 4.5);
+ var best = tint;
+ var k: f32 = 1.1;
+ while (k <= 12) : (k += 0.1) {
+ const pushed = lerp(th.tag_bg, tint, k);
+ if (contrast(ink, pushed) < text_floor) break;
+ best = pushed;
+ if (contrast(pushed, th.tag_bg) >= floor) break;
+ }
+ return best;
+}
+
+test "every native theme's separators stand off its page, and its focus tint off its tag" {
+ for (themes[0..native_count]) |*theme| {
+ const chrome = ChromeTheme.fromTheme(theme);
+ const page = theme.bg orelse null_page;
+ try std.testing.expect(contrast(chrome.border, page) >= separator_floor);
+ // A dark page's focused tag is told by its band too, not the grip
+ // alone; acme's (light, and acme's own) is told by its button.
+ if (relativeLuminance(page) < 0.18) try std.testing.expect(theme.tag_active_bg != null);
+ if (theme.tag_active_bg) |own| {
+ try std.testing.expect(contrast(chrome.tag_active_bg, theme.tag_bg) >= focusTintFloor(theme));
+ // And its text still reads.
+ const ink = theme.tag_active_fg orelse theme.tag_fg;
+ try std.testing.expect(contrast(ink, chrome.tag_active_bg) >= @min(contrast(ink, own), 4.5) - 0.01);
+ }
+ }
+}
+
+test "a derived separator or tint keeps the theme's own when it already stands off" {
+ const forge = &themes[for (themes[0..native_count], 0..) |t, i| {
+ if (std.mem.eql(u8, t.name, "forge")) break i;
+ } else unreachable];
+ const chrome = ChromeTheme.fromTheme(forge);
+ try std.testing.expectEqual(forge.border.?, chrome.border);
+ // forge's own tint is 1.23:1 off its tag: pushed on the way it leans.
+ try std.testing.expect(!std.meta.eql(forge.tag_active_bg.?, chrome.tag_active_bg));
+ for (chrome.tag_active_bg, forge.tag_active_bg.?, forge.tag_bg) |pushed, own, plain|
+ try std.testing.expect(pushed >= own and own >= plain);
+}
+
+/// sRGB bytes to OKLab and back (Björn Ottosson's), and a mix in it.
+fn toOklab(rgb: [3]u8) [3]f32 {
+ var l: [3]f32 = undefined;
+ for (&l, rgb) |*out, v| {
+ const x = @as(f32, @floatFromInt(v)) / 255;
+ out.* = if (x <= 0.04045) x / 12.92 else std.math.pow(f32, (x + 0.055) / 1.055, 2.4);
+ }
+ const lm = std.math.cbrt(0.4122214708 * l[0] + 0.5363325363 * l[1] + 0.0514459929 * l[2]);
+ const mm = std.math.cbrt(0.2119034982 * l[0] + 0.6806995451 * l[1] + 0.1073969566 * l[2]);
+ const sm = std.math.cbrt(0.0883024619 * l[0] + 0.2817188376 * l[1] + 0.6299787005 * l[2]);
+ return .{
+ 0.2104542553 * lm + 0.7936177850 * mm - 0.0040720468 * sm,
+ 1.9779984951 * lm - 2.4285922050 * mm + 0.4505937099 * sm,
+ 0.0259040371 * lm + 0.7827717662 * mm - 0.8086757660 * sm,
+ };
+}
+
+fn fromOklab(lab: [3]f32) [3]u8 {
+ const lm = lab[0] + 0.3963377774 * lab[1] + 0.2158037573 * lab[2];
+ const mm = lab[0] - 0.1055613458 * lab[1] - 0.0638541728 * lab[2];
+ const sm = lab[0] - 0.0894841775 * lab[1] - 1.2914855480 * lab[2];
+ const l = lm * lm * lm;
+ const m = mm * mm * mm;
+ const s = sm * sm * sm;
+ const linear: [3]f32 = .{
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
+ -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
+ };
+ var out: [3]u8 = undefined;
+ for (&out, linear) |*o, v| {
+ const x = std.math.clamp(v, 0, 1);
+ const srgb = if (x <= 0.0031308) x * 12.92 else 1.055 * std.math.pow(f32, x, 1.0 / 2.4) - 0.055;
+ o.* = @intFromFloat(@round(std.math.clamp(srgb, 0, 1) * 255));
+ }
+ return out;
+}
+
+pub fn mixOklab(a: [3]u8, b: [3]u8, t: f32) [3]u8 {
+ if (std.mem.eql(u8, &a, &b)) return a;
+ const x = toOklab(a);
+ const y = toOklab(b);
+ return fromOklab(.{ x[0] + (y[0] - x[0]) * t, x[1] + (y[1] - x[1]) * t, x[2] + (y[2] - x[2]) * t });
+}
+
+test "an OKLab fade holds its ends exactly and keeps a mid-way bright" {
+ for ([_][3]u8{ .{ 0, 0, 0 }, .{ 255, 255, 255 }, .{ 0x0c, 0x0c, 0x0e }, .{ 0xe8, 0xc4, 0x6a }, .{ 0x1c, 0x51, 0x72 } }) |rgb| {
+ try std.testing.expectEqual(rgb, fromOklab(toOklab(rgb)));
+ try std.testing.expectEqual(rgb, mixOklab(rgb, .{ 10, 200, 30 }, 0));
+ }
+ // Red to green through sRGB bytes dips dark at the middle; through
+ // OKLab it keeps its lightness.
+ const red: [3]u8 = .{ 255, 0, 0 };
+ const green: [3]u8 = .{ 0, 200, 0 };
+ const srgb_mid = mix(red, green);
+ const lab_mid = mixOklab(red, green, 0.5);
+ try std.testing.expect(relativeLuminance(lab_mid) > relativeLuminance(srgb_mid) * 1.3);
+}
+
pub fn mix(a: [3]u8, b: [3]u8) [3]u8 {
var out: [3]u8 = undefined;
for (&out, a, b) |*c, x, y| c.* = @intCast((@as(u16, x) + y) / 2);
return out;
}
+/// A theme's ornament, drawn by a pixel shell inside the chrome it already
+/// has (docs/render-pipeline.md §10: no gaps between panes): the lapis
+/// theme's. Sizes are logical pixels. None of it touches a focus indicator:
+/// the dots lie on the page's own ground, the checker in the scroll track
+/// (never its thumb), a tag's plaque inside its band beside its grip (and
+/// the focused one only the more striking), and the shadow under a file
+/// name's own letters.
+pub const Decor = struct {
+ /// A dot grid on the page's ground, `page_dot_alpha` of 255 strong, a
+ /// dot one logical pixel across (fading out by a pixel and a half) every
+ /// `page_dot_px`, fixed to the window.
+ page_dots: ?[3]u8 = null,
+ page_dot_alpha: u8 = 0x22,
+ page_dot_px: u8 = 22,
+ /// A checker in the scroll track, squares of `rail_checker_px`, this
+ /// colour and the track's.
+ rail_checker: ?[3]u8 = null,
+ rail_checker_px: u8 = 3,
+ /// Every tag drawn as a raised plaque inside its own band (the site's
+ /// trail and title bars): a `tag_border` frame round the text, a hard
+ /// `tag_shadow` offset down and right by `tag_shadow_px`, both within
+ /// the band's rows and columns, never past them; the focused pane's
+ /// plaque striped `tag_stripe` and the tag's ground, `tag_stripe_px`
+ /// each, its words on plates of the ground. Column and workspace tags
+ /// take a quieter one (a one-pixel frame, a shadow of two, no stripes).
+ tag_border: ?[3]u8 = null,
+ tag_border_px: u8 = 2,
+ tag_shadow: ?[3]u8 = null,
+ tag_shadow_px: u8 = 5,
+ tag_stripe: ?[3]u8 = null,
+ tag_stripe_px: u8 = 2,
+ /// The file name's offset shadow, down and right, in a pane's tag.
+ title_shadow: ?[3]u8 = null,
+ title_shadow_px: u8 = 2,
+};
+
pub const Theme = struct {
name: []const u8,
bg: ?[3]u8,
@@ -88,6 +283,8 @@ pub const Theme = struct {
/// in pixels at a 17px tagline, the thumb one narrower (acme's 12px
/// Scrollwid, the default).
rail_px: ?u8 = null,
+ /// A pixel shell's ornament (Decor): lapis's.
+ decor: ?Decor = null,
};
const curated = struct {
@@ -109,6 +306,7 @@ const curated = struct {
pub const helix = @import("themes/helix.zig");
pub const dark = @import("themes/dark.zig");
pub const acme = @import("themes/acme.zig");
+ pub const lapis = @import("themes/lapis.zig");
};
fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme {
@@ -119,7 +317,15 @@ fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme {
var t: Theme = undefined;
const source = @field(C, d.name).theme;
for (@typeInfo(Theme).@"struct".fields) |f| {
- @field(t, f.name) = if (@hasField(@TypeOf(source), f.name))
+ @field(t, f.name) = if (f.type == ?Decor and @hasField(@TypeOf(source), f.name)) decor: {
+ // A nested literal is folded field by field too.
+ var decor: Decor = .{};
+ const given = @field(source, f.name);
+ for (@typeInfo(Decor).@"struct".fields) |g| if (@hasField(@TypeOf(given), g.name)) {
+ @field(decor, g.name) = @field(given, g.name);
+ };
+ break :decor decor;
+ } else if (@hasField(@TypeOf(source), f.name))
@field(source, f.name)
else if (f.default_value_ptr) |ptr|
@as(*const f.type, @ptrCast(@alignCast(ptr))).*
@@ -187,15 +393,17 @@ pub const ChromeTheme = struct {
scroll_thumb: [3]u8,
pub fn fromTheme(th: *const Theme) ChromeTheme {
+ // The initial chrome is resolved at compile time: contrast takes pow.
+ @setEvalBranchQuota(200_000);
return .{
.tag_bg = th.tag_bg,
.tag_fg = th.tag_fg,
- .tag_active_bg = th.tag_active_bg orelse th.tag_bg,
+ .tag_active_bg = focusTint(th),
.tag_active_fg = th.tag_active_fg orelse th.tag_fg,
.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),
+ .border = separatorOf(th),
+ .empty_col = th.empty_col orelse separatorOf(th),
.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),
@@ -212,10 +420,14 @@ pub const ChromeTheme = struct {
};
}
+ /// Partway from one chrome to another, each colour through OKLab
+ /// (G10): a fade keeps its lightness even, never going muddy or dark
+ /// between two hues as a mix of sRGB bytes does.
pub fn interpolate(from: ChromeTheme, to: ChromeTheme, step: u16, steps: u16) ChromeTheme {
var out: ChromeTheme = undefined;
+ const t = @as(f32, @floatFromInt(@min(step, steps))) / @as(f32, @floatFromInt(@max(1, steps)));
inline for (@typeInfo(ChromeTheme).@"struct".fields) |field|
- @field(out, field.name) = animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps);
+ @field(out, field.name) = if (step == 0) @field(from, field.name) else if (step >= steps) @field(to, field.name) else mixOklab(@field(from, field.name), @field(to, field.name), t);
return out;
}
};
@@ -655,8 +867,20 @@ test "legacy ThemeFile documents inherit Pardes UI roles without new fields" {
@setEvalBranchQuota(10000);
var encoded: std.Io.Writer.Allocating = .init(std.testing.allocator);
defer encoded.deinit();
- try std.zon.stringify.serialize(curated.helix.theme, .{ .whitespace = true }, &encoded.writer);
- const source = try std.testing.allocator.dupeZ(u8, encoded.written());
+ try std.zon.stringify.serialize(curated.helix.theme, .{ .whitespace = false }, &encoded.writer);
+ // helix without the focused tag's pair it has since gained: a document
+ // written before those fields existed.
+ var legacy: std.ArrayList(u8) = .empty;
+ defer legacy.deinit(std.testing.allocator);
+ var rest: []const u8 = encoded.written();
+ while (true) {
+ const at = @min(std.mem.indexOf(u8, rest, ".tag_active_bg") orelse rest.len, std.mem.indexOf(u8, rest, ".tag_active_fg") orelse rest.len);
+ try legacy.appendSlice(std.testing.allocator, rest[0..at]);
+ if (at == rest.len) break;
+ rest = rest[at + std.mem.indexOfScalar(u8, rest[at..], '}').? + 1 ..];
+ rest = std.mem.trimStart(u8, rest, " ,");
+ }
+ const source = try std.testing.allocator.dupeZ(u8, legacy.items);
defer std.testing.allocator.free(source);
const parsed = try std.zon.parse.fromSliceAlloc(Theme, std.testing.allocator, source, null, .{});
defer std.zon.parse.free(std.testing.allocator, parsed);
diff --git a/src/config.zig b/src/config.zig
index 2fa6f89d..229c70ea 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -122,6 +122,7 @@ pub const leader_path = paths: {
if (builtins.capabilities.window_blur) table.set(.WindowBlur, null);
if (builtins.capabilities.ligatures) table.set(.Ligatures, null);
if (builtins.capabilities.lift) table.set(.CursorBlink, null);
+ if (builtins.capabilities.lift) table.set(.SelectionGlow, null);
if (builtins.capabilities.lift) table.set(.Lift, null);
table.set(.Motion, null);
table.set(.InactiveDim, null);
@@ -141,6 +142,9 @@ pub const leader_path = paths: {
}
if (builtins.capabilities.scene_shaders) {
table.set(.Crt, "tr");
+ table.set(.Bloom, null);
+ table.set(.Vignette, null);
+ table.set(.Grain, null);
table.set(.Shader, null);
table.set(.ShaderAnimation, null);
}
@@ -663,12 +667,17 @@ pub const Runtime = struct {
/// solid while typing and for half a second after, eased at each edge,
/// solid again after ten idle seconds.
cursor_blink: bool = true,
+ /// G9: a soft halo round the selection in the body, fading in over
+ /// 100 ms, a pixel shell's (docs/effects.md).
+ selection_glow: bool = false,
/// How long a message stays after the key or click that dismisses it.
message_linger_ms: u16 = 800,
- /// How long its ease-in fall into the row takes.
+ /// How long its fall into the row takes, easing out (§8.1: arriving,
+ /// 150 to 220 ms).
message_fall_ms: u16 = 180,
- /// How long its ease-out dissolve takes.
- message_dissolve_ms: u16 = 300,
+ /// How long its dissolve takes, easing in: leaving is quicker than
+ /// arriving (§8.1: 100 to 160 ms).
+ message_dissolve_ms: u16 = 150,
/// Where Dump writes and a relative Restore looks; empty is the default
/// ($XDG_DATA_HOME/pardes, else ~/.local/share/pardes).
dump_dir: Text(limits.host_path_cap) = .{},
@@ -884,9 +893,10 @@ 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, syntax_bold, verbose, message_animation, ligatures, cursor_blink };
- /// The bundled post pass, a Shadertoy file in shaders/post/.
- pub const Scene = enum { crt };
+ pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, syntax_bold, verbose, message_animation, ligatures, cursor_blink, selection_glow };
+ /// A bundled post pass, a Shadertoy file in shaders/post/: the CRT, and
+ /// G4's bloom, vignette and grain (docs/effects.md).
+ pub const Scene = enum { crt, bloom, vignette, grain };
pub const Action = union(enum) {
toggle: Toggle,
@@ -942,6 +952,7 @@ pub const Runtime = struct {
.{ .word = "MessageAnimation", .action = .{ .toggle = .message_animation }, .doc = "A message falls into its row and dissolves out of it, on or off." },
.{ .word = "Ligatures", .action = .{ .toggle = .ligatures }, .availability = .ligatures, .doc = "A font's programming ligatures draw across their cells, on or off." },
.{ .word = "CursorBlink", .action = .{ .toggle = .cursor_blink }, .availability = .lift, .doc = "The cursor blinks, solid while typing, on or off." },
+ .{ .word = "SelectionGlow", .action = .{ .toggle = .selection_glow }, .availability = .lift, .doc = "A soft halo of the selection's colour round it in the body, on or off." },
.{ .word = "Lift", .action = .lift, .availability = .lift, .doc = "How the focused pane stands off the page: off, shadow, rim or auto." },
.{ .word = "Motion", .action = .motion, .doc = "How animations move: off, crisp, smooth, bouncy or playful." },
.{ .word = "InactiveDim", .action = .inactive_dim, .doc = "Fade the unfocused panes' text by a percentage, 0 through 100, 0 off." },
@@ -968,6 +979,9 @@ pub const Runtime = struct {
.{ .word = "PanelScramble", .action = .{ .transition = .scramble }, .availability = .panel_transitions, .doc = "Panes open and close through scrambled text, on or off." },
.{ .word = "PanelType", .action = .{ .transition = .typewriter }, .availability = .panel_transitions, .doc = "Panes type themselves in as they open, on or off." },
.{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders, .doc = "The bundled CRT post pass over the window, on or off." },
+ .{ .word = "Bloom", .action = .{ .scene = .bloom }, .availability = .scene_shaders, .doc = "The brightest ink glows a little: a level, 0 (off) through 3." },
+ .{ .word = "Vignette", .action = .{ .scene = .vignette }, .availability = .scene_shaders, .doc = "The window's corners fall into shade: a level, 0 (off) through 3." },
+ .{ .word = "Grain", .action = .{ .scene = .grain }, .availability = .scene_shaders, .doc = "The page takes a fine, still grain, like paper: a level, 0 (off) through 3." },
.{ .word = "Shader", .action = .shader, .availability = .scene_shaders, .doc = "A Shadertoy file into the post chain, or out of it; off empties the chain." },
.{ .word = "ShaderAnimation", .action = .shader_animation, .availability = .scene_shaders, .doc = "When the post chain animates on its own: off, on (while focused) or always." },
.{ .word = "Placement", .action = .{ .choice = .placement }, .doc = "Where a new pane goes: acme, as makenewwindow does, or pardes, the older rules." },
@@ -1015,9 +1029,9 @@ pub const Runtime = struct {
.toggle => |field| if (field == .ligatures) {
if (setting.availability != .ligatures)
@compileError("the ligatures toggle must use the ligatures capability");
- } else if (field == .cursor_blink) {
+ } else if (field == .cursor_blink or field == .selection_glow) {
if (setting.availability != .lift)
- @compileError("CursorBlink must use the lift capability (a pixel shell's)");
+ @compileError("CursorBlink and SelectionGlow must use the lift capability (a pixel shell's)");
} else if (setting.availability != null)
@compileError("unconditional toggles cannot carry a backend capability"),
else => if (setting.availability != null)
@@ -1672,6 +1686,7 @@ pub const Runtime = struct {
.message_animation => "MessageAnimation off\n",
.ligatures => "Ligatures off\n",
.cursor_blink => "CursorBlink off\n",
+ .selection_glow => "SelectionGlow on\n",
},
.shell => "Shell requested (new panes) fish\n",
.tagline_size => "TaglineSize 73\n",
@@ -1699,6 +1714,9 @@ pub const Runtime = struct {
},
.scene => |effect| switch (effect) {
.crt => "Crt 2\n",
+ .bloom => "Bloom 2\n",
+ .vignette => "Vignette 2\n",
+ .grain => "Grain 2\n",
},
.shader => "Shader ~/crt.glsl\n",
.shader_animation => "ShaderAnimation always\n",
diff --git a/src/detached/wire.zig b/src/detached/wire.zig
index 53a1a70d..3326277d 100644
--- a/src/detached/wire.zig
+++ b/src/detached/wire.zig
@@ -13,7 +13,7 @@ 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 = 0xc91196fcdef144e2;
+const chrome_layout: u64 = 0x7f982e44ef87de72;
comptime {
@setEvalBranchQuota(10_000);
var hash = std.hash.Fnv1a_64.init();
diff --git a/src/draw.zig b/src/draw.zig
index a2005e59..6c0cc077 100644
--- a/src/draw.zig
+++ b/src/draw.zig
@@ -197,8 +197,11 @@ fn focusedCursorBox(p: *const Pardes, s: *const Surface) ?animation.Box {
const caret = layer.cursor orelse continue;
if (layer.rows == 0 or layer.viewport.y + caret.y != cursor.y) continue;
if (cursor.x < layer.viewport.x or cursor.x >= layer.viewport.x + layer.viewport.w) continue;
- const x = @as(f32, @floatFromInt(layer.viewport.x)) + @as(f32, @floatFromInt(caret.x)) * tw / bw;
const w = if (caret.bar) tw / bw / 8 else tw / bw;
+ // A caret past a full row's end sits at the tag's right edge, where
+ // the grid's is clamped too.
+ const right = @as(f32, @floatFromInt(layer.viewport.x + layer.viewport.w));
+ const x = @min(@as(f32, @floatFromInt(layer.viewport.x)) + @as(f32, @floatFromInt(caret.x)) * tw / bw, right - w);
return .{ .x = x, .y = @as(f32, @floatFromInt(cursor.y)) + (bh - th) / 2 / bh, .w = w, .h = th / bh };
}
return .{ .x = @floatFromInt(cursor.x), .y = @floatFromInt(cursor.y), .w = if (cursor.bar) 1.0 / 8.0 else 1, .h = 1 };
@@ -467,7 +470,25 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
.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)),
+ .name_ink = chrome.tag_name_fg,
+ .active_name_ink = if (p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg,
+ .selection_glow = @intFromBool(p.settings.selection_glow),
};
+ if (p.theme().decor) |decor| {
+ s.chrome.decor_dots = decor.page_dots;
+ s.chrome.decor_dot_alpha = decor.page_dot_alpha;
+ s.chrome.decor_dot_px = decor.page_dot_px;
+ s.chrome.decor_checker = decor.rail_checker;
+ s.chrome.decor_checker_px = decor.rail_checker_px;
+ s.chrome.decor_box_border = decor.tag_border;
+ s.chrome.decor_box_border_px = decor.tag_border_px;
+ s.chrome.decor_box_shadow = decor.tag_shadow;
+ s.chrome.decor_box_shadow_px = decor.tag_shadow_px;
+ s.chrome.decor_box_stripe = decor.tag_stripe;
+ s.chrome.decor_box_stripe_px = decor.tag_stripe_px;
+ s.chrome.decor_title = decor.title_shadow;
+ s.chrome.decor_title_px = decor.title_shadow_px;
+ }
place(p, s);
// Separators have their own role; a scrollbar need not frame the page.
const whole = s.regions[0].rect;
@@ -1787,6 +1808,56 @@ test "the focused cursor glides a jump, lands a step at once, and is exact at re
try std.testing.expectEqual(@as(u8, 2), s.chrome.cursor_glide);
}
+test "a tag caret past its row's end glides to the tag's right edge, where the layer's is" {
+ if (!builtins.capabilities.lift) return error.SkipZigTest;
+ const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 16 });
+ defer p.deinit();
+ p.update(.{ .resize = .{ .cols = 60, .rows = 16, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } });
+ const pane = try p.setTestFile("text\n");
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ // The tag's text grown a letter at a time, the caret after its last one
+ // in insert mode, until a row is full and the caret is past its end.
+ var found = false;
+ var n: usize = 1;
+ while (n < 200 and !found) : (n += 1) {
+ _ = arena.reset(.retain_capacity);
+ if (pane.tag.own) |own| p.gpa.free(own);
+ const own = try p.gpa.alloc(u8, n);
+ @memset(own, 'x');
+ own[0] = ' ';
+ pane.tag.own = own;
+ tagline.enterTag(p, pane);
+ const whole = try tagline.tagText(p, arena.allocator(), pane);
+ pane.tag.mode = .insert;
+ pane.tag.cur_row = 0;
+ pane.tag.cur_col = @intCast(modal.lineSlice(whole, 0).len);
+ p.sync();
+ const s = try p.render(arena.allocator());
+ const layer = for (s.tagLayers()) |*layer| {
+ if (layer.rows != 0 and layer.kind == .pane and layer.cursor != null) break layer;
+ } else continue;
+ const caret = layer.cursor.?;
+ const right: f32 = @floatFromInt(layer.viewport.x + layer.viewport.w);
+ const box = focusedCursorBox(p, s).?;
+ // Never past the tag's right edge, and the pane's.
+ try std.testing.expect(box.x + box.w <= right + 1e-4);
+ try std.testing.expect(box.x + box.w <= @as(f32, @floatFromInt(p.rects[p.active].x + p.rects[p.active].w)));
+ const at = @as(f32, @floatFromInt(layer.viewport.x)) + @as(f32, @floatFromInt(caret.x)) * 6 / 10;
+ if (at + box.w <= right) {
+ // Inside its row: exactly where the layer's caret is.
+ try std.testing.expectApproxEqAbs(at, box.x, 1e-4);
+ } else {
+ // Past it: on the tag's right edge, as the pixel shell's caret
+ // is (gui.zig cursorBoxes).
+ try std.testing.expectApproxEqAbs(right, box.x + box.w, 1e-4);
+ try std.testing.expectApproxEqAbs(@as(f32, 6.0 / 10.0 / 8.0), box.w, 1e-4);
+ found = true;
+ }
+ }
+ try std.testing.expect(found);
+}
+
test "a terminal's cursor lands with scrolling output, never gliding against it" {
if (!builtins.capabilities.lift or !pardes.terminal_panes) return error.SkipZigTest;
const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 12 });
diff --git a/src/effect_sources.zig b/src/effect_sources.zig
index 83bd1313..328ce730 100644
--- a/src/effect_sources.zig
+++ b/src/effect_sources.zig
@@ -14,6 +14,11 @@ const image_fragment_path = shader_dir ++ "image.frag.glsl";
const post_vertex_path = shader_dir ++ "post.vert.glsl";
// The bundled post pass as compiled: the prefix and the pass together.
const post_crt_path = shader_dir ++ "post-crt.frag.glsl";
+const post_bloom_path = shader_dir ++ "post-bloom.frag.glsl";
+const post_vignette_path = shader_dir ++ "post-vignette.frag.glsl";
+const post_grain_path = shader_dir ++ "post-grain.frag.glsl";
+const bloom_down_path = shader_dir ++ "bloom-down.frag.glsl";
+const bloom_up_path = shader_dir ++ "bloom-up.frag.glsl";
pub const Backend = @TypeOf(build_config.platform);
pub const backend: Backend = build_config.platform;
@@ -53,6 +58,11 @@ pub const files = switch (backend) {
.{ .path = image_fragment_path, .contents = @embedFile("effect-source-image.frag.glsl") },
.{ .path = post_vertex_path, .contents = @embedFile("effect-source-post.vert.glsl") },
.{ .path = post_crt_path, .contents = @embedFile("effect-source-post-crt.frag.glsl") },
+ .{ .path = post_bloom_path, .contents = @embedFile("effect-source-post-bloom.frag.glsl") },
+ .{ .path = post_vignette_path, .contents = @embedFile("effect-source-post-vignette.frag.glsl") },
+ .{ .path = post_grain_path, .contents = @embedFile("effect-source-post-grain.frag.glsl") },
+ .{ .path = bloom_down_path, .contents = @embedFile("effect-source-bloom-down.frag.glsl") },
+ .{ .path = bloom_up_path, .contents = @embedFile("effect-source-bloom-up.frag.glsl") },
},
.macos => [_]filesystem.Source{
.{ .path = "src/macos/Sources/PardesView.swift", .contents = @embedFile("macos/Sources/PardesView.swift") },
@@ -73,6 +83,9 @@ pub fn forSetting(setting: config.Runtime.Setting) ?[]const []const u8 {
.scene => |effect| switch (backend) {
.gui => switch (effect) {
.crt => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_crt_path },
+ .bloom => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, bloom_down_path, bloom_up_path, post_bloom_path },
+ .vignette => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_vignette_path },
+ .grain => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_grain_path },
},
.macos => &.{ "src/macos/Sources/PardesView.swift", "src/macos/Sources/ScenePostprocessor.swift", "shaders/crt.ci.metal" },
.tty, .web, .esp32p4 => null,
diff --git a/src/gui/Post.zig b/src/gui/Post.zig
index f715965f..61ebc2cd 100644
--- a/src/gui/Post.zig
+++ b/src/gui/Post.zig
@@ -28,7 +28,27 @@ const prefix = @embedFile("post-prefix.glsl");
const vert_spv = @embedFile("post.vert.spv");
const bundled_spv = std.EnumArray(Scene, []const u8).init(.{
.crt = @embedFile("post-crt.frag.spv"),
+ .bloom = @embedFile("post-bloom.frag.spv"),
+ .vignette = @embedFile("post-vignette.frag.spv"),
+ .grain = @embedFile("post-grain.frag.spv"),
});
+const bloom_down_spv = @embedFile("bloom-down.frag.spv");
+const bloom_up_spv = @embedFile("bloom-up.frag.spv");
+/// Bloom's steps below the frame, at levels 1 to 3: a half, a quarter, ...
+const bloom_steps = [_]u8{ 3, 4, 5 };
+const bloom_format = c.SDL_GPU_TEXTUREFORMAT_R16G16B16A16_FLOAT;
+
+/// pardes's own block (shaders/post/prefix.glsl PardesPost), std140.
+pub const Own = extern struct {
+ opaque_window: f32 = 1,
+ level: f32 = 0,
+ scale: f32 = 1,
+ cap: f32 = 0,
+ spare_count: f32 = 0,
+ _pad: [3]f32 = @splat(0),
+ spare: [max_spare][4]f32 = @splat(@splat(0)),
+};
+pub const max_spare = 32;
/// ghostty's Shadertoy uniform block, member for member in the GLSL block's
/// order and std140 layout (shaders/post/prefix.glsl). Filled by the GLSL
@@ -98,6 +118,21 @@ ping: [2]?*c.SDL_GPUTexture = .{ null, null },
ping_w: u32 = 0,
ping_h: u32 = 0,
uniforms: Uniforms = .{},
+/// The display's density, for a pass whose pattern is in logical pixels
+/// (pardesScale); the shell sets it.
+scale: f32 = 1,
+/// What a bundled pass may do on this theme (pardesCap), each scene's, and
+/// the rectangles every bundled pass leaves alone (pardesSpare): both from
+/// the last frame described.
+caps: std.EnumArray(Scene, f32) = .initFill(0),
+spare: [max_spare][4]f32 = @splat(@splat(0)),
+spare_len: usize = 0,
+/// Bloom's float chain, a half the frame and down, and its two steps.
+bloom_tex: [bloom_steps[bloom_steps.len - 1]]?*c.SDL_GPUTexture = @splat(null),
+bloom_w: u32 = 0,
+bloom_h: u32 = 0,
+bloom_down: ?*Pipeline = null,
+bloom_up: ?*Pipeline = null,
started_ns: ?u64 = null,
last_ns: u64 = 0,
@@ -116,7 +151,11 @@ pub fn sync(post: *Post, gpa: std.mem.Allocator, io: std.Io, device: *c.SDL_GPUD
var next: [max]Pass = @splat(.{});
for (chain.list(), 0..) |entry, i| {
if (entry.scene) |scene| {
- if (post.bundled.get(scene) == null) post.bundled.set(scene, makePipeline(device, format, bundled_spv.get(scene)) catch null);
+ if (post.bundled.get(scene) == null) post.bundled.set(scene, makePipeline(device, format, bundled_spv.get(scene), if (scene == .bloom) 2 else 1, 4) catch null);
+ if (scene == .bloom and post.bloom_down == null) {
+ post.bloom_down = makePipeline(device, bloom_format, bloom_down_spv, 1, 1) catch null;
+ post.bloom_up = makePipeline(device, bloom_format, bloom_up_spv, 1, 1) catch null;
+ }
next[i] = .{ .scene = scene, .level = entry.level, .pipeline = post.bundled.get(scene) };
continue;
}
@@ -179,7 +218,7 @@ fn collect(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, format
.spirv => |spirv| {
defer gpa.free(spirv);
const target = pass orelse continue;
- const made = makePipeline(device, format, spirv) catch {
+ const made = makePipeline(device, format, spirv, 1, 4) catch {
core.setMessage(core.active, std.fmt.bufPrint(&buf, "Shader {s}: the GPU refused it", .{path}) catch "Shader: the GPU refused it");
continue;
};
@@ -263,10 +302,10 @@ fn compileOne(gpa: std.mem.Allocator, io: std.Io, path: []const u8) Job.Result {
return .{ .failed = named };
}
-fn makePipeline(device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, frag: []const u8) !*Pipeline {
+fn makePipeline(device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, frag: []const u8, samplers: u32, uniform_buffers: u32) !*Pipeline {
const vs = try gui.makeShader(device, vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0);
defer c.SDL_ReleaseGPUShader(device, vs);
- const fs = try gui.makeShader(device, frag, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 2);
+ const fs = try gui.makeShader(device, frag, c.SDL_GPU_SHADERSTAGE_FRAGMENT, samplers, uniform_buffers);
defer c.SDL_ReleaseGPUShader(device, fs);
var target = c.SDL_GPUColorTargetDescription{ .format = format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) };
var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo);
@@ -300,6 +339,9 @@ pub fn animating(post: *const Post, mode: pardes.config.Runtime.ShaderAnimation)
pub const Frame = struct {
pub const Cursor = struct { x0: f32, y0: f32, x1: f32, y1: f32, rgb: [3]u8, text: [3]u8, bar: bool };
cursor: ?Cursor,
+ /// What a bundled pass leaves alone, in framebuffer pixels: the tags,
+ /// grips and notices (and the cursor, added here).
+ spare: []const [4]f32 = &.{},
background: [3]u8,
foreground: [3]u8,
selection_foreground: [3]u8,
@@ -315,6 +357,10 @@ fn rgba(rgb: [3]u8) [4]f32 {
/// colour makes the old one the previous and stamps the change.
pub fn describe(post: *Post, frame: Frame) void {
const u = &post.uniforms;
+ post.caps = capsOf(frame);
+ post.spare_len = 0;
+ for (frame.spare) |rect| post.addSpare(rect);
+ if (frame.cursor) |cursor| post.addSpare(.{ cursor.x0, cursor.y0, cursor.x1, cursor.y1 });
u.background_color = rgba(frame.background);
u.foreground_color = rgba(frame.foreground);
u.selection_foreground_color = rgba(frame.selection_foreground);
@@ -340,6 +386,102 @@ pub fn describe(post: *Post, frame: Frame) void {
u.cursor_change_time = u.time;
}
+/// A rectangle a bundled pass leaves alone; past `max_spare` the last one
+/// grows to cover the rest (sparing more, never less).
+fn addSpare(post: *Post, rect: [4]f32) void {
+ if (rect[2] <= rect[0] or rect[3] <= rect[1]) return;
+ if (post.spare_len < max_spare) {
+ post.spare[post.spare_len] = rect;
+ post.spare_len += 1;
+ return;
+ }
+ const last = &post.spare[max_spare - 1];
+ last.* = .{ @min(last[0], rect[0]), @min(last[1], rect[1]), @max(last[2], rect[2]), @max(last[3], rect[3]) };
+}
+
+/// sRGB's relative luminance (WCAG) and a pair's contrast.
+fn luminance(rgb: [3]f32) f32 {
+ var sum: f32 = 0;
+ for (rgb, [3]f32{ 0.2126, 0.7152, 0.0722 }) |v, weight|
+ sum += weight * (if (v <= 0.04045) v / 12.92 else std.math.pow(f32, (v + 0.055) / 1.055, 2.4));
+ return sum;
+}
+fn contrastOf(a: f32, b: f32) f32 {
+ return (@max(a, b) + 0.05) / (@min(a, b) + 0.05);
+}
+fn unit(rgb: [3]u8) [3]f32 {
+ return .{ @as(f32, @floatFromInt(rgb[0])) / 255, @as(f32, @floatFromInt(rgb[1])) / 255, @as(f32, @floatFromInt(rgb[2])) / 255 };
+}
+
+/// Each bundled pass's ceiling on this theme (pardesCap, docs/effects.md):
+/// the most it may do while the text on the page, and the selection's text
+/// on its own ground, keep min(their contrast, 4.5). Bloom adds light to
+/// both of a pair (linear, as a share of full white); a vignette darkens
+/// both (a share); grain moves the page alone, either way (sRGB units).
+pub fn capsOf(frame: Frame) std.EnumArray(Scene, f32) {
+ const pairs = [_][2][3]f32{ .{ unit(frame.foreground), unit(frame.background) }, .{ unit(frame.selection_foreground), unit(frame.selection_background) } };
+ var bloom: f32 = 1;
+ var shade: f32 = 1;
+ for (pairs) |pair| {
+ const a = luminance(pair[0]);
+ const b = luminance(pair[1]);
+ const light = @max(a, b);
+ const dark = @min(a, b);
+ const target = @min(contrastOf(a, b), 4.5);
+ // (light + e + .05) / (dark + e + .05) >= target
+ bloom = @min(bloom, @max(0, (light + 0.05 - target * (dark + 0.05)) / @max(target - 1, 1e-3)));
+ // (k light + .05) / (k dark + .05) >= target, darkened by 1 - k
+ const room = light - target * dark;
+ shade = @min(shade, if (room <= 0) 0 else std.math.clamp(1 - 0.05 * (target - 1) / room, 0, 1));
+ }
+ // Grain: the largest step off the page, up and down, that keeps the text
+ // on it at its contrast or 4.5 (searched in 1/1020 steps up to 8/255).
+ const ink = luminance(unit(frame.foreground));
+ const page = unit(frame.background);
+ const target = @min(contrastOf(ink, luminance(page)), 4.5);
+ var grain: f32 = 0;
+ var step: f32 = 0.25 / 255.0;
+ while (step <= 8.0 / 255.0) : (step += 0.25 / 255.0) {
+ var up = page;
+ var down = page;
+ for (&up, &down) |*u, *d| {
+ u.* = @min(1, u.* + step);
+ d.* = @max(0, d.* - step);
+ }
+ if (contrastOf(ink, luminance(up)) < target - 1e-4 or contrastOf(ink, luminance(down)) < target - 1e-4) break;
+ grain = step;
+ }
+ var caps: std.EnumArray(Scene, f32) = .initFill(1);
+ caps.set(.bloom, bloom);
+ caps.set(.vignette, shade);
+ caps.set(.grain, grain);
+ return caps;
+}
+
+test "no bundled pass at its ceiling takes a native theme's text or selection below its contrast or 4.5" {
+ for (pardes.themes[0..pardes.colors.native_count]) |*theme| {
+ const frame: Frame = .{ .cursor = null, .background = theme.bg orelse .{ 18, 18, 18 }, .foreground = theme.fg orelse .{ 204, 204, 204 }, .selection_foreground = theme.sel_fg, .selection_background = theme.sel_bg };
+ const caps = capsOf(frame);
+ for ([_][2][3]u8{ .{ frame.foreground, frame.background }, .{ frame.selection_foreground, frame.selection_background } }, 0..) |pair, i| {
+ const a = luminance(unit(pair[0]));
+ const b = luminance(unit(pair[1]));
+ const target = @min(contrastOf(a, b), 4.5) - 1e-3;
+ // Bloom at its ceiling, over both.
+ const e = @min(caps.get(.bloom), 0.05);
+ try std.testing.expect(contrastOf(a + e, b + e) >= target);
+ // The vignette at its ceiling, over both.
+ const k = 1 - @min(caps.get(.vignette), 0.28);
+ try std.testing.expect(contrastOf(a * k, b * k) >= target);
+ // Grain moves the page alone.
+ if (i == 0) {
+ var up = unit(pair[1]);
+ for (&up) |*v| v.* = @min(1, v.* + caps.get(.grain));
+ try std.testing.expect(contrastOf(a, luminance(up)) >= target);
+ }
+ }
+ }
+}
+
/// The window gained or lost the focus.
pub fn focus(post: *Post, focused: bool) void {
if (focused and post.uniforms.focus == 0) post.uniforms.time_focus = post.uniforms.time;
@@ -417,13 +559,25 @@ pub fn draw(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer,
info.texture = output;
info.load_op = if (last) c.SDL_GPU_LOADOP_DONT_CARE else c.SDL_GPU_LOADOP_CLEAR;
info.store_op = c.SDL_GPU_STOREOP_STORE;
+ // Bloom's glow first, from this pass's input, at half size and down.
+ // A chain that could not be made adds no glow, and the pass is the
+ // frame as it was.
+ const glow = if (pass.scene == .bloom) post.bloomChain(device, cmd, sampler, input, w, h, pass.level) catch null else null;
const pass_rp = c.SDL_BeginGPURenderPass(cmd, &info, 1, null);
c.SDL_BindGPUGraphicsPipeline(pass_rp, pipeline);
- const binding = c.SDL_GPUTextureSamplerBinding{ .texture = input, .sampler = sampler };
- c.SDL_BindGPUFragmentSamplers(pass_rp, 0, &binding, 1);
- c.SDL_PushGPUFragmentUniformData(cmd, 0, u, @sizeOf(Uniforms));
- const own: [4]f32 = .{ @floatFromInt(@intFromBool(opaque_window)), @floatFromInt(pass.level), 0, 0 };
- c.SDL_PushGPUFragmentUniformData(cmd, 1, &own, @sizeOf(@TypeOf(own)));
+ const bindings = [2]c.SDL_GPUTextureSamplerBinding{ .{ .texture = input, .sampler = sampler }, .{ .texture = glow orelse input, .sampler = sampler } };
+ c.SDL_BindGPUFragmentSamplers(pass_rp, 0, &bindings, if (pass.scene == .bloom) 2 else 1);
+ pushUniforms(cmd, u);
+ var own: Own = .{
+ .opaque_window = @floatFromInt(@intFromBool(opaque_window)),
+ .level = @floatFromInt(pass.level),
+ .scale = post.scale,
+ // A user's file has no ceiling of ours, and no spared rectangles.
+ .cap = if (pass.scene) |scene| (if (scene == .bloom and glow == null) 0 else post.caps.get(scene)) else 1,
+ .spare_count = if (pass.scene != null) @floatFromInt(post.spare_len) else 0,
+ };
+ if (pass.scene != null) @memcpy(own.spare[0..post.spare_len], post.spare[0..post.spare_len]);
+ c.SDL_PushGPUFragmentUniformData(cmd, 3, &own, @sizeOf(Own));
c.SDL_DrawGPUPrimitives(pass_rp, 3, 1, 0, 0);
c.SDL_EndGPURenderPass(pass_rp);
input = output;
@@ -431,7 +585,107 @@ pub fn draw(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer,
u.frame +%= 1;
}
+/// ghostty's block as the prefix cuts it (SDL GPU binds at most 4 KiB of a
+/// block): the globals up to the palette, the palette, the colours after it,
+/// each a slice of the one struct, at set 3 bindings 0 to 2.
+fn pushUniforms(cmd: *c.SDL_GPUCommandBuffer, u: *const Uniforms) void {
+ const bytes = std.mem.asBytes(u);
+ const palette = @offsetOf(Uniforms, "palette");
+ const colors = @offsetOf(Uniforms, "background_color");
+ c.SDL_PushGPUFragmentUniformData(cmd, 0, bytes.ptr, @intCast(palette));
+ c.SDL_PushGPUFragmentUniformData(cmd, 1, bytes.ptr + palette, @intCast(colors - palette));
+ c.SDL_PushGPUFragmentUniformData(cmd, 2, bytes.ptr + colors, @intCast(@sizeOf(Uniforms) - colors));
+}
+
+test "each slice of the uniforms fits the 4 KiB SDL GPU binds of a block" {
+ try std.testing.expect(@offsetOf(Uniforms, "palette") <= 4096);
+ try std.testing.expect(@offsetOf(Uniforms, "background_color") - @offsetOf(Uniforms, "palette") <= 4096);
+ try std.testing.expect(@sizeOf(Uniforms) - @offsetOf(Uniforms, "background_color") <= 4096);
+ try std.testing.expect(@sizeOf(Own) <= 4096);
+}
+
+/// Bloom's glow of `input` (w x h): a bright pass and dual Kawase steps
+/// down to 1/2^n of the frame and back up to a half, in float textures;
+/// the half-size result, for the composite (shaders/post/bloom.glsl).
+fn bloomChain(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer, sampler: *c.SDL_GPUSampler, input: *c.SDL_GPUTexture, w: u32, h: u32, level: u8) !*c.SDL_GPUTexture {
+ const down = post.bloom_down orelse return error.GpuCreate;
+ const up = post.bloom_up orelse return error.GpuCreate;
+ const steps = bloom_steps[std.math.clamp(level, 1, 3) - 1];
+ if (post.bloom_tex[0] == null or post.bloom_w != w or post.bloom_h != h) {
+ for (&post.bloom_tex, 0..) |*texture, i| {
+ if (texture.*) |old| c.SDL_ReleaseGPUTexture(device, old);
+ var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo);
+ info.type = c.SDL_GPU_TEXTURETYPE_2D;
+ info.format = bloom_format;
+ info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | c.SDL_GPU_TEXTUREUSAGE_SAMPLER;
+ info.width = @max(1, w >> @intCast(i + 1));
+ info.height = @max(1, h >> @intCast(i + 1));
+ info.layer_count_or_depth = 1;
+ info.num_levels = 1;
+ info.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
+ texture.* = c.SDL_CreateGPUTexture(device, &info) orelse return error.GpuCreate;
+ }
+ post.bloom_w = w;
+ post.bloom_h = h;
+ }
+ const Step = extern struct { texels: [4]f32, pass: [4]f32 };
+ const size = struct {
+ fn of(full: u32, i: usize) f32 {
+ return @floatFromInt(if (i == 0) full else @max(1, full >> @intCast(i)));
+ }
+ };
+ // Down: the frame (0) to 1, 1 to 2, ...; the first through the bright
+ // pass (a channel's linear peak over 0.6 to 0.75, by level).
+ const threshold: f32 = switch (std.math.clamp(level, 1, 3)) {
+ 1 => 0.75,
+ 2 => 0.7,
+ else => 0.6,
+ };
+ // Only what is brighter than the page glows: on a light page, where the
+ // ground itself would pass, nothing does.
+ const page_peak = peak: {
+ var peak: f32 = 0;
+ for (post.uniforms.background_color[0..3]) |v| peak = @max(peak, if (v <= 0.04045) v / 12.92 else std.math.pow(f32, (v + 0.055) / 1.055, 2.4));
+ break :peak peak;
+ };
+ for (0..steps) |i| {
+ const from = if (i == 0) input else post.bloom_tex[i - 1].?;
+ const to = post.bloom_tex[i].?;
+ const params: Step = .{
+ .texels = .{ 1 / size.of(w, i), 1 / size.of(h, i), 1 / size.of(w, i + 1), 1 / size.of(h, i + 1) },
+ .pass = .{ if (i == 0) @max(threshold, page_peak + 0.15) else 0, 0.25, 0, 0 },
+ };
+ bloomStep(cmd, down, sampler, from, to, &params, @sizeOf(Step));
+ }
+ // Up: each level into the one above it, back to the half.
+ var i: usize = steps - 1;
+ while (i > 0) : (i -= 1) {
+ const params: Step = .{
+ .texels = .{ 1 / size.of(w, i + 1), 1 / size.of(h, i + 1), 1 / size.of(w, i), 1 / size.of(h, i) },
+ .pass = @splat(0),
+ };
+ bloomStep(cmd, up, sampler, post.bloom_tex[i].?, post.bloom_tex[i - 1].?, &params, @sizeOf(Step));
+ }
+ return post.bloom_tex[0].?;
+}
+
+fn bloomStep(cmd: *c.SDL_GPUCommandBuffer, pipeline: *Pipeline, sampler: *c.SDL_GPUSampler, from: *c.SDL_GPUTexture, to: *c.SDL_GPUTexture, params: *const anyopaque, len: u32) void {
+ var info = std.mem.zeroes(c.SDL_GPUColorTargetInfo);
+ info.texture = to;
+ info.load_op = c.SDL_GPU_LOADOP_DONT_CARE;
+ info.store_op = c.SDL_GPU_STOREOP_STORE;
+ const rp = c.SDL_BeginGPURenderPass(cmd, &info, 1, null);
+ c.SDL_BindGPUGraphicsPipeline(rp, pipeline);
+ const binding = c.SDL_GPUTextureSamplerBinding{ .texture = from, .sampler = sampler };
+ c.SDL_BindGPUFragmentSamplers(rp, 0, &binding, 1);
+ c.SDL_PushGPUFragmentUniformData(cmd, 0, params, len);
+ c.SDL_DrawGPUPrimitives(rp, 3, 1, 0, 0);
+ c.SDL_EndGPURenderPass(rp);
+}
+
pub fn deinit(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice) void {
+ for (post.bloom_tex) |texture| if (texture) |t| c.SDL_ReleaseGPUTexture(device, t);
+ for ([_]?*Pipeline{ post.bloom_down, post.bloom_up }) |pipeline| if (pipeline) |p| c.SDL_ReleaseGPUGraphicsPipeline(device, p);
if (post.job) |job| {
job.thread.join();
for (job.paths[0..job.len], job.results[0..job.len]) |path, result| {
@@ -508,16 +762,22 @@ test "the uniform block is ghostty's, offset for offset, and the prefix names it
.{ "vec3", "iCursorText" }, .{ "vec3", "iSelectionForegroundColor" }, .{ "vec3", "iSelectionBackgroundColor" },
};
try std.testing.expectEqual(ours.len, glsl.len);
- const block = prefix[std.mem.indexOf(u8, prefix, "uniform Globals {").?..];
- var lines = std.mem.tokenizeScalar(u8, block[0..std.mem.indexOfScalar(u8, block, '}').?], '\n');
- _ = lines.next();
- for (glsl) |member| {
- var words = std.mem.tokenizeAny(u8, lines.next().?, " ;");
+ // Its three blocks (Globals, Palette, Colors) read as one, in order.
+ const start = std.mem.indexOf(u8, prefix, "uniform Globals {").?;
+ const end = std.mem.indexOf(u8, prefix, "uniform PardesPost {").?;
+ var lines = std.mem.tokenizeScalar(u8, prefix[start..end], '\n');
+ var n: usize = 0;
+ while (lines.next()) |line| {
+ if (!std.mem.startsWith(u8, line, " uniform ")) continue;
+ var words = std.mem.tokenizeAny(u8, line, " ;");
try std.testing.expectEqualStrings("uniform", words.next().?);
- try std.testing.expectEqualStrings(member[0], words.next().?);
- try std.testing.expectEqualStrings(member[1], words.next().?);
+ try std.testing.expectEqualStrings(glsl[n][0], words.next().?);
+ try std.testing.expectEqualStrings(glsl[n][1], words.next().?);
+ n += 1;
}
- try std.testing.expect(lines.next() == null);
+ try std.testing.expectEqual(glsl.len, n);
+ // Each block starts where its first member is in the one struct.
+ try std.testing.expect(std.mem.indexOf(u8, prefix, "uniform Palette {\n uniform vec3 iPalette[256];\n};") != null);
}
test "a moved cursor becomes the previous one, stamped with the time of the move" {
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index de5da1e7..bd2dca69 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -507,6 +507,12 @@ 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;
+/// Decor that is a checker (decor.frag.glsl).
+const checker_bit: u32 = 0x0800_0000;
+/// A cell on the page's ground under the theme's dots, and a file name's
+/// cell that casts the theme's shadow (ui.frag.glsl, Chrome.decor_*).
+const dotted_bit: u32 = 0x1000_0000;
+const title_bit: u32 = 0x0800_0000;
const SavedImagePlace = struct {
key: pardes.ImageCacheKey,
@@ -1787,6 +1793,10 @@ const Gui = struct {
device: *c.SDL_GPUDevice,
swapchain_format: c.SDL_GPUTextureFormat,
pipeline: *c.SDL_GPUGraphicsPipeline,
+ /// The cell pipeline, blending premultiplied over what is drawn under:
+ /// for a frame with a theme's tag plaques, whose blank tag cells let
+ /// them show (tagPlaques).
+ pipeline_over: *c.SDL_GPUGraphicsPipeline,
decor_pipeline: *c.SDL_GPUGraphicsPipeline,
overlay_pipeline: *c.SDL_GPUGraphicsPipeline,
image_pipeline: *c.SDL_GPUGraphicsPipeline,
@@ -1856,6 +1866,12 @@ const Gui = struct {
scroll_lag: f32 = 0, // picture position - core position, in rows
scroll_edge: []pardes.Cell = &.{}, // the row that just left the pane
scroll_edge_len: u16 = 0,
+ /// G6: a wheel's notches, whole lines, scroll the core at once and the
+ /// picture follows on a spring (Glide), never the other way round.
+ scroll_notch: i32 = 0,
+ glide: Glide = .{},
+ /// G9: the selection's glow blocks as last drawn, for their fade-in.
+ sel_glow: SelGlow = .{},
/// The cursor's blink (blinkNow): when the frame drawn last arrived,
/// a redraw owed for an edge, and this frame's cursor mode and alpha.
@@ -1981,7 +1997,7 @@ const CellLayout = struct { w: f32, h: f32, x_off: f32, y_off: f32 };
const MouseCell = struct { col: u16, row: u16, body_hit: ?pardes.Mouse.BodyHit = null, tag_hit: ?pardes.TagHit = null, rule_above: bool = false };
/// How near a row's top edge a press is on the rule drawn there between two
-/// panes: the rule's 2 px and a pixel either side.
+/// panes: the rule's 2 px and a pixel either side, in logical pixels (dp).
const rule_grab_px: f32 = 3;
const WindowGeometry = struct {
window_w: f32,
@@ -2068,6 +2084,9 @@ const Group = struct {
image_count: u32 = 0,
decor_start: u32 = 0,
decor_count: u32 = 0,
+ /// The first `under_count` of the group's decor are drawn before its
+ /// cells, under them (a theme's tag plaques, whose cells let them show).
+ under_count: u32 = 0,
};
/// The frame's draw groups, bottom to top: tier 0 (the page and the panes
@@ -2331,6 +2350,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
return error.SdlInit;
};
defer c.SDL_DestroyWindow(window);
+ updateDisplayScale(window);
if (native_wayland) log.info("Wayland opacity: background-only transparent surface", .{});
const arrow_cursor = c.SDL_CreateCursor(&p9_arrow_set, &p9_arrow_mask, 16, 16, 1, 1);
defer if (arrow_cursor) |cursor| c.SDL_DestroyCursor(cursor);
@@ -2403,7 +2423,13 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
font_allocator = std.heap.smp_allocator;
c.ui_font_prime(font);
font_allocator = gpa;
- const px: f32 = 27.0;
+ // A harness forcing a density (PARDES_TEST_SCALE) draws the text as a
+ // person on that display would size it, so its chrome is seen at the
+ // proportion it has there; PARDES_TEST_FONT_SCALE sizes the text alone
+ // (a density's text with 1x chrome: how it looked before dp). A live
+ // window keeps the size the user sets.
+ const font_scale: f32 = if (env.get("PARDES_TEST_FONT_SCALE")) |v| std.fmt.parseFloat(f32, v) catch display_scale else display_scale;
+ const px: f32 = if (test_mode) 27.0 * std.math.clamp(font_scale, 1, 4) else 27.0;
var cw: c_int = 10;
var chh: c_int = 20;
var asc: c_int = 16;
@@ -2448,6 +2474,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
const atlas_xfer = c.SDL_CreateGPUTransferBuffer(device, &ax_info) orelse return error.GpuCreate;
const pipeline = try makePipeline(device, swapchain_format, frag_spv, 1, std.mem.zeroes(c.SDL_GPUColorTargetBlendState));
+ const pipeline_over = try makePipeline(device, swapchain_format, frag_spv, 1, premultipliedBlend());
const decor_pipeline = try makePipeline(device, swapchain_format, decor_frag_spv, 0, backgroundLayerBlend());
const overlay_pipeline = try makeOverlayPipeline(device, swapchain_format);
const image_pipeline = try makeImagePipeline(device, swapchain_format);
@@ -2478,6 +2505,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
.device = device,
.swapchain_format = swapchain_format,
.pipeline = pipeline,
+ .pipeline_over = pipeline_over,
.decor_pipeline = decor_pipeline,
.overlay_pipeline = overlay_pipeline,
.image_pipeline = image_pipeline,
@@ -2516,6 +2544,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
// regrown to; the image ones are released below.
defer {
c.SDL_ReleaseGPUGraphicsPipeline(device, g.pipeline);
+ c.SDL_ReleaseGPUGraphicsPipeline(device, g.pipeline_over);
c.SDL_ReleaseGPUGraphicsPipeline(device, g.decor_pipeline);
c.SDL_ReleaseGPUGraphicsPipeline(device, g.overlay_pipeline);
c.SDL_ReleaseGPUTexture(device, g.atlas_tex);
@@ -2551,6 +2580,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u
defer if (g.font_bytes.len != 0) gpa.free(g.font_bytes); // set by Font, if it ran
defer c.ui_font_free(g.font);
defer gpa.free(g.scroll_edge); // grown on demand by stepScroll
+ defer g.glide.deinit(gpa);
resetGlyphAtlas(&g);
if (attach) |requested| return attachRequested(gpa, &g, requested);
@@ -3081,6 +3111,7 @@ const StdinFeed = struct {
var seq_start: usize = 0;
while (seq_start < len) {
// ESC]777;mouse;<down|up|motion>;<SDL button, 0 for none>;<x>;<y>BEL
+ // ESC]777;mouse;wheel;<amount>;<x>;<y>BEL
// A mouse event at window pixels, built as SDL would and run
// through dispatch, so a driver exercises the real pointer path
// (pixel to cell, tag and body hits). SGR mouse carries only cells.
@@ -3095,11 +3126,21 @@ const StdinFeed = struct {
if (g) |gp| {
var parts = std.mem.splitScalar(u8, body[0..end], ';');
const kind = parts.next() orelse "";
- const button = std.fmt.parseInt(u8, parts.next() orelse "", 10) catch 0;
+ const second = parts.next() orelse "";
+ const button = std.fmt.parseInt(u8, second, 10) catch 0;
const x = std.fmt.parseFloat(f32, parts.next() orelse "") catch 0;
const y = std.fmt.parseFloat(f32, parts.next() orelse "") catch 0;
var sev = std.mem.zeroes(c.SDL_Event);
- if (std.mem.eql(u8, kind, "motion")) {
+ if (std.mem.eql(u8, kind, "wheel")) {
+ // ESC]777;mouse;wheel;<y, SDL's sign: up positive>;<x>;<y>BEL:
+ // a whole number is a mouse's notches, else a touchpad's.
+ const amount = std.fmt.parseFloat(f32, second) catch 0;
+ sev.type = c.SDL_EVENT_MOUSE_WHEEL;
+ sev.wheel.y = amount;
+ sev.wheel.integer_y = @intFromFloat(@trunc(amount));
+ sev.wheel.mouse_x = x;
+ sev.wheel.mouse_y = y;
+ } else if (std.mem.eql(u8, kind, "motion")) {
sev.type = c.SDL_EVENT_MOUSE_MOTION;
sev.motion.x = x;
sev.motion.y = y;
@@ -3349,6 +3390,12 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
const at = windowPointToPixels(windowGeometry(g.window), sev.button.x, sev.button.y);
g.post.mouse(at.x, at.y, sev.button.down);
},
+ c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED => {
+ updateDisplayScale(g.window);
+ // Underlines are drawn into the glyphs a logical pixel thick.
+ resetGlyphAtlas(g);
+ if (in.core) |core| core.needs_frame = true;
+ },
c.SDL_EVENT_WINDOW_FOCUS_GAINED => g.post.focus(true),
c.SDL_EVENT_WINDOW_FOCUS_LOST => g.post.focus(false),
else => {},
@@ -3504,7 +3551,12 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void {
g.scroll_pane = id;
g.scroll_col = mc.col;
g.scroll_row = mc.row;
- g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y);
+ // A mouse wheel's notch is a whole line and
+ // glides (G6); a touchpad's fraction moves the
+ // picture by itself, under the finger.
+ if (notchOf(w.y, w.integer_y)) |lines| {
+ g.scroll_notch +|= lines;
+ } else g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y);
}
}
} else {
@@ -3734,7 +3786,7 @@ fn gridCellAtDimensions(
const row: u16 = @intFromFloat(@min(@floor(@max(y, 0) / @max(body_h, 1)), 10_000));
var point: MouseCell = .{ .col = @intFromFloat(@min(@floor(@max(x, 0) / @max(body_w, 1)), 10_000)), .row = row };
// On the rule over this row, which is where two panes meet if they do.
- point.rule_above = row > 0 and @max(y, 0) - @as(f32, @floatFromInt(row)) * @max(body_h, 1) < rule_grab_px;
+ point.rule_above = row > 0 and @max(y, 0) - @as(f32, @floatFromInt(row)) * @max(body_h, 1) < dp(rule_grab_px);
if (core) |p| if (p.presentation.pointerFractional(p.screen_w, p.screen_h, x / @max(body_w, 1), y / @max(body_h, 1))) |mapped| {
for (p.surface.tagLayers()) |*layer| {
if (layer.tagHit(mapped.x * body_w, mapped.y * body_h, body_w, body_h, tagline_w, @floatFromInt(p.row_metrics.tagline_h))) |hit| {
@@ -4185,7 +4237,7 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void {
} else for (s.ptys) |slot| {
if (slot) |pt| if (pt.kill_at != 0) break true;
} else false;
- const polls = g.scroll_lag != 0 or g.scroll_delta != 0 or g.gamepad != null or s.test_mode or core.needs_frame or retiring or
+ const polls = g.scroll_lag != 0 or g.scroll_delta != 0 or g.scroll_notch != 0 or g.glide.moving() or g.gamepad != null or s.test_mode or core.needs_frame or retiring or
g.post.animating(core.settings.shader_animation);
const minimized = !s.test_mode and c.SDL_GetWindowFlags(g.window) & (c.SDL_WINDOW_MINIMIZED | c.SDL_WINDOW_OCCLUDED) != 0;
// A latency trace is fed on stdin, whose poll below paces the loop:
@@ -4776,14 +4828,356 @@ test "fractional scroll reversals preserve signed distance" {
try std.testing.expect(reverse.lag >= 0 and reverse.lag < 1);
}
+/// A mouse wheel's step as whole lines (down positive), or null for a
+/// touchpad's fraction, which follows the finger as it is.
+fn notchOf(raw_y: f32, integer_y: i32) ?i32 {
+ if (!std.math.isFinite(raw_y) or integer_y == 0 or raw_y != @round(raw_y)) return null;
+ return @intFromFloat(std.math.clamp(-raw_y, -64, 64));
+}
+
+test "a wheel's notch is whole lines, a touchpad's fraction is not" {
+ try std.testing.expectEqual(@as(?i32, 1), notchOf(-1, -1));
+ try std.testing.expectEqual(@as(?i32, -3), notchOf(3, 3));
+ try std.testing.expectEqual(@as(?i32, null), notchOf(-0.25, 0));
+ try std.testing.expectEqual(@as(?i32, null), notchOf(-1.5, -1));
+ try std.testing.expectEqual(@as(?i32, null), notchOf(std.math.nan(f32), 1));
+}
+
+/// G6 (docs/render-pipeline.md §9.1): a wheel's lines scroll the core at
+/// once, so every key and click acts on where the text is, and the picture
+/// glides there on the Motion flavour's spring (critically damped: text
+/// never passes its mark). How far the rows moved is measured on the next
+/// frame against the last, a line wrapped over several rows or a terminal's
+/// output included, and the rows that left are kept to draw in the gap.
+const Glide = struct {
+ spring: pardes.animation.Spring = .{},
+ /// The picture's offset from the core's rows now (rows; negative, the
+ /// picture still shows what is above).
+ offset: f32 = 0,
+ now_ns: u64 = 0,
+ pane: usize = 0,
+ rect: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 },
+ body_y: u16 = 0,
+ body_h: u16 = 0,
+ /// A scroll made and not yet measured: its direction, and the body's
+ /// rows before it.
+ pending: bool = false,
+ dir: i8 = 0,
+ before: []pardes.Cell = &.{},
+ /// The rows past the body's top (`above`) and bottom (`below`), nearest
+ /// first, `bw` cells each: what the gap shows while the picture lags.
+ above: []pardes.Cell = &.{},
+ below: []pardes.Cell = &.{},
+ nabove: u16 = 0,
+ nbelow: u16 = 0,
+ /// The Motion flavour the last loop step read.
+ motion: pardes.animation.Motion = .{},
+
+ fn moving(glide: *const Glide) bool {
+ return !glide.spring.settled or glide.pending;
+ }
+
+ fn stop(glide: *Glide) void {
+ glide.spring = .{};
+ glide.offset = 0;
+ glide.pending = false;
+ glide.nabove = 0;
+ glide.nbelow = 0;
+ }
+
+ fn deinit(glide: *Glide, gpa: std.mem.Allocator) void {
+ gpa.free(glide.before);
+ gpa.free(glide.above);
+ gpa.free(glide.below);
+ }
+
+ fn bw(glide: *const Glide) u16 {
+ return glide.rect.w -| config.GUTTER;
+ }
+
+ /// The core's rows moved by `rows` (down positive) since the last
+ /// frame: the picture now lags by as many, carrying its speed.
+ fn kick(glide: *Glide, rows: f32, now_ns: u64, motion: pardes.animation.Motion) void {
+ // Follows input: the flavour's pace, half again, never overshooting.
+ var pace = motion;
+ pace.omega = motion.omega * 1.5;
+ glide.spring.carry(-rows, now_ns, pace);
+ // Never more than a body's rows behind: a flick past that jumps.
+ const limit: f32 = @floatFromInt(glide.body_h);
+ const now = glide.spring.value(now_ns);
+ if (@abs(now) > limit) glide.spring.carry(std.math.clamp(now, -limit, limit) - now, now_ns, pace);
+ glide.offset = glide.spring.value(now_ns);
+ }
+
+ /// Finds how far the pane's body rows moved, `dir` rows at a time, by
+ /// the rows of `surface` against `before`; keeps the rows that left.
+ fn measure(glide: *Glide, gpa: std.mem.Allocator, surface: *const pardes.Surface) void {
+ glide.pending = false;
+ const w = glide.bw();
+ const h = glide.body_h;
+ const x0 = glide.rect.x + config.GUTTER;
+ if (w == 0 or h == 0 or x0 + w > surface.cols or glide.body_y + h > surface.rows or glide.before.len < @as(usize, w) * h) return glide.stop();
+ const now = struct {
+ fn row(s: *const pardes.Surface, x: u16, y: u16, n: u16) []const pardes.Cell {
+ return s.cells[@as(usize, y) * s.cols + x ..][0..n];
+ }
+ };
+ // The least shift under which the rows agree, but for a few: the
+ // cursor's line and its number's weight move with the scroll.
+ const shift: u16 = found: for (1..h) |k| {
+ const kk: u16 = @intCast(k);
+ const allowed = @max(2, (h - kk) / 8);
+ var misses: usize = 0;
+ for (0..h - kk) |r| {
+ const rr: u16 = @intCast(r);
+ const old_row = glide.before[@as(usize, if (glide.dir > 0) rr + kk else rr) * w ..][0..w];
+ const new_row = now.row(surface, x0, glide.body_y + if (glide.dir > 0) rr else rr + kk, w);
+ if (!sameRow(old_row, new_row)) {
+ misses += 1;
+ if (misses > allowed) continue :found;
+ }
+ }
+ // Too few rows left to tell: no glide.
+ if (h - kk < 3) break :found 0;
+ break :found kk;
+ } else 0;
+ if (shift == 0) return glide.stop();
+ // The rows that left: past the top going down, the bottom going up.
+ if (!glide.ensure(gpa)) return glide.stop();
+ const keep = if (glide.dir > 0) &glide.above else &glide.below;
+ const count = if (glide.dir > 0) &glide.nabove else &glide.nbelow;
+ const kept = @min(count.*, h - shift);
+ std.mem.copyBackwards(pardes.Cell, keep.*[@as(usize, shift) * w ..][0 .. @as(usize, kept) * w], keep.*[0 .. @as(usize, kept) * w]);
+ for (0..shift) |i| {
+ const src_row: usize = if (glide.dir > 0) shift - 1 - i else h - shift + i;
+ @memcpy(keep.*[i * w ..][0..w], glide.before[src_row * w ..][0..w]);
+ }
+ count.* = kept + shift;
+ // What came into view is no longer past the other edge.
+ const other = if (glide.dir > 0) &glide.below else &glide.above;
+ const other_n = if (glide.dir > 0) &glide.nbelow else &glide.nabove;
+ const dropped = @min(other_n.*, shift);
+ std.mem.copyForwards(pardes.Cell, other.*[0 .. @as(usize, other_n.* - dropped) * w], other.*[@as(usize, dropped) * w ..][0 .. @as(usize, other_n.* - dropped) * w]);
+ other_n.* -= dropped;
+ glide.kick(@as(f32, @floatFromInt(shift)) * @as(f32, @floatFromInt(glide.dir)), glide.now_ns, glide.motion);
+ }
+
+ fn ensure(glide: *Glide, gpa: std.mem.Allocator) bool {
+ const n = @as(usize, glide.bw()) * glide.body_h;
+ inline for (.{ &glide.above, &glide.below }) |buffer| if (buffer.len < n) {
+ const grown = gpa.realloc(buffer.*, n) catch return false;
+ buffer.* = grown;
+ };
+ return true;
+ }
+};
+
+fn sameRow(a: []const pardes.Cell, b: []const pardes.Cell) bool {
+ for (a, b) |x, y| {
+ if (x.default != y.default) return false;
+ if (x.default) continue;
+ if (!std.mem.eql(u8, x.grapheme(), y.grapheme()) or !std.meta.eql(x.style, y.style)) return false;
+ }
+ return true;
+}
+
+/// Feeds a wheel's notches to the core, snapshotting the body first so the
+/// next frame can measure the move (Glide.measure); advances the glide and
+/// asks for frames while it moves.
+fn stepGlide(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void {
+ const glide = &g.glide;
+ // The glide runs on the core's continuous path: frames every loop, and
+ // a virtual clock carried on.
+ defer core.shell_continuous = glide.moving() or g.sel_glow.fading;
+ glide.now_ns = core.now_ns;
+ glide.motion = pardes.animation.Motion.of(core.settings.motion);
+ if (!glide.spring.settled) {
+ _ = glide.spring.step(core.now_ns);
+ core.needs_frame = true;
+ if (glide.spring.settled) {
+ glide.offset = 0;
+ glide.nabove = 0;
+ glide.nbelow = 0;
+ }
+ }
+ const notch = g.scroll_notch;
+ if (notch == 0) return;
+ g.scroll_notch = 0;
+ const id = g.scroll_pane orelse return;
+ const pane = core.panes[id] orelse return resetScroll(g);
+ const r = core.rects[id];
+ if (r.h <= pane.tag_rows or r.w <= config.GUTTER) return resetScroll(g);
+ const body_y = core.bodyTop(pane, r);
+ const body_h = r.h -| pane.tag_rows;
+ // A new pane or place: what was kept belongs to another picture.
+ if (glide.pane != id or !std.meta.eql(glide.rect, r) or glide.body_y != body_y or glide.body_h != body_h or glide.pending) glide.stop();
+ glide.pane = id;
+ glide.rect = r;
+ glide.body_y = body_y;
+ glide.body_h = body_h;
+ // The body as the last frame drew it, which the move is measured from.
+ const s = &core.surface;
+ const w = r.w - config.GUTTER;
+ const x0 = r.x + config.GUTTER;
+ const animate = !glide.motion.instant and x0 + w <= s.cols and body_y + body_h <= s.rows and s.cells.len != 0;
+ if (animate) {
+ const n = @as(usize, w) * body_h;
+ if (glide.before.len < n) glide.before = gpa.realloc(glide.before, n) catch &.{};
+ if (glide.before.len >= n) {
+ for (0..body_h) |row| @memcpy(glide.before[row * w ..][0..w], s.cells[(@as(usize, body_y) + row) * s.cols + x0 ..][0..w]);
+ }
+ }
+ var left = notch;
+ var moved = false;
+ while (left != 0) {
+ const down = left > 0;
+ left += if (down) -1 else 1;
+ const was = pane.scroll();
+ core.update(.{ .mouse = .{ .button = if (down) .wheel_down else .wheel_up, .kind = .press, .col = g.scroll_col, .row = g.scroll_row } });
+ if (pane.scroll() == was) break;
+ moved = true;
+ }
+ if (!moved) return;
+ core.needs_frame = true;
+ if (animate and glide.before.len >= @as(usize, w) * body_h) {
+ glide.pending = true;
+ glide.dir = @intCast(std.math.sign(notch));
+ }
+}
+
+/// The glide's picture: the pane's body rows moved by the offset, and in
+/// the gap it opens the rows that left, as `emitScrollRows` draws a
+/// touchpad's fraction.
+fn emitGlideRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32, page: Ground) u32 {
+ const glide = &g.glide;
+ for (surface.panelTracks()) |track|
+ if (track.active() and track.pane == glide.pane) return 0;
+ const r = glide.rect;
+ const bh = glide.body_h;
+ const bw = glide.bw();
+ const x0 = r.x + config.GUTTER;
+ const y0 = glide.body_y;
+ if (bw == 0 or bh == 0 or x0 + bw > surface.cols or y0 + bh > surface.rows) return 0;
+ // The scissor the shifted rows are drawn in is the scroll's.
+ g.scroll_rect = r;
+ g.scroll_body_y = y0;
+ g.scroll_body_h = bh;
+ var shifted = layout;
+ shifted.y_off -= glide.offset * layout.h;
+ const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else std.math.maxInt(u32);
+ const cursor_bar = if (surface.cursor) |cu| cu.bar else false;
+ var n: u32 = 0;
+ var row = y0;
+ while (row < y0 + bh) : (row += 1) {
+ const line = surface.cells[@as(usize, row) * surface.cols ..][0..surface.cols];
+ const row_cursor: ?usize = if (surface.cursor) |cu| if (cu.y == row) cu.x else null else null;
+ var col = x0;
+ while (col < x0 + bw) : (col += 1) {
+ const sidx: u32 = @as(u32, row) * surface.cols + col;
+ emitInstance(g, instances, base + n, col, row, shifted, win_w, win_h, null, cellFontRole(surface.at(col, row)), line, col, row_cursor, false, sidx == cursor_idx and !cursor_bar, page);
+ n += 1;
+ }
+ }
+ // The gap: rows past the top while the picture lags a move down, past
+ // the bottom for one up; as many as it opens.
+ const gap: u16 = @intFromFloat(@min(@ceil(@abs(glide.offset)), @as(f32, @floatFromInt(bh))));
+ const kept = if (glide.offset < 0) glide.nabove else glide.nbelow;
+ const rows = if (glide.offset < 0) glide.above else glide.below;
+ for (0..@min(gap, kept)) |i| {
+ const cells = rows[i * bw ..][0..bw];
+ // Row -1 - i above the body, or bh + i below it, in grid rows.
+ const at_row: i32 = if (glide.offset < 0) @as(i32, y0) - 1 - @as(i32, @intCast(i)) else @as(i32, y0) + bh + @as(i32, @intCast(i));
+ var at = shifted;
+ // emitInstance takes an unsigned row: move the layout instead.
+ at.y_off += @as(f32, @floatFromInt(at_row - @as(i32, y0))) * layout.h;
+ for (0..bw) |c_i| {
+ emitInstance(g, instances, base + n, x0 + @as(u16, @intCast(c_i)), y0, at, win_w, win_h, null, cellFontRole(&cells[c_i]), cells, c_i, null, false, false, page);
+ n += 1;
+ }
+ }
+ return n;
+}
+
+test "a notch scrolls the core at once and the picture glides after it, a wrapped line's rows at a time" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 });
+ defer core.deinit();
+ // Lines of two rows each at this width, numbered so no two rows match.
+ var text: std.ArrayList(u8) = .empty;
+ defer text.deinit(gpa);
+ for (0..60) |i| {
+ try text.print(gpa, "{d:0>4} ", .{i});
+ try text.appendNTimes(gpa, 'a' + @as(u8, @intCast(i % 26)), 100);
+ try text.append(gpa, '\n');
+ }
+ _ = try core.setTestFile(text.items);
+ core.settings.motion = .smooth;
+ var arena: std.heap.ArenaAllocator = .init(gpa);
+ defer arena.deinit();
+ const ms = std.time.ns_per_ms;
+ var now: u64 = std.time.ns_per_s;
+ core.advance(now);
+ var s = try core.render(arena.allocator());
+ var g: Gui = undefined;
+ g.glide = .{};
+ defer g.glide.deinit(gpa);
+ g.scroll_delta = 0;
+ g.scroll_lag = 0;
+ g.scroll_edge_len = 0;
+ const r = core.rects[core.active];
+ g.scroll_pane = core.active;
+ g.scroll_col = r.x + 10;
+ g.scroll_row = r.y + r.h - 2;
+ const body_y = core.bodyTop(core.panes[core.active].?, r);
+ const row_before = try gpa.dupe(pardes.Cell, s.cells[@as(usize, body_y + 1) * s.cols + r.x + config.GUTTER ..][0 .. r.w - config.GUTTER]);
+ defer gpa.free(row_before);
+ // One notch down: the core is a line on at once.
+ g.scroll_notch = 1;
+ const scrolled = core.panes[core.active].?.scroll();
+ stepGlide(&g, core, gpa);
+ try std.testing.expectEqual(scrolled + 1, core.panes[core.active].?.scroll());
+ try std.testing.expect(g.glide.pending);
+ // On the core's continuous path while it moves: frames every loop.
+ try std.testing.expect(core.shell_continuous and core.continuous());
+ _ = arena.reset(.retain_capacity);
+ s = try core.render(arena.allocator());
+ g.glide.measure(gpa, s);
+ // Its two rows: the picture two rows behind, the rows that left kept,
+ // nearest first.
+ try std.testing.expectApproxEqAbs(@as(f32, -2), g.glide.offset, 1e-4);
+ try std.testing.expectEqual(@as(u16, 2), g.glide.nabove);
+ try std.testing.expect(sameRow(g.glide.above[0 .. r.w - config.GUTTER], row_before));
+ // Frame by frame it comes back, never past, and settles.
+ var frames: usize = 0;
+ while (g.glide.moving()) : (frames += 1) {
+ now += 7 * ms;
+ core.advance(now);
+ stepGlide(&g, core, gpa);
+ try std.testing.expect(g.glide.spring.value(now) <= 1e-4);
+ if (frames > 200) return error.NeverSettled;
+ }
+ try std.testing.expect(frames > 5);
+ try std.testing.expectEqual(@as(f32, 0), g.glide.offset);
+ try std.testing.expect(!core.shell_continuous);
+ // Motion off: a notch lands at once.
+ core.settings.motion = .off;
+ g.scroll_notch = 1;
+ stepGlide(&g, core, gpa);
+ try std.testing.expect(!g.glide.pending and !g.glide.moving());
+}
+
+
fn resetScroll(g: *Gui) void {
g.scroll_pane = null;
g.scroll_delta = 0;
g.scroll_lag = 0;
g.scroll_edge_len = 0;
+ g.scroll_notch = 0;
+ g.glide.stop();
}
fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void {
+ stepGlide(g, core, gpa);
const id = g.scroll_pane orelse return;
const pane = core.panes[id] orelse {
resetScroll(g);
@@ -4792,7 +5186,7 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void {
const queued_delta = g.scroll_delta;
g.scroll_delta = 0;
if (queued_delta == 0) {
- if (g.scroll_lag == 0) resetScroll(g);
+ if (g.scroll_lag == 0 and g.scroll_notch == 0 and !g.glide.moving()) resetScroll(g);
return;
}
const r = core.rects[id];
@@ -4847,6 +5241,7 @@ fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void {
}
fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32, page: Ground) u32 {
+ if (g.glide.offset != 0) return emitGlideRows(g, instances, base, surface, layout, win_w, win_h, page);
const scroll_pane = g.scroll_pane orelse return 0;
if (g.scroll_lag == 0) return 0;
for (surface.panelTracks()) |track|
@@ -5413,6 +5808,27 @@ fn renderFrame(
defer if (building) build_zone.end();
const layout = fixedCellLayout(g);
const chrome = &surface.chrome;
+ // G6: what the scroll just done moved the pane's rows by, measured on
+ // this frame against the last (the glide follows it from there).
+ if (g.glide.pending) g.glide.measure(gpa, surface);
+ g.sel_glow.now_ns = if (core) |p| p.now_ns else c.SDL_GetTicksNS();
+ // Off, or while panes move, no glow and nothing fading.
+ if (surface.chrome.selection_glow == 0 or surface.panelTracks().len != 0) g.sel_glow = .{ .now_ns = g.sel_glow.now_ns };
+ if (g.glide.moving()) g.glide.offset = g.glide.spring.value(g.glide.now_ns);
+ cell_decor = CellDecor.of(chrome);
+ // Plaques need an opaque window: with WindowOpacity under 100 the cells
+ // are drawn as they are, and the plaques not at all.
+ cell_decor.plaques_off = backgroundOpacity(g.applied_window_opacity) < 1;
+ if (chrome.decor_box_border != null and !cell_decor.plaques_off) {
+ cell_decor.grounds = .{ chrome.tag_bg, chrome.tag_focus_bg };
+ for (surface.regionList()) |region| {
+ if (region.kind != .tag and region.kind != .column_tag and region.kind != .workspace_tag) continue;
+ if (cell_decor.nplaques == cell_decor.plaques.len) break;
+ const plaque = plaqueBox(g, chrome, surface.regionList(), region, layout, win_w, win_h) orelse continue;
+ cell_decor.plaques[cell_decor.nplaques] = plaque.band;
+ cell_decor.nplaques += 1;
+ }
+ }
var groups = makeGroups(surface.panelTracks(), surface.hasPanelDiff());
// Cursors stand still while panes move, as they always have; while the
// focused one glides or blinks, it is the overlay's quad instead. One
@@ -5535,7 +5951,8 @@ fn renderFrame(
}
const decor_len: u32 = @intCast(g.decor.items.len);
if (cell_total != 0 or decor_len != 0) {
- const capacity = std.math.add(u32, cell_total, cells + decor_len) catch return error.GpuCapacity;
+ // The shifted rows of a scroll: its body, and a glide's rows past it.
+ const capacity = std.math.add(u32, cell_total, 2 * cells + decor_len) catch return error.GpuCapacity;
try ensureVbuf(g, capacity);
const vptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.vxfer.?, false) orelse {
@@ -5559,7 +5976,9 @@ fn renderFrame(
const track = groups.items[group_index].track;
const data_effect = if (track) |active| active.effect == .dissolve else false;
const data_diff = data_effect and gripCellChanged(surface, covered, previous_covered, col, row);
- if (data_diff) emitSurfaceCell(
+ // The new cell, then (a dissolve) the old one fading out over
+ // it, as the old layers come after the new ones.
+ emitSurfaceCell(
g,
chrome,
instances,
@@ -5569,16 +5988,16 @@ fn renderFrame(
layout,
win_w,
win_h,
- track,
- surface.previous_cells[@as(usize, row) * surface.cols ..][0..surface.cols],
- previous_covered[@as(usize, row) * surface.cols ..][0..surface.cols],
+ if (data_effect and !data_diff) null else track,
+ line,
+ line_cover,
col,
- null,
- true,
+ line_cursor,
false,
+ cursors_shown and at == cursor_idx and !cursor_bar and bodyLayerAt(surface, col, row) == null,
page,
);
- emitSurfaceCell(
+ if (data_diff) emitSurfaceCell(
g,
chrome,
instances,
@@ -5588,13 +6007,13 @@ fn renderFrame(
layout,
win_w,
win_h,
- if (data_effect and !data_diff) null else track,
- line,
- line_cover,
+ track,
+ surface.previous_cells[@as(usize, row) * surface.cols ..][0..surface.cols],
+ previous_covered[@as(usize, row) * surface.cols ..][0..surface.cols],
col,
- line_cursor,
+ null,
+ true,
false,
- cursors_shown and at == cursor_idx and !cursor_bar and bodyLayerAt(surface, col, row) == null,
page,
);
}
@@ -5691,9 +6110,19 @@ fn renderFrame(
const whole = c.SDL_Rect{ .x = 0, .y = 0, .w = @intCast(sw), .h = @intCast(sh) };
const decor_base = cell_total + shifted;
for (groups.items[0..groups.len], 0..) |group, index| {
+ if (group.under_count != 0 and g.vbuf != null) {
+ c.SDL_BindGPUGraphicsPipeline(rp, g.decor_pipeline);
+ pushLayerOpacity(cmd, rp, background_opacity);
+ const binding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = (decor_base + group.decor_start) * @sizeOf(CellInstance) };
+ c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1);
+ c.SDL_DrawGPUPrimitives(rp, 6, group.under_count, 0, 0);
+ }
const has_shifted = index == 0 and shifted != 0;
if ((group.cell_count != 0 or has_shifted) and g.vbuf != null) {
- c.SDL_BindGPUGraphicsPipeline(rp, g.pipeline);
+ // Blended over what is under: a theme's plaques show through
+ // blank tag cells; a dissolve's old cells fade over its new.
+ const dissolving = if (group.track) |active| active.effect == .dissolve else false;
+ c.SDL_BindGPUGraphicsPipeline(rp, if (cell_decor.nplaques != 0 or dissolving) g.pipeline_over else g.pipeline);
const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.atlas_tex, .sampler = g.atlas_sampler };
c.SDL_BindGPUFragmentSamplers(rp, 0, &samp_binding, 1);
if (group.cell_count != 0) {
@@ -5711,13 +6140,13 @@ fn renderFrame(
}
}
if (group.image_count != 0) drawNativeImagesGpu(g, rp, surface, group, whole);
- if (group.decor_count != 0 and g.vbuf != null) {
+ if (group.decor_count > group.under_count and g.vbuf != null) {
c.SDL_BindGPUGraphicsPipeline(rp, g.decor_pipeline);
// Ink keeps its colour at any WindowOpacity.
pushLayerOpacity(cmd, rp, if (index == groups.ink) 1 else background_opacity);
- const binding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = (decor_base + group.decor_start) * @sizeOf(CellInstance) };
+ const binding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = (decor_base + group.decor_start + group.under_count) * @sizeOf(CellInstance) };
c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1);
- c.SDL_DrawGPUPrimitives(rp, 6, group.decor_count, 0, 0);
+ c.SDL_DrawGPUPrimitives(rp, 6, group.decor_count - group.under_count, 0, 0);
}
}
drawOverlayGpu(g, cmd, rp, overlay_count);
@@ -5727,8 +6156,10 @@ fn renderFrame(
if (core) |p| {
var boxes: [max_cursors]CursorBox = undefined;
const cursors = if (cursors_shown) cursorBoxes(g, surface, layout, win_h, &boxes) else 0;
+ var spare: [Post.max_spare][4]f32 = undefined;
g.post.describe(.{
.cursor = if (cursors > 0) boxes[0] else null,
+ .spare = spare[0..postSpare(surface, layout, &spare)],
.background = chrome.page orelse bg_default,
.foreground = chrome.fg orelse fg_default,
.selection_foreground = p.theme().sel_fg,
@@ -5737,6 +6168,7 @@ fn renderFrame(
}
const chain_zone = tracy.zone(@src(), "post chain");
defer chain_zone.end();
+ g.post.scale = display_scale;
g.post.draw(g.device, cmd, g.linear_sampler, g.swapchain_format, g.scene_tex.?, target, sw, sh, c.SDL_GetTicksNS(), background_opacity >= 1) catch {
g.scene_target_failed = true;
};
@@ -6016,6 +6448,9 @@ fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const p
const y = layer.viewport.y + @as(u16, @intCast(index));
const top = @as(f32, @floatFromInt(y)) * bh;
emitInstance(g, instances, next.*, 0, y, .{ .w = band_right - left, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .body, (&background)[0..1], 0, null, old_layer, false, page);
+ // A theme's plaque is drawn under the band: the band's own ground
+ // lets it show (tagPlaques).
+ if (cell_decor.nplaques != 0 and cell_decor.inPlaque(left + 0.5, top + 0.5)) instances[next.*].effect |= clear_bg_bit;
if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h);
next.* += 1;
const cells = layer.cells[index * layer.cols ..][0..layer.cols];
@@ -6139,7 +6574,7 @@ fn scrollColumn(g: *const Gui, chrome: *const pardes.Chrome, col: u16, layout: C
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));
+ const w = std.math.clamp(scaled, dp(4), @max(dp(4), @floor(layout.w * @as(f32, @floatFromInt(config.GUTTER))) - rule - dp(3)));
return .{ x0, x0 + w };
}
@@ -6221,6 +6656,10 @@ fn emitSurfaceCell(
// this underlay at tagline height leaves page-colored gutters between
// adjacent workspace, column and pane tags at TaglineSize < 100.
emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .body, (&band_base)[0..1], 0, null, old_layer, false, page);
+ // Over a theme's plaque (not a grip's button) the underlay lets it
+ // show (tagPlaques).
+ if (cell_decor.nplaques != 0 and !here.grip and cell_decor.inPlaque((@as(f32, @floatFromInt(col)) + 0.5) * body_layout.w + body_layout.x_off, @as(f32, @floatFromInt(row)) * body_layout.h + body_layout.y_off + 0.5))
+ instances[next.*].effect |= clear_bg_bit;
next.* += 1;
// Tag glyphs keep their font size inside a full physical-width grip.
// A pane's grip cells are the cell shells' form (a dirty `*`): the
@@ -6545,6 +6984,21 @@ fn emitInstance(
// A block cursor is foreground UI ink, even though its color is carried
// in the reversed cell background. Keep it visible at WindowOpacity 0.
if (is_cursor) instances[idx].effect |= opaque_bg_bit;
+ // The theme's ornament (cell_decor): dots on the page's own ground, a
+ // shadow under a file name's letters. Never on a cursor.
+ if (!is_cursor) {
+ // A blank cell on a tag's ground inside a plaque: the plaque shows.
+ if (cell_decor.nplaques != 0 and resolved.role == .tagline and cellCodepoint(&line[at]) == ' ' and
+ (std.mem.eql(u8, &resolved.bg, &cell_decor.grounds[0]) or std.mem.eql(u8, &resolved.bg, &cell_decor.grounds[1])) and
+ (cell_decor.inPlaque(px0 + 0.5, py0 + 0.5) or cell_decor.inPlaque(px0 + layout.w - 0.5, py0 + 0.5)))
+ instances[idx].effect |= clear_bg_bit;
+ if (cell_decor.dots) |dotted| {
+ if (!resolved.clear_bg and std.mem.eql(u8, &resolved.bg, &dotted)) instances[idx].effect |= dotted_bit;
+ }
+ if (cell_decor.title) |inks| {
+ if (resolved.role == .tagline and (std.mem.eql(u8, &resolved.fg, &inks[0]) or std.mem.eql(u8, &resolved.fg, &inks[1]))) instances[idx].effect |= title_bit;
+ }
+ }
}
test "block cursor alone overrides transparent cell background" {
@@ -6561,6 +7015,107 @@ test "block cursor alone overrides transparent cell background" {
}
}
+test "lapis dots only the page's own ground and shadows only a file name, never a cursor" {
+ var g: Gui = undefined;
+ g.words = .{};
+ g.cell_w = 10;
+ g.cell_h = 20;
+ g.tagline_width = 8;
+ g.tagline_height = 16;
+ g.space_slot = .{ .u = 0, .v = 0 };
+ const page: [3]u8 = .{ 0x0a, 0x10, 0x30 };
+ const name: [3]u8 = .{ 0xe8, 0xc4, 0x6a };
+ var chrome: pardes.Chrome = .{ .page = page, .decor_dots = .{ 1, 2, 3 }, .decor_dot_alpha = 0x22, .decor_title = .{ 4, 5, 6 }, .name_ink = name, .active_name_ink = name };
+ cell_decor = CellDecor.of(&chrome);
+ defer cell_decor = .{};
+ const on_page: pardes.Cell = .{ .default = false, .style = .{ .fg = .{ .rgb = .{ 200, 200, 200 } }, .bg = .{ .rgb = page } } };
+ const selected: pardes.Cell = .{ .default = false, .style = .{ .fg = .{ .rgb = .{ 0, 0, 0 } }, .bg = .{ .rgb = name } } };
+ const title: pardes.Cell = .{ .default = false, .style = .{ .fg = .{ .rgb = name }, .bg = .{ .rgb = .{ 0x0f, 0x1a, 0x4a } }, .font_role = .tagline } };
+ const plain_tag: pardes.Cell = .{ .default = false, .style = .{ .fg = .{ .rgb = .{ 0x9a, 0xa6, 0xd9 } }, .bg = .{ .rgb = .{ 0x0f, 0x1a, 0x4a } }, .font_role = .tagline } };
+ var instances: [1]CellInstance = undefined;
+ const Case = struct { cell: pardes.Cell, role: pardes.FontRole, cursor: bool, dotted: bool, shadowed: bool };
+ for ([_]Case{
+ .{ .cell = on_page, .role = .body, .cursor = false, .dotted = true, .shadowed = false },
+ .{ .cell = on_page, .role = .body, .cursor = true, .dotted = false, .shadowed = false },
+ .{ .cell = selected, .role = .body, .cursor = false, .dotted = false, .shadowed = false },
+ .{ .cell = title, .role = .tagline, .cursor = false, .dotted = false, .shadowed = true },
+ .{ .cell = title, .role = .tagline, .cursor = true, .dotted = false, .shadowed = false },
+ .{ .cell = plain_tag, .role = .tagline, .cursor = false, .dotted = false, .shadowed = false },
+ }) |case| {
+ emitInstance(&g, &instances, 0, 0, 0, fixedCellLayout(&g), 800, 480, null, case.role, (&case.cell)[0..1], 0, null, false, case.cursor, Ground.opaqueRgb(page));
+ try std.testing.expectEqual(case.dotted, instances[0].effect & dotted_bit != 0);
+ try std.testing.expectEqual(case.shadowed, instances[0].effect & title_bit != 0);
+ }
+ // A theme without decor marks nothing.
+ chrome = .{ .page = page };
+ cell_decor = CellDecor.of(&chrome);
+ emitInstance(&g, &instances, 0, 0, 0, fixedCellLayout(&g), 800, 480, null, .body, (&on_page)[0..1], 0, null, false, false, Ground.opaqueRgb(page));
+ try std.testing.expectEqual(@as(u32, 0), instances[0].effect & (dotted_bit | title_bit));
+}
+
+test "lapis plaques stay in their tag's band, clear of every grip; only the focused one is striped" {
+ 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 lapis"));
+ for (0..40) |_| core.update(.tick);
+ pardes.test_api.sync(core);
+ const s = try core.render(core.scratch.allocator());
+ try std.testing.expect(s.chrome.decor_box_border != null and s.chrome.decor_checker != null and s.chrome.decor_dots != null and s.chrome.decor_title != null);
+ var g: Gui = undefined;
+ g.decor = .empty;
+ defer g.decor.deinit(gpa);
+ g.cell_w = 16;
+ g.cell_h = 32;
+ g.tagline_width = 12;
+ g.tagline_height = 22;
+ display_scale = 1;
+ var groups = makeGroups(&.{}, false);
+ try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 1280, 768, true);
+ const layout = fixedCellLayout(&g);
+ const under = g.decor.items[groups.items[0].decor_start..][0..groups.items[0].under_count];
+ try std.testing.expect(under.len != 0);
+ var stripes: usize = 0;
+ var shadows: usize = 0;
+ for (under) |item| {
+ const r: [4]f32 = .{ (item.x0 + 1) / 2 * 1280, (1 - item.y0) / 2 * 768, (item.x1 + 1) / 2 * 1280, (1 - item.y1) / 2 * 768 };
+ // Inside one tag's band, whole: never on a body, a grip or the
+ // next pane.
+ const home = for (s.regionList()) |region| {
+ if (region.kind != .tag and region.kind != .column_tag and region.kind != .workspace_tag) continue;
+ const band = plaqueBox(&g, &s.chrome, s.regionList(), region, layout, 1280, 768) orelse continue;
+ if (r[0] >= band.band[0] - 0.01 and r[1] >= band.band[1] - 0.01 and r[2] <= band.band[2] + 0.01 and r[3] <= band.band[3] + 0.01) break region;
+ } else return error.PlaqueOutsideItsBand;
+ for (s.regionList()) |grip| {
+ if (grip.kind != .grip and grip.kind != .column_grip) continue;
+ const gx0 = @as(f32, @floatFromInt(grip.rect.x)) * 16;
+ const gx1 = @as(f32, @floatFromInt(grip.rect.x + grip.rect.w)) * 16;
+ const gy0 = @as(f32, @floatFromInt(grip.rect.y)) * 32;
+ const gy1 = @as(f32, @floatFromInt(grip.rect.y + grip.rect.h)) * 32;
+ try std.testing.expect(r[2] <= gx0 + 0.01 or r[0] >= gx1 - 0.01 or r[3] <= gy0 + 0.01 or r[1] >= gy1 - 0.01);
+ }
+ if (item.effect & checker_bit != 0) {
+ try std.testing.expect(home.kind == .tag and home.active);
+ stripes += 1;
+ }
+ const rgb: [3]u8 = .{ @intFromFloat(@round(item.br * 255)), @intFromFloat(@round(item.bg * 255)), @intFromFloat(@round(item.bb * 255)) };
+ if (std.mem.eql(u8, &rgb, &s.chrome.decor_box_shadow.?)) shadows += 1;
+ }
+ try std.testing.expectEqual(@as(usize, 1), stripes);
+ try std.testing.expect(shadows >= 2);
+ // The scroll track is chequered.
+ var checkers: usize = 0;
+ for (g.decor.items[groups.items[0].decor_start + groups.items[0].under_count ..]) |item| checkers += @intFromBool(item.effect & checker_bit != 0);
+ try std.testing.expect(checkers >= 1);
+ // No other theme draws any of it.
+ try std.testing.expect(core.executeBuiltinLine(0, "Theme forge"));
+ for (0..40) |_| core.update(.tick);
+ const plain = try core.render(core.scratch.allocator());
+ try std.testing.expect(plain.chrome.decor_box_border == null and plain.chrome.decor_checker == null and plain.chrome.decor_dots == null and plain.chrome.decor_title == null);
+}
+
fn cellCodepoint(cell: *const pardes.Cell) u32 {
const grapheme = cell.grapheme();
return if (cell.default or grapheme.len == 0) ' ' else firstCp(grapheme);
@@ -7158,31 +7713,34 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" {
// the outline.
fn decorateLine(out: [*]u8, stride: usize, width: usize, height: usize, baseline: i32, decoration: GlyphDecoration) void {
if (width == 0 or height == 0) return;
+ // A line is a logical pixel thick, its dots and waves as long: the same
+ // weight at any density (dp).
+ const t: usize = @intFromFloat(dp(1));
if (decoration.underline != .off) {
const base: usize = @intCast(@max(0, baseline));
- const row = @min(base + 1, height - 1);
+ const row = @min(base + 1, height -| t);
for (0..width) |x| {
- switch (decoration.underline) {
+ const top = switch (decoration.underline) {
.off => unreachable,
- .single => out[row * stride + x] = 255,
+ .single => row,
.double => {
- const upper = @min(base + 1, height -| 3);
- out[upper * stride + x] = 255;
- out[@min(upper + 2, height - 1) * stride + x] = 255;
- },
- .curly => out[(row -| @as(usize, if (x % 4 < 2) 1 else 0)) * stride + x] = 255,
- .dotted => if (x % 3 == 0) {
- out[row * stride + x] = 255;
- },
- .dashed => if (x % 6 < 4) {
- out[row * stride + x] = 255;
+ const upper = @min(base + 1, height -| 3 * t);
+ for (0..t) |k| {
+ out[@min(upper + k, height - 1) * stride + x] = 255;
+ out[@min(upper + 2 * t + k, height - 1) * stride + x] = 255;
+ }
+ continue;
},
- }
+ .curly => row -| @as(usize, if (x % (4 * t) < 2 * t) t else 0),
+ .dotted => if (x % (3 * t) < t) row else continue,
+ .dashed => if (x % (6 * t) < 4 * t) row else continue,
+ };
+ for (0..t) |k| out[@min(top + k, height - 1) * stride + x] = 255;
}
}
if (decoration.strikethrough) {
- const row = @min(height - 1, @as(usize, @intCast(@max(0, baseline))) * 2 / 3);
- @memset(out[row * stride ..][0..width], 255);
+ const row = @min(height -| t, @as(usize, @intCast(@max(0, baseline))) * 2 / 3);
+ for (0..t) |k| @memset(out[@min(row + k, height - 1) * stride ..][0..width], 255);
}
}
@@ -7341,6 +7899,20 @@ fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat,
return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate;
}
+/// Premultiplied over: an opaque cell writes itself exactly, a see-through
+/// one only its ink.
+fn premultipliedBlend() c.SDL_GPUColorTargetBlendState {
+ var blend = std.mem.zeroes(c.SDL_GPUColorTargetBlendState);
+ blend.src_color_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE;
+ blend.dst_color_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA;
+ blend.color_blend_op = c.SDL_GPU_BLENDOP_ADD;
+ blend.src_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE;
+ blend.dst_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA;
+ blend.alpha_blend_op = c.SDL_GPU_BLENDOP_ADD;
+ blend.enable_blend = true;
+ return blend;
+}
+
fn backgroundLayerBlend() c.SDL_GPUColorTargetBlendState {
var blend = std.mem.zeroes(c.SDL_GPUColorTargetBlendState);
blend.src_color_blendfactor = c.SDL_GPU_BLENDFACTOR_SRC_ALPHA;
@@ -7731,6 +8303,29 @@ fn addDecor(g: *Gui, gpa: std.mem.Allocator, x0_in: f32, y0_in: f32, x1_in: f32,
try g.decor.append(gpa, instance);
}
+/// A checker (decor.frag.glsl): squares of `px` window pixels in `a` and
+/// `b` from the quad's corner (lapis's scroll track); `stripes`, bands of
+/// `px` across it instead (lapis's focused title bar), `a` first.
+fn addChecker(g: *Gui, gpa: std.mem.Allocator, x0: f32, y0: f32, x1: f32, y1: f32, a: [3]u8, b: [3]u8, px: f32, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void {
+ return addPattern(g, gpa, x0, y0, x1, y1, a, b, px, false, track, win_w, win_h);
+}
+
+fn addPattern(g: *Gui, gpa: std.mem.Allocator, x0: f32, y0: f32, x1: f32, y1: f32, a: [3]u8, b: [3]u8, px: f32, stripes: bool, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void {
+ const first = g.decor.items.len;
+ try addDecor(g, gpa, x0, y0, x1, y1, a, 1, track, win_w, win_h);
+ if (g.decor.items.len == first) return;
+ const instance = &g.decor.items[first];
+ instance.u0 = px;
+ instance.u1 = px;
+ instance.v0 = if (stripes) 1 else 0;
+ instance.v1 = if (stripes) 1 else 0;
+ instance.fr = @as(f32, @floatFromInt(b[0])) / 255.0;
+ instance.fg = @as(f32, @floatFromInt(b[1])) / 255.0;
+ instance.fb = @as(f32, @floatFromInt(b[2])) / 255.0;
+ instance.effect |= checker_bit;
+}
+
+
/// 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.
@@ -7827,7 +8422,7 @@ fn addLift(g: *Gui, gpa: std.mem.Allocator, style: pardes.config.Runtime.LiftSty
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);
+ try addDecor(g, gpa, edge[0], edge[2] - dp(1), edge[1], edge[2], ceilings.rim, alpha, track, win_w, win_h);
};
}
@@ -8051,6 +8646,13 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
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;
+ // Under the cells: a theme's tag plaques, this group's panes' (and
+ // the column and workspace tags with the still ones).
+ if (index <= groups.tracks) {
+ const closing = if (group.track) |active| active.phase == .closing else false;
+ try tagPlaques(g, gpa, if (closing) surface.previous_regions else regions, groups, index, chrome, layout, win_w, win_h, group.track);
+ }
+ group.under_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) {
@@ -8067,7 +8669,7 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
else => {},
} else false;
// 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));
+ const height: u32 = @intFromFloat(@max(1, @min(rulePx(chrome), @as(f32, @floatFromInt((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);
@@ -8101,6 +8703,8 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
};
const bodies = body_buf[0..nbodies];
try paneDecor(g, gpa, placed, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, track);
+ // G9, while nothing moves: a halo round the body's selection.
+ if (index == 0 and groups.tracks == 0 and chrome.selection_glow != 0) try selectionGlow(g, gpa, surface, ceilings, layout, win_w, win_h);
// A single spine runs down each column, through every tag and body.
if (index == 0) for (regions) |column| {
if (column.kind != .column) continue;
@@ -8131,6 +8735,34 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
} else null;
try addLift(g, gpa, lift_style, ceilings, box, rim_edge, bodies, grip.lift, track, win_w, win_h);
}
+ // G7: a pane on the move stands off the page, rising as it sets
+ // out and settling as it lands, its shadow on the other panes'
+ // bodies only (never a tag, a grip or its own text), moving
+ // with it.
+ if (track) |active| if (active.phase != .closing and (active.effect == .slide or active.effect == .zoom or active.effect == .vertical)) {
+ const steps: f32 = @floatFromInt(@max(1, active.frames() -| 1));
+ const u = std.math.clamp(@as(f32, @floatFromInt(active.frame)) / steps, 0, 1);
+ const lift = @sin(std.math.pi * u);
+ var box: [4]f32 = .{ std.math.inf(f32), std.math.inf(f32), -std.math.inf(f32), -std.math.inf(f32) };
+ var others: [pardes.MAX_PANES][4]f32 = undefined;
+ var nothers: usize = 0;
+ for (placed) |part| {
+ if (part.kind != .tag and part.kind != .rail and part.kind != .body and part.kind != .grip) continue;
+ const r = bodyPixels(part, layout);
+ if (part.owner == active.pane) {
+ box = .{ @min(box[0], r[0]), @min(box[1], r[1]), @max(box[2], r[2]), @max(box[3], r[3]) };
+ } else if ((part.kind == .body or part.kind == .rail) and nothers < others.len) {
+ others[nothers] = r;
+ nothers += 1;
+ }
+ }
+ if (box[2] > box[0] and lift > 0.01) {
+ const h: f32 = @floatFromInt(g.cell_h);
+ const first = g.decor.items.len;
+ try addSoft(g, gpa, box, 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, others[0..nothers]);
+ }
+ };
}
// 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
@@ -8153,9 +8785,9 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g
// A band still falling into place is cut at its row's top; its
// rule comes with it, and shows only once it is below that cut.
const rule_y = top + (1 + layer.slide) * layout.h;
- if (rule_y - 1 < top) continue;
+ if (rule_y - dp(1) < top) continue;
const track = groups.items[groupAt(groups, layer.viewport.x, layer.viewport.y)].track;
- try addDecor(g, gpa, x0, rule_y - 1, rule_x1, rule_y, chrome.border, layer.fade, track, win_w, win_h);
+ try addDecor(g, gpa, x0, rule_y - dp(1), rule_x1, rule_y, chrome.border, layer.fade, track, win_w, win_h);
}
if (index == groups.ink and cursors_shown) try cursorDecor(g, gpa, surface, layout, win_w, win_h);
}
@@ -8181,7 +8813,7 @@ 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.tag_rule orelse mixRgb(chrome.tag_bg, page), 1, track, win_w, win_h);
+ try addDecor(g, gpa, x0, if (tag_bottom) rule_y else rule_y - dp(1), rule_x1, if (tag_bottom) rule_y + dp(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).
{
@@ -8197,10 +8829,10 @@ fn paneDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, gr
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);
+ try addDecor(g, gpa, x0, y, rule_x1, y + @max(1, @min(rulePx(chrome), @as(f32, @floatFromInt(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);
+ try addDecor(g, gpa, x0, y - @max(1, @min(rulePx(chrome), @as(f32, @floatFromInt(room)))), rule_x1, y, chrome.border, 1, track, win_w, win_h);
}
}
@@ -8215,11 +8847,14 @@ fn paneDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, gr
// 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, column[0], y0, column[1], y1, chrome.scroll_track, 1, track, win_w, win_h);
+ if (chrome.decor_checker) |gold|
+ try addChecker(g, gpa, column[0], y0, column[1], y1, chrome.scroll_track, gold, dp(@floatFromInt(@max(1, chrome.decor_checker_px))), track, win_w, win_h)
+ else
+ 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, column[0], thumb_y0, column[1] - 1, thumb_y1, chrome.scroll_thumb, 1, track, win_w, win_h);
+ try addDecor(g, gpa, column[0], thumb_y0, column[1] - dp(1), thumb_y1, chrome.scroll_thumb, 1, track, win_w, win_h);
}
}
}
@@ -8233,7 +8868,242 @@ fn mixRgb(a: [3]u8, b: [3]u8) [3]u8 {
/// 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));
+ return dp(@floatFromInt(@max(1, chrome.rule_px)));
+}
+
+/// The display's density (SDL's window display scale: 2 on a 2x screen),
+/// by which every size given in logical pixels is drawn, as acme scales its
+/// Border, ButtonBorder and Scrollgap (scalesize). Cells, glyphs and what is
+/// measured in them are already the display's pixels and are not scaled.
+/// ponytail: one window per process, so one global; per window if ever two.
+var display_scale: f32 = 1;
+
+/// The window's display scale, or PARDES_TEST_SCALE (a harness forcing 2x);
+/// a test window is 1x unless forced, whatever display it lands on, so its
+/// captures are the same on any machine.
+fn updateDisplayScale(window: *c.SDL_Window) void {
+ if (std.c.getenv("PARDES_TEST_SCALE")) |forced| {
+ display_scale = std.fmt.parseFloat(f32, std.mem.span(forced)) catch 1;
+ } else if (std.c.getenv("PARDES_TEST") != null) {
+ display_scale = 1;
+ } else {
+ const scale = c.SDL_GetWindowDisplayScale(window);
+ display_scale = if (scale > 0 and std.math.isFinite(scale)) scale else 1;
+ }
+ display_scale = std.math.clamp(display_scale, 1, 4);
+}
+
+/// A theme's ornament as the cell shader draws it (Chrome.decor_*), for the
+/// frame being built: renderFrame sets it, writing a cell marks the cells
+/// it falls on, and pushLayerOpacity hands the shader its colours and
+/// sizes. ponytail: one window per process, as display_scale.
+var cell_decor: CellDecor = .{};
+const CellDecor = struct {
+ /// The page's ground, when the theme dots it.
+ dots: ?[3]u8 = null,
+ /// A file name's inks in a tag, when the theme shadows it.
+ title: ?[2][3]u8 = null,
+ /// UiUniforms after background_opacity (ui.frag.glsl): the dots' colour
+ /// and strength, their period and radii, the shadow's colour and offset.
+ uniforms: [12]f32 = @splat(0),
+ /// The tag plaques' bands (window pixels) and the tag grounds: a blank
+ /// tag cell there lets its plaque show through (tagPlaques).
+ plaques: [pardes.MAX_TAG_LAYERS][4]f32 = undefined,
+ nplaques: usize = 0,
+ grounds: [2][3]u8 = .{ .{ 0, 0, 0 }, .{ 0, 0, 0 } },
+ plaques_off: bool = false,
+
+ fn inPlaque(decor: *const CellDecor, x: f32, y: f32) bool {
+ for (decor.plaques[0..decor.nplaques]) |band|
+ if (x >= band[0] and x < band[2] and y >= band[1] and y < band[3]) return true;
+ return false;
+ }
+
+ fn of(chrome: *const pardes.Chrome) CellDecor {
+ var out: CellDecor = .{};
+ if (chrome.decor_dots) |rgb| if (chrome.page) |page| {
+ out.dots = page;
+ out.uniforms[0..4].* = .{ unit(rgb[0]), unit(rgb[1]), unit(rgb[2]), unit(chrome.decor_dot_alpha) };
+ out.uniforms[4..8].* = .{ dp(@floatFromInt(@max(2, chrome.decor_dot_px))), 0.75 * display_scale, 1.25 * display_scale, 0 };
+ };
+ if (chrome.decor_title) |rgb| {
+ out.title = .{ chrome.name_ink, chrome.active_name_ink };
+ out.uniforms[8..12].* = .{ unit(rgb[0]), unit(rgb[1]), unit(rgb[2]), dp(@floatFromInt(@max(1, chrome.decor_title_px))) };
+ }
+ return out;
+ }
+
+ fn unit(v: u8) f32 {
+ return @as(f32, @floatFromInt(v)) / 255;
+ }
+};
+
+/// A logical pixel size in the display's pixels: rounded, at least one.
+fn dp(logical: f32) f32 {
+ return @max(1, @round(logical * display_scale));
+}
+
+/// G9's state: the selection's blocks last drawn and when each appeared.
+const SelGlow = struct {
+ const Block = struct { box: [4]u16, start_ns: u64 };
+ blocks: [32]Block = undefined,
+ len: usize = 0,
+ now_ns: u64 = 0,
+ /// A block is still fading in: the core keeps frames coming.
+ fading: bool = false,
+};
+
+/// How long a glow takes to come up (§8.1's micro-feedback: 80 to 120 ms).
+const sel_glow_ns: u64 = 100 * std.time.ns_per_ms;
+
+/// G9: a soft halo of the selection's own colour round each block of it in
+/// a pane body (rows of selected cells that touch), fading in over 100 ms
+/// with an ease-out. The selection itself is the halo's caster, never
+/// touched; so is a cursor beside it; the halo stays on bodies (never a
+/// tag or a grip) and at most 22%, less where the text round it would drop
+/// below min(its contrast, 4.5).
+fn selectionGlow(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, ceilings: LiftCeilings, layout: CellLayout, win_w: f32, win_h: f32) !void {
+ _ = ceilings;
+ const chrome = &surface.chrome;
+ const sel = chrome.sel_bg;
+ const page = chrome.page orelse bg_default;
+ const fg = chrome.fg orelse fg_default;
+ const cap = @min(0.22, lightenCeiling(fg, page, sel));
+ var blocks: [32][4]u16 = undefined;
+ var nblocks: usize = 0;
+ var bodies: [pardes.MAX_PANES][4]f32 = undefined;
+ var nbodies: usize = 0;
+ for (surface.regionList()) |body| {
+ if (body.kind != .body or body.rect.w == 0) continue;
+ if (nbodies < bodies.len) {
+ bodies[nbodies] = bodyPixels(body, layout);
+ nbodies += 1;
+ }
+ var open: ?[4]u16 = null; // x0, y0, x1, y1 of the block being grown
+ var row = body.rect.y;
+ while (row < body.rect.y + body.rect.h and row < surface.rows) : (row += 1) {
+ var x0: ?u16 = null;
+ var x1: u16 = 0;
+ var col = body.rect.x;
+ while (col < body.rect.x + body.rect.w and col < surface.cols) : (col += 1) {
+ const cell = &surface.cells[@as(usize, row) * surface.cols + col];
+ const selected = !cell.default and switch (cell.style.bg) {
+ .rgb => |rgb| std.mem.eql(u8, &rgb, &sel),
+ else => false,
+ };
+ if (!selected) continue;
+ if (x0 == null) x0 = col;
+ x1 = col + 1;
+ }
+ if (x0) |start| {
+ if (open) |*block| {
+ block.* = .{ @min(block[0], start), block[1], @max(block[2], x1), row + 1 };
+ } else open = .{ start, row, x1, row + 1 };
+ } else if (open) |block| {
+ if (nblocks < blocks.len) blocks[nblocks] = block;
+ nblocks += 1;
+ open = null;
+ }
+ }
+ if (open) |block| if (nblocks < blocks.len) {
+ blocks[nblocks] = block;
+ nblocks += 1;
+ };
+ }
+ nblocks = @min(nblocks, blocks.len);
+ // Each block keeps the start of the one it overlaps from the last frame.
+ var next: SelGlow = .{ .now_ns = g.sel_glow.now_ns };
+ for (blocks[0..nblocks]) |block| {
+ const start = for (g.sel_glow.blocks[0..g.sel_glow.len]) |old| {
+ if (old.box[0] < block[2] and block[0] < old.box[2] and old.box[1] < block[3] and block[1] < old.box[3]) break old.start_ns;
+ } else g.sel_glow.now_ns;
+ next.blocks[next.len] = .{ .box = block, .start_ns = start };
+ next.len += 1;
+ const t = @as(f32, @floatFromInt(g.sel_glow.now_ns -| start)) / @as(f32, @floatFromInt(sel_glow_ns));
+ if (t < 1) next.fading = true;
+ const u = std.math.clamp(t, 0, 1);
+ const fade = 1 - (1 - u) * (1 - u) * (1 - u);
+ var caster: [4]f32 = .{ @as(f32, @floatFromInt(block[0])) * layout.w, @as(f32, @floatFromInt(block[1])) * layout.h, @as(f32, @floatFromInt(block[2])) * layout.w, @as(f32, @floatFromInt(block[3])) * layout.h };
+ // A cursor beside the selection is not glowed over: it joins the
+ // caster.
+ if (surface.cursor) |cu| if (cu.x + 1 >= block[0] and cu.x <= block[2] and cu.y + 1 >= block[1] and cu.y <= block[3]) {
+ caster = .{ @min(caster[0], @as(f32, @floatFromInt(cu.x)) * layout.w), @min(caster[1], @as(f32, @floatFromInt(cu.y)) * layout.h), @max(caster[2], @as(f32, @floatFromInt(cu.x + 1)) * layout.w), @max(caster[3], @as(f32, @floatFromInt(cu.y + 1)) * layout.h) };
+ };
+ const first = g.decor.items.len;
+ try addSoft(g, gpa, caster, 0.3 * layout.h, 0, 0, cap * fade, sel, null, win_w, win_h);
+ try clipToBodies(g, gpa, first, bodies[0..nbodies]);
+ }
+ g.sel_glow = next;
+}
+
+/// A theme's tag plaques (Chrome.decor_box_*), drawn under the cells of
+/// group `index`: each pane tag, and with the still panes the column and
+/// workspace tags, becomes a framed box inside its own band with a hard
+/// shadow down and right that stays inside the band too, so it never falls
+/// on a body, a grip or the next pane. The focused pane's box is striped,
+/// its words on plates of the tag's ground (their cells stay opaque; the
+/// spaces between let the stripes through, emitInstance); a column or the
+/// workspace takes a quieter box, a one-pixel frame and a two-pixel shadow.
+fn tagPlaques(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, groups: *const Groups, index: usize, chrome: *const pardes.Chrome, layout: CellLayout, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void {
+ const border_rgb = chrome.decor_box_border orelse return;
+ if (cell_decor.plaques_off) return;
+ const page = chrome.page orelse bg_default;
+ for (regions) |tag| {
+ const pane = tag.kind == .tag;
+ if (!pane and tag.kind != .column_tag and tag.kind != .workspace_tag) continue;
+ if (tag.rect.w == 0 or tag.rect.h == 0) continue;
+ if (pane and groupOf(groups, tag.serial) != index) continue;
+ if (!pane and index != 0) continue;
+ const plaque = plaqueBox(g, chrome, regions, tag, layout, win_w, win_h) orelse continue;
+ const b = plaque.border;
+ const sh = plaque.shadow;
+ const fill = if (tag.active) chrome.tag_focus_bg else chrome.tag_bg;
+ // The band's own ground, then the shadow, the frame and the fill.
+ try addDecor(g, gpa, plaque.band[0], plaque.band[1], plaque.band[2], plaque.band[3], page, 1, track, win_w, win_h);
+ const shadow_rgb = chrome.decor_box_shadow orelse border_rgb;
+ try addDecor(g, gpa, plaque.box[0] + sh[0], plaque.box[1] + sh[1], plaque.box[2] + sh[0], plaque.box[3] + sh[1], if (pane) shadow_rgb else mixRgb(shadow_rgb, page), 1, track, win_w, win_h);
+ try addDecor(g, gpa, plaque.box[0], plaque.box[1], plaque.box[2], plaque.box[3], border_rgb, 1, track, win_w, win_h);
+ const inner: [4]f32 = .{ plaque.box[0] + b, plaque.box[1] + b, plaque.box[2] - b, plaque.box[3] - b };
+ if (pane and tag.active and chrome.decor_box_stripe != null)
+ try addPattern(g, gpa, inner[0], inner[1], inner[2], inner[3], chrome.decor_box_stripe.?, fill, dp(@floatFromInt(@max(1, chrome.decor_box_stripe_px))), true, track, win_w, win_h)
+ else
+ try addDecor(g, gpa, inner[0], inner[1], inner[2], inner[3], fill, 1, track, win_w, win_h);
+ }
+}
+
+const Plaque = struct { band: [4]f32, box: [4]f32, border: f32, shadow: [2]f32 };
+
+/// A tag's plaque in window pixels: its band (the rows, from after the
+/// grip to the end), the framed box round the tagline's text, and the
+/// frame's and the shadow's widths, the shadow cut to what the band's slack
+/// below the text and its right end leave. Null where there is no room.
+fn plaqueBox(g: *const Gui, chrome: *const pardes.Chrome, regions: []const pardes.Region, tag: pardes.Region, layout: CellLayout, win_w: f32, win_h: f32) ?Plaque {
+ const pane = tag.kind == .tag;
+ const first = tag.rect.y;
+ const last = tag.rect.y + tag.rect.h - 1;
+ // From its start, right of its grip: the gap cell before its text is
+ // the plaque's margin, so its frame is never under the first letter.
+ _ = regions;
+ const x0 = @as(f32, @floatFromInt(tag.rect.x)) * layout.w;
+ const x1_cells = @as(f32, @floatFromInt(tag.rect.x + tag.rect.w)) * layout.w;
+ const x1 = if (win_w - x1_cells < layout.w) @max(x1_cells, win_w) else x1_cells;
+ const row_top = @as(f32, @floatFromInt(first)) * layout.h;
+ const row_bottom = @as(f32, @floatFromInt(last + 1)) * layout.h;
+ const text_top = row_top + @as(f32, @floatFromInt(taglineBandOffset(first, win_h, g.cell_h, g.tagline_height)));
+ const text_bottom = @as(f32, @floatFromInt(last)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(last, win_h, g.cell_h, g.tagline_height) + g.tagline_height));
+ const border = @min(if (pane) dp(@floatFromInt(@max(1, chrome.decor_box_border_px))) else dp(1), @floor(text_top - row_top));
+ if (border < 1) return null;
+ // The shadow's full offset to the right; down, what the slack under the
+ // text leaves (a pixel or three at a tagline under the body's size).
+ const want: f32 = if (pane) dp(@floatFromInt(chrome.decor_box_shadow_px)) else dp(2);
+ const drop = std.math.clamp(@floor(row_bottom - text_bottom - border), 0, want);
+ if (x1 - x0 < 4 * border + want) return null;
+ return .{
+ .band = .{ x0, row_top, x1, row_bottom },
+ .box = .{ x0, text_top - border, x1 - want, text_bottom + border },
+ .border = border,
+ .shadow = .{ want, drop },
+ };
}
/// The two short bars on each row of a grip with no mode glyph there,
@@ -8270,22 +9140,25 @@ fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, ce
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 top_rule: f32 = if (tag_bottom) dp(1) else if (stacked) @max(1, @min(rulePx(chrome), @as(f32, @floatFromInt(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) dp(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);
+ // acme's ButtonBorder: two logical pixels.
+ const b = dp(2);
+ try addDecor(g, gpa, x0, y0, x1, y0 + b, ring, 1, track, win_w, win_h);
+ try addDecor(g, gpa, x0, y1 - b, x1, y1, ring, 1, track, win_w, win_h);
+ try addDecor(g, gpa, x0, y0 + b, x0 + b, y1 - b, ring, 1, track, win_w, win_h);
+ try addDecor(g, gpa, x1 - b, y0 + b, x1, y1 - b, 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 gap = b + dp(1);
+ try addDecor(g, gpa, x0 + b, y0 + b, x1 - b, y0 + gap, band, 1, track, win_w, win_h);
+ try addDecor(g, gpa, x0 + b, y1 - gap, x1 - b, y1 - b, band, 1, track, win_w, win_h);
+ try addDecor(g, gpa, x0 + b, y0 + gap, x0 + gap, y1 - gap, band, 1, track, win_w, win_h);
+ try addDecor(g, gpa, x1 - gap, y0 + gap, x1 - b, y1 - gap, band, 1, track, win_w, win_h);
}
}
const x = grip.rect.x;
@@ -8300,9 +9173,12 @@ fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, ce
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, @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);
+ const half_w = @max(dp(1), @min(dp(3), @floor((button[2] - button[0] - 2 * dp(3)) / 2)));
+ // The mode's two bars: a logical pixel thick, a pixel either
+ // side of the middle.
+ const bar = dp(1);
+ try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y - bar - dp(1)), @floor(center_x + half_w), @floor(center_y - dp(1)), rgb, 1, track, win_w, win_h);
+ try addDecor(g, gpa, @floor(center_x - half_w), @floor(center_y + dp(1)), @floor(center_x + half_w), @floor(center_y + dp(1) + bar), rgb, 1, track, win_w, win_h);
}
}
}
@@ -8420,6 +9296,75 @@ test "an empty column is ruled on both sides, as between two filled ones" {
}
}
+test "at a 2x display every logical pixel is two: rules, the tag rule, the grip's ring, the rail's edge" {
+ 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());
+ for ([_]f32{ 1, 2 }) |scale| {
+ display_scale = scale;
+ defer display_scale = 1;
+ var g: Gui = undefined;
+ g.decor = .empty;
+ defer g.decor.deinit(gpa);
+ g.cell_w = 16;
+ g.cell_h = 32;
+ g.tagline_width = 12;
+ g.tagline_height = 24;
+ var groups = makeGroups(&.{}, false);
+ try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 1280, 768, true);
+ const w = struct {
+ fn of(item: CellInstance) f32 {
+ return (item.x1 - item.x0) / 2 * 1280;
+ }
+ fn h(item: CellInstance) f32 {
+ return (item.y0 - item.y1) / 2 * 768;
+ }
+ };
+ var spine: ?f32 = null;
+ var tag_rule: ?f32 = null;
+ var ring: ?f32 = null;
+ for (g.decor.items) |item| {
+ const rgb: [3]u8 = .{ @intFromFloat(@round(item.br * 255)), @intFromFloat(@round(item.bg * 255)), @intFromFloat(@round(item.bb * 255)) };
+ // The spine between the columns: black, tall.
+ if (std.mem.eql(u8, &rgb, &.{ 0, 0, 0 }) and w.h(item) > 300) spine = @round(w.of(item));
+ // The tag rule: #8888cc, wide and thin.
+ if (std.mem.eql(u8, &rgb, &.{ 0x88, 0x88, 0xcc }) and w.of(item) > 200) tag_rule = @round(w.h(item));
+ // The ring's left side: #8888cc, a tag row tall.
+ if (std.mem.eql(u8, &rgb, &.{ 0x88, 0x88, 0xcc }) and w.h(item) > 10 and w.of(item) < 8) ring = @round(w.of(item));
+ }
+ try std.testing.expectEqual(@as(?f32, 2 * scale), spine);
+ try std.testing.expectEqual(@as(?f32, 1 * scale), tag_rule);
+ try std.testing.expectEqual(@as(?f32, 2 * scale), ring);
+ // The rail starts after the rule, a logical 2 in, and keeps a
+ // logical 3 clear of the text when the cell is narrow.
+ const column = scrollColumn(&g, &s.chrome, 40, fixedCellLayout(&g));
+ try std.testing.expectEqual(40 * 16 + 2 * scale, column[0]);
+ g.cell_w = 12;
+ defer g.cell_w = 16;
+ const narrow = scrollColumn(&g, &s.chrome, 40, fixedCellLayout(&g));
+ try std.testing.expectEqual(@as(f32, 40 * 12 + 2 * scale), narrow[0]);
+ try std.testing.expectEqual(@as(f32, 40 * 12) + 12 * @as(f32, @floatFromInt(config.GUTTER)) - 3 * scale, narrow[1]);
+ g.cell_w = 8;
+ // A bar cursor is an eighth of its cell, never under a logical pixel
+ // (an 8 px cell: one pixel, two at 2x).
+ const saved = s.cursor;
+ defer s.cursor = saved;
+ s.cursor = .{ .x = 60, .y = 10, .bar = true };
+ var boxes: [max_cursors]CursorBox = undefined;
+ const n = cursorBoxes(&g, s, fixedCellLayout(&g), 768, &boxes);
+ const bar = for (boxes[0..n]) |box| {
+ if (box.bar and box.x0 == 60 * 8) break box;
+ } else return error.NoBarCursor;
+ try std.testing.expectEqual(scale, bar.x1 - bar.x0);
+ }
+}
+
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 });
@@ -8483,6 +9428,140 @@ test "decor is read from the regions, each pane's in its own group" {
try std.testing.expectEqual(@as(u32, 0), groups.items[groups.ink].decor_count);
}
+test "a pane on the move casts a shadow on the other panes' bodies alone, most at mid-way" {
+ 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());
+ 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);
+ const serial = core.panes[1].?.serial;
+ var strengths: [3]f32 = @splat(0);
+ for ([_]u16{ 0, 6, 12 }, 0..) |frame, i| {
+ g.decor.clearRetainingCapacity();
+ const moving: pardes.animation.Track = .{ .pane = 1, .serial = serial, .phase = .moving, .effect = .slide, .frame = frame, .frame_count = 13, .from = .{ .x = 0, .y = 1, .w = 40, .h = 23 }, .to = .{ .x = 40, .y = 1, .w = 40, .h = 23 } };
+ var groups = makeGroups(&.{moving}, true);
+ if (!moving.active()) groups = makeGroups(&.{}, true);
+ try buildDecor(&g, gpa, s, &groups, layout, 800, 480, false);
+ for (g.decor.items) |item| {
+ if (item.effect & soft_shadow_bit == 0) continue;
+ strengths[i] = @max(strengths[i], item.fr);
+ // Clipped to pane 0's body or rail, never pane 1's, never a tag.
+ try std.testing.expect(item.clip_x1 >= item.clip_x0);
+ for (s.regionList()) |region| {
+ if (region.kind != .tag and region.kind != .grip and !(region.owner == 1 and (region.kind == .body or region.kind == .rail))) continue;
+ const r = bodyPixels(region, layout);
+ try std.testing.expect(item.clip_x1 <= r[0] or item.clip_x0 >= r[2] or item.clip_y1 <= r[1] or item.clip_y0 >= r[3]);
+ }
+ }
+ }
+ // Rising as it sets out, gone as it lands.
+ try std.testing.expectEqual(@as(f32, 0), strengths[0]);
+ try std.testing.expect(strengths[1] > 0.05);
+ try std.testing.expectEqual(@as(f32, 0), strengths[2]);
+}
+
+test "the selection glows round its block, on the body only, fading in over 100 ms" {
+ const gpa = std.testing.allocator;
+ const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 });
+ defer core.deinit();
+ _ = try core.setTestFile("some text on a line\n" ** 30);
+ core.settings.selection_glow = true;
+ pardes.test_api.sync(core);
+ const s = try core.render(core.scratch.allocator());
+ try std.testing.expectEqual(@as(u8, 1), s.chrome.selection_glow);
+ // Two rows of a selection in the body, as the core paints one.
+ const body = for (s.regionList()) |region| {
+ if (region.kind == .body) break region;
+ } else return error.NoBody;
+ for (0..2) |dy| for (5..12) |x| {
+ const cell = &s.cells[(@as(usize, body.rect.y) + 3 + dy) * s.cols + body.rect.x + x];
+ cell.default = false;
+ cell.style.bg = .{ .rgb = s.chrome.sel_bg };
+ };
+ s.cursor = null;
+ 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;
+ g.sel_glow = .{ .now_ns = std.time.ns_per_s };
+ var strengths: [2]f32 = .{ 0, 0 };
+ for ([_]u64{ 0, 150 * std.time.ns_per_ms }, 0..) |later, i| {
+ g.decor.clearRetainingCapacity();
+ g.sel_glow.now_ns = std.time.ns_per_s + later;
+ var groups = makeGroups(&.{}, false);
+ try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 800, 480, true);
+ for (g.decor.items) |item| {
+ if (item.effect & soft_shadow_bit == 0) continue;
+ const rgb: [3]u8 = .{ @intFromFloat(@round(item.br * 255)), @intFromFloat(@round(item.bg * 255)), @intFromFloat(@round(item.bb * 255)) };
+ if (!std.mem.eql(u8, &rgb, &s.chrome.sel_bg)) continue;
+ strengths[i] = @max(strengths[i], item.fr);
+ // On the body alone.
+ const r = bodyPixels(body, fixedCellLayout(&g));
+ try std.testing.expect(item.clip_x0 >= r[0] - 0.01 and item.clip_x1 <= r[2] + 0.01 and item.clip_y0 >= r[1] - 0.01 and item.clip_y1 <= r[3] + 0.01);
+ }
+ try std.testing.expectEqual(@as(usize, 1), g.sel_glow.len);
+ }
+ // Just appeared: nothing yet, and still fading; later, up to its cap.
+ try std.testing.expectEqual(@as(f32, 0), strengths[0]);
+ try std.testing.expect(strengths[1] > 0.05 and strengths[1] <= 0.22 + 1e-4);
+ try std.testing.expect(!g.sel_glow.fading);
+}
+
+/// What a bundled post pass leaves alone (Post.Frame.spare), in window
+/// pixels: every tag with its grip, the column and workspace tags, and the
+/// notices, the chrome the focus indicators live in. A grip, the gap after
+/// it and the tag beside it are one rectangle; past the list's end the last
+/// grows to cover the rest.
+fn postSpare(surface: *const pardes.Surface, layout: CellLayout, out: *[Post.max_spare][4]f32) usize {
+ var n: usize = 0;
+ for (surface.regionList()) |region| {
+ switch (region.kind) {
+ .grip, .tag, .notice, .column_grip, .column_tag, .workspace_tag => {},
+ else => continue,
+ }
+ if (region.rect.w == 0 or region.rect.h == 0) continue;
+ const rect: [4]f32 = .{
+ @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,
+ };
+ const joined = for (out[0..n]) |*other| {
+ // The same rows, a gap cell or two apart (a grip, the gap after
+ // it, its tag): one band.
+ const gap = 2 * layout.w + 0.5;
+ if (other[1] == rect[1] and other[3] == rect[3] and rect[0] <= other[2] + gap and other[0] <= rect[2] + gap) {
+ other.* = .{ @min(other[0], rect[0]), rect[1], @max(other[2], rect[2]), rect[3] };
+ break true;
+ }
+ } else false;
+ if (joined) continue;
+ if (n == out.len) {
+ const last = &out[n - 1];
+ last.* = .{ @min(last[0], rect[0]), @min(last[1], rect[1]), @max(last[2], rect[2]), @max(last[3], rect[3]) };
+ continue;
+ }
+ out[n] = rect;
+ n += 1;
+ }
+ return n;
+}
+
const CursorBox = Post.Frame.Cursor;
const max_cursors = 1 + pardes.MAX_PANES + pardes.MAX_TAG_LAYERS;
@@ -8501,7 +9580,7 @@ fn cursorBoxes(g: *const Gui, surface: *const pardes.Surface, layout: CellLayout
const height = std.math.clamp(if (role == .tagline) @as(f32, @floatFromInt(g.tagline_height)) else layout.h, 1.0, layout.h);
const x0 = @as(f32, @floatFromInt(cursor.x)) * layout.w;
const y0 = @as(f32, @floatFromInt(cursor.y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(cursor.y, win_h, @intFromFloat(layout.h), @intFromFloat(height))));
- out[n] = .{ .x0 = x0, .y0 = y0, .x1 = x0 + if (cursor.bar) @max(1.0, width / 8.0) else width, .y1 = y0 + height, .rgb = cursorColor(chrome, cell, role), .text = page, .bar = cursor.bar };
+ out[n] = .{ .x0 = x0, .y0 = y0, .x1 = x0 + if (cursor.bar) @max(dp(1), width / 8.0) else width, .y1 = y0 + height, .rgb = cursorColor(chrome, cell, role), .text = page, .bar = cursor.bar };
n += 1;
}
}
@@ -8518,7 +9597,7 @@ fn cursorBoxes(g: *const Gui, surface: *const pardes.Surface, layout: CellLayout
const width: f32 = if (role == .tagline) @floatFromInt(g.tagline_width) else layout.w;
const x = left + @as(f32, @floatFromInt(cursor.x)) * width;
const cell = &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x];
- out[n] = .{ .x0 = x, .y0 = top, .x1 = x + if (cursor.bar) @max(1, width / 8) else width, .y1 = @min(bottom, top + height), .rgb = cursorColor(chrome, cell, role), .text = page, .bar = cursor.bar };
+ out[n] = .{ .x0 = x, .y0 = top, .x1 = x + if (cursor.bar) @max(dp(1), width / 8) else width, .y1 = @min(bottom, top + height), .rgb = cursorColor(chrome, cell, role), .text = page, .bar = cursor.bar };
n += 1;
}
for (surface.tagLayers()) |*layer| {
@@ -8528,11 +9607,12 @@ fn cursorBoxes(g: *const Gui, surface: *const pardes.Surface, layout: CellLayout
const width: f32 = @floatFromInt(g.tagline_width);
const left = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w;
const right = left + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w;
- const x = left + @as(f32, @floatFromInt(cursor.x)) * width;
- if (x >= right) continue;
+ // Past a full row's end the caret sits at the tag's right edge, as
+ // the grid's does (and the core's cursor box, draw.focusedCursorBox).
+ const x = @min(left + @as(f32, @floatFromInt(cursor.x)) * width, right - if (cursor.bar) @max(dp(1), width / 8) else width);
const y = layer.viewport.y + cursor.y;
const top = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, win_h, g.cell_h, g.tagline_height)));
- out[n] = .{ .x0 = x, .y0 = top, .x1 = @min(right, x + if (cursor.bar) @max(1, width / 8) else width), .y1 = top + @as(f32, @floatFromInt(g.tagline_height)), .rgb = cursorColor(chrome, &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x], .tagline), .text = page, .bar = cursor.bar };
+ out[n] = .{ .x0 = x, .y0 = top, .x1 = @min(right, x + if (cursor.bar) @max(dp(1), width / 8) else width), .y1 = top + @as(f32, @floatFromInt(g.tagline_height)), .rgb = cursorColor(chrome, &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x], .tagline), .text = page, .bar = cursor.bar };
n += 1;
}
return n;
@@ -8749,7 +9829,8 @@ fn uploadOverlayGpu(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, vertex_count: u32) bo
}
fn pushLayerOpacity(cmd: *c.SDL_GPUCommandBuffer, pass: ?*c.SDL_GPURenderPass, opacity: f32) void {
- const uniforms = [4]f32{ opacity, 0, 0, 0 };
+ var uniforms = [4]f32{ opacity, 0, 0, 0 } ++ @as([12]f32, @splat(0));
+ uniforms[4..].* = cell_decor.uniforms;
c.SDL_PushGPUFragmentUniformData(cmd, 0, &uniforms, @sizeOf(@TypeOf(uniforms)));
// The source alpha is geometric/image coverage, not window opacity. Its
// alpha contribution is multiplied by this constant separately, so a
diff --git a/src/macos.zig b/src/macos.zig
index d6daf944..4ff13b23 100644
--- a/src/macos.zig
+++ b/src/macos.zig
@@ -2995,7 +2995,7 @@ test "mac panel mask is a literal normalized grayscale texture" {
test "mac panel ABI has the documented compact byte layout" {
try std.testing.expectEqual(@as(usize, 16), @sizeOf(PanelBox));
- try std.testing.expectEqual(@as(usize, 44), @sizeOf(PanelTrack));
+ try std.testing.expectEqual(@as(usize, 48), @sizeOf(PanelTrack));
try std.testing.expectEqual(@as(usize, 0), @offsetOf(PanelTrack, "serial"));
try std.testing.expectEqual(@as(usize, 4), @offsetOf(PanelTrack, "pane"));
try std.testing.expectEqual(@as(usize, 5), @offsetOf(PanelTrack, "phase"));
diff --git a/src/macos/pardes.h b/src/macos/pardes.h
index 29f8a37d..46453429 100644
--- a/src/macos/pardes.h
+++ b/src/macos/pardes.h
@@ -108,11 +108,12 @@ typedef struct {
uint8_t pane;
uint8_t phase;
uint8_t effect;
- uint8_t reserved0;
+ uint8_t motion;
uint16_t frame;
uint16_t frame_count;
pardes_panel_box_s from;
pardes_panel_box_s to;
+ float launch;
} pardes_panel_track_s;
#define PARDES_PANEL_OPENING 0u
diff --git a/src/pardes.zig b/src/pardes.zig
index 52d11bec..72a90244 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -474,6 +474,8 @@ test {
_ = @import("draw.zig");
_ = @import("tag_layer.zig");
_ = @import("dump.zig"); // its tests ran nowhere before
+ _ = @import("Presentation.zig");
+ _ = @import("animation.zig");
}
pub const Builtin = builtins.registry.Builtin();
@@ -4341,6 +4343,10 @@ pub const Pardes = struct {
/// the pane and view origin it was last aimed in, and the last key or
/// click, which a blink holds for.
cursor_glide: animation.CursorGlide = .{},
+ /// A pixel shell's own render-time motion is under way (its smooth
+ /// scroll, G6): the core then keeps the continuous path running, so the
+ /// shell draws every loop and a virtual clock moves on, as for a lift.
+ shell_continuous: bool = false,
cursor_view: struct { serial: u32 = 0, line: i64 = 0 } = .{},
input_ns: u64 = 0,
animate_theme_changes: bool = false,
@@ -6675,7 +6681,7 @@ pub const Pardes = struct {
/// 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 p.cursor_glide.moving();
+ return p.cursor_glide.moving() or p.shell_continuous;
}
/// For shells that only need a yes or no: macOS and the board, which
diff --git a/src/surface.zig b/src/surface.zig
index 523cba43..7256ecc1 100644
--- a/src/surface.zig
+++ b/src/surface.zig
@@ -428,6 +428,28 @@ pub const Chrome = struct {
/// 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 theme's ornament (colors.Decor), a pixel shell's alone: the dot
+ /// grid on the page, the scroll track's checker, the tags' plaques
+ /// (frame, shadow, the focused one's stripes) and the file name's shadow, each null when the theme has none, sizes
+ /// in logical pixels. `name_ink` and `active_name_ink` are the file
+ /// name's colours in a tag, which the shadow goes under.
+ decor_dots: ?[3]u8 = null,
+ decor_dot_alpha: u8 = 0,
+ decor_dot_px: u8 = 22,
+ decor_checker: ?[3]u8 = null,
+ decor_checker_px: u8 = 3,
+ decor_box_border: ?[3]u8 = null,
+ decor_box_border_px: u8 = 2,
+ decor_box_shadow: ?[3]u8 = null,
+ decor_box_shadow_px: u8 = 5,
+ decor_box_stripe: ?[3]u8 = null,
+ decor_box_stripe_px: u8 = 2,
+ decor_title: ?[3]u8 = null,
+ decor_title_px: u8 = 2,
+ name_ink: [3]u8 = .{ 0, 0, 0 },
+ active_name_ink: [3]u8 = .{ 0, 0, 0 },
+ /// G9: a pixel shell glows round the body's selection (SelectionGlow).
+ selection_glow: u8 = 0,
};
/// The canonical interface: what a frame of pardes IS. The tty shell writes
diff --git a/src/themes/dark.zig b/src/themes/dark.zig
index 82396397..e93fd489 100644
--- a/src/themes/dark.zig
+++ b/src/themes/dark.zig
@@ -17,6 +17,10 @@ pub const theme = .{
.sel_fg = .{ 0xd8, 0xc8, 0xa8 },
.tag_bg = .{ 0x2c, 0x2a, 0x3e },
.tag_fg = .{ 0x96, 0x96, 0x96 },
+ // The focused tag: its band lighter in its own hue (1.54:1 off the
+ // plain one), its ink lighter to keep reading on it.
+ .tag_active_bg = .{ 0x48, 0x45, 0x66 },
+ .tag_active_fg = .{ 0xc8, 0xc8, 0xc8 },
.box = .{ 0x66, 0x3f, 0xe0 },
.box_dim = .{ 0x35, 0x3a, 0x58 },
.kw = .{ 0xda, 0x5e, 0xea },
diff --git a/src/themes/helix.zig b/src/themes/helix.zig
index db51de3a..61dc8e63 100644
--- a/src/themes/helix.zig
+++ b/src/themes/helix.zig
@@ -43,6 +43,10 @@ pub const theme = .{
.sel_fg = .{ 0x08, 0x08, 0x08 },
.tag_bg = .{ 0x12, 0x12, 0x12 },
.tag_fg = .{ 0x94, 0x94, 0x94 },
+ // The focused tag, still on the ramp: 237 #3a3a3a under 250 #bdbdbd
+ // (the page's text), 1.65:1 off the plain bar.
+ .tag_active_bg = .{ 0x3a, 0x3a, 0x3a },
+ .tag_active_fg = .{ 0xbd, 0xbd, 0xbd },
.box = .{ 0x62, 0x62, 0x62 },
.box_dim = .{ 0x26, 0x26, 0x26 },
.kw = .{ 0xd7, 0x76, 0xf9 },
diff --git a/src/themes/lapis.zig b/src/themes/lapis.zig
new file mode 100644
index 00000000..46623fc2
--- /dev/null
+++ b/src/themes/lapis.zig
@@ -0,0 +1,92 @@
+//! Lapis: 0x4200.cafe's illuminated manuscript (web/theme/lapis.css) as a
+//! pardes theme. Ultramarine vellum, gold leaf and vermilion rubrics: the
+//! page is lapis-deep, the tags lapis with their names in gold, the text
+//! vellum. The site's ornament comes with it as the theme's `decor`, drawn
+//! inside the existing chrome (docs/render-pipeline.md §10, decision 1):
+//! a gold dot grid on the page under the pane bodies, a gold and lapis
+//! checker in the scroll column, every tag a gold-framed plaque with the
+//! trail's hard vermilion shadow (inside its own band: in a tiled layout a
+//! shadow past it would fall on the next pane or its grip, and no ornament
+//! may cover a focus indicator), the focused one striped like the code
+//! window's title bar, and the file name's vermilion offset shadow.
+pub const theme = .{
+ .name = "lapis",
+ // --lapis-deep, --vellum
+ .bg = .{ 0x0a, 0x10, 0x30 },
+ .fg = .{ 0xee, 0xe6, 0xd2 },
+ // A gold highlight in lapis ink, as the directory's current entry.
+ .sel_bg = .{ 0xe8, 0xc4, 0x6a },
+ .sel_fg = .{ 0x0f, 0x1a, 0x4a },
+ // --lapis, --vellum-dim
+ .tag_bg = .{ 0x0f, 0x1a, 0x4a },
+ .tag_fg = .{ 0x9a, 0xa6, 0xd9 },
+ .tag_name_fg = .{ 0xe8, 0xc4, 0x6a },
+ .tag_active_name_fg = .{ 0xf2, 0xd2, 0x80 },
+ // The plaques' hover lapis, taken a step further: 1.5:1 off the tag.
+ .tag_active_bg = .{ 0x25, 0x39, 0x7d },
+ .tag_active_fg = .{ 0xee, 0xe6, 0xd2 },
+ .box = .{ 0xe8, 0xc4, 0x6a },
+ .box_dim = .{ 0x3a, 0x4a, 0x8a },
+ .box_border = .{ 0x9a, 0xa6, 0xd9 },
+ .box_dirty = .{ 0xff, 0x6a, 0x4a },
+ .column_box = .{ 0xff, 0x6a, 0x4a },
+ .column_box_dim = .{ 0x8a, 0x3a, 0x3a },
+ // Rubrics: vermilion keywords, gilded strings, the code window's green
+ // for numbers, vellum-dim comments.
+ .kw = .{ 0xff, 0x6a, 0x4a },
+ .str = .{ 0xe8, 0xc4, 0x6a },
+ .num = .{ 0xe7, 0xff, 0xe0 },
+ .comment = .{ 0x9a, 0xa6, 0xd9 },
+ .lineno = .{ 0x5a, 0x6a, 0xa8 },
+ .lineno_active = .{ 0x6a, 0x7a, 0xb8 },
+ // --lapis-ink under a gold thumb (the page's scrollbar-color).
+ .scroll_track = .{ 0x05, 0x0a, 0x24 },
+ .scroll_thumb = .{ 0xe8, 0xc4, 0x6a },
+ // Gold rules, a vermilion hairline between a tag and its text.
+ .border = .{ 0xe8, 0xc4, 0x6a },
+ // An empty column is the page, not a slab of gold leaf.
+ .empty_col = .{ 0x0a, 0x10, 0x30 },
+ .tag_rule = .{ 0xd4, 0x41, 0x2f },
+ .search_bg = .{ 0x6a, 0x2a, 0x1c },
+ .search_fg = .{ 0xff, 0xd0, 0xb0 },
+ .diagnostic_error = .{ 0xff, 0x6a, 0x4a },
+ .diagnostic_warning = .{ 0xe8, 0xc4, 0x6a },
+ .diagnostic_info = .{ 0x9a, 0xc6, 0xff },
+ .diagnostic_hint = .{ 0xa8, 0xe0, 0xc0 },
+ .decor = .{
+ .page_dots = .{ 0xe8, 0xc4, 0x6a },
+ // #e8c46a22 on the site: a dot a pixel and a half across every 22.
+ .page_dot_alpha = 0x22,
+ .page_dot_px = 22,
+ .rail_checker = .{ 0xe8, 0xc4, 0x6a },
+ .rail_checker_px = 3,
+ // The trail: a 2px gold frame and a 5px vermilion shadow; the code
+ // window's title bar: gold and lapis stripes of 2px.
+ .tag_border = .{ 0xe8, 0xc4, 0x6a },
+ .tag_border_px = 2,
+ .tag_shadow = .{ 0xd4, 0x41, 0x2f },
+ .tag_shadow_px = 5,
+ .tag_stripe = .{ 0xe8, 0xc4, 0x6a },
+ .tag_stripe_px = 2,
+ .title_shadow = .{ 0xd4, 0x41, 0x2f },
+ .title_shadow_px = 2,
+ },
+ .palette = .{
+ .{ 0x0a, 0x10, 0x30 },
+ .{ 0xff, 0x6a, 0x4a },
+ .{ 0xa8, 0xe0, 0xc0 },
+ .{ 0xe8, 0xc4, 0x6a },
+ .{ 0x9a, 0xc6, 0xff },
+ .{ 0xd8, 0xa0, 0xe8 },
+ .{ 0x8a, 0xd8, 0xd8 },
+ .{ 0xee, 0xe6, 0xd2 },
+ .{ 0x9a, 0xa6, 0xd9 },
+ .{ 0xff, 0x9a, 0x6a },
+ .{ 0xc8, 0xf0, 0xd8 },
+ .{ 0xff, 0xde, 0x90 },
+ .{ 0xc0, 0xdc, 0xff },
+ .{ 0xe8, 0xc0, 0xf4 },
+ .{ 0xb0, 0xec, 0xec },
+ .{ 0xff, 0xf8, 0xe8 },
+ },
+};