From 8f31fc6bfaae2d29b2bb5cc2149fcd7033cd9724 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 12:52:47 -0300 Subject: Hash settled GUI frames of sixteen scenes, a local gate for render refactors test/gui_golden.py drives a hidden pardes-gui through 9P and window-pixel mouse events on its test stdin into sixteen scenes: documents, a notice, sticky context rows with the scroll thumb mid-rail, a body selection, a tag holding the keyboard with a selection and caret, a prompt notice, wide glyphs across a tag's and a body row's right edge, a collapsed pane with focus tint, Tagbottom, column tags, a multi-line tag, an image, a dark and a light theme, and Debug. Each scene waits until two captures agree and is hashed. The goldens are machine- and driver-specific: a local gate, never a CI one. --- test/gui-goldens.txt | 16 ++++ test/gui_golden.py | 205 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 test/gui-goldens.txt create mode 100644 test/gui_golden.py diff --git a/test/gui-goldens.txt b/test/gui-goldens.txt new file mode 100644 index 00000000..5531b578 --- /dev/null +++ b/test/gui-goldens.txt @@ -0,0 +1,16 @@ +01-boot d4e3eb9dba35133df10d46b1d27ab7a90fa9bdefb64bcc304add221326ffdfc9 +02-second-document 235cdf75f3ce1d5800dbbdd7dcfde066af50cde01231a35d00c66c4310213a05 +03-notice 4d59205fe6b89a4589e94c68521257f21b40917c0198ea20248925687663d9f5 +04-context-rows-mid-thumb 7129ee318975f858c430b1e610b26ca756d5bb37553338c0dc6caab2290de2a4 +05-body-selection 39103f3747aae07a12b5151e7e377430b88b96e504670ac2f2d0272197eb827b +06-tag-focus-selection 2bedec24842109aa2cc302226d53a994ff547791ad54d5781fe48d8eaac179ac +07-prompt-notice 6d6eda3f3a74d3ff048cb0e433e3794ca061e356184ff62a4878395c58482727 +08-wide-glyph-edges 123a182b82a2a8c455291a5c89ccfa28e1f8ce9566172ca404c0b9c71dd33b48 +09-collapsed-focus-tint ae66076fdfb0cab9a906b052df257deee5ba91a6274712d52a76d8a7adc5b415 +10-tagbottom dd8ba0d3ecabf3420d000b0c9591a57278179cbd2a9060f849d283f3881d1847 +11-column-tags 6b1731d1611404597c96ad77f34c2d5cd5a81c1085ec68c6779388e07ce28614 +12-multiline-tag 1b9ccd331814dc76111470f18448b1ca192e1edc3d79ea6eee19a6a78a4b1831 +13-image de18edee2ab2e0f2d4bf4162d86e3d8a1852fccfc344830491fa1a359095d4ea +14-theme-ink 6e733d4212373c90b860b2e41102ced8719b07480591b5da3905267c8358db18 +15-theme-acme-light 39f9256b7c8ed5187cd8d2cc3840db274efcecad263e519c4f9c79eb9efea19c +16-debug e97ce74b7a3d4a08f6a529b1f0de111d62e5532c829a6b1633b966dd45218f55 diff --git a/test/gui_golden.py b/test/gui_golden.py new file mode 100644 index 00000000..6586af3a --- /dev/null +++ b/test/gui_golden.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Hash settled SDL frames of a fixed set of scenes, to prove a GUI refactor +left every pixel where it was. + +python3 -B test/gui_golden.py compare +python3 -B test/gui_golden.py --update rewrite goldens + +The window is hidden (PARDES_TEST renders into an offscreen capture texture), +and the capture carries PARDES_TEST_PAD leftover pixels so edge bugs show. +GPU rasterisation differs between machines and drivers: the goldens in +test/gui-goldens.txt are a LOCAL gate for one machine, never a CI one. Each +scene waits until two captures 400 ms apart agree before it is hashed. +""" +import argparse +import hashlib +import os +from pathlib import Path +import tempfile +import shutil +import time + +from fs import session +from ui_review import require, wait_for + +GOLDENS = Path(__file__).resolve().parent / 'gui-goldens.txt' + +ZIG = b'''const std = @import("std"); + +pub const Shape = union(enum) { + circle: f32, + square: f32, + + pub fn area(shape: Shape) f32 { + return switch (shape) { + .circle => |r| std.math.pi * r * r, + .square => |s| s * s, + }; + } +}; + +pub fn main() void { + var total: f32 = 0; + const shapes = [_]Shape{ .{ .circle = 1 }, .{ .square = 2 } }; + for (shapes) |shape| { +''' + b''.join(b' total += shape.area() * %d; // line %d\n' % (n, n) for n in range(60)) + b''' } + std.debug.print("{d}\\n", .{total}); +} +''' + + +def ppm(width, height): + rows = [] + for y in range(height): + rows.append(bytes(v for x in range(width) for v in ((x * 255) // width, (y * 255) // height, 128))) + return b'P6\n%d %d\n255\n' % (width, height) + b''.join(rows) + + +def panes(client): + serials = [int(row.split()[0]) for row in client.read('/index').decode().splitlines() if row.strip()] + return {serial: client.read(f'/pane/{serial}/tag').decode(errors='replace') for serial in serials} + + +def settled(root): + """The capture once it stops changing: two reads 400 ms apart agree.""" + latest = root / 'latest.ppm' + previous = None + deadline = time.monotonic() + 20 + while time.monotonic() < deadline: + time.sleep(.4) + try: + data = latest.read_bytes() + except FileNotFoundError: + continue + if not data.startswith(b'P6'): + continue + digest = hashlib.sha256(data).hexdigest() + if digest == previous: + return digest + previous = digest + raise RuntimeError('capture never settled') + + +def run(args): + results = [] + # A fixed directory: the paths it holds are drawn in the tags. + root = Path(tempfile.gettempdir()) / 'pardes-gui-golden' + shutil.rmtree(root, ignore_errors=True) + root.mkdir(mode=0o700) + name = 'gui-golden' + config = root / 'config/pardes' + config.mkdir(parents=True) + (config / 'init').write_text('Shell /bin/sh\nTheme forge\n') + source = root / 'shapes.zig' + source.write_bytes(ZIG) + notes = root / 'notes.txt' + notes.write_bytes(b'Pardes GUI golden\n\nA second document, opened by Look.\n' * 4) + wide = root / 'wide.txt' + # One of the two alignments puts a wide glyph across the body's right edge. + wide.write_bytes(('x' + '\u754c' * 80 + '\n' + '\u754c' * 80 + '\n').encode()) + image = root / 'ramp.ppm' + image.write_bytes(ppm(96, 64)) + # The session points XDG_RUNTIME_DIR at its own root, so the caller's + # Wayland socket is passed as an absolute path. + display = Path(os.environ.get('WAYLAND_DISPLAY', 'wayland-0')) + if not display.is_absolute(): + display = Path(os.environ.get('XDG_RUNTIME_DIR', '/run/user/%d' % os.getuid())) / display + inherited = {'PARDES_TEST': '1', 'PARDES_TEST_COLS': '120', 'PARDES_TEST_ROWS': '40', + 'PARDES_TEST_PAD': '7', 'PARDES_TEST_CAPTURE_DIR': str(root), + 'SDL_VIDEODRIVER': 'wayland', 'WAYLAND_DISPLAY': str(display)} + launch = ['-c', 'stty cols 120 rows 40; exec "$@"', name, str(args.binary.resolve()), + '--9p=' + name, str(source)] + handles = {} + with session('/bin/sh', root, name, socket_name=name, tty=True, + launch=launch, inherited=inherited, terminal=handles) as (client, _): + first = require(wait_for(lambda: next((s for s, tag in panes(client).items() + if 'shapes.zig' in tag), None)), 'source pane missing') + + def scene(label): + results.append((label, settled(root))) + if args.keep: + args.keep.mkdir(parents=True, exist_ok=True) + shutil.copyfile(root / 'latest.ppm', args.keep / (label + '.ppm')) + print(label, results[-1][1][:16], flush=True) + + def mouse(kind, button, x, y): + # The test shell's stdin takes window-pixel mouse events. + os.write(handles['input_fd'], f'\x1b]777;mouse;{kind};{button};{x};{y}\x07'.encode()) + time.sleep(.1) + + def serial_of(word): + return require(wait_for(lambda: next((s for s, tag in panes(client).items() if word in tag), None)), + word + ' missing') + + scene('01-boot') + client.write(f'/pane/{first}/look', f'{notes}\n'.encode()) + notes_pane = serial_of('notes.txt') + scene('02-second-document') + client.write(f'/pane/{first}/ctl', b'Msg a golden notice\n') + scene('03-notice') + client.write(f'/pane/{first}/ctl', b'TreeContext\n') + client.write(f'/pane/{first}/dot', b'45\n') + scene('04-context-rows-mid-thumb') + client.write(f'/pane/{first}/dot', b'40,43\n') + scene('05-body-selection') + # A press and a drag along the source's tag: the tag takes the + # keyboard with a selection and a caret. The pointer then rests on + # empty body space, far right. + mouse('down', 1, 500, 87) + mouse('motion', 1, 620, 87) + mouse('up', 1, 620, 87) + mouse('motion', 0, 1880, 1300) + scene('06-tag-focus-selection') + os.write(handles['input_fd'], b'\x1b') + time.sleep(.2) + scratch = int(client.read('/pane/new')) + client.write(f'/pane/{scratch}/exec', b'Save\n') + scene('07-prompt-notice') + os.write(handles['input_fd'], b'\x1b') + time.sleep(.2) + client.write(f'/pane/{scratch}/exec', b'Del\n') + client.write(f'/pane/{first}/look', f'{wide}\n'.encode()) + wide_pane = serial_of('wide.txt') + client.write(f'/pane/{wide_pane}/name', ('x' + '\u754c' * 70 + '\n').encode()) + client.write('/ctl', b'Wrap off\n') + scene('08-wide-glyph-edges') + client.write('/ctl', b'Wrap on\n') + client.write(f'/pane/{wide_pane}/exec', b'Del\n') + client.write('/ctl', b'FocusTint on\n') + client.write(f'/pane/{notes_pane}/ctl', b'Collapse\n') + scene('09-collapsed-focus-tint') + client.write(f'/pane/{notes_pane}/ctl', b'Collapse\n') + client.write('/ctl', b'Tagbottom\n') + scene('10-tagbottom') + client.write('/ctl', b'Tagbottom\n') + client.write('/ctl', b'ColumnTags\n') + scene('11-column-tags') + client.write('/ctl', b'ColumnTags\n') + client.write(f'/pane/{first}/tag', b' Second\nthird line of the tag') + scene('12-multiline-tag') + client.write(f'/pane/{first}/look', f'{image}\n'.encode()) + serial_of('ramp.ppm') + scene('13-image') + client.write('/ctl', b'Theme ink\n') + scene('14-theme-ink') + client.write('/ctl', b'Theme acme\n') + scene('15-theme-acme-light') + client.write('/ctl', b'Debug\n') + scene('16-debug') + lines = ''.join(f'{label} {digest}\n' for label, digest in results) + if args.update: + GOLDENS.write_text(lines) + print('updated', GOLDENS) + return + expected = dict(line.split() for line in GOLDENS.read_text().splitlines() if line.strip()) + bad = [label for label, digest in results if expected.get(label) != digest] + require(not bad, 'frames changed: ' + ', '.join(bad)) + print(f'{len(results)} GUI frames byte-identical') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('binary', type=Path) + parser.add_argument('--update', action='store_true') + parser.add_argument('--keep', type=Path, help='copy each hashed frame here as .ppm') + run(parser.parse_args()) -- cgit v1.3 From 30c2c8a154e770b3920bcfd7d553825fe9182bf4 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 12:57:47 -0300 Subject: Move the frame into draw.zig and the transitions out of layout.zig Pure moves, no behaviour change. draw.zig holds the whole core frame in order: render, the pane, tag, header and notice painters it calls, and the character-effect composition (Pardes.render stays a declaration alias). Presentation.zig is the panel presentation state as a file struct, and animation.zig the easing curves, transition kinds, tracks and boxes, the character effects' sources and the generic displayed-value transition, all of which lived in layout.zig. layout.zig keeps only layout. --- src/Messages.zig | 3 +- src/Presentation.zig | 721 +++++++++++++++++++++ src/animation.zig | 744 ++++++++++++++++++++++ src/body_layer.zig | 449 ------------- src/colors.zig | 9 +- src/config.zig | 29 +- src/draw.zig | 913 +++++++++++++++++++++++++++ src/gui/gui.zig | 66 +- src/layout.zig | 1439 +----------------------------------------- src/look.zig | 5 +- src/macos.zig | 45 +- src/mouse.zig | 34 +- src/ninep/screen.zig | 2 +- src/pardes.zig | 473 +------------- src/surface.zig | 7 +- src/tty/panel_compositor.zig | 27 +- src/tty/tty.zig | 4 +- test/panes.zig | 3 +- test/pdf.zig | 3 +- 19 files changed, 2532 insertions(+), 2444 deletions(-) create mode 100644 src/Presentation.zig create mode 100644 src/animation.zig create mode 100644 src/draw.zig diff --git a/src/Messages.zig b/src/Messages.zig index 72b3b63c..97a2f939 100644 --- a/src/Messages.zig +++ b/src/Messages.zig @@ -5,6 +5,7 @@ const pardes = @import("pardes.zig"); const memory = @import("memory.zig"); const std = @import("std"); const layout = @import("layout.zig"); +const animation = @import("animation.zig"); const ctlfs = @import("ninep/tree.zig"); const panes = @import("panes.zig"); const CellStyle = @import("surface.zig").CellStyle; @@ -285,7 +286,7 @@ pub const MessageMotion = struct { alpha: f32 = 1, slide: f32 = 0 }; /// Animation frames a message timing lasts; at least one. pub fn messageFrames(ms: u16) u16 { - return @intCast(@max(1, (@as(u32, ms) + layout.Animation.frame_ms - 1) / layout.Animation.frame_ms)); + return @intCast(@max(1, (@as(u32, ms) + animation.Animation.frame_ms - 1) / animation.Animation.frame_ms)); } pub fn messageMotion(p: *const Pardes, life: Pane.MessageLife) MessageMotion { diff --git a/src/Presentation.zig b/src/Presentation.zig new file mode 100644 index 00000000..fe8317dd --- /dev/null +++ b/src/Presentation.zig @@ -0,0 +1,721 @@ +//! Panel presentation: which pane geometry the shells have been shown, the +//! transition tracks that move it there, and the frozen previous grid the +//! character and dissolve effects read. Only acknowledged draws advance the +//! shown geometry; committed and submitted may be ahead of it. +const std = @import("std"); +const pardes = @import("pardes.zig"); +const layout = @import("layout.zig"); +const animation = @import("animation.zig"); +const Pardes = pardes.Pardes; +const MAX_PANES = pardes.MAX_PANES; +const Box = animation.Box; +const Track = animation.Track; +const Phase = animation.Phase; +const Transition = animation.Transition; +const CellArea = animation.CellArea; +const CharSource = animation.CharSource; +const charSource = animation.charSource; +const dissolveRevealed = animation.dissolveRevealed; +const openingBox = animation.openingBox; +const closingBox = animation.closingBox; +const paintOrder = animation.paintOrder; +const panelBox = layout.panelBox; + +const Presentation = @This(); + +pub const Snapshot = struct { serial: u32, box: Box }; + +// Only acknowledged draws advance shown geometry; committed and submitted may be ahead. +committed: [MAX_PANES]?Snapshot = @splat(null), +initialized: bool = false, +enabled: bool = false, +snap_once: bool = false, +tracks: [MAX_PANES]?Track = @splat(null), +closing: [MAX_PANES]Track = undefined, +closing_len: usize = 0, +submitted: [MAX_PANES]?Snapshot = @splat(null), +submitted_ready: bool = false, +shown: [MAX_PANES]?Snapshot = @splat(null), +shown_tracks: [MAX_PANES]?Track = @splat(null), +shown_closing: [MAX_PANES]Track = undefined, +shown_closing_len: usize = 0, +acknowledged: bool = false, +pending: bool = false, +previous_cells: []pardes.Cell = &.{}, +previous_body_layers: [MAX_PANES]pardes.BodyLayer = @splat(.{}), +previous_tag_layers: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = @splat(.{}), +previous_cols: u16 = 0, +previous_rows: u16 = 0, +previous_valid: bool = false, +previous_layout: [MAX_PANES]?Snapshot = @splat(null), +diffs: []pardes.PanelCellDiff = &.{}, +diff_state: enum { none, requested, ready } = .none, + +pub const CellPosition = struct { col: u16, row: u16 }; + +pub fn deinit(self: *Presentation, gpa: std.mem.Allocator) void { + if (self.previous_cells.len > 0) gpa.free(self.previous_cells); + for (&self.previous_body_layers) |*layer| gpa.free(layer.cells); + for (&self.previous_tag_layers) |*layer| gpa.free(layer.cells); + if (self.diffs.len > 0) gpa.free(self.diffs); +} + +pub fn sync(self: *Presentation, p: *Pardes) void { + const initializing = !self.initialized or !self.enabled; + if (initializing or self.snap_once) { + const had_layout = self.initialized; + self.committed = @splat(null); + self.tracks = @splat(null); + self.closing_len = 0; + self.diff_state = .none; + if (initializing) { + self.shown_tracks = @splat(null); + self.shown_closing_len = 0; + } + for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + self.committed[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; + } + self.initialized = true; + self.snap_once = false; + if (had_layout and self.acknowledged) + self.pending = true; + return; + } + + const effect = p.settings.panel_transition; + if (effect.needsPreviousGrid() and self.diff_state != .none) { + var second_change = false; + for (p.panes, self.committed, 0..) |slot, snapshot, id| { + const pane = slot orelse { + second_change = second_change or snapshot != null; + continue; + }; + const target = panelBox(p.rects[id]); + second_change = second_change or snapshot == null or + snapshot.?.serial != pane.serial or !snapshot.?.box.eql(target); + } + if (second_change) { + p.abandonPanelAnimations(); + self.committed = @splat(null); + for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + self.committed[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; + } + return; + } + } + var changed = false; + var animated_change = false; + for (p.panes, 0..) |slot, id| { + const pane = slot orelse { + if (self.committed[id]) |old| { + changed = true; + if (effect.lifecycleOnly()) { + if (self.appendClosing(.{ + .serial = old.serial, + .pane = @intCast(id), + .phase = .closing, + .effect = effect, + .from = old.box, + .to = closingBox(effect, old.box), + })) animated_change = true; + } + } + self.committed[id] = null; + self.tracks[id] = null; + // Never let pixels from a dead pane address a reused slot. + self.shown_tracks[id] = null; + continue; + }; + const target = panelBox(p.rects[id]); + const previous = self.committed[id]; + self.committed[id] = .{ .serial = pane.serial, .box = target }; + + if (effect == .off) { + changed = changed or previous == null or previous.?.serial != pane.serial or + !previous.?.box.eql(target); + self.tracks[id] = null; + continue; + } + if (previous) |old| { + if (old.serial == pane.serial and old.box.eql(target)) continue; + const same_lifetime = old.serial == pane.serial; + const prior = if (self.tracks[id]) |track| + if (track.serial == pane.serial and track.active()) track else null + else + null; + if (effect.lifecycleOnly() and same_lifetime) { + if (prior) |active| if (active.phase == .opening) { + var next = active; + next.from = openingBox(effect, target, p.screen_w); + next.to = target; + self.tracks[id] = next; + changed = true; + animated_change = true; + continue; + }; + self.tracks[id] = null; + changed = true; + continue; + } + if (effect.lifecycleOnly() and !same_lifetime) { + _ = self.appendClosing(.{ + .serial = old.serial, + .pane = @intCast(id), + .phase = .closing, + .effect = effect, + .from = old.box, + .to = closingBox(effect, old.box), + }); + } + const shown = if (self.shown[id]) |snapshot| + if (snapshot.serial == pane.serial) snapshot.box else null + else + null; + const from = if (!same_lifetime) + openingBox(effect, target, p.screen_w) + else if (shown) |box| + box + else if (self.acknowledged and prior != null) + prior.?.from + else + old.box; + const next: Track = .{ + .serial = pane.serial, + .pane = @intCast(id), + .phase = if (!same_lifetime or + (prior != null and prior.?.phase == .opening)) .opening else .moving, + .effect = effect, + .from = from, + .to = target, + }; + self.tracks[id] = next; + changed = true; + animated_change = true; + } else { + const next: Track = .{ + .serial = pane.serial, + .pane = @intCast(id), + .phase = .opening, + .effect = effect, + .from = openingBox(effect, target, p.screen_w), + .to = target, + }; + self.tracks[id] = next; + changed = true; + animated_change = true; + } + } + if (animated_change and effect.needsPreviousGrid()) { + self.diff_state = .requested; + } + if (changed and self.acknowledged) self.pending = true; +} + +fn appendClosing(self: *Presentation, track: Track) bool { + std.debug.assert(track.phase == .closing); + const baseline = self.previous_layout[track.pane] orelse return false; + if (!self.previous_valid or baseline.serial != track.serial or + !baseline.box.eql(track.from)) return false; + if (self.closing_len == self.closing.len) { + std.mem.copyForwards( + Track, + self.closing[0 .. self.closing.len - 1], + self.closing[1..], + ); + self.closing_len -= 1; + } + self.closing[self.closing_len] = track; + self.closing_len += 1; + return true; +} + +pub fn advance(self: *Presentation) void { + for (&self.tracks) |*slot| { + const track = if (slot.*) |*track| track else continue; + track.frame +|= 1; + if (!track.active()) slot.* = null; + } + var out: usize = 0; + for (self.closing[0..self.closing_len]) |value| { + var track = value; + track.frame +|= 1; + if (!track.active()) continue; + self.closing[out] = track; + out += 1; + } + self.closing_len = out; +} + +pub fn acknowledge(self: *Presentation, p: *Pardes, tracks: []const Track) void { + var presented: [MAX_PANES]?Track = @splat(null); + var presented_closing: [MAX_PANES]Track = undefined; + var nclosing: usize = 0; + var presented_layout: [MAX_PANES]?Snapshot = if (self.submitted_ready) + self.submitted + else + @splat(null); + if (!self.submitted_ready) for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + presented_layout[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; + }; + for (&presented_layout, 0..) |*snapshot, id| if (snapshot.*) |saved| { + const pane = p.panes[id] orelse { + snapshot.* = null; + continue; + }; + if (pane.serial != saved.serial) snapshot.* = null; + }; + for (tracks) |track| { + if (track.phase == .closing) { + const current = for (self.closing[0..self.closing_len]) |candidate| { + if (candidate.serial == track.serial and candidate.pane == track.pane and + candidate.effect == track.effect) break true; + } else false; + if (!current or !track.active() or nclosing == presented_closing.len) continue; + presented_closing[nclosing] = track; + nclosing += 1; + continue; + } + const id: usize = track.pane; + if (id >= p.panes.len or !track.active()) continue; + const pane = p.panes[id] orelse continue; + if (pane.serial != track.serial) continue; + presented[id] = track; + presented_layout[id] = .{ .serial = track.serial, .box = track.visualBox() }; + } + self.shown_tracks = presented; + self.shown_closing = presented_closing; + self.shown_closing_len = nclosing; + self.shown = presented_layout; + self.acknowledged = true; + self.pending = false; + if (tracks.len == 0) { + self.tracks = @splat(null); + self.closing_len = 0; + self.diff_state = .none; + self.capturePrevious(p.gpa, &p.surface); + } +} + +fn capturePrevious(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) void { + self.diff_state = .none; + const cells = surface.cells; + if (cells.len == 0) { + self.previous_valid = false; + self.previous_layout = @splat(null); + return; + } + if (self.previous_cells.len != cells.len) { + const next = gpa.alloc(pardes.Cell, cells.len) catch { + self.previous_valid = false; + self.previous_layout = @splat(null); + return; + }; + if (self.previous_cells.len > 0) gpa.free(self.previous_cells); + self.previous_cells = next; + } + for (&self.previous_body_layers, surface.bodyLayers()) |*old, *current| { + const count = if (current.rows > 0) current.cells.len else 0; + const storage = if (old.cells.len == count) old.cells else gpa.realloc(old.cells, count) catch { + self.previous_valid = false; + self.previous_layout = @splat(null); + return; + }; + if (count > 0) @memcpy(storage, current.cells); + old.* = current.*; + old.cells = storage; + } + for (&self.previous_tag_layers, surface.tagLayers()) |*old, *current| { + const count = if (current.cols > 0) current.cells.len else 0; + const storage = if (old.cells.len == count) old.cells else gpa.realloc(old.cells, count) catch { + self.previous_valid = false; + self.previous_layout = @splat(null); + return; + }; + if (count > 0) @memcpy(storage, current.cells); + old.* = current.*; + old.cells = storage; + } + @memcpy(self.previous_cells, cells); + self.previous_cols = surface.cols; + self.previous_rows = surface.rows; + self.previous_valid = true; + self.previous_layout = if (self.submitted_ready) + self.submitted + else + @splat(null); +} + +pub fn cellDiff(self: *const Presentation, cols: u16, rows: u16, col: u16, row: u16) pardes.PanelCellDiff { + if (self.diff_state != .ready or col >= cols or row >= rows or + self.diffs.len != @as(usize, cols) * rows) return .unchanged; + return self.diffs[@as(usize, row) * cols + col]; +} + +pub const Point = struct { x: f32, y: f32 }; + +/// Coordinates stay fractional until the host resolves its body font grid. +pub fn pointerFractional(self: *const Presentation, cols: u16, rows: u16, x: f32, y: f32) ?Point { + if (self.pending or !std.math.isFinite(x) or !std.math.isFinite(y) or + x < 0 or y < 0 or x >= @as(f32, @floatFromInt(cols)) or y >= @as(f32, @floatFromInt(rows))) return null; + var closing = self.shown_closing_len; + while (closing > 0) { + closing -= 1; + const track = self.shown_closing[closing]; + if (track.active() and boxContainsPoint(track.contentBox(), x, y) and + boxContainsPoint(track.presented(), x, y)) return null; + } + for ([_]Phase{ .opening, .moving }) |phase| { + var index = self.shown_tracks.len; + while (index > 0) { + index -= 1; + const track = self.shown_tracks[index] orelse continue; + if (!track.active() or track.phase != phase) continue; + switch (track.effect) { + .slide, .zoom, .vertical => { + const target = track.contentBox(); + if (track.effect == .vertical and !boxContainsPoint(target, x, y)) continue; + const shown = track.presented(); + if (!boxContainsPoint(shown, x, y)) { + if (track.effect == .vertical) return null; + continue; + } + return .{ + .x = target.x + (x - shown.x) / shown.w * target.w, + .y = target.y + (y - shown.y) / shown.h * target.h, + }; + }, + else => { + if (!boxContainsPoint(track.to, x, y)) continue; + _ = self.pointer(cols, rows, @intFromFloat(@floor(x)), @intFromFloat(@floor(y))) orelse return null; + return .{ .x = x, .y = y }; + }, + } + } + } + _ = self.pointer(cols, rows, @intFromFloat(@floor(x)), @intFromFloat(@floor(y))) orelse return null; + return .{ .x = x, .y = y }; +} + +fn boxContainsPoint(box: Box, x: f32, y: f32) bool { + return box.w > 0 and box.h > 0 and x >= box.x and y >= box.y and x < box.x + box.w and y < box.y + box.h; +} + +pub fn pointer(self: *const Presentation, cols: u16, rows: u16, col: u16, row: u16) ?CellPosition { + if (self.pending) return null; + var closing = self.shown_closing_len; + while (closing > 0) { + closing -= 1; + const track = self.shown_closing[closing]; + if (!track.active()) continue; + if (boxContainsCell(track.contentBox(), col, row) and + boxContainsCell(track.presented(), col, row)) return null; + } + const phases = [_]Phase{ .opening, .moving }; + for (phases) |phase| { + // Backends paint pane slots forward within a phase. Probe them in + // reverse so overlapping transition quads address the top pixel. + var index = self.shown_tracks.len; + while (index > 0) { + index -= 1; + const track = self.shown_tracks[index] orelse continue; + if (!track.active() or track.phase != phase) continue; + switch (track.effect) { + .slide, .zoom => { + const shown = track.presented(); + if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) continue; + const x = @as(f32, @floatFromInt(col)) + 0.5; + const y = @as(f32, @floatFromInt(row)) + 0.5; + const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999); + const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999); + const logical_x: i32 = @intFromFloat(@floor(track.to.x + u * track.to.w)); + const logical_y: i32 = @intFromFloat(@floor(track.to.y + v * track.to.h)); + return .{ + .col = @intCast(std.math.clamp(logical_x, 0, @as(i32, cols -| 1))), + .row = @intCast(std.math.clamp(logical_y, 0, @as(i32, rows -| 1))), + }; + }, + .vertical => { + const clip = track.contentBox(); + if (!boxContainsCell(clip, col, row)) continue; + const shown = track.presented(); + if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) + return null; + const x = @as(f32, @floatFromInt(col)) + 0.5; + const y = @as(f32, @floatFromInt(row)) + 0.5; + const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999); + const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999); + return .{ + .col = @intFromFloat(@floor(clip.x + u * clip.w)), + .row = @intFromFloat(@floor(clip.y + v * clip.h)), + }; + }, + .dissolve, .ascii => { + if (!boxContainsCell(track.to, col, row)) continue; + const cell_diff = self.cellDiff(cols, rows, col, row); + if (!cell_diff.changed()) + return .{ .col = col, .row = row }; + const relative_col: u16 = @intFromFloat(@floor( + @as(f32, @floatFromInt(col)) + 0.5 - track.to.x, + )); + const relative_row: u16 = @intFromFloat(@floor( + @as(f32, @floatFromInt(row)) + 0.5 - track.to.y, + )); + const visible = switch (track.effect) { + .dissolve => dissolveRevealed( + track.serial, + relative_col, + relative_row, + track.amount(), + ), + .ascii => switch (cell_diff) { + .ascii => |diff| diff.complete(track.frame), + .unchanged, .visual => true, + }, + else => unreachable, + }; + return if (visible) .{ .col = col, .row = row } else null; + }, + .edges, .fall, .wave, .curtain, .scramble, .typewriter => { + if (!boxContainsCell(track.to, col, row)) continue; + const area = CellArea.of(track.to); + const settled = std.meta.eql( + charSource( + track, + col -| area.x0, + row -| area.y0, + area, + ), + CharSource.settled, + ); + return if (settled) .{ .col = col, .row = row } else null; + }, + .off => {}, + } + } + } + for (self.shown_tracks) |maybe| { + const track = maybe orelse continue; + if (!track.active() or (track.effect != .slide and track.effect != .zoom and + track.effect != .vertical)) continue; + if (boxContainsCell(track.to, col, row)) return null; + } + return .{ .col = col, .row = row }; +} + +fn boxContainsCell(box: Box, col: u16, row: u16) bool { + const x = @as(f32, @floatFromInt(col)) + 0.5; + const y = @as(f32, @floatFromInt(row)) + 0.5; + return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h; +} + +pub fn cancel(self: *Presentation) void { + self.tracks = @splat(null); + self.closing_len = 0; + self.shown_tracks = @splat(null); + self.shown_closing_len = 0; + self.diff_state = .none; + self.pending = self.acknowledged; +} + +fn prepareDiff(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) !bool { + const count = surface.cells.len; + if (!self.previous_valid or + self.previous_cols != surface.cols or + self.previous_rows != surface.rows or + self.previous_cells.len != count) return false; + if (self.diffs.len != count) { + const next = try gpa.alloc(pardes.PanelCellDiff, count); + if (self.diffs.len > 0) gpa.free(self.diffs); + self.diffs = next; + } + for (self.diffs, self.previous_cells, surface.cells) |*diff, *old, *new| + diff.* = pardes.PanelCellDiff.between(old, new); + for (&self.tracks) |*slot| { + const track = if (slot.*) |*track| track else continue; + if (track.effect != .ascii) continue; + var longest: u16 = 1; + var row: u16 = 0; + while (row < surface.rows) : (row += 1) { + var col: u16 = 0; + while (col < surface.cols) : (col += 1) { + if (!boxContainsCell(track.to, col, row)) continue; + const index = @as(usize, row) * surface.cols + col; + switch (self.diffs[index]) { + .ascii => |diff| longest = @max(longest, diff.frameCount()), + .unchanged, .visual => {}, + } + } + } + track.frame_count = @max(track.frame_count, longest); + } + self.diff_state = .ready; + return true; +} + +pub fn submit(self: *Presentation, p: *Pardes, s: *pardes.Surface) !void { + self.submitted = @splat(null); + for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + self.submitted[id] = .{ + .serial = pane.serial, + .box = panelBox(p.rects[id]), + }; + } + self.submitted_ready = true; + if (self.diff_state != .none and !try self.prepareDiff(p.gpa, s)) { + for (&self.tracks) |*slot| { + const track = slot.* orelse continue; + if (track.effect.needsPreviousGrid()) slot.* = null; + } + self.closing_len = 0; + self.diff_state = .none; + } + if (self.diff_state == .ready) { + s.previous_cells = self.previous_cells; + s.previous_body_layers = &self.previous_body_layers; + s.previous_tag_layers = &self.previous_tag_layers; + s.cell_diffs = self.diffs; + } + s.npanel_tracks = paintOrder( + &self.tracks, + self.closing[0..self.closing_len], + &s.panel_tracks, + ); +} + +pub fn animating(self: *const Presentation) bool { + for (self.tracks) |track| if (track != null and track.?.active()) return true; + for (self.closing[0..self.closing_len]) |track| + if (track.active()) return true; + return false; +} + +test "presentation cache allocation failures retain ownership and allow retry" { + var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{}); + const gpa = allocator.allocator(); + var state: Presentation = .{}; + defer state.deinit(gpa); + var cells: [3]pardes.Cell = @splat(.{}); + cells[0].default = false; + cells[0].text[0] = 'a'; + var surface: pardes.Surface = .{ .cols = 2, .rows = 1, .cells = cells[0..2] }; + state.submitted[0] = .{ .serial = 7, .box = .{ .x = 0, .y = 0, .w = 2, .h = 1 } }; + state.submitted_ready = true; + state.capturePrevious(gpa, &surface); + try std.testing.expect(state.previous_valid); + try std.testing.expectEqual(@as(u32, 7), state.previous_layout[0].?.serial); + const previous = state.previous_cells.ptr; + const allocated = allocator.allocations; + allocator.fail_index = allocator.alloc_index; + state.capturePrevious(gpa, &surface); + try std.testing.expect(state.previous_valid); + try std.testing.expectEqual(allocated, allocator.allocations); + try std.testing.expect(!allocator.has_induced_failure); + + surface.cols = 3; + surface.cells = &cells; + state.submitted[0].?.box.w = 3; + state.diff_state = .ready; + state.capturePrevious(gpa, &surface); + try std.testing.expect(allocator.has_induced_failure); + try std.testing.expect(!state.previous_valid); + try std.testing.expectEqual(previous, state.previous_cells.ptr); + try std.testing.expectEqual(@as(usize, 2), state.previous_cells.len); + try std.testing.expectEqual(@as(u8, 'a'), state.previous_cells[0].text[0]); + try std.testing.expect(state.previous_layout[0] == null); + try std.testing.expectEqual(.none, state.diff_state); + + allocator.fail_index = std.math.maxInt(usize); + state.capturePrevious(gpa, &surface); + try std.testing.expect(state.previous_valid); + try std.testing.expectEqual(@as(usize, 3), state.previous_cells.len); + try std.testing.expectEqual(@as(f32, 3), state.previous_layout[0].?.box.w); + state.diff_state = .requested; + allocator.fail_index = allocator.alloc_index; + try std.testing.expectError(error.OutOfMemory, state.prepareDiff(gpa, &surface)); + try std.testing.expectEqual(.requested, state.diff_state); + try std.testing.expectEqual(@as(usize, 0), state.diffs.len); + allocator.fail_index = std.math.maxInt(usize); + try std.testing.expect(try state.prepareDiff(gpa, &surface)); + try std.testing.expectEqual(.ready, state.diff_state); + try std.testing.expectEqual(@as(usize, 3), state.diffs.len); + for (state.diffs) |diff| try std.testing.expectEqual(.unchanged, diff); +} + +test "presentation submission failure preserves shown geometry until retry is acknowledged" { + var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{}); + const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + p.acknowledgePanelPresentation(&.{}); + const shown = p.presentation.shown; + const previous = p.presentation.previous_cells.ptr; + p.presentation.diff_state = .requested; + p.presentation.pending = true; + allocator.fail_index = allocator.alloc_index; + try std.testing.expectError(error.OutOfMemory, p.presentation.submit(p, &p.surface)); + try std.testing.expectEqualDeep(shown, p.presentation.shown); + try std.testing.expectEqual(previous, p.presentation.previous_cells.ptr); + try std.testing.expectEqual(.requested, p.presentation.diff_state); + try std.testing.expect(p.presentation.pending); + try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 5, 3) == null); + + allocator.fail_index = std.math.maxInt(usize); + try p.presentation.submit(p, &p.surface); + try std.testing.expectEqualDeep(shown, p.presentation.shown); + try std.testing.expect(p.presentation.pending); + p.acknowledgePanelPresentation(&.{}); + try std.testing.expect(!p.presentation.pending); + try std.testing.expectEqual(CellPosition{ .col = 5, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 5, 3).?); +} + +test "fractional panel inverse preserves compact header rows through slide and zoom" { + const layer: pardes.BodyLayer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 30, .h = 10 }, .cols = 30, .rows = 15, .context_rows = 4 }; + for ([_]Transition{ .slide, .zoom, .vertical }) |effect| { + var presentation: Presentation = .{}; + const track: Track = .{ .serial = 7, .effect = effect, .frame = 4, .frame_count = 10, .from = .{ .x = 2, .y = 2, .w = 20, .h = 8 }, .to = .{ .x = 3, .y = 2, .w = 32, .h = 12 } }; + presentation.shown_tracks[0] = track; + const shown = track.presented(); + for (0..4) |index| { + const row: u16 = @intCast(index); + const source_x: f32 = 5.3; + const source_y = (layer.rowTop(row, 20, 10) + 5) / 20; + const x = shown.x + (source_x - track.to.x) / track.to.w * shown.w; + const y = shown.y + (source_y - track.to.y) / track.to.h * shown.h; + const point = presentation.pointerFractional(80, 30, x, y).?; + try std.testing.expectApproxEqAbs(source_x, point.x, 0.0001); + try std.testing.expectApproxEqAbs(source_y, point.y, 0.0001); + const body_hit = layer.hitAt(point.x * 10, point.y * 20, 10, 20, 8, 10).?; + try std.testing.expectEqual(row, body_hit.row); + } + } +} + +test "presentation snapshots own compact body and tag rows across later edits" { + var state: Presentation = .{}; + defer state.deinit(std.testing.allocator); + var cells: [1]pardes.Cell = .{.{}}; + var body: [2]pardes.Cell = .{ .{}, .{} }; + body[0].text[0] = 'A'; + var surface: pardes.Surface = .{ .cols = 1, .rows = 1, .cells = &cells }; + surface.body_layers[0] = .{ .serial = 9, .cols = 1, .rows = 2, .context_rows = 1, .cells = &body }; + var tag: [1]pardes.Cell = .{.{}}; + tag[0].text[0] = 'T'; + surface.tag_layers[0] = .{ .kind = .pane, .serial = 9, .cols = 1, .cells = &tag }; + state.capturePrevious(std.testing.allocator, &surface); + try std.testing.expect(state.previous_valid); + tag[0].text[0] = 'U'; + try std.testing.expectEqual(@as(u8, 'T'), state.previous_tag_layers[0].cells[0].text[0]); + body[0].text[0] = 'B'; + surface.body_layers[0].context_rows = 0; + try std.testing.expectEqual(@as(u8, 'A'), state.previous_body_layers[0].cells[0].text[0]); + try std.testing.expectEqual(@as(u16, 1), state.previous_body_layers[0].context_rows); + state.capturePrevious(std.testing.allocator, &surface); + 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]); +} diff --git a/src/animation.zig b/src/animation.zig new file mode 100644 index 00000000..b190c1a1 --- /dev/null +++ b/src/animation.zig @@ -0,0 +1,744 @@ +//! Animation curves and the panel transition records every shell reads: +//! easing presets, transition kinds and their tracks, the fractional boxes +//! they move through, the character effects' per-cell sources, and the +//! generic displayed-value Transition the chrome colours use. +const std = @import("std"); + +pub const ascii_max_movement_frames: u16 = 12; + +pub const Easing = enum(u8) { + linear, + smooth, + /// Quintic ease-in-out. It creeps at both ends and crosses the middle of + /// the distance fast, inside the same frame count a linear walk would use. + smoother, + in_cubic, + out_cubic, + out_back, +}; + +pub const Transition = enum(u8) { + // Numeric values are shared with the GUI shader ABI. + off = 0, + slide = 1, + zoom = 2, + dissolve = 3, + ascii = 4, + vertical = 5, + edges = 6, + fall = 7, + wave = 8, + curtain = 9, + scramble = 10, + typewriter = 11, + + pub fn easing(effect: Transition) Easing { + return switch (effect) { + .off, .dissolve, .wave => .smooth, + .slide, .vertical, .edges => .out_cubic, + .zoom => .out_back, + // Character walks and per-cell locks read best with a slow start, + // a fast middle, and a slow settle over their fixed frame count. + .ascii, .fall, .scramble => .smoother, + // A sweep and a typewriter are constant-rate by definition: easing + // their head would make the pass visibly hesitate mid-pane. + .curtain, .typewriter => .linear, + }; + } + + pub fn frames(effect: Transition) u16 { + return switch (effect) { + .off => 0, + .slide => 12, + .zoom => 14, + .dissolve => 10, + .ascii => ascii_max_movement_frames + 1, + .vertical => 12, + .edges, .curtain, .scramble => 12, + // Travelling motion needs a couple more samples than a lock or a + // rigid slide before it stops reading as a jump. + .fall, .wave, .typewriter => 14, + }; + } + + pub fn composedByCore(effect: Transition) bool { + return switch (effect) { + .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => true, + .off, .slide, .zoom, .dissolve, .vertical => false, + }; + } + + pub fn needsPreviousGrid(effect: Transition) bool { + return effect == .dissolve or effect == .vertical or effect.composedByCore(); + } + + pub fn lifecycleOnly(effect: Transition) bool { + return effect == .vertical; + } +}; + +pub const SceneEffect = struct { + crt: bool = false, + ripple: bool = false, + glitch: bool = false, +}; + +pub const Phase = enum(u8) { + opening = 0, + moving = 1, + /// Presentation-only content whose pane lifetime has already ended. + /// It is never a valid input target. + closing = 2, +}; + +pub const Box = extern struct { + x: f32 = 0, + y: f32 = 0, + w: f32 = 0, + h: f32 = 0, + + pub fn eql(a: Box, b: Box) bool { + return a.x == b.x and a.y == b.y and a.w == b.w and a.h == b.h; + } + + // A cell belongs to a fractional box when its center lies inside. + pub fn contains(box: Box, col: u16, row: u16) bool { + const x: f32 = @floatFromInt(col); + const y: f32 = @floatFromInt(row); + return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h; + } +}; + +// Drawing and hit testing share this moving/opening/closing order. +pub fn paintOrder(live: []const ?Track, closing: []const Track, out: []Track) usize { + var len: usize = 0; + for ([_]Phase{ .moving, .opening }) |phase| for (live) |maybe| { + const track = maybe orelse continue; + if (!track.active() or track.phase != phase) continue; + if (len == out.len) return len; + out[len] = track; + len += 1; + }; + for (closing) |track| { + if (!track.active()) continue; + if (len == out.len) return len; + out[len] = track; + len += 1; + } + return len; +} + +/// One POD record is enough for every backend. `from` and `to` are logical +/// cell boxes; frontends convert them to pixels only at their render edge. +pub const Track = extern struct { + serial: u32 = 0, + pane: u8 = 0, + phase: Phase = .moving, + effect: Transition = .off, + _padding: u8 = 0, + frame: u16 = 0, + frame_count: u16 = 0, + from: Box = .{}, + to: Box = .{}, + + pub fn active(track: Track) bool { + return track.effect != .off and track.frame < track.frames(); + } + + pub fn frames(track: Track) u16 { + return if (track.frame_count != 0) track.frame_count else track.effect.frames(); + } + + pub fn amount(track: Track) f32 { + // Opening rises quickly and settles; closing reverses that motion and + // accelerates down out of the fixed clip. + if (track.phase == .closing and track.effect == .vertical) + return progressEased(.in_cubic, track.frames(), track.frame); + return progressEased(track.effect.easing(), track.frames(), track.frame); + } + + pub fn presented(track: Track) Box { + return lerpBox(track.from, track.to, track.amount()); + } + + pub fn visualBox(track: Track) Box { + return switch (track.effect) { + .slide, .zoom, .vertical => track.presented(), + .off, .dissolve => track.to, + // Every character effect stays inside the pane's final rectangle. + .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => track.to, + }; + } + + pub fn contentBox(track: Track) Box { + return if (track.phase == .closing) track.from else track.to; + } +}; + +pub fn openingBox(effect: Transition, target: Box, screen_width: u16) Box { + return switch (effect) { + .slide => blk: { + var from = target; + const middle = target.x + target.w * 0.5; + from.x = if (middle < @as(f32, @floatFromInt(screen_width)) * 0.5) + -target.w + else + @floatFromInt(screen_width); + break :blk from; + }, + .zoom => .{ + .x = target.x + target.w * 0.5, + .y = target.y + target.h * 0.5, + .w = 0, + .h = 0, + }, + .vertical => blk: { + var from = target; + from.y += target.h; + break :blk from; + }, + .off, .dissolve => target, + // Character effects own the glyphs inside a fixed rectangle, so their + // panel opens at exactly its final geometry. + .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => target, + }; +} + +pub fn closingBox(effect: Transition, source: Box) Box { + return switch (effect) { + .vertical => blk: { + var to = source; + to.y += source.h; + break :blk to; + }, + else => source, + }; +} + +pub fn sample(easing: Easing, raw: f32) f32 { + const t = std.math.clamp(raw, 0.0, 1.0); + return switch (easing) { + .linear => t, + .smooth => t * t * (3.0 - 2.0 * t), + .smoother => t * t * t * (t * (6.0 * t - 15.0) + 10.0), + .in_cubic => t * t * t, + .out_cubic => 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t), + // Robert Penner's ease-out-back polynomial. It intentionally travels + // a little past one before settling exactly on the endpoint. + .out_back => blk: { + const c1: f32 = 1.70158; + const c3 = c1 + 1.0; + const u = t - 1.0; + break :blk 1.0 + c3 * u * u * u + c1 * u * u; + }, + }; +} + +pub fn progress(effect: Transition, frame: u16) f32 { + return progressEased(effect.easing(), effect.frames(), frame); +} + +fn progressEased(easing: Easing, frames: u16, frame: u16) f32 { + if (frames <= 1 or frame >= frames - 1) return 1.0; + return sample(easing, @as(f32, @floatFromInt(frame)) / @as(f32, @floatFromInt(frames - 1))); +} + +pub fn lerpBox(from: Box, to: Box, t: f32) Box { + const u = @max(0.0, t); + return .{ + .x = from.x + (to.x - from.x) * u, + .y = from.y + (to.y - from.y) * u, + .w = @max(0.0, from.w + (to.w - from.w) * u), + .h = @max(0.0, from.h + (to.h - from.h) * u), + }; +} + +/// Stable cell noise shared by the TTY reveal and shader ports. Integer-only +/// hashing means resizing or repainting a frame does not make cells flicker. +pub fn cellNoise(serial: u32, col: u16, row: u16) f32 { + var x = serial ^ (@as(u32, col) *% 0x9e37_79b9) ^ (@as(u32, row) *% 0x85eb_ca6b); + x ^= x >> 16; + x *%= 0x7feb_352d; + x ^= x >> 15; + x *%= 0x846c_a68b; + x ^= x >> 16; + return @as(f32, @floatFromInt(x & 0xffff)) / 65535.0; +} + +/// Whether a changed dissolve cell has crossed from the frozen old grid to +/// the new one. Exact endpoints are part of the presentation contract. +pub fn dissolveRevealed(serial: u32, col: u16, row: u16, raw_progress: f32) bool { + const t = std.math.clamp(raw_progress, 0.0, 1.0); + if (t <= 0) return false; + if (t >= 1) return true; + return cellNoise(serial, col, row) < t; +} + +pub const CellArea = struct { + x0: u16 = 0, + y0: u16 = 0, + cols: u16 = 1, + rows: u16 = 1, + + pub fn of(box: Box) CellArea { + return .{ + .x0 = floorCell(box.x), + .y0 = floorCell(box.y), + .cols = ceilCell(box.w), + .rows = ceilCell(box.h), + }; + } +}; + +fn floorCell(value: f32) u16 { + return @intFromFloat(std.math.clamp(@floor(value), 0.0, @as(f32, std.math.maxInt(u16)))); +} + +fn ceilCell(value: f32) u16 { + return @intFromFloat(std.math.clamp(@ceil(value), 1.0, @as(f32, std.math.maxInt(u16)))); +} + +pub const CharSource = union(enum) { + /// Nothing has arrived here yet: keep the frozen old cell. + old, + at: Offset, + /// Paint this printable byte in the destination cell's own style, whatever + /// that cell holds — a caret marching over empty space is still a caret. + byte: u8, + churn: u8, + + pub const Offset = struct { cols: i32 = 0, rows: i32 = 0 }; + + pub const settled: CharSource = .{ .at = .{} }; +}; + +pub fn charSource(track: Track, col: u16, row: u16, area: CellArea) CharSource { + const t = track.amount(); + if (t >= 1.0) return .settled; + const w: f32 = @floatFromInt(area.cols); + const h: f32 = @floatFromInt(area.rows); + const c: f32 = @floatFromInt(col); + const r: f32 = @floatFromInt(row); + const remaining = 1.0 - t; + return switch (track.effect) { + .edges => blk: { + const travel = cellsOf(remaining * (w + 1.0)); + break :blk .{ .at = .{ .cols = if (row % 2 == 0) travel else -travel } }; + }, + // Columns rain down, each with its own stable head start, so the pane + // fills from the top and the last glyphs land at the bottom. + .fall => blk: { + const local = staggered(t, cellNoise(track.serial, col, 0) * 0.4); + if (local <= 0.0) break :blk .old; + break :blk .{ .at = .{ .rows = cellsOf((1.0 - local) * (h + 1.0)) } }; + }, + // A vertical ripple travels left to right and its amplitude decays, so + // the pane settles out of a wave instead of a fade. + .wave => .{ .at = .{ + .rows = cellsOf(remaining * @min(4.0, h) * @sin(c * 0.55 - t * 9.0)), + } }, + // A curtain of glyphs marches in from the right, column by column, left + // to right; each column still has a short slide of its own. + .curtain => blk: { + const lead = t * (w + 1.0) - c; + if (lead <= 0.0) break :blk .old; + break :blk .{ .at = .{ .cols = -cellsOf(@max(0.0, 3.0 - lead)) } }; + }, + // Every cell churns through printable ASCII and locks onto its final + // glyph at its own stable threshold: the pane resolves out of noise. + .scramble => blk: { + if (t >= cellNoise(track.serial, col, row) * 0.8) break :blk .settled; + const churn = cellNoise( + track.serial ^ (@as(u32, track.frame) *% 0x27d4_eb2f), + col, + row, + ); + break :blk .{ .churn = @intCast(33 + @min(93, @as(u32, @intFromFloat(churn * 94.0)))) }; + }, + // Reading-order reveal with a caret sitting on the write head. + .typewriter => blk: { + const head = t * w * h; + const index = r * w + c; + if (index + 1.0 <= head) break :blk .settled; + if (index <= head) break :blk .{ .byte = '_' }; + break :blk .old; + }, + // PanelAscii walks its own byte distance per cell, and the geometry + // effects never reach this path at all. + .off, .slide, .zoom, .dissolve, .vertical, .ascii => .settled, + }; +} + +fn cellsOf(distance: f32) i32 { + return @intFromFloat(@round(std.math.clamp(distance, -65535.0, 65535.0))); +} + +/// Remap track progress into one cell's own window. A stagger delays a glyph +/// without making the effect as a whole end after its last frame. +fn staggered(t: f32, delay: f32) f32 { + if (delay >= 1.0) return t; + return (t - delay) / (1.0 - delay); +} + +test "easing presets have exact endpoints and intended shapes" { + inline for (std.enums.values(Easing)) |easing| { + try std.testing.expectEqual(@as(f32, 0), sample(easing, 0)); + try std.testing.expectEqual(@as(f32, 1), sample(easing, 1)); + } + try std.testing.expectEqual(@as(f32, 0.5), sample(.linear, 0.5)); + try std.testing.expect(sample(.in_cubic, 0.5) < sample(.linear, 0.5)); + try std.testing.expect(sample(.out_cubic, 0.5) > sample(.linear, 0.5)); + try std.testing.expect(sample(.out_back, 0.8) > 1.0); + // Slow at both ends, fast through the middle, and symmetric about the + // halfway point: the same curve the integer byte walk reproduces. + try std.testing.expectEqual(@as(f32, 0.5), sample(.smoother, 0.5)); + try std.testing.expect(sample(.smoother, 0.15) < sample(.smooth, 0.15)); + try std.testing.expect(sample(.smoother, 0.85) > sample(.smooth, 0.85)); + try std.testing.expect(sample(.smoother, 0.6) - sample(.smoother, 0.4) > + sample(.linear, 0.6) - sample(.linear, 0.4)); +} + +test "transition progress completes exactly" { + inline for (std.enums.values(Transition)) |effect| { + try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames())); + if (effect.frames() > 0) + try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames() - 1)); + try std.testing.expectEqual(@as(f32, 1), progress(effect, std.math.maxInt(u16))); + } + try std.testing.expectEqual(@as(f32, 1), progress(.off, 0)); + + const shrinking = lerpBox(.{ .w = 100, .h = 40 }, .{}, sample(.out_back, 0.8)); + try std.testing.expectEqual(@as(f32, 0), shrinking.w); + try std.testing.expectEqual(@as(f32, 0), shrinking.h); + const opening = lerpBox(.{}, .{ .w = 100, .h = 40 }, sample(.out_back, 0.8)); + try std.testing.expect(opening.w > 100); + try std.testing.expect(opening.h > 40); +} + +test "character effects are core-composed and settle on the canonical glyph" { + const box: Box = .{ .x = 4, .y = 2, .w = 20, .h = 6 }; + const area: CellArea = .of(box); + try std.testing.expectEqual(@as(u16, 4), area.x0); + try std.testing.expectEqual(@as(u16, 2), area.y0); + try std.testing.expectEqual(@as(u16, 20), area.cols); + try std.testing.expectEqual(@as(u16, 6), area.rows); + + inline for (std.enums.values(Transition)) |effect| { + if (comptime !effect.composedByCore()) continue; + // Core composition needs the frozen old grid for every glyph which has + // not arrived, so no character effect may animate without it. + try std.testing.expect(effect.needsPreviousGrid()); + if (comptime effect == .ascii) continue; // owns its own per-cell byte walk + + const last: Track = .{ .effect = effect, .frame = effect.frames() - 1, .to = box }; + const first: Track = .{ .effect = effect, .frame = 0, .to = box }; + var moving = false; + var row: u16 = 0; + while (row < area.rows) : (row += 1) { + var col: u16 = 0; + while (col < area.cols) : (col += 1) { + // The last active sample is the exact canonical grid: no cell + // is displaced, churning, or still frozen. + try std.testing.expectEqual(CharSource.settled, charSource(last, col, row, area)); + if (!std.meta.eql(CharSource.settled, charSource(first, col, row, area))) + moving = true; + } + } + try std.testing.expect(moving); + } +} + +test "each character effect moves glyphs along its own axis" { + const box: Box = .{ .w = 30, .h = 8 }; + const area: CellArea = .of(box); + + // Rows alternate which screen edge they come from, and every glyph in a row + // travels as one rigid slide: one offset, no vertical component. + var edges: Track = .{ .effect = .edges, .frame = 2, .to = box }; + const even = charSource(edges, 5, 0, area).at; + const odd = charSource(edges, 5, 1, area).at; + try std.testing.expect(even.cols > 0); + try std.testing.expectEqual(-even.cols, odd.cols); + try std.testing.expectEqual(@as(i32, 0), even.rows); + try std.testing.expectEqual(even, charSource(edges, 17, 0, area).at); + edges.frame = 5; + try std.testing.expect(charSource(edges, 5, 0, area).at.cols < even.cols); + + // Falling columns are vertical only, staggered, and sample from below the + // destination because the new text is still above the pane. + const fall: Track = .{ .effect = .fall, .frame = 4, .to = box }; + var falling = false; + var col: u16 = 0; + while (col < area.cols) : (col += 1) switch (charSource(fall, col, 0, area)) { + .old => {}, + .byte, .churn => return error.FallShouldNotChurn, + .at => |offset| { + try std.testing.expectEqual(@as(i32, 0), offset.cols); + try std.testing.expect(offset.rows >= 0); + if (offset.rows > 0) falling = true; + }, + }; + try std.testing.expect(falling); + + // The wave displaces rows both ways as it travels, and only rows. + const wave: Track = .{ .effect = .wave, .frame = 1, .to = box }; + var above = false; + var below = false; + col = 0; + while (col < area.cols) : (col += 1) { + const offset = charSource(wave, col, 3, area).at; + try std.testing.expectEqual(@as(i32, 0), offset.cols); + if (offset.rows < 0) above = true; + if (offset.rows > 0) below = true; + } + try std.testing.expect(above and below); + + // The curtain has a head: columns behind it hold the old grid, columns the + // head has passed are settled, and the head itself is still sliding. + const curtain: Track = .{ .effect = .curtain, .frame = 5, .to = box }; + try std.testing.expectEqual(CharSource.settled, charSource(curtain, 0, 0, area)); + try std.testing.expectEqual(CharSource{ .old = {} }, charSource(curtain, 29, 0, area)); + var sliding = false; + col = 0; + while (col < area.cols) : (col += 1) switch (charSource(curtain, col, 0, area)) { + .at => |offset| if (offset.cols < 0) { + sliding = true; + }, + .old, .byte, .churn => {}, + }; + try std.testing.expect(sliding); + + var scramble: Track = .{ .effect = .scramble, .frame = 3, .to = box }; + var churning: usize = 0; + var locked: usize = 0; + var changed = false; + col = 0; + while (col < area.cols) : (col += 1) switch (charSource(scramble, col, 0, area)) { + .churn => |byte| { + try std.testing.expect(byte >= ' ' and byte <= '~'); + churning += 1; + scramble.frame = 4; + switch (charSource(scramble, col, 0, area)) { + .churn => |next| changed = changed or next != byte, + .old, .at, .byte => {}, + } + scramble.frame = 3; + }, + .at => locked += 1, + .old, .byte => return error.ScrambleShouldNotFreeze, + }; + try std.testing.expect(churning > 0 and locked > 0 and changed); + + // The typewriter writes in reading order with a caret on its head. + const typewriter: Track = .{ .effect = .typewriter, .frame = 7, .to = box }; + try std.testing.expectEqual(CharSource.settled, charSource(typewriter, 0, 0, area)); + try std.testing.expectEqual( + CharSource{ .old = {} }, + charSource(typewriter, area.cols - 1, area.rows - 1, area), + ); + var carets: usize = 0; + var row: u16 = 0; + while (row < area.rows) : (row += 1) { + col = 0; + while (col < area.cols) : (col += 1) switch (charSource(typewriter, col, row, area)) { + .byte => |byte| { + try std.testing.expectEqual(@as(u8, '_'), byte); + carets += 1; + }, + .old, .at, .churn => {}, + }; + } + try std.testing.expectEqual(@as(usize, 1), carets); +} + +test "opening presets separate geometry and content transitions" { + const target: Box = .{ .x = 30, .y = 2, .w = 20, .h = 8 }; + try std.testing.expectEqual(target, openingBox(.ascii, target, 80)); + try std.testing.expectEqual(@as(f32, 0), openingBox(.zoom, target, 80).w); + try std.testing.expectEqual(@as(f32, 80), openingBox(.slide, target, 80).x); + try std.testing.expectEqual(@as(f32, target.y + target.h), openingBox(.vertical, target, 80).y); + try std.testing.expectEqual(@as(f32, target.y + target.h), closingBox(.vertical, target).y); + + var track: Track = .{ .effect = .slide, .from = target, .to = target }; + try std.testing.expect(track.active()); + track.frame = track.effect.frames(); + try std.testing.expect(!track.active()); + + track = .{ .effect = .dissolve, .frame = 3, .from = .{}, .to = target }; + try std.testing.expectEqual(target, track.visualBox()); + + var closing: Track = .{ + .phase = .closing, + .effect = .vertical, + .from = target, + .to = closingBox(.vertical, target), + }; + try std.testing.expectEqual(target, closing.contentBox()); + closing.frame = 2; + try std.testing.expect(closing.amount() < progress(.vertical, closing.frame)); +} + +test "dissolve has exact stable endpoints" { + for (0..64) |col| { + const x: u16 = @intCast(col); + try std.testing.expect(!dissolveRevealed(42, x, 7, 0)); + try std.testing.expect(dissolveRevealed(42, x, 7, 1)); + if (dissolveRevealed(42, x, 7, 0.25)) + try std.testing.expect(dissolveRevealed(42, x, 7, 0.75)); + } +} + +pub const Animation = struct { + pub const frame_ms: u32 = 16; + pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms; + pub const transition_steps: u16 = 10; + + pub fn Transition(comptime Value: type) type { + return struct { + const Self = @This(); + + from: Value, + to: Value, + displayed: Value, + step: u16 = transition_steps, + + pub fn init(value: Value) Self { + return .{ .from = value, .to = value, .displayed = value }; + } + + pub fn isActive(a: *const Self) bool { + return a.step < transition_steps; + } + + pub fn retarget(a: *Self, target: Value) void { + 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; + } + + pub fn advance(a: *Self) void { + if (!a.isActive()) return; + a.step += 1; + a.displayed = if (a.step == transition_steps) + a.to + else + Value.interpolate(a.from, a.to, a.step, transition_steps); + } + + pub fn snap(a: *Self, value: Value) void { + a.* = init(value); + } + }; + } + + pub fn Immediate(comptime Value: type) type { + return struct { + const Self = @This(); + + displayed: Value, + + pub fn init(value: Value) Self { + return .{ .displayed = value }; + } + + pub fn isActive(_: *const Self) bool { + return false; + } + + pub fn retarget(a: *Self, target: Value) void { + a.displayed = target; + } + + pub fn advance(_: *Self) void {} + + pub fn snap(a: *Self, value: Value) void { + a.displayed = value; + } + }; + } + + pub fn interpolateRgb(from: [3]u8, to: [3]u8, step: u16, steps: u16) [3]u8 { + if (step == 0) return from; + if (step >= steps) return to; + var out: [3]u8 = undefined; + for (&out, from, to) |*dst, a, b| { + const numerator = @as(u32, a) * (steps - step) + @as(u32, b) * step; + dst.* = @intCast((numerator + steps / 2) / steps); + } + return out; + } + + const TestColor = struct { + rgb: [3]u8, + + pub fn interpolate(from: TestColor, to: TestColor, step: u16, steps: u16) TestColor { + return .{ .rgb = interpolateRgb(from.rgb, to.rgb, step, steps) }; + } + }; + + test "Immediate lands where a completed Transition lands" { + const from: TestColor = .{ .rgb = .{ 240, 3, 90 } }; + const to: TestColor = .{ .rgb = .{ 5, 222, 90 } }; + + var faded = Animation.Transition(TestColor).init(from); + faded.retarget(to); + for (0..transition_steps) |_| faded.advance(); + + var instant = Immediate(TestColor).init(from); + try std.testing.expect(!instant.isActive()); + instant.retarget(to); + try std.testing.expectEqual(faded.displayed, instant.displayed); + + // Never active, so a frontend that renders only while something is animating stops immediately + // rather than spending ten frames discovering there is nothing to draw. + try std.testing.expect(!instant.isActive()); + instant.advance(); + try std.testing.expectEqual(to, instant.displayed); + + instant.snap(from); + try std.testing.expectEqual(from, instant.displayed); + } + + test "fixed-step interpolation has exact monotonic endpoints" { + const Tween = Animation.Transition(TestColor); + const from: TestColor = .{ .rgb = .{ 240, 3, 90 } }; + const to: TestColor = .{ .rgb = .{ 5, 222, 90 } }; + var tween = Tween.init(from); + tween.retarget(to); + try std.testing.expectEqual(from, tween.displayed); + + var previous = tween.displayed; + for (0..transition_steps) |_| { + tween.advance(); + try std.testing.expect(tween.displayed.rgb[0] <= previous.rgb[0]); + try std.testing.expect(tween.displayed.rgb[1] >= previous.rgb[1]); + try std.testing.expectEqual(@as(u8, 90), tween.displayed.rgb[2]); + previous = tween.displayed; + } + try std.testing.expect(!tween.isActive()); + try std.testing.expectEqual(to, tween.displayed); + tween.advance(); + try std.testing.expectEqual(to, tween.displayed); + } + + test "retarget starts at the currently displayed value" { + const Tween = Animation.Transition(TestColor); + const first: TestColor = .{ .rgb = .{ 0, 40, 200 } }; + const second: TestColor = .{ .rgb = .{ 200, 140, 0 } }; + const third: TestColor = .{ .rgb = .{ 20, 10, 250 } }; + var tween = Tween.init(first); + tween.retarget(second); + tween.advance(); + tween.advance(); + tween.advance(); + const on_screen = tween.displayed; + + tween.retarget(third); + try std.testing.expectEqual(on_screen, tween.from); + try std.testing.expectEqual(on_screen, tween.displayed); + try std.testing.expect(tween.isActive()); + for (0..transition_steps) |_| tween.advance(); + try std.testing.expectEqual(third, tween.displayed); + } +}; diff --git a/src/body_layer.zig b/src/body_layer.zig index f9e26b10..aa52957c 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -555,452 +555,3 @@ pub fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 { if (pane.file) |*f| return panes.File.bodyText(arena, pane, f, p.settings.wrap); return panes.Terminal.bodyText(arena, pane); } - -/// The workspace and column tags as the grid shows them: their text, the -/// word under the pointer and, in the one being edited, its selection and -/// caret. Their tag layers are drawn by renderTagLayers. -pub fn paintHeaders(p: *Pardes) !void { - const s = &p.surface; - const chrome = p.chromeTheme(); - const th = p.theme(); - // global tagbar: full width, top rows — unless a native shell has - // taken its commands over, in which case the rows are not ours to draw - tagline.revealHeader(p); - const global = tagline.topbar(p); - const global_rows = p.topBarHeight(); - const global_scroll = if (p.header_focus and p.header_column == null) p.header_scroll else 0; - const global_top = if (p.header_focus and p.header_column == null) p.header_top else 0; - if (p.settings.workspace_tag) { - s.fill(0, 0, s.cols, global_rows, .{ - .bg = .{ .rgb = chrome.tag_bg }, - .font_role = .tagline, - }); - for (0..global_rows) |row| { - const line = modal.lineSlice(global, row + global_top); - _ = s.print(0, @intCast(row), s.cols, line[panes.File.rawAtDisplay(line, global_scroll)..], .{ - .fg = .{ .rgb = chrome.tag_fg }, - .bg = .{ .rgb = chrome.tag_bg }, - .font_role = .tagline, - }); - } - } - if (p.pointer_inside and p.hover_row < global_rows) { - const bar = modal.lineSlice(global, p.hover_row + global_top); - if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col + global_scroll))) |bounds| { - var col = panes.File.rawDisplayCol(bar, bounds.lo) -| global_scroll; - const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| global_scroll; - while (col < hi and col < s.cols) : (col += 1) { - const cell = s.at(@intCast(col), p.hover_row); - cell.default = false; - cell.style.bg = .{ .rgb = th.sel_bg }; - cell.style.fg = .{ .rgb = th.sel_fg }; - } - } - } - const column_rows = p.columnBarHeight(); - if (column_rows > 0) for (0..p.ncol) |c| { - const grip = @min(config.GUTTER, p.col_w[c]); - const inset = @min(TAG_TEXT_INSET, p.col_w[c]); - const x = p.col_x[c] + inset; - const w = p.col_w[c] - inset; - const y0 = global_rows; - if (p.col_w[c] == 0 or p.col_x[c] >= s.cols) continue; - const active_col = if (layout.findPane(p, p.active)) |f| f.col == c else false; - const bg = if (active_col and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; - const fg = if (active_col and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; - // A grip is muted until it is grabbed; then it lights, as a pane's - // box does for the active pane, so the drag shows what it holds. - const grabbed = p.drag == .column_move and layout.columnBySerial(p, p.drag.column_move.serial) == c; - const grip_bg = if (grabbed) chrome.column_box else chrome.column_box_dim; - const lum = (@as(u16, grip_bg[0]) * 3 + @as(u16, grip_bg[1]) * 6 + @as(u16, grip_bg[2])) / 10; - const ink: [3]u8 = if (lum > 140) .{ 0, 0, 0 } else .{ 255, 255, 255 }; - const grip_style: CellStyle = .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline }; - s.fill(p.col_x[c], y0, grip, column_rows, grip_style); - s.fill(p.col_x[c] + grip, y0, inset - grip, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); - - s.fill(x, y0, w, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); - const bar_scroll = if (p.header_focus and p.header_column == c) p.header_scroll else 0; - const bar_top = if (p.header_focus and p.header_column == c) p.header_top else 0; - const tag = tagline.columnTag(p, c); - for (0..column_rows) |row| { - const line = modal.lineSlice(tag, row + bar_top); - _ = s.print(x, y0 + @as(u16, @intCast(row)), w, line[panes.File.rawAtDisplay(line, bar_scroll)..], .{ - .fg = .{ .rgb = fg }, - .bg = .{ .rgb = bg }, - .font_role = .tagline, - }); - } - // A carried column's pointer is aiming at a place, not a word. - if (p.pointer_inside and p.drag != .column_move and p.hover_row >= y0 and p.hover_row < y0 + column_rows and p.hover_col >= x and p.hover_col < x + w) { - const bar = modal.lineSlice(tag, p.hover_row - y0 + bar_top); - if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col - x + bar_scroll))) |bounds| { - var col = panes.File.rawDisplayCol(bar, bounds.lo) -| bar_scroll; - const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| bar_scroll; - while (col < hi and col < w) : (col += 1) { - const cell = s.at(x + @as(u16, @intCast(col)), p.hover_row); - cell.style.bg = .{ .rgb = th.sel_bg }; - cell.style.fg = .{ .rgb = th.sel_fg }; - } - } - } - }; - if (tagline.focusedHeader(p)) |t| { - const text = try edit.flatSurface(p, t); - const x = if (p.header_column) |c| p.col_x[c] + TAG_TEXT_INSET else 0; - const w = if (p.header_column) |c| p.col_w[c] -| TAG_TEXT_INSET else s.cols; - const y: u16 = if (p.header_column != null) global_rows else 0; - const rows = if (p.header_column != null) column_rows else global_rows; - for (0..rows) |row| { - const bar = modal.lineSlice(text, row + p.header_top); - const span = tagline.headerSelection(t, text, row + p.header_top) orelse continue; - var col = panes.File.rawDisplayCol(bar, span.lo) -| p.header_scroll; - const hi = panes.File.rawDisplayCol(bar, span.hi) -| p.header_scroll; - while (col < hi and col < w) : (col += 1) { - const cell = s.at(x + @as(u16, @intCast(col)), y + @as(u16, @intCast(row))); - cell.style.fg = .{ .rgb = th.sel_fg }; - cell.style.bg = .{ .rgb = th.sel_bg }; - } - } - // A prompt answering for the header has the caret on its band. - const asking = if (p.panes[p.active]) |active| active.prompt != .none else false; - const cur: usize = @intCast(@max(0, t.cur_row)); - if (!asking and cur >= p.header_top and cur < p.header_top + rows) { - const bar = modal.lineSlice(text, cur); - const caret = panes.File.rawDisplayCol(bar, @min(@as(usize, @intCast(@max(0, t.cur_col))), bar.len)) -| p.header_scroll; - if (caret < w) s.cursor = .{ .x = x + @as(u16, @intCast(caret)), .y = y + @as(u16, @intCast(cur - p.header_top)), .bar = t.mode == .insert }; - } - } -} - -fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { lo: usize, hi: usize } { - if (pane.isTerminal()) { - var words = std.mem.tokenizeAny(u8, tag[prefix_len..], " \t\r\n"); - while (words.next()) |word| { - if (!std.mem.eql(u8, word, "Tty")) continue; - const lo = @intFromPtr(word.ptr) - @intFromPtr(tag.ptr); - return .{ .lo = lo, .hi = lo + word.len }; - } - return null; - } - var start: usize = 0; - const name = if (pane.file) |file| - if (pane.prompt == .name) pane.input.own orelse file.path else file.path - else if (pane.image) |state| blk: { - // Image tags put renderer controls before the path. - start = std.mem.lastIndexOf(u8, tag[0..prefix_len], state.path) orelse return null; - break :blk state.path; - } else if (comptime pdf_enabled) blk: { - break :blk if (pane.pdf) |state| state.path else return null; - } else return null; - const basename = if (std.mem.lastIndexOfScalar(u8, name, '/')) |slash| slash + 1 else 0; - if (basename == name.len) return null; - return .{ .lo = start + basename, .hi = start + name.len }; -} - -pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { - const chrome = p.chromeTheme(); - for (p.panes, 0..) |slot, id| { - const pane = slot orelse continue; - const r = p.rects[id]; - if (r.w <= TAG_TEXT_INSET or r.h == 0) continue; - const tag = try tagline.tagText(p, arena, pane); - const rows = pane.tag_rows; - // As wide as the widest line it shows, and never narrower than - // its viewport. - var widest: usize = 0; - var split = std.mem.splitScalar(u8, tag, '\n'); - for (0..pane.tag_top) |_| _ = split.next(); - for (0..rows) |_| widest = @max(widest, panes.File.displayWidth(split.next() orelse "")); - const columns: u16 = @intCast(@min(std.math.maxInt(u16) - TAG_TEXT_INSET, @max(p.tagCapacity(r.w - TAG_TEXT_INSET), widest + 1))); - const pitch = @as(usize, columns) + TAG_TEXT_INSET; - const storage = try arena.alloc(Cell, pitch * rows); - @memset(storage, .{}); - var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = rows, .cells = storage }; - std.mem.swap(Surface, &p.surface, &temporary); - paintPaneTag(p, arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, id == p.active, p.tagCapacity(r.w - TAG_TEXT_INSET)) catch |err| { - std.mem.swap(Surface, &p.surface, &temporary); - return err; - }; - std.mem.swap(Surface, &p.surface, &temporary); - // A layer to a line: the first in the pane's own slot, the rest - // in the slots kept for the lines of taller tags. - for (0..rows) |line| { - const layer = &p.surface.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1]; - if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); - @memcpy(layer.cells, storage[line * pitch + TAG_TEXT_INSET ..][0..columns]); - layer.kind = .pane; - layer.id = @intCast(id); - layer.serial = pane.serial; - layer.line = @intCast(line); - layer.viewport = .{ .x = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r) + @as(u16, @intCast(line)), .w = r.w - TAG_TEXT_INSET, .h = BOX_H }; - layer.cols = columns; - layer.bg = if (id == p.active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; - layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor| - (if (cursor.y == line) .{ .col = cursor.x - TAG_TEXT_INSET, .bar = cursor.bar } else null) - else - null; - } - } - // With the workspace tag row handed to native chrome there is no - // workspace layer to render; the menu bar carries its commands. - if (p.settings.workspace_tag) { - for (0..p.topBarHeight()) |line| { - const index = if (line == 0) MAX_PANES + MAX_COLS else HEADER_LINE_LAYER_BASE + MAX_COLS * (MAX_TAG_ROWS - 1) + line - 1; - const top = if (p.header_focus and p.header_column == null) p.header_top else 0; - try renderHeaderLayer(p, arena, index, .workspace, 0, @intCast(line), .{ .x = 0, .y = @intCast(line), .w = p.screen_w, .h = TOPBAR_H }, modal.lineSlice(tagline.topbar(p), line + top), chrome.tag_bg, chrome.tag_fg); - } - } - // Each notice is its own tagline-shaped chip over the top of the body. - // Going through the same layer machinery the pane and column tags use - // is what gives it the tagline's height, its small-font metrics, the - // band offset and the border -- none of which a body-grid row can have - // just by wearing a tagline font. The geometry is the one the grid - // pass already chose and recorded, so the two cannot disagree and the - // GUI's "skip the cells a tag layer covers" leaves no stub behind. - // A prompt is one of them too: left on the grid, a pixel shell drew - // its tagline glyphs one to a BODY cell, spaced out like a banner. - for (p.panes, 0..) |slot, id| { - const pane = slot orelse continue; - const r = p.rects[id]; - if (r.w <= TAG_TEXT_INSET or r.h <= pane.tag_rows) continue; - const first = p.bodyTop(pane, r); - var leader_buf: [16]u8 = undefined; - for (pane.notices.slice(), 0..) |kind, i| { - const text = Messages.noticeText(p, pane, i, &leader_buf); - if (text.len == 0) continue; - const cx = pane.notices.left[i]; - if (cx == std.math.maxInt(u16) or cx >= r.x + r.w) continue; - const chip = r.x + r.w - cx; - // Right aligned inside the chip, a blank cell short of its - // edge: the same one the grid pass leaves for a prompt caret. - // Wider than the chip, the text loses its head, as on the grid. - const room = p.tagCapacity(chip) -| 1; - const shown = panes.File.displayWidth(text); - var kept = text; - if (shown > room) { - var start = panes.File.rawAtDisplay(text, shown - room); - if (panes.File.rawDisplayCol(text, start) < shown - room) start = panes.File.rawAtDisplay(text, shown - room + 1); - kept = text[start..]; - } - const pad = room -| panes.File.displayWidth(kept); - const line = try arena.alloc(u8, pad + kept.len); - @memset(line[0..pad], ' '); - @memcpy(line[pad..], kept); - // A pixel shell slides the message in as it fades up, and - // fades it on the way out. - const motion: Messages.MessageMotion = if (Messages.noticeLife(pane, i)) |life| Messages.messageMotion(p, life) else .{}; - const fade = motion.alpha; - const page = p.theme().bg orelse chrome.tag_bg; - const layer_index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i; - try renderHeaderLayer(p, arena, layer_index, .notice, @intCast(id), 0, .{ - .x = cx, - .y = first + @as(u16, pane.notices.row[i]), - .w = chip, - .h = BOX_H, - }, line, Messages.blendRgb(chrome.tag_bg, page, fade), Messages.blendRgb(chrome.tag_fg, page, fade)); - p.surface.tag_layers[layer_index].slide = motion.slide; - p.surface.tag_layers[layer_index].fade = fade; - // The caret rides the band, where the prompt's glyphs are, - // at the same place in the text the grid pass puts it. - if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue; - const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); - const dropped = shown - panes.File.displayWidth(kept); - if (typed < dropped) continue; - p.surface.tag_layers[layer_index].cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.input.mode == .insert }; - } - } - if (p.columnBarHeight() > 0) for (0..p.ncol) |column| { - if (p.col_w[column] == 0) continue; - const active = if (layout.findPane(p, p.active)) |found| found.col == column and p.settings.focus_tint else false; - const top = if (p.header_focus and p.header_column == column) p.header_top else 0; - for (0..p.columnBarHeight()) |line| { - const index = if (line == 0) MAX_PANES + column else HEADER_LINE_LAYER_BASE + column * (MAX_TAG_ROWS - 1) + line - 1; - try renderHeaderLayer(p, arena, index, .column, @intCast(column), @intCast(line), .{ .x = p.col_x[column] + @min(TAG_TEXT_INSET, p.col_w[column]), .y = p.topBarHeight() + @as(u16, @intCast(line)), .w = p.col_w[column] -| TAG_TEXT_INSET, .h = COLBAR_H }, modal.lineSlice(tagline.columnTag(p, column), line + top), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg); - } - }; -} - -/// One row of a header or a notice band as a tag layer: `text` is line -/// `line` of what it shows. -fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: TagKind, id: u16, line: u16, viewport: Rect, text: []const u8, bg: [3]u8, fg: [3]u8) !void { - _ = arena; - const layer = &p.surface.tag_layers[index]; - if (viewport.w == 0) { - layer.cols = 0; - return; - } - const columns: u16 = @intCast(@min(std.math.maxInt(u16), @max(p.tagCapacity(viewport.w), panes.File.displayWidth(text) + 1))); - if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); - layer.kind = kind; - layer.id = id; - layer.line = line; - layer.serial = if (kind == .column) p.col_serial[id] else 0; - layer.viewport = viewport; - layer.cols = columns; - layer.bg = bg; - layer.cursor = null; - layer.slide = 0; - layer.fade = 1; - var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells }; - // A notice band carries a PANE id, not a column index, so it must not - // answer to the column being edited -- that would scroll and select - // its text with the column tag's offsets and give it a second caret. - const editing: ?*Text = if (p.header_focus and switch (kind) { - .workspace => p.header_column == null, - .column => p.header_column == id, - .pane, .notice => false, - }) tagline.focusedHeader(p) else null; - const scroll = if (editing != null) p.header_scroll else 0; - surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); - _ = surface.print(0, 0, columns, text[panes.File.rawAtDisplay(text, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); - if (editing) |t| { - if (tagline.headerSelection(t, try edit.flatSurface(p, t), line + p.header_top)) |span| { - var col = panes.File.rawDisplayCol(text, span.lo) -| scroll; - const end = panes.File.rawDisplayCol(text, span.hi) -| scroll; - while (col < end and col < columns) : (col += 1) { - surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg }; - surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg }; - } - } - const caret = panes.File.rawDisplayCol(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.len)) -| scroll; - const asking = if (p.panes[p.active]) |active| active.prompt != .none else false; - if (!asking and t.cur_row == line + p.header_top and caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = t.mode == .insert }; - } else if (p.pointer_inside) { - const tag_hit = p.pointer_tag_hit; - const col: ?usize = if (tag_hit) |pointer| - (if (pointer.kind == kind and pointer.id == id and pointer.line == line) pointer.col else null) - else if (p.hover_row == viewport.y and p.hover_col >= viewport.x and p.hover_col - viewport.x < viewport.w) - p.hover_col - viewport.x - else - null; - if (col) |hover| if (wordBoundsAtCol(text, panes.File.rawAtDisplay(text, hover + scroll))) |bounds| { - var cell = panes.File.rawDisplayCol(text, bounds.lo) -| scroll; - const end = panes.File.rawDisplayCol(text, bounds.hi) -| scroll; - while (cell < end and cell < columns) : (cell += 1) { - surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg }; - surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg }; - } - }; - } -} - -pub fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void { - const s = &p.surface; - const th = p.theme(); - const chrome = p.chromeTheme(); - const tx = r.x + @min(TAG_TEXT_INSET, r.w); - const tw = r.w -| TAG_TEXT_INSET; - const tag_y = p.tagTop(pane, r); - const body_y = p.bodyTop(pane, r); - const rows = pane.tag_rows; - const page_rgb = th.bg orelse th.tag_bg; - // Focus is visible along the tag without changing its live text grid. - const tag_bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; - const tag_fg = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; - const tag_style: CellStyle = .{ .fg = .{ .rgb = tag_fg }, .bg = .{ .rgb = tag_bg }, .font_role = .tagline }; - // Padding belongs to the same font row but keeps its default ink: a - // role must not manufacture a foreground color for blank cells. - s.fill(r.x + config.GUTTER, tag_y, @min(TAG_GAP, r.w -| config.GUTTER), rows, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline }); - s.fill(tx, tag_y, tw, rows, .{ - .bg = .{ .rgb = tag_bg }, - .font_role = .tagline, - }); - const whole = try tagline.tagText(p, arena, pane); - const tail = tagline.curTail(pane); - const prefix_len = whole.len - tail.len; - // A row per line shown, from `tag_top`: the tag's first line carries - // the prefix, the rest are the tag's own text alone. - const top = pane.tag_top; - var lines: [MAX_TAG_ROWS][]const u8 = @splat(""); - var split = std.mem.splitScalar(u8, whole, '\n'); - for (0..top) |_| _ = split.next(); - for (lines[0..rows]) |*line| line.* = split.next() orelse ""; - // The keyboard's caret, as a row and a byte offset in that row's - // line: in the tag's own text when the tag has the keyboard, in the - // path while a name is drafted. - const Caret = struct { row: usize, at: usize }; - const caret: ?Caret = if (!active) - null - else if (pane.prompt == .name) - .{ .row = 0, .at = @min(lines[0].len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) } - else if (pane.focus == .tag and pane.prompt == .none and pane.tag.cur_row >= top and pane.tag.cur_row < top + rows) caret: { - const row: usize = @intCast(pane.tag.cur_row - top); - break :caret .{ .row = row, .at = @min(lines[row].len, @as(usize, @intCast(@max(0, pane.tag.cur_col)))) }; - } else null; - if (caret) |at| { - const line = lines[at.row]; - const col = panes.File.rawDisplayCol(line, at.at); - var scroll: usize = pane.tag_scroll; - if (col < scroll) scroll = col; - if (col >= scroll + capacity) scroll = col - capacity + 1; - var raw = panes.File.rawAtDisplay(line, scroll); - scroll = panes.File.rawDisplayCol(line, raw); - if (col >= scroll + capacity) { - raw = modal.nextGrapheme(line, raw); - scroll = panes.File.rawDisplayCol(line, raw); - } - pane.tag_scroll = @intCast(scroll); - } else if (pane.focus != .tag) pane.tag_scroll = 0; - for (lines[0..rows], 0..) |line, row| { - const start = panes.File.rawAtDisplay(line, pane.tag_scroll); - _ = s.print(tx, tag_y + @as(u16, @intCast(row)), tw, line[start..], tag_style); - } - const tag = lines[0]; - if (top == 0) if (tagTintRange(pane, tag, prefix_len)) |name| { - const lo = @max(name.lo, panes.File.rawAtDisplay(tag, pane.tag_scroll)); - const hi = @min(name.hi, tag.len); - if (lo < hi) { - var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll; - const end = panes.File.rawDisplayCol(tag, hi) -| pane.tag_scroll; - const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg; - while (col < end and col < tw) : (col += 1) - s.at(tx + @as(u16, @intCast(col)), tag_y).style.fg = .{ .rgb = name_fg }; - } - }; - if (p.look_hover_preview) |preview| { - if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) { - const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); - paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); - }; - } - // The tag's own selections, while it has the keyboard, a line at a - // time. A range of one grapheme is the block cursor, not a selection. - if (pane.focus == .tag) { - var ranges: [Text.max_selections]modal.Selection = undefined; - const got = pane.tag.ranges(whole, 0, &ranges); - for (ranges[0..got.n]) |range| { - const lo = @min(range.anchor, range.head); - const hi = @max(range.anchor, range.head); - if (modal.nextGrapheme(whole, lo) >= hi) continue; - const first = modal.positionAt(whole, lo); - const last = modal.positionAt(whole, hi); - var row = @max(first.row, top); - while (row <= last.row and row < top + rows) : (row += 1) { - const line = lines[row - top]; - const from = if (row == first.row) first.col else 0; - const to = @min(line.len, if (row == last.row) last.col else line.len); - var col = panes.File.rawDisplayCol(line, from) -| pane.tag_scroll; - const end = panes.File.rawDisplayCol(line, to); - const y = tag_y + @as(u16, @intCast(row - top)); - while (col + pane.tag_scroll < end and col < tw) : (col += 1) { - const cell = s.at(tx + @as(u16, @intCast(col)), y); - cell.default = false; - cell.style.bg = .{ .rgb = th.sel_bg }; - cell.style.fg = .{ .rgb = th.sel_fg }; - } - } - } - } - // the caret: bar while typing, block in normal mode (same rule as a body) - if (caret) |at| { - const col = panes.File.rawDisplayCol(lines[at.row], at.at) -| pane.tag_scroll; - const mode = if (pane.prompt == .name) pane.input.mode else pane.tag.mode; - if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y + @as(u16, @intCast(at.row)), .bar = mode == .insert }; - } - - for (pane.sel, 0..) |selection, button| { - if (selection.state == .none or (selection.state == .done and button != 0)) continue; - if (@min(selection.r0, selection.r1) >= rows) continue; - paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg); - } -} diff --git a/src/colors.zig b/src/colors.zig index bb366fc0..f814d55d 100644 --- a/src/colors.zig +++ b/src/colors.zig @@ -5,6 +5,7 @@ const tagline = @import("tagline.zig"); const panes = @import("panes.zig"); const std = @import("std"); const layout = @import("layout.zig"); +const animation = @import("animation.zig"); const Color = @import("surface.zig").Color; const FontRole = @import("surface.zig").FontRole; const config = @import("config.zig"); @@ -185,15 +186,15 @@ pub const ChromeTheme = struct { pub fn interpolate(from: ChromeTheme, to: ChromeTheme, step: u16, steps: u16) ChromeTheme { var out: ChromeTheme = undefined; inline for (@typeInfo(ChromeTheme).@"struct".fields) |field| - @field(out, field.name) = layout.Animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps); + @field(out, field.name) = animation.Animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps); return out; } }; pub const ChromeAnimation = if (theme_animation) - layout.Animation.Transition(ChromeTheme) + animation.Animation.Transition(ChromeTheme) else - layout.Animation.Immediate(ChromeTheme); + animation.Animation.Immediate(ChromeTheme); pub const initial_chrome = ChromeTheme.fromTheme(&themes[0]); @@ -413,7 +414,7 @@ test "runtime theme changes animate chrome and retarget without a jump" { try std.testing.expectEqualStrings("acme", p.theme().name); try std.testing.expectEqual(midflight, p.chromeTheme().*); try std.testing.expect(p.animationActive()); - for (0..layout.Animation.transition_steps) |_| p.update(.tick); + for (0..animation.Animation.transition_steps) |_| p.update(.tick); try std.testing.expect(!p.animationActive()); try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*); // Extra ticks are inert at the exact endpoint. diff --git a/src/config.zig b/src/config.zig index 6d0cf0a6..122c0e75 100644 --- a/src/config.zig +++ b/src/config.zig @@ -1,6 +1,7 @@ const std = @import("std"); const builtin = @import("builtin"); const layout = @import("layout.zig"); +const animation = @import("animation.zig"); const limits = @import("memory.zig").limits; const pardes = @import("pardes.zig"); const builtins = @import("builtins.zig"); @@ -609,10 +610,10 @@ pub const Runtime = struct { tagline_percent: u8 = 100, } = .{}, - panel_transition: layout.Transition = .off, - scene_effects: layout.SceneEffect = .{}, + panel_transition: animation.Transition = .off, + scene_effects: animation.SceneEffect = .{}, - pub fn toggleTransition(state: *Runtime, effect: layout.Transition) void { + pub fn toggleTransition(state: *Runtime, effect: animation.Transition) void { std.debug.assert(effect != .off); state.panel_transition = if (state.panel_transition == effect) .off else effect; } @@ -689,7 +690,7 @@ pub const Runtime = struct { pub const MessageTime = enum { linger, fall, dissolve }; pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose, message_animation, ligatures }; - pub const Scene = std.meta.FieldEnum(layout.SceneEffect); + pub const Scene = std.meta.FieldEnum(animation.SceneEffect); pub const Action = union(enum) { toggle: Toggle, @@ -701,7 +702,7 @@ pub const Runtime = struct { window_blur, message_ms: MessageTime, dump_dir, - transition: layout.Transition, + transition: animation.Transition, scene: Scene, }; @@ -806,7 +807,7 @@ pub const Runtime = struct { if (actionCount(.shell) != 1 or actionCount(.theme) != 1 or actionCount(.font) != 1 or actionCount(.tagline_size) != 1 or actionCount(.window_opacity) != 1 or actionCount(.window_blur) != 1) @compileError("Shell, Theme, Font, TaglineSize, WindowOpacity, and WindowBlur actions must each occur exactly once"); - for (std.enums.values(layout.Transition)) |effect| { + for (std.enums.values(animation.Transition)) |effect| { const expected: comptime_int = @intFromBool(effect != .off); if (actionCount(.{ .transition = effect }) != expected) @compileError("non-off panel transition must occur exactly once: " ++ @tagName(effect)); @@ -814,7 +815,7 @@ pub const Runtime = struct { for (std.enums.values(Scene)) |effect| { if (actionCount(.{ .scene = effect }) != 1) @compileError("scene effect must occur exactly once: " ++ @tagName(effect)); - if (@FieldType(layout.SceneEffect, @tagName(effect)) != bool) + if (@FieldType(animation.SceneEffect, @tagName(effect)) != bool) @compileError("scene effect fields must be booleans: " ++ @tagName(effect)); } } @@ -929,7 +930,7 @@ pub const Runtime = struct { return if (text.len == 0) "(none)" else text; } - fn transitionSettingName(transition: layout.Transition) []const u8 { + fn transitionSettingName(transition: animation.Transition) []const u8 { if (transition == .off) return "off"; return findAction(.{ .transition = transition }).?.word; } @@ -1080,13 +1081,13 @@ pub const Runtime = struct { const slide = findAction(.{ .transition = .slide }).?; const zoom = findAction(.{ .transition = .zoom }).?; try std.testing.expect(apply(&state, slide, "on")); - try std.testing.expectEqual(layout.Transition.slide, state.panel_transition); + try std.testing.expectEqual(animation.Transition.slide, state.panel_transition); try std.testing.expect(apply(&state, zoom, "off")); - try std.testing.expectEqual(layout.Transition.slide, state.panel_transition); + try std.testing.expectEqual(animation.Transition.slide, state.panel_transition); try std.testing.expect(apply(&state, slide, "on")); - try std.testing.expectEqual(layout.Transition.slide, state.panel_transition); + try std.testing.expectEqual(animation.Transition.slide, state.panel_transition); try std.testing.expect(apply(&state, slide, null)); - try std.testing.expectEqual(layout.Transition.off, state.panel_transition); + try std.testing.expectEqual(animation.Transition.off, state.panel_transition); try std.testing.expect(!apply(&state, slide, "yes")); const scene = findAction(.{ .scene = std.enums.values(Scene)[0] }).?; try std.testing.expect(apply(&state, scene, "on")); @@ -1126,9 +1127,9 @@ pub const Runtime = struct { try std.testing.expectEqualStrings("fish", state.shell.requested.get()); try std.testing.expect(state.shell.pending); try std.testing.expect(apply(&state, find("PanelAscii").?, null)); - try std.testing.expectEqual(layout.Transition.ascii, state.panel_transition); + try std.testing.expectEqual(animation.Transition.ascii, state.panel_transition); try std.testing.expect(apply(&state, find("PanelAscii").?, null)); - try std.testing.expectEqual(layout.Transition.off, state.panel_transition); + try std.testing.expectEqual(animation.Transition.off, state.panel_transition); try std.testing.expect(apply(&state, find("Crt").?, null)); try std.testing.expect(state.scene_effects.crt); } diff --git a/src/draw.zig b/src/draw.zig new file mode 100644 index 00000000..5db6da30 --- /dev/null +++ b/src/draw.zig @@ -0,0 +1,913 @@ +//! The frame: how the core builds the canonical Surface every shell draws. +//! `render` is the whole order, top to bottom: the page, each pane (its +//! grip, tag and body, and the body layer of its compact rows), the notice +//! chips, the workspace and column tags, the tag layers, drag overlays, the +//! debug box, then the presentation's tracks and the character effects. +const std = @import("std"); +const pardes = @import("pardes.zig"); +const layout = @import("layout.zig"); +const animation = @import("animation.zig"); +const config = @import("config.zig"); +const panes = @import("panes.zig"); +const modal = @import("modal.zig"); +const tracy = @import("tracy.zig"); +const colors = @import("colors.zig"); +const edit = @import("edit.zig"); +const mouse = @import("mouse.zig"); +const tagline = @import("tagline.zig"); +const body_layer = @import("body_layer.zig"); +const Messages = @import("Messages.zig"); +const Color = @import("surface.zig").Color; +const CellStyle = @import("surface.zig").CellStyle; +const Cell = @import("surface.zig").Cell; +const Surface = @import("surface.zig").Surface; +const Pardes = pardes.Pardes; +const Pane = panes.Pane; +const Text = panes.Text; +const Rect = layout.Rect; +const TagKind = @import("tag_layer.zig").Kind; +const pdf_enabled = panes.Pdf.enabled; +const wordBoundsAtCol = pardes.wordBoundsAtCol; +const BOX_H = pardes.BOX_H; +const TAG_GAP = pardes.TAG_GAP; +const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET; +const TOPBAR_H = pardes.TOPBAR_H; +const COLBAR_H = pardes.COLBAR_H; +const MAX_PANES = pardes.MAX_PANES; +const MAX_COLS = pardes.MAX_COLS; +const MAX_TAG_ROWS = pardes.MAX_TAG_ROWS; +const NOTICE_LAYER_BASE = pardes.NOTICE_LAYER_BASE; +const TAG_LINE_LAYER_BASE = pardes.TAG_LINE_LAYER_BASE; +const HEADER_LINE_LAYER_BASE = pardes.HEADER_LINE_LAYER_BASE; + +pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { + panes.File.refreshHighlights(p); + const s = &p.surface; + const ncells = @as(usize, p.screen_w) * p.screen_h; + if (s.cells.len != ncells) { + const cells = try p.gpa.alloc(Cell, ncells); + p.gpa.free(s.cells); + s.cells = cells; + } + s.cols = p.screen_w; + s.rows = p.screen_h; + s.cursor = null; + s.pointer_shape = .arrow; + if (comptime pdf_enabled) if (p.pdf_hover_preview) |preview| { + if (preview.probe.link != null and preview.pane < p.panes.len) { + if (p.panes[preview.pane]) |pane| { + if (pane.serial == preview.serial and panes.Pdf.paneNativeReady(p, pane)) + s.pointer_shape = .link; + } + } + }; + // A look-hover preview means the pointed word is a real Look or Exec + // operand: the same plumbing a click would act on. Hosts that model + // the pointer per-text (the macOS I-beam) read this as "target" and + // afford it the arrow instead. + if (p.look_hover_preview != null) s.pointer_shape = .target; + // Stale hover bits must not survive into a frame whose pointer has + // moved on: the ordinary content paint does not clear fields it + // never set. + for (s.cells) |*c| c.hover = false; + for (&s.body_layers) |*layer| layer.rows = 0; + for (&s.tag_layers) |*layer| layer.cols = 0; + s.nimages = 0; + s.npanel_tracks = 0; + s.previous_cells = &.{}; + s.previous_body_layers = &.{}; + s.previous_tag_layers = &.{}; + s.cell_diffs = &.{}; + const chrome = p.chromeTheme(); + const th = p.theme(); + // Separators have their own role; a scrollbar need not frame the page. + s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = chrome.border } }); + + for (&p.panes, 0..) |*slot, id| { + const pane = slot.* orelse continue; + pane.body_rows = 0; + pane.body_visible_rows = 0; + pane.context_row_limit = null; + s.body_layers[id].rows = 0; + Messages.collectNotices(p, pane, p.rects[id], id); + try renderPane(p, arena, pane, p.rects[id], id, id == p.active); + try body_layer.renderBodyLayer(p, arena, pane, p.rects[id], id); + } + for (&p.panes, 0..) |*slot, id| { + const pane = slot.* orelse continue; + if (pane.notices.len == 0) continue; + const r = p.rects[id]; + const tx = r.x + config.GUTTER; + const tw = r.w - config.GUTTER; + // Painted the way the sticky context headers above them are: the + // tagline font and the tagline's own colours, so a notice reads as + // a band belonging to the pane rather than as a line of body text + // that happens to sit at the bottom of it. + const msg_style: CellStyle = .{ + .fg = .{ .rgb = chrome.tag_fg }, + .bg = .{ .rgb = chrome.tag_bg }, + .font_role = .tagline, + }; + // An OVERLAY over the body's top rows, at the right edge, one + // chip to a row in the order collectNotices chose -- not a row + // taken out of the body the way a tree-sitter context header is. + // The chip is as wide as its own message and no wider, so the text + // beneath the rest of the row keeps reading as the file it is. + const first = p.bodyTop(pane, r); + var leader_buf: [16]u8 = undefined; + for (pane.notices.slice(), 0..) |kind, i| { + const row = first + @as(u16, pane.notices.row[i]); + // Every notice is painted on the canonical grid as well as + // emitted as a tag layer: the grid is what a terminal client + // draws, and a band it cannot see is a message it never gets. + // The GUI skips grid cells a tag layer covers, so nothing is + // drawn twice there. + const text = Messages.noticeText(p, pane, i, &leader_buf); + const chip = Messages.noticeCols(p, text, tw); + if (chip == 0) continue; + const cx = tx + tw - chip; + pane.notices.left[i] = cx; + // A message coming or going blends toward the page it sits + // on; a grid cannot move it by less than a row. + var style = msg_style; + if (Messages.noticeLife(pane, i)) |life| { + // No page colour to fade to (a terminal's own default): + // the text dissolves into its chip instead. + const alpha = Messages.messageMotion(p, life).alpha; + const page = th.bg orelse chrome.tag_bg; + style.fg = .{ .rgb = Messages.blendRgb(chrome.tag_fg, page, alpha) }; + style.bg = .{ .rgb = Messages.blendRgb(chrome.tag_bg, page, alpha) }; + } + s.fill(cx, row, chip, 1, .{ .bg = style.bg, .font_role = .tagline }); + // One column short of the chip's right edge: a prompt's caret + // sits AFTER its last character the moment it opens, and flush + // right would put it past the pane, off the grid, and past what + // the detached wire will encode -- which drops every frame for + // as long as the prompt is up. + const printed = Messages.printRight(s, cx, row, chip -| 1, text, style); + if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue; + // The cursor follows the text to wherever it landed; a caret + // in the part a narrow band dropped has nowhere to be. + const prompt_col = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); + if (prompt_col < printed.dropped) continue; + const caret = printed.left + (prompt_col - printed.dropped); + if (caret < tx + tw) + s.cursor = .{ .x = @intCast(caret), .y = row, .bar = pane.input.mode == .insert }; + } + } + + try paintHeaders(p); + try renderTagLayers(p, arena); + + switch (p.drag) { + .border_v => |d| { + if (d.cur_x < s.cols) { + var row: u16 = p.topBarHeight(); + while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎"); + } + if (d.corner) |k| if (d.cur_y < s.rows) { + var col = if (k.col == d.left_col) p.col_x[k.col] else d.cur_x +| 1; + const end = if (k.col == d.left_col) d.cur_x else p.col_x[k.col] +| p.col_w[k.col] -| 1; + while (col <= end and col < s.cols) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); + }; + }, + .border_h => |d| if (d.cur_y < s.rows) { + var col = p.col_x[d.col]; + while (col < p.col_x[d.col] + p.col_w[d.col]) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); + }, + .column_move => |d| rail: { + const source = layout.columnBySerial(p, d.serial) orelse break :rail; + // Where the column's left edge would land, reordered or not. + const edge: u16 = switch (layout.columnDrop(p, source, d.cur_x, d.grab)) { + .stay => break :rail, + .reorder => |target| if (target < source) p.col_x[target] else p.col_x[target] + p.col_w[target], + .edge => |x| x, + }; + // Leave the outside insertion rail visible above compact + // text layers. This clips presentation only, never reflows. + if (edge == s.cols) { + for (&s.body_layers) |*layer| if (layer.rows > 0 and layer.viewport.x + layer.viewport.w == s.cols) { + layer.viewport.w -|= 1; + }; + for (&s.tag_layers) |*layer| if (layer.cols > 0 and layer.viewport.x + layer.viewport.w == s.cols and layer.viewport.y >= p.topBarHeight()) { + layer.viewport.w -|= 1; + }; + } + // The rail runs in the seam cell left of the edge, where a + // border drag's does: the edge cell itself is a gutter, which + // the GUI paints as a scroll rail that swallowed the dashes. + // At the window's left edge there is no seam, so it runs just + // past that scroll rail instead. It takes the lit grip's + // color: this is where the grabbed column lands. + const x = @min(if (edge == 0) config.GUTTER - 1 else edge - 1, s.cols -| 1); + var row: u16 = p.topBarHeight(); + while (row < s.rows) : (row += 1) { + // Past the left edge's scroll rail the rail would cut + // through column 0's grip and pane boxes: leave them whole. + if (edge == 0 and s.at(x, row).style.font_role == .tagline) continue; + s.overlayDash(x, row, "╎"); + s.at(x, row).style.fg = .{ .rgb = chrome.column_box }; + } + }, + .move => |d| if (d.cur_x < s.cols) { + if (layout.movePlacement(p, d.id, d.cur_x, d.cur_y)) |placement| { + var col: u16 = p.col_x[placement.preview_col]; + while (col < p.col_x[placement.preview_col] + p.col_w[placement.preview_col]) : (col += 1) { + s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = chrome.lineno } }); + } + s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = chrome.lineno } }); + } + }, + .none, .select => if (p.pointer_inside) { + // hover hints on a pane's own trailing edge + var c: usize = 0; + while (c + 1 < p.ncol) : (c += 1) { + if (p.hover_col == p.col_x[c] + p.col_w[c] -| 1) { + var row: u16 = p.topBarHeight(); + while (row < s.rows) : (row += 1) s.overlayDash(p.hover_col, row, "╎"); + } + } + for (0..p.ncol) |cc| { + if (p.hover_col < p.col_x[cc] or p.hover_col >= p.col_x[cc] + p.col_w[cc]) continue; + // the same seam the h-handle hit test picks (see there) + if (mouse.seamIdxAt(p, cc, p.hover_row) != null) { + var col = p.col_x[cc]; + while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌"); + } + } + for (0..p.ncol -| 1) |cn| { + if (p.hover_col != p.col_x[cn] + p.col_w[cn] -| 1) continue; + if (mouse.seamIdxAt(p, cn, p.hover_row) != null) continue; + if (mouse.seamIdxAt(p, cn + 1, p.hover_row) == null) continue; + var col = p.col_x[cn + 1]; + while (col < p.col_x[cn + 1] + p.col_w[cn + 1]) : (col += 1) s.overlayDash(col, p.hover_row, "╌"); + } + }, + } + + // debug overlay: a small stats box drawn last, top-right ("Debug") + if (p.settings.debug) if (p.panes[p.active]) |at| { + var nlive: usize = 0; + for (p.panes) |slot| { + if (slot != null) nlive += 1; + } + var sb_off: usize = undefined; + var sb_total: usize = undefined; + if (at.file) |*f| { + sb_off = f.scroll; + sb_total = panes.File.nlines(p.gpa, f); + } else if (at.pdfPage()) |page| { + sb_off = page; + sb_total = if (comptime pdf_enabled) at.pdf.?.page_count else 0; + } else { + const sb = panes.Terminal.scrollbar(at); + sb_off = sb.offset; + sb_total = sb.total; + } + const effective_font = p.settings.font.effective_name.get(); + const font_name = if (effective_font.len == 0) "default" else effective_font; + var ov_buf: [256]u8 = undefined; + const text = std.fmt.bufPrint( + &ov_buf, + "pardes resizes={d}\ntheme {s}\nfont {s}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}\npinch {d:6.4}\ntscroll {d:7.4}", + .{ p.resize_count, p.theme().name, font_name[0..@min(font_name.len, 64)], nlive, p.ncol, p.active, at.cols, at.rows, sb_off, sb_total, p.ov_pinch_scale, p.ov_touch_scroll_delta }, + ) catch ""; + // wide enough for the longest theme name compiled in, which is the + // only row here whose width is not ours to choose + const ow: u16 = 38; + const oh: u16 = 10; + const ox: u16 = if (s.cols > ow) s.cols - ow else 0; + if (ox + ow <= s.cols and p.topBarHeight() + oh <= s.rows) { + // vaxis single_rounded border ring, default style + s.set(ox, p.topBarHeight(), "╭", .{}); + s.set(ox + ow - 1, p.topBarHeight(), "╮", .{}); + s.set(ox, p.topBarHeight() + oh - 1, "╰", .{}); + s.set(ox + ow - 1, p.topBarHeight() + oh - 1, "╯", .{}); + var bx = ox + 1; + while (bx < ox + ow - 1) : (bx += 1) { + s.set(bx, p.topBarHeight(), "─", .{}); + s.set(bx, p.topBarHeight() + oh - 1, "─", .{}); + } + var by = p.topBarHeight() + 1; + while (by < p.topBarHeight() + oh - 1) : (by += 1) { + s.set(ox, by, "│", .{}); + s.set(ox + ow - 1, by, "│", .{}); + } + s.fill(ox + 1, p.topBarHeight() + 1, ow - 2, oh - 2, .{ .bg = .{ .rgb = chrome.scroll_track } }); + var lines = std.mem.splitScalar(u8, text, '\n'); + var ly: u16 = p.topBarHeight() + 1; + while (lines.next()) |line| : (ly += 1) { + if (ly >= p.topBarHeight() + oh - 1) break; + _ = s.print(ox + 1, ly, ow - 2, line, .{ .fg = .{ .rgb = chrome.tag_fg } }); + } + } + }; + try p.presentation.submit(p, s); + return composeAsciiTransitions(p, arena, s); +} + +pub fn composeAsciiTransitions(p: *Pardes, arena: std.mem.Allocator, canonical: *Surface) !*Surface { + _ = p; + if (!canonical.hasPanelDiff()) return canonical; + var presented: ?*Surface = null; + for (canonical.panelTracks()) |track| { + if (!track.effect.composedByCore() or track.phase == .closing) continue; + const area = animation.CellArea.of(track.to); + const col_end = @min(canonical.cols, area.x0 +| area.cols); + const row_end = @min(canonical.rows, area.y0 +| area.rows); + var row: u16 = area.y0; + while (row < row_end) : (row += 1) { + var col: u16 = area.x0; + while (col < col_end) : (col += 1) { + const index = @as(usize, row) * canonical.cols + col; + const composed = composedCell(canonical, track, area, col, row, index) orelse + continue; + if (composed.visuallyEqual(&canonical.cells[index])) continue; + if (presented == null) { + const next = try arena.create(Surface); + next.* = canonical.*; + next.cells = try arena.dupe(Cell, canonical.cells); + presented = next; + } + presented.?.cells[index] = composed; + } + } + } + return presented orelse canonical; +} + +fn composedCell( + canonical: *const Surface, + track: animation.Track, + area: animation.CellArea, + col: u16, + row: u16, + index: usize, +) ?Cell { + if (track.effect == .ascii) { + const diff = switch (canonical.cell_diffs[index]) { + .ascii => |diff| diff, + .unchanged, .visual => return null, + }; + const byte = diff.byteAt(track.frame); + if (byte == diff.to) return null; + if (track.frame == 0) return canonical.previous_cells[index]; + return withByte(canonical.cells[index], byte); + } + return switch (animation.charSource(track, col - area.x0, row - area.y0, area)) { + .old => canonical.previous_cells[index], + .byte => |byte| withByte(canonical.cells[index], byte), + // Churn belongs on the glyph, not on the pane's empty space, and it + // borrows whichever side of the diff actually has one. + .churn => |byte| blk: { + const new_cell = &canonical.cells[index]; + if (hasGlyph(new_cell)) break :blk withByte(new_cell.*, byte); + const old_cell = &canonical.previous_cells[index]; + if (hasGlyph(old_cell)) break :blk withByte(old_cell.*, byte); + break :blk old_cell.*; + }, + .at => |offset| blk: { + const source_col = @as(i32, col) + offset.cols; + const source_row = @as(i32, row) + offset.rows; + // A glyph whose source lies outside the pane has not arrived + // yet: the frozen old cell is the only truthful thing under it. + if (source_col < area.x0 or source_row < area.y0 or + source_col >= @as(i32, area.x0) + area.cols or + source_row >= @as(i32, area.y0) + area.rows or + source_col >= canonical.cols or source_row >= canonical.rows) + break :blk canonical.previous_cells[index]; + const source = @as(usize, @intCast(source_row)) * canonical.cols + + @as(usize, @intCast(source_col)); + break :blk canonical.cells[source]; + }, + }; +} + +fn withByte(cell: Cell, byte: u8) Cell { + var out = cell; + out.text[0] = byte; + out.len = 1; + out.default = false; + return out; +} + +/// Whether a cell shows a glyph a character effect can work on. An unpainted +/// cell and a space are both blank space, whatever style they carry. +fn hasGlyph(cell: *const Cell) bool { + return !cell.default and !(cell.len == 1 and cell.text[0] == ' '); +} + +fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void { + const tz = tracy.zone(@src(), "renderPane"); + defer tz.end(); + if (r.w <= config.GUTTER or r.h == 0) return; + const s = &p.surface; + const th = p.theme(); + const chrome = p.chromeTheme(); + const tx = r.x + config.GUTTER; // text area (tag + body), right of the gutter + const tw = r.w - config.GUTTER; + const tag_y = p.tagTop(pane, r); + const body_y = p.bodyTop(pane, r); + const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default; + + // text area resets to terminal-default cells (vaxis clear semantics); + // light themes paint the page over it. + { + // Two full passes over every cell in the pane, every frame. + const tz_clear = tracy.zone(@src(), "paneClear"); + defer tz_clear.end(); + s.clearRect(tx, r.y, tw, r.h); + if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } }); + } + + const box_bg = if (active) chrome.box else chrome.box_dim; + const box_lum = (@as(u16, box_bg[0]) * 3 + @as(u16, box_bg[1]) * 6 + @as(u16, box_bg[2])) / 10; + const box_ink: [3]u8 = if (box_lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff }; + const box_style: CellStyle = .{ + .fg = .{ .rgb = box_ink }, + .bg = .{ .rgb = box_bg }, + // Grips keep physical grid dimensions, independently of tag text. + .font_role = .tagline, + }; + s.fill(r.x, tag_y, config.GUTTER, pane.tag_rows, box_style); + s.set(r.x, tag_y, switch (pane.body.mode) { + .normal => config.tag_normal, + .insert => config.tag_insert, + .tty => config.tag_tty, + }, box_style); + + try paintPaneTag(p, arena, pane, r, id, active, p.tagCapacity(r.w -| TAG_TEXT_INSET)); + + // Folded panes retain their body state, but neither body pixels nor + // its cursor belong outside the one-row tag rectangle. + if (pane.collapsed) return; + + if (comptime pdf_enabled) + if (pane.hasPdf() and panes.Pdf.draw(p, pane, r, id, tx, tw)) return; + + if (pane.image) |*iv| { + // Below the notice chips: a picture is drawn after the cells (the + // GUI's image pass, kitty's z-order), so a chip over it would be + // painted out. Text gets the overlay; a picture gives up the rows. + const shown = pane.notices.span(); + const image_h = (r.h -| pane.tag_rows) -| shown; + panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y + shown, tw, image_h); + // thumbless, but the same one column as the real scrollbar below — + // that is the whole point of drawing it, and like that one it runs + // past the notice bands so the gutter has no notch in it + s.fill(r.x, body_y, 1, r.h -| pane.tag_rows, .{ .bg = .{ .rgb = chrome.scroll_track } }); + s.fill(r.x + 1, body_y, 1, r.h -| pane.tag_rows, .{ .bg = pane_bg }); + return; + } + + try body_layer.renderBody(p, arena, pane, r, id, active, true); +} + +/// The workspace and column tags as the grid shows them: their text, the +/// word under the pointer and, in the one being edited, its selection and +/// caret. Their tag layers are drawn by renderTagLayers. +pub fn paintHeaders(p: *Pardes) !void { + const s = &p.surface; + const chrome = p.chromeTheme(); + const th = p.theme(); + // global tagbar: full width, top rows — unless a native shell has + // taken its commands over, in which case the rows are not ours to draw + tagline.revealHeader(p); + const global = tagline.topbar(p); + const global_rows = p.topBarHeight(); + const global_scroll = if (p.header_focus and p.header_column == null) p.header_scroll else 0; + const global_top = if (p.header_focus and p.header_column == null) p.header_top else 0; + if (p.settings.workspace_tag) { + s.fill(0, 0, s.cols, global_rows, .{ + .bg = .{ .rgb = chrome.tag_bg }, + .font_role = .tagline, + }); + for (0..global_rows) |row| { + const line = modal.lineSlice(global, row + global_top); + _ = s.print(0, @intCast(row), s.cols, line[panes.File.rawAtDisplay(line, global_scroll)..], .{ + .fg = .{ .rgb = chrome.tag_fg }, + .bg = .{ .rgb = chrome.tag_bg }, + .font_role = .tagline, + }); + } + } + if (p.pointer_inside and p.hover_row < global_rows) { + const bar = modal.lineSlice(global, p.hover_row + global_top); + if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col + global_scroll))) |bounds| { + var col = panes.File.rawDisplayCol(bar, bounds.lo) -| global_scroll; + const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| global_scroll; + while (col < hi and col < s.cols) : (col += 1) { + const cell = s.at(@intCast(col), p.hover_row); + cell.default = false; + cell.style.bg = .{ .rgb = th.sel_bg }; + cell.style.fg = .{ .rgb = th.sel_fg }; + } + } + } + const column_rows = p.columnBarHeight(); + if (column_rows > 0) for (0..p.ncol) |c| { + const grip = @min(config.GUTTER, p.col_w[c]); + const inset = @min(TAG_TEXT_INSET, p.col_w[c]); + const x = p.col_x[c] + inset; + const w = p.col_w[c] - inset; + const y0 = global_rows; + if (p.col_w[c] == 0 or p.col_x[c] >= s.cols) continue; + const active_col = if (layout.findPane(p, p.active)) |f| f.col == c else false; + const bg = if (active_col and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; + const fg = if (active_col and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; + // A grip is muted until it is grabbed; then it lights, as a pane's + // box does for the active pane, so the drag shows what it holds. + const grabbed = p.drag == .column_move and layout.columnBySerial(p, p.drag.column_move.serial) == c; + const grip_bg = if (grabbed) chrome.column_box else chrome.column_box_dim; + const lum = (@as(u16, grip_bg[0]) * 3 + @as(u16, grip_bg[1]) * 6 + @as(u16, grip_bg[2])) / 10; + const ink: [3]u8 = if (lum > 140) .{ 0, 0, 0 } else .{ 255, 255, 255 }; + const grip_style: CellStyle = .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline }; + s.fill(p.col_x[c], y0, grip, column_rows, grip_style); + s.fill(p.col_x[c] + grip, y0, inset - grip, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); + + s.fill(x, y0, w, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); + const bar_scroll = if (p.header_focus and p.header_column == c) p.header_scroll else 0; + const bar_top = if (p.header_focus and p.header_column == c) p.header_top else 0; + const tag = tagline.columnTag(p, c); + for (0..column_rows) |row| { + const line = modal.lineSlice(tag, row + bar_top); + _ = s.print(x, y0 + @as(u16, @intCast(row)), w, line[panes.File.rawAtDisplay(line, bar_scroll)..], .{ + .fg = .{ .rgb = fg }, + .bg = .{ .rgb = bg }, + .font_role = .tagline, + }); + } + // A carried column's pointer is aiming at a place, not a word. + if (p.pointer_inside and p.drag != .column_move and p.hover_row >= y0 and p.hover_row < y0 + column_rows and p.hover_col >= x and p.hover_col < x + w) { + const bar = modal.lineSlice(tag, p.hover_row - y0 + bar_top); + if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col - x + bar_scroll))) |bounds| { + var col = panes.File.rawDisplayCol(bar, bounds.lo) -| bar_scroll; + const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| bar_scroll; + while (col < hi and col < w) : (col += 1) { + const cell = s.at(x + @as(u16, @intCast(col)), p.hover_row); + cell.style.bg = .{ .rgb = th.sel_bg }; + cell.style.fg = .{ .rgb = th.sel_fg }; + } + } + } + }; + if (tagline.focusedHeader(p)) |t| { + const text = try edit.flatSurface(p, t); + const x = if (p.header_column) |c| p.col_x[c] + TAG_TEXT_INSET else 0; + const w = if (p.header_column) |c| p.col_w[c] -| TAG_TEXT_INSET else s.cols; + const y: u16 = if (p.header_column != null) global_rows else 0; + const rows = if (p.header_column != null) column_rows else global_rows; + for (0..rows) |row| { + const bar = modal.lineSlice(text, row + p.header_top); + const span = tagline.headerSelection(t, text, row + p.header_top) orelse continue; + var col = panes.File.rawDisplayCol(bar, span.lo) -| p.header_scroll; + const hi = panes.File.rawDisplayCol(bar, span.hi) -| p.header_scroll; + while (col < hi and col < w) : (col += 1) { + const cell = s.at(x + @as(u16, @intCast(col)), y + @as(u16, @intCast(row))); + cell.style.fg = .{ .rgb = th.sel_fg }; + cell.style.bg = .{ .rgb = th.sel_bg }; + } + } + // A prompt answering for the header has the caret on its band. + const asking = if (p.panes[p.active]) |active| active.prompt != .none else false; + const cur: usize = @intCast(@max(0, t.cur_row)); + if (!asking and cur >= p.header_top and cur < p.header_top + rows) { + const bar = modal.lineSlice(text, cur); + const caret = panes.File.rawDisplayCol(bar, @min(@as(usize, @intCast(@max(0, t.cur_col))), bar.len)) -| p.header_scroll; + if (caret < w) s.cursor = .{ .x = x + @as(u16, @intCast(caret)), .y = y + @as(u16, @intCast(cur - p.header_top)), .bar = t.mode == .insert }; + } + } +} + +fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { lo: usize, hi: usize } { + if (pane.isTerminal()) { + var words = std.mem.tokenizeAny(u8, tag[prefix_len..], " \t\r\n"); + while (words.next()) |word| { + if (!std.mem.eql(u8, word, "Tty")) continue; + const lo = @intFromPtr(word.ptr) - @intFromPtr(tag.ptr); + return .{ .lo = lo, .hi = lo + word.len }; + } + return null; + } + var start: usize = 0; + const name = if (pane.file) |file| + if (pane.prompt == .name) pane.input.own orelse file.path else file.path + else if (pane.image) |state| blk: { + // Image tags put renderer controls before the path. + start = std.mem.lastIndexOf(u8, tag[0..prefix_len], state.path) orelse return null; + break :blk state.path; + } else if (comptime pdf_enabled) blk: { + break :blk if (pane.pdf) |state| state.path else return null; + } else return null; + const basename = if (std.mem.lastIndexOfScalar(u8, name, '/')) |slash| slash + 1 else 0; + if (basename == name.len) return null; + return .{ .lo = start + basename, .hi = start + name.len }; +} + +pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { + const chrome = p.chromeTheme(); + for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + const r = p.rects[id]; + if (r.w <= TAG_TEXT_INSET or r.h == 0) continue; + const tag = try tagline.tagText(p, arena, pane); + const rows = pane.tag_rows; + // As wide as the widest line it shows, and never narrower than + // its viewport. + var widest: usize = 0; + var split = std.mem.splitScalar(u8, tag, '\n'); + for (0..pane.tag_top) |_| _ = split.next(); + for (0..rows) |_| widest = @max(widest, panes.File.displayWidth(split.next() orelse "")); + const columns: u16 = @intCast(@min(std.math.maxInt(u16) - TAG_TEXT_INSET, @max(p.tagCapacity(r.w - TAG_TEXT_INSET), widest + 1))); + const pitch = @as(usize, columns) + TAG_TEXT_INSET; + const storage = try arena.alloc(Cell, pitch * rows); + @memset(storage, .{}); + var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = rows, .cells = storage }; + std.mem.swap(Surface, &p.surface, &temporary); + paintPaneTag(p, arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, id == p.active, p.tagCapacity(r.w - TAG_TEXT_INSET)) catch |err| { + std.mem.swap(Surface, &p.surface, &temporary); + return err; + }; + std.mem.swap(Surface, &p.surface, &temporary); + // A layer to a line: the first in the pane's own slot, the rest + // in the slots kept for the lines of taller tags. + for (0..rows) |line| { + const layer = &p.surface.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1]; + if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); + @memcpy(layer.cells, storage[line * pitch + TAG_TEXT_INSET ..][0..columns]); + layer.kind = .pane; + layer.id = @intCast(id); + layer.serial = pane.serial; + layer.line = @intCast(line); + layer.viewport = .{ .x = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r) + @as(u16, @intCast(line)), .w = r.w - TAG_TEXT_INSET, .h = BOX_H }; + layer.cols = columns; + layer.bg = if (id == p.active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; + layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor| + (if (cursor.y == line) .{ .col = cursor.x - TAG_TEXT_INSET, .bar = cursor.bar } else null) + else + null; + } + } + // With the workspace tag row handed to native chrome there is no + // workspace layer to render; the menu bar carries its commands. + if (p.settings.workspace_tag) { + for (0..p.topBarHeight()) |line| { + const index = if (line == 0) MAX_PANES + MAX_COLS else HEADER_LINE_LAYER_BASE + MAX_COLS * (MAX_TAG_ROWS - 1) + line - 1; + const top = if (p.header_focus and p.header_column == null) p.header_top else 0; + try renderHeaderLayer(p, arena, index, .workspace, 0, @intCast(line), .{ .x = 0, .y = @intCast(line), .w = p.screen_w, .h = TOPBAR_H }, modal.lineSlice(tagline.topbar(p), line + top), chrome.tag_bg, chrome.tag_fg); + } + } + // Each notice is its own tagline-shaped chip over the top of the body. + // Going through the same layer machinery the pane and column tags use + // is what gives it the tagline's height, its small-font metrics, the + // band offset and the border -- none of which a body-grid row can have + // just by wearing a tagline font. The geometry is the one the grid + // pass already chose and recorded, so the two cannot disagree and the + // GUI's "skip the cells a tag layer covers" leaves no stub behind. + // A prompt is one of them too: left on the grid, a pixel shell drew + // its tagline glyphs one to a BODY cell, spaced out like a banner. + for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + const r = p.rects[id]; + if (r.w <= TAG_TEXT_INSET or r.h <= pane.tag_rows) continue; + const first = p.bodyTop(pane, r); + var leader_buf: [16]u8 = undefined; + for (pane.notices.slice(), 0..) |kind, i| { + const text = Messages.noticeText(p, pane, i, &leader_buf); + if (text.len == 0) continue; + const cx = pane.notices.left[i]; + if (cx == std.math.maxInt(u16) or cx >= r.x + r.w) continue; + const chip = r.x + r.w - cx; + // Right aligned inside the chip, a blank cell short of its + // edge: the same one the grid pass leaves for a prompt caret. + // Wider than the chip, the text loses its head, as on the grid. + const room = p.tagCapacity(chip) -| 1; + const shown = panes.File.displayWidth(text); + var kept = text; + if (shown > room) { + var start = panes.File.rawAtDisplay(text, shown - room); + if (panes.File.rawDisplayCol(text, start) < shown - room) start = panes.File.rawAtDisplay(text, shown - room + 1); + kept = text[start..]; + } + const pad = room -| panes.File.displayWidth(kept); + const line = try arena.alloc(u8, pad + kept.len); + @memset(line[0..pad], ' '); + @memcpy(line[pad..], kept); + // A pixel shell slides the message in as it fades up, and + // fades it on the way out. + const motion: Messages.MessageMotion = if (Messages.noticeLife(pane, i)) |life| Messages.messageMotion(p, life) else .{}; + const fade = motion.alpha; + const page = p.theme().bg orelse chrome.tag_bg; + const layer_index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i; + try renderHeaderLayer(p, arena, layer_index, .notice, @intCast(id), 0, .{ + .x = cx, + .y = first + @as(u16, pane.notices.row[i]), + .w = chip, + .h = BOX_H, + }, line, Messages.blendRgb(chrome.tag_bg, page, fade), Messages.blendRgb(chrome.tag_fg, page, fade)); + p.surface.tag_layers[layer_index].slide = motion.slide; + p.surface.tag_layers[layer_index].fade = fade; + // The caret rides the band, where the prompt's glyphs are, + // at the same place in the text the grid pass puts it. + if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue; + const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); + const dropped = shown - panes.File.displayWidth(kept); + if (typed < dropped) continue; + p.surface.tag_layers[layer_index].cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.input.mode == .insert }; + } + } + if (p.columnBarHeight() > 0) for (0..p.ncol) |column| { + if (p.col_w[column] == 0) continue; + const active = if (layout.findPane(p, p.active)) |found| found.col == column and p.settings.focus_tint else false; + const top = if (p.header_focus and p.header_column == column) p.header_top else 0; + for (0..p.columnBarHeight()) |line| { + const index = if (line == 0) MAX_PANES + column else HEADER_LINE_LAYER_BASE + column * (MAX_TAG_ROWS - 1) + line - 1; + try renderHeaderLayer(p, arena, index, .column, @intCast(column), @intCast(line), .{ .x = p.col_x[column] + @min(TAG_TEXT_INSET, p.col_w[column]), .y = p.topBarHeight() + @as(u16, @intCast(line)), .w = p.col_w[column] -| TAG_TEXT_INSET, .h = COLBAR_H }, modal.lineSlice(tagline.columnTag(p, column), line + top), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg); + } + }; +} + +/// One row of a header or a notice band as a tag layer: `text` is line +/// `line` of what it shows. +fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: TagKind, id: u16, line: u16, viewport: Rect, text: []const u8, bg: [3]u8, fg: [3]u8) !void { + _ = arena; + const layer = &p.surface.tag_layers[index]; + if (viewport.w == 0) { + layer.cols = 0; + return; + } + const columns: u16 = @intCast(@min(std.math.maxInt(u16), @max(p.tagCapacity(viewport.w), panes.File.displayWidth(text) + 1))); + if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); + layer.kind = kind; + layer.id = id; + layer.line = line; + layer.serial = if (kind == .column) p.col_serial[id] else 0; + layer.viewport = viewport; + layer.cols = columns; + layer.bg = bg; + layer.cursor = null; + layer.slide = 0; + layer.fade = 1; + var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells }; + // A notice band carries a PANE id, not a column index, so it must not + // answer to the column being edited -- that would scroll and select + // its text with the column tag's offsets and give it a second caret. + const editing: ?*Text = if (p.header_focus and switch (kind) { + .workspace => p.header_column == null, + .column => p.header_column == id, + .pane, .notice => false, + }) tagline.focusedHeader(p) else null; + const scroll = if (editing != null) p.header_scroll else 0; + surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); + _ = surface.print(0, 0, columns, text[panes.File.rawAtDisplay(text, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); + if (editing) |t| { + if (tagline.headerSelection(t, try edit.flatSurface(p, t), line + p.header_top)) |span| { + var col = panes.File.rawDisplayCol(text, span.lo) -| scroll; + const end = panes.File.rawDisplayCol(text, span.hi) -| scroll; + while (col < end and col < columns) : (col += 1) { + surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg }; + surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg }; + } + } + const caret = panes.File.rawDisplayCol(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.len)) -| scroll; + const asking = if (p.panes[p.active]) |active| active.prompt != .none else false; + if (!asking and t.cur_row == line + p.header_top and caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = t.mode == .insert }; + } else if (p.pointer_inside) { + const tag_hit = p.pointer_tag_hit; + const col: ?usize = if (tag_hit) |pointer| + (if (pointer.kind == kind and pointer.id == id and pointer.line == line) pointer.col else null) + else if (p.hover_row == viewport.y and p.hover_col >= viewport.x and p.hover_col - viewport.x < viewport.w) + p.hover_col - viewport.x + else + null; + if (col) |hover| if (wordBoundsAtCol(text, panes.File.rawAtDisplay(text, hover + scroll))) |bounds| { + var cell = panes.File.rawDisplayCol(text, bounds.lo) -| scroll; + const end = panes.File.rawDisplayCol(text, bounds.hi) -| scroll; + while (cell < end and cell < columns) : (cell += 1) { + surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg }; + surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg }; + } + }; + } +} + +pub fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void { + const s = &p.surface; + const th = p.theme(); + const chrome = p.chromeTheme(); + const tx = r.x + @min(TAG_TEXT_INSET, r.w); + const tw = r.w -| TAG_TEXT_INSET; + const tag_y = p.tagTop(pane, r); + const body_y = p.bodyTop(pane, r); + const rows = pane.tag_rows; + const page_rgb = th.bg orelse th.tag_bg; + // Focus is visible along the tag without changing its live text grid. + const tag_bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; + const tag_fg = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; + const tag_style: CellStyle = .{ .fg = .{ .rgb = tag_fg }, .bg = .{ .rgb = tag_bg }, .font_role = .tagline }; + // Padding belongs to the same font row but keeps its default ink: a + // role must not manufacture a foreground color for blank cells. + s.fill(r.x + config.GUTTER, tag_y, @min(TAG_GAP, r.w -| config.GUTTER), rows, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline }); + s.fill(tx, tag_y, tw, rows, .{ + .bg = .{ .rgb = tag_bg }, + .font_role = .tagline, + }); + const whole = try tagline.tagText(p, arena, pane); + const tail = tagline.curTail(pane); + const prefix_len = whole.len - tail.len; + // A row per line shown, from `tag_top`: the tag's first line carries + // the prefix, the rest are the tag's own text alone. + const top = pane.tag_top; + var lines: [MAX_TAG_ROWS][]const u8 = @splat(""); + var split = std.mem.splitScalar(u8, whole, '\n'); + for (0..top) |_| _ = split.next(); + for (lines[0..rows]) |*line| line.* = split.next() orelse ""; + // The keyboard's caret, as a row and a byte offset in that row's + // line: in the tag's own text when the tag has the keyboard, in the + // path while a name is drafted. + const Caret = struct { row: usize, at: usize }; + const caret: ?Caret = if (!active) + null + else if (pane.prompt == .name) + .{ .row = 0, .at = @min(lines[0].len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) } + else if (pane.focus == .tag and pane.prompt == .none and pane.tag.cur_row >= top and pane.tag.cur_row < top + rows) caret: { + const row: usize = @intCast(pane.tag.cur_row - top); + break :caret .{ .row = row, .at = @min(lines[row].len, @as(usize, @intCast(@max(0, pane.tag.cur_col)))) }; + } else null; + if (caret) |at| { + const line = lines[at.row]; + const col = panes.File.rawDisplayCol(line, at.at); + var scroll: usize = pane.tag_scroll; + if (col < scroll) scroll = col; + if (col >= scroll + capacity) scroll = col - capacity + 1; + var raw = panes.File.rawAtDisplay(line, scroll); + scroll = panes.File.rawDisplayCol(line, raw); + if (col >= scroll + capacity) { + raw = modal.nextGrapheme(line, raw); + scroll = panes.File.rawDisplayCol(line, raw); + } + pane.tag_scroll = @intCast(scroll); + } else if (pane.focus != .tag) pane.tag_scroll = 0; + for (lines[0..rows], 0..) |line, row| { + const start = panes.File.rawAtDisplay(line, pane.tag_scroll); + _ = s.print(tx, tag_y + @as(u16, @intCast(row)), tw, line[start..], tag_style); + } + const tag = lines[0]; + if (top == 0) if (tagTintRange(pane, tag, prefix_len)) |name| { + const lo = @max(name.lo, panes.File.rawAtDisplay(tag, pane.tag_scroll)); + const hi = @min(name.hi, tag.len); + if (lo < hi) { + var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll; + const end = panes.File.rawDisplayCol(tag, hi) -| pane.tag_scroll; + const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg; + while (col < end and col < tw) : (col += 1) + s.at(tx + @as(u16, @intCast(col)), tag_y).style.fg = .{ .rgb = name_fg }; + } + }; + if (p.look_hover_preview) |preview| { + if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) { + const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); + body_layer.paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); + }; + } + // The tag's own selections, while it has the keyboard, a line at a + // time. A range of one grapheme is the block cursor, not a selection. + if (pane.focus == .tag) { + var ranges: [Text.max_selections]modal.Selection = undefined; + const got = pane.tag.ranges(whole, 0, &ranges); + for (ranges[0..got.n]) |range| { + const lo = @min(range.anchor, range.head); + const hi = @max(range.anchor, range.head); + if (modal.nextGrapheme(whole, lo) >= hi) continue; + const first = modal.positionAt(whole, lo); + const last = modal.positionAt(whole, hi); + var row = @max(first.row, top); + while (row <= last.row and row < top + rows) : (row += 1) { + const line = lines[row - top]; + const from = if (row == first.row) first.col else 0; + const to = @min(line.len, if (row == last.row) last.col else line.len); + var col = panes.File.rawDisplayCol(line, from) -| pane.tag_scroll; + const end = panes.File.rawDisplayCol(line, to); + const y = tag_y + @as(u16, @intCast(row - top)); + while (col + pane.tag_scroll < end and col < tw) : (col += 1) { + const cell = s.at(tx + @as(u16, @intCast(col)), y); + cell.default = false; + cell.style.bg = .{ .rgb = th.sel_bg }; + cell.style.fg = .{ .rgb = th.sel_fg }; + } + } + } + } + // the caret: bar while typing, block in normal mode (same rule as a body) + if (caret) |at| { + const col = panes.File.rawDisplayCol(lines[at.row], at.at) -| pane.tag_scroll; + const mode = if (pane.prompt == .name) pane.input.mode else pane.tag.mode; + if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y + @as(u16, @intCast(at.row)), .bar = mode == .insert }; + } + + for (pane.sel, 0..) |selection, button| { + if (selection.state == .none or (selection.state == .done and button != 0)) continue; + if (@min(selection.r0, selection.r1) >= rows) continue; + body_layer.paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg); + } +} diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 6978284b..0737bb63 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -209,11 +209,11 @@ const AnimationClock = struct { return false; } if (clock.next_ns == 0) { - clock.next_ns = now_ns +| pardes.layout.Animation.frame_ns; + clock.next_ns = now_ns +| pardes.animation.Animation.frame_ns; return false; } if (now_ns < clock.next_ns) return false; - clock.next_ns = now_ns +| pardes.layout.Animation.frame_ns; + clock.next_ns = now_ns +| pardes.animation.Animation.frame_ns; return true; } }; @@ -221,7 +221,7 @@ const AnimationClock = struct { fn finishPresentedAnimationFrame( clock: *AnimationClock, core: *pardes.Pardes, - tracks: []const pardes.layout.Track, + tracks: []const pardes.animation.Track, now_ns: u64, ) void { core.acknowledgePanelPresentation(tracks); @@ -232,9 +232,9 @@ test "GUI animation clock is active-only and cadence gated" { var clock: AnimationClock = .{}; try std.testing.expect(!clock.due(false, 100)); try std.testing.expect(!clock.due(true, 100)); - try std.testing.expect(!clock.due(true, 100 + pardes.layout.Animation.frame_ns - 1)); - try std.testing.expect(clock.due(true, 100 + pardes.layout.Animation.frame_ns)); - try std.testing.expect(!clock.due(false, 100 + 2 * pardes.layout.Animation.frame_ns)); + try std.testing.expect(!clock.due(true, 100 + pardes.animation.Animation.frame_ns - 1)); + try std.testing.expect(clock.due(true, 100 + pardes.animation.Animation.frame_ns)); + try std.testing.expect(!clock.due(false, 100 + 2 * pardes.animation.Animation.frame_ns)); try std.testing.expectEqual(@as(u64, 0), clock.next_ns); } @@ -248,7 +248,7 @@ test "a persistent scene presents a new panel's frame zero before advancing it" core.settings.scene_effects.crt = true; const scene_frame = try core.render(arena.allocator()); finishPresentedAnimationFrame(&clock, core, scene_frame.panelTracks(), 100); - try std.testing.expectEqual(@as(u64, 100 + pardes.layout.Animation.frame_ns), clock.next_ns); + try std.testing.expectEqual(@as(u64, 100 + pardes.animation.Animation.frame_ns), clock.next_ns); core.settings.panel_transition = .slide; core.update(.{ .command = "Newcol" }); @@ -262,7 +262,7 @@ test "a persistent scene presents a new panel's frame zero before advancing it" &clock, core, first.panelTracks(), - 100 + pardes.layout.Animation.frame_ns, + 100 + pardes.animation.Animation.frame_ns, ); _ = arena.reset(.retain_capacity); const second = try core.render(arena.allocator()); @@ -557,8 +557,8 @@ const SavedImagePlace = struct { const PreparedImage = struct { place: SavedImagePlace, texture: *c.SDL_GPUTexture, - track: ?pardes.layout.Track = null, - clip: ?pardes.layout.Box = null, + track: ?pardes.animation.Track = null, + clip: ?pardes.animation.Box = null, old_layer: bool = false, }; @@ -592,7 +592,7 @@ test "shader instance ABI carries aligned transition vectors" { try std.testing.expectEqual(@as(usize, 64), @offsetOf(ImageInstance, "effect")); try std.testing.expectEqual(@as(usize, 32), @sizeOf([8]f32)); - const tracks = [_]pardes.layout.Track{ + const tracks = [_]pardes.animation.Track{ .{ .pane = 0, .phase = .opening, .effect = .slide }, .{ .pane = 1, .phase = .moving, .effect = .slide }, .{ .pane = 2, .phase = .moving, .effect = .slide }, @@ -606,19 +606,19 @@ test "shader instance ABI carries aligned transition vectors" { } test "GUI paint plan snaps history effects without a frozen grid" { - const tracks = [_]pardes.layout.Track{ + const tracks = [_]pardes.animation.Track{ .{ .pane = 0, .phase = .opening, .effect = .ascii }, .{ .pane = 1, .phase = .closing, .effect = .vertical }, }; try std.testing.expectEqual(@as(usize, 1), makePaintPlan(&tracks, false).len); const ready = makePaintPlan(&tracks, true); try std.testing.expectEqual(@as(usize, 3), ready.len); - try std.testing.expectEqual(pardes.layout.Phase.opening, ready.batches[1].track.?.phase); - try std.testing.expectEqual(pardes.layout.Phase.closing, ready.batches[2].track.?.phase); + try std.testing.expectEqual(pardes.animation.Phase.opening, ready.batches[1].track.?.phase); + try std.testing.expectEqual(pardes.animation.Phase.closing, ready.batches[2].track.?.phase); } test "closing tombstone overlays but never owns canonical cells" { - const closing: pardes.layout.Track = .{ + const closing: pardes.animation.Track = .{ .pane = 0, .phase = .closing, .effect = .vertical, @@ -628,7 +628,7 @@ test "closing tombstone overlays but never owns canonical cells" { const plan = makePaintPlan(&.{closing}, true); try std.testing.expectEqual(@as(usize, 0), paintBatchAt(&plan, 4, 4)); try std.testing.expectEqual(@as(usize, 2), plan.len); - try std.testing.expectEqual(pardes.layout.Phase.closing, plan.batches[1].track.?.phase); + try std.testing.expectEqual(pardes.animation.Phase.closing, plan.batches[1].track.?.phase); } fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cell_h: u32, tag_w: u32, tag_h: u32) bool { @@ -2031,10 +2031,10 @@ fn taglineLayoutForCell(g: *const Gui, col: u16) CellLayout { return compactTaglineLayout(g, @floatFromInt(col)); } -const boxContains = pardes.layout.Box.contains; +const boxContains = pardes.animation.Box.contains; const PaintBatch = struct { - track: ?pardes.layout.Track = null, + track: ?pardes.animation.Track = null, cell_start: u32 = 0, cell_count: u32 = 0, image_start: u32 = 0, @@ -2046,9 +2046,9 @@ const PaintPlan = struct { len: usize = 1, }; -fn makePaintPlan(tracks: []const pardes.layout.Track, has_diff: bool) PaintPlan { +fn makePaintPlan(tracks: []const pardes.animation.Track, has_diff: bool) PaintPlan { var plan: PaintPlan = .{}; - for ([_]pardes.layout.Phase{ .moving, .opening, .closing }) |phase| { + for ([_]pardes.animation.Phase{ .moving, .opening, .closing }) |phase| { for (tracks) |track| { if (!track.active() or track.phase != phase) continue; if (track.effect.needsPreviousGrid() and !has_diff) continue; @@ -2074,21 +2074,21 @@ fn paintBatchForSerial(plan: *const PaintPlan, serial: u32) usize { return 0; } -fn panelCellCoord(track: pardes.layout.Track, col: u16, row: u16) u32 { +fn panelCellCoord(track: pardes.animation.Track, col: u16, row: u16) u32 { const source = track.contentBox(); const x0: u16 = @intFromFloat(@max(0.0, @floor(source.x))); const y0: u16 = @intFromFloat(@max(0.0, @floor(source.y))); return @as(u32, row -| y0) << 16 | @as(u32, col -| x0); } -fn panelGridSize(track: pardes.layout.Track) u32 { +fn panelGridSize(track: pardes.animation.Track) u32 { const source = track.contentBox(); const cols: u16 = @intFromFloat(@min(@as(f32, std.math.maxInt(u16)), @max(1.0, @ceil(source.w)))); const rows: u16 = @intFromFloat(@min(@as(f32, std.math.maxInt(u16)), @max(1.0, @ceil(source.h)))); return @as(u32, rows) << 16 | @as(u32, cols); } -fn ndcBox(box: pardes.layout.Box, layout: CellLayout, win_w: f32, win_h: f32) [4]f32 { +fn ndcBox(box: pardes.animation.Box, layout: CellLayout, win_w: f32, win_h: f32) [4]f32 { const px0 = layout.x_off + box.x * layout.w; const py0 = layout.y_off + box.y * layout.h; const px1 = px0 + box.w * layout.w; @@ -2103,7 +2103,7 @@ fn ndcBox(box: pardes.layout.Box, layout: CellLayout, win_w: f32, win_h: f32) [4 fn setTransitionFields( instance: anytype, - track: ?pardes.layout.Track, + track: ?pardes.animation.Track, layout: CellLayout, win_w: f32, win_h: f32, @@ -4636,7 +4636,7 @@ fn snapshotContainsPlacement(g: *const Gui, current: pardes.ImagePlace) bool { return false; } -fn placeIntersectsBox(place: SavedImagePlace, box: pardes.layout.Box) bool { +fn placeIntersectsBox(place: SavedImagePlace, box: pardes.animation.Box) bool { const x0: f32 = @floatFromInt(place.x); const y0: f32 = @floatFromInt(place.y); const x1: f32 = @floatFromInt(@as(u32, place.x) + place.w); @@ -4808,8 +4808,8 @@ fn appendPreparedImage( g: *Gui, gpa: std.mem.Allocator, place: SavedImagePlace, - track: ?pardes.layout.Track, - clip: ?pardes.layout.Box, + track: ?pardes.animation.Track, + clip: ?pardes.animation.Box, old_layer: bool, ) void { const texture = g.native_images.get(place.key) orelse return; @@ -4998,7 +4998,7 @@ fn imageScissor( }; } -fn panelBoxScissor(g: *const Gui, box: pardes.layout.Box, max_w: u32, max_h: u32) c.SDL_Rect { +fn panelBoxScissor(g: *const Gui, box: pardes.animation.Box, max_w: u32, max_h: u32) c.SDL_Rect { const x0: i32 = @intFromFloat(@floor(box.x * @as(f32, @floatFromInt(g.cell_w)))); const y0: i32 = @intFromFloat(@floor(box.y * @as(f32, @floatFromInt(g.cell_h)))); const x1: i32 = @intFromFloat(@ceil((box.x + box.w) * @as(f32, @floatFromInt(g.cell_w)))); @@ -5059,7 +5059,7 @@ fn renderFrame( theme_bg: ?[3]u8, topbar_pane_border_rgb: [3]u8, tagline_rgb: [3]u8, - scene_effects: pardes.layout.SceneEffect, + scene_effects: pardes.animation.SceneEffect, debug_on: bool, ) !bool { if (surface.pointer_shape != g.pointer_shape) { @@ -5763,7 +5763,7 @@ fn tagLayerCellCount(g: *const Gui, layer: *const pardes.TagLayer) u32 { return @min(layer.cols, (pixels + g.tagline_width - 1) / g.tagline_width); } -fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.TagLayer, win_w: f32, win_h: f32, track: ?pardes.layout.Track, page: Ground, show_cursor: bool, old_layer: bool) void { +fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.TagLayer, win_w: f32, win_h: f32, track: ?pardes.animation.Track, page: Ground, show_cursor: bool, old_layer: bool) void { const bw: f32 = @floatFromInt(g.cell_w); const bh: f32 = @floatFromInt(g.cell_h); const tw: f32 = @floatFromInt(g.tagline_width); @@ -5826,7 +5826,7 @@ fn clipBodyInstance(instance: *CellInstance, bottom: f32, win_h: f32) void { instance.y1 = limit; } -fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.BodyLayer, win_w: f32, win_h: f32, track: ?pardes.layout.Track, page: Ground, show_cursor: bool, old_layer: bool, border_rgb: [3]u8) void { +fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.BodyLayer, win_w: f32, win_h: f32, track: ?pardes.animation.Track, page: Ground, show_cursor: bool, old_layer: bool, border_rgb: [3]u8) void { const bw: f32 = @floatFromInt(g.cell_w); const bh: f32 = @floatFromInt(g.cell_h); const tw: f32 = @floatFromInt(g.tagline_width); @@ -5919,7 +5919,7 @@ fn emitSurfaceCell( body_layout: CellLayout, win_w: f32, win_h: f32, - track: ?pardes.layout.Track, + track: ?pardes.animation.Track, line: []const pardes.Cell, at: usize, cursor: ?usize, @@ -6018,7 +6018,7 @@ fn emitInstance( layout: CellLayout, win_w: f32, win_h: f32, - track: ?pardes.layout.Track, + track: ?pardes.animation.Track, role: pardes.FontRole, line: []const pardes.Cell, at: usize, diff --git a/src/layout.zig b/src/layout.zig index 8e44f1d9..b642c52c 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -4,666 +4,16 @@ const tagline = @import("tagline.zig"); const dump = @import("dump.zig"); const config = @import("config.zig"); const panes = @import("panes.zig"); +const animation = @import("animation.zig"); +const Box = animation.Box; const Pardes = pardes.Pardes; const Pane = pardes.Pane; pub const Rect = struct { x: u16 = 0, y: u16 = 0, w: u16 = 0, h: u16 = 0 }; -pub const Snapshot = struct { serial: u32, box: Box }; const MAX_COLS = pardes.MAX_COLS; const MAX_PANES = pardes.MAX_PANES; const BOX_H = pardes.BOX_H; -pub const Presentation = struct { - // Only acknowledged draws advance shown geometry; committed and submitted may be ahead. - committed: [MAX_PANES]?Snapshot = @splat(null), - initialized: bool = false, - enabled: bool = false, - snap_once: bool = false, - tracks: [MAX_PANES]?Track = @splat(null), - closing: [MAX_PANES]Track = undefined, - closing_len: usize = 0, - submitted: [MAX_PANES]?Snapshot = @splat(null), - submitted_ready: bool = false, - shown: [MAX_PANES]?Snapshot = @splat(null), - shown_tracks: [MAX_PANES]?Track = @splat(null), - shown_closing: [MAX_PANES]Track = undefined, - shown_closing_len: usize = 0, - acknowledged: bool = false, - pending: bool = false, - previous_cells: []pardes.Cell = &.{}, - previous_body_layers: [MAX_PANES]pardes.BodyLayer = @splat(.{}), - previous_tag_layers: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = @splat(.{}), - previous_cols: u16 = 0, - previous_rows: u16 = 0, - previous_valid: bool = false, - previous_layout: [MAX_PANES]?Snapshot = @splat(null), - diffs: []pardes.PanelCellDiff = &.{}, - diff_state: enum { none, requested, ready } = .none, - - pub const CellPosition = struct { col: u16, row: u16 }; - - pub fn deinit(self: *Presentation, gpa: std.mem.Allocator) void { - if (self.previous_cells.len > 0) gpa.free(self.previous_cells); - for (&self.previous_body_layers) |*layer| gpa.free(layer.cells); - for (&self.previous_tag_layers) |*layer| gpa.free(layer.cells); - if (self.diffs.len > 0) gpa.free(self.diffs); - } - - pub fn sync(self: *Presentation, p: *Pardes) void { - const initializing = !self.initialized or !self.enabled; - if (initializing or self.snap_once) { - const had_layout = self.initialized; - self.committed = @splat(null); - self.tracks = @splat(null); - self.closing_len = 0; - self.diff_state = .none; - if (initializing) { - self.shown_tracks = @splat(null); - self.shown_closing_len = 0; - } - for (p.panes, 0..) |slot, id| { - const pane = slot orelse continue; - self.committed[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; - } - self.initialized = true; - self.snap_once = false; - if (had_layout and self.acknowledged) - self.pending = true; - return; - } - - const effect = p.settings.panel_transition; - if (effect.needsPreviousGrid() and self.diff_state != .none) { - var second_change = false; - for (p.panes, self.committed, 0..) |slot, snapshot, id| { - const pane = slot orelse { - second_change = second_change or snapshot != null; - continue; - }; - const target = panelBox(p.rects[id]); - second_change = second_change or snapshot == null or - snapshot.?.serial != pane.serial or !snapshot.?.box.eql(target); - } - if (second_change) { - p.abandonPanelAnimations(); - self.committed = @splat(null); - for (p.panes, 0..) |slot, id| { - const pane = slot orelse continue; - self.committed[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; - } - return; - } - } - var changed = false; - var animated_change = false; - for (p.panes, 0..) |slot, id| { - const pane = slot orelse { - if (self.committed[id]) |old| { - changed = true; - if (effect.lifecycleOnly()) { - if (self.appendClosing(.{ - .serial = old.serial, - .pane = @intCast(id), - .phase = .closing, - .effect = effect, - .from = old.box, - .to = closingBox(effect, old.box), - })) animated_change = true; - } - } - self.committed[id] = null; - self.tracks[id] = null; - // Never let pixels from a dead pane address a reused slot. - self.shown_tracks[id] = null; - continue; - }; - const target = panelBox(p.rects[id]); - const previous = self.committed[id]; - self.committed[id] = .{ .serial = pane.serial, .box = target }; - - if (effect == .off) { - changed = changed or previous == null or previous.?.serial != pane.serial or - !previous.?.box.eql(target); - self.tracks[id] = null; - continue; - } - if (previous) |old| { - if (old.serial == pane.serial and old.box.eql(target)) continue; - const same_lifetime = old.serial == pane.serial; - const prior = if (self.tracks[id]) |track| - if (track.serial == pane.serial and track.active()) track else null - else - null; - if (effect.lifecycleOnly() and same_lifetime) { - if (prior) |active| if (active.phase == .opening) { - var next = active; - next.from = openingBox(effect, target, p.screen_w); - next.to = target; - self.tracks[id] = next; - changed = true; - animated_change = true; - continue; - }; - self.tracks[id] = null; - changed = true; - continue; - } - if (effect.lifecycleOnly() and !same_lifetime) { - _ = self.appendClosing(.{ - .serial = old.serial, - .pane = @intCast(id), - .phase = .closing, - .effect = effect, - .from = old.box, - .to = closingBox(effect, old.box), - }); - } - const shown = if (self.shown[id]) |snapshot| - if (snapshot.serial == pane.serial) snapshot.box else null - else - null; - const from = if (!same_lifetime) - openingBox(effect, target, p.screen_w) - else if (shown) |box| - box - else if (self.acknowledged and prior != null) - prior.?.from - else - old.box; - const next: Track = .{ - .serial = pane.serial, - .pane = @intCast(id), - .phase = if (!same_lifetime or - (prior != null and prior.?.phase == .opening)) .opening else .moving, - .effect = effect, - .from = from, - .to = target, - }; - self.tracks[id] = next; - changed = true; - animated_change = true; - } else { - const next: Track = .{ - .serial = pane.serial, - .pane = @intCast(id), - .phase = .opening, - .effect = effect, - .from = openingBox(effect, target, p.screen_w), - .to = target, - }; - self.tracks[id] = next; - changed = true; - animated_change = true; - } - } - if (animated_change and effect.needsPreviousGrid()) { - self.diff_state = .requested; - } - if (changed and self.acknowledged) self.pending = true; - } - - fn appendClosing(self: *Presentation, track: Track) bool { - std.debug.assert(track.phase == .closing); - const baseline = self.previous_layout[track.pane] orelse return false; - if (!self.previous_valid or baseline.serial != track.serial or - !baseline.box.eql(track.from)) return false; - if (self.closing_len == self.closing.len) { - std.mem.copyForwards( - Track, - self.closing[0 .. self.closing.len - 1], - self.closing[1..], - ); - self.closing_len -= 1; - } - self.closing[self.closing_len] = track; - self.closing_len += 1; - return true; - } - - pub fn advance(self: *Presentation) void { - for (&self.tracks) |*slot| { - const track = if (slot.*) |*track| track else continue; - track.frame +|= 1; - if (!track.active()) slot.* = null; - } - var out: usize = 0; - for (self.closing[0..self.closing_len]) |value| { - var track = value; - track.frame +|= 1; - if (!track.active()) continue; - self.closing[out] = track; - out += 1; - } - self.closing_len = out; - } - - pub fn acknowledge(self: *Presentation, p: *Pardes, tracks: []const Track) void { - var presented: [MAX_PANES]?Track = @splat(null); - var presented_closing: [MAX_PANES]Track = undefined; - var nclosing: usize = 0; - var presented_layout: [MAX_PANES]?Snapshot = if (self.submitted_ready) - self.submitted - else - @splat(null); - if (!self.submitted_ready) for (p.panes, 0..) |slot, id| { - const pane = slot orelse continue; - presented_layout[id] = .{ .serial = pane.serial, .box = panelBox(p.rects[id]) }; - }; - for (&presented_layout, 0..) |*snapshot, id| if (snapshot.*) |saved| { - const pane = p.panes[id] orelse { - snapshot.* = null; - continue; - }; - if (pane.serial != saved.serial) snapshot.* = null; - }; - for (tracks) |track| { - if (track.phase == .closing) { - const current = for (self.closing[0..self.closing_len]) |candidate| { - if (candidate.serial == track.serial and candidate.pane == track.pane and - candidate.effect == track.effect) break true; - } else false; - if (!current or !track.active() or nclosing == presented_closing.len) continue; - presented_closing[nclosing] = track; - nclosing += 1; - continue; - } - const id: usize = track.pane; - if (id >= p.panes.len or !track.active()) continue; - const pane = p.panes[id] orelse continue; - if (pane.serial != track.serial) continue; - presented[id] = track; - presented_layout[id] = .{ .serial = track.serial, .box = track.visualBox() }; - } - self.shown_tracks = presented; - self.shown_closing = presented_closing; - self.shown_closing_len = nclosing; - self.shown = presented_layout; - self.acknowledged = true; - self.pending = false; - if (tracks.len == 0) { - self.tracks = @splat(null); - self.closing_len = 0; - self.diff_state = .none; - self.capturePrevious(p.gpa, &p.surface); - } - } - - fn capturePrevious(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) void { - self.diff_state = .none; - const cells = surface.cells; - if (cells.len == 0) { - self.previous_valid = false; - self.previous_layout = @splat(null); - return; - } - if (self.previous_cells.len != cells.len) { - const next = gpa.alloc(pardes.Cell, cells.len) catch { - self.previous_valid = false; - self.previous_layout = @splat(null); - return; - }; - if (self.previous_cells.len > 0) gpa.free(self.previous_cells); - self.previous_cells = next; - } - for (&self.previous_body_layers, surface.bodyLayers()) |*old, *current| { - const count = if (current.rows > 0) current.cells.len else 0; - const storage = if (old.cells.len == count) old.cells else gpa.realloc(old.cells, count) catch { - self.previous_valid = false; - self.previous_layout = @splat(null); - return; - }; - if (count > 0) @memcpy(storage, current.cells); - old.* = current.*; - old.cells = storage; - } - for (&self.previous_tag_layers, surface.tagLayers()) |*old, *current| { - const count = if (current.cols > 0) current.cells.len else 0; - const storage = if (old.cells.len == count) old.cells else gpa.realloc(old.cells, count) catch { - self.previous_valid = false; - self.previous_layout = @splat(null); - return; - }; - if (count > 0) @memcpy(storage, current.cells); - old.* = current.*; - old.cells = storage; - } - @memcpy(self.previous_cells, cells); - self.previous_cols = surface.cols; - self.previous_rows = surface.rows; - self.previous_valid = true; - self.previous_layout = if (self.submitted_ready) - self.submitted - else - @splat(null); - } - - pub fn cellDiff(self: *const Presentation, cols: u16, rows: u16, col: u16, row: u16) pardes.PanelCellDiff { - if (self.diff_state != .ready or col >= cols or row >= rows or - self.diffs.len != @as(usize, cols) * rows) return .unchanged; - return self.diffs[@as(usize, row) * cols + col]; - } - - pub const Point = struct { x: f32, y: f32 }; - - /// Coordinates stay fractional until the host resolves its body font grid. - pub fn pointerFractional(self: *const Presentation, cols: u16, rows: u16, x: f32, y: f32) ?Point { - if (self.pending or !std.math.isFinite(x) or !std.math.isFinite(y) or - x < 0 or y < 0 or x >= @as(f32, @floatFromInt(cols)) or y >= @as(f32, @floatFromInt(rows))) return null; - var closing = self.shown_closing_len; - while (closing > 0) { - closing -= 1; - const track = self.shown_closing[closing]; - if (track.active() and boxContainsPoint(track.contentBox(), x, y) and - boxContainsPoint(track.presented(), x, y)) return null; - } - for ([_]Phase{ .opening, .moving }) |phase| { - var index = self.shown_tracks.len; - while (index > 0) { - index -= 1; - const track = self.shown_tracks[index] orelse continue; - if (!track.active() or track.phase != phase) continue; - switch (track.effect) { - .slide, .zoom, .vertical => { - const target = track.contentBox(); - if (track.effect == .vertical and !boxContainsPoint(target, x, y)) continue; - const shown = track.presented(); - if (!boxContainsPoint(shown, x, y)) { - if (track.effect == .vertical) return null; - continue; - } - return .{ - .x = target.x + (x - shown.x) / shown.w * target.w, - .y = target.y + (y - shown.y) / shown.h * target.h, - }; - }, - else => { - if (!boxContainsPoint(track.to, x, y)) continue; - _ = self.pointer(cols, rows, @intFromFloat(@floor(x)), @intFromFloat(@floor(y))) orelse return null; - return .{ .x = x, .y = y }; - }, - } - } - } - _ = self.pointer(cols, rows, @intFromFloat(@floor(x)), @intFromFloat(@floor(y))) orelse return null; - return .{ .x = x, .y = y }; - } - - fn boxContainsPoint(box: Box, x: f32, y: f32) bool { - return box.w > 0 and box.h > 0 and x >= box.x and y >= box.y and x < box.x + box.w and y < box.y + box.h; - } - - pub fn pointer(self: *const Presentation, cols: u16, rows: u16, col: u16, row: u16) ?CellPosition { - if (self.pending) return null; - var closing = self.shown_closing_len; - while (closing > 0) { - closing -= 1; - const track = self.shown_closing[closing]; - if (!track.active()) continue; - if (boxContainsCell(track.contentBox(), col, row) and - boxContainsCell(track.presented(), col, row)) return null; - } - const phases = [_]Phase{ .opening, .moving }; - for (phases) |phase| { - // Backends paint pane slots forward within a phase. Probe them in - // reverse so overlapping transition quads address the top pixel. - var index = self.shown_tracks.len; - while (index > 0) { - index -= 1; - const track = self.shown_tracks[index] orelse continue; - if (!track.active() or track.phase != phase) continue; - switch (track.effect) { - .slide, .zoom => { - const shown = track.presented(); - if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) continue; - const x = @as(f32, @floatFromInt(col)) + 0.5; - const y = @as(f32, @floatFromInt(row)) + 0.5; - const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999); - const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999); - const logical_x: i32 = @intFromFloat(@floor(track.to.x + u * track.to.w)); - const logical_y: i32 = @intFromFloat(@floor(track.to.y + v * track.to.h)); - return .{ - .col = @intCast(std.math.clamp(logical_x, 0, @as(i32, cols -| 1))), - .row = @intCast(std.math.clamp(logical_y, 0, @as(i32, rows -| 1))), - }; - }, - .vertical => { - const clip = track.contentBox(); - if (!boxContainsCell(clip, col, row)) continue; - const shown = track.presented(); - if (shown.w <= 0 or shown.h <= 0 or !boxContainsCell(shown, col, row)) - return null; - const x = @as(f32, @floatFromInt(col)) + 0.5; - const y = @as(f32, @floatFromInt(row)) + 0.5; - const u = std.math.clamp((x - shown.x) / shown.w, 0, 0.999_999); - const v = std.math.clamp((y - shown.y) / shown.h, 0, 0.999_999); - return .{ - .col = @intFromFloat(@floor(clip.x + u * clip.w)), - .row = @intFromFloat(@floor(clip.y + v * clip.h)), - }; - }, - .dissolve, .ascii => { - if (!boxContainsCell(track.to, col, row)) continue; - const cell_diff = self.cellDiff(cols, rows, col, row); - if (!cell_diff.changed()) - return .{ .col = col, .row = row }; - const relative_col: u16 = @intFromFloat(@floor( - @as(f32, @floatFromInt(col)) + 0.5 - track.to.x, - )); - const relative_row: u16 = @intFromFloat(@floor( - @as(f32, @floatFromInt(row)) + 0.5 - track.to.y, - )); - const visible = switch (track.effect) { - .dissolve => dissolveRevealed( - track.serial, - relative_col, - relative_row, - track.amount(), - ), - .ascii => switch (cell_diff) { - .ascii => |diff| diff.complete(track.frame), - .unchanged, .visual => true, - }, - else => unreachable, - }; - return if (visible) .{ .col = col, .row = row } else null; - }, - .edges, .fall, .wave, .curtain, .scramble, .typewriter => { - if (!boxContainsCell(track.to, col, row)) continue; - const area = CellArea.of(track.to); - const settled = std.meta.eql( - charSource( - track, - col -| area.x0, - row -| area.y0, - area, - ), - CharSource.settled, - ); - return if (settled) .{ .col = col, .row = row } else null; - }, - .off => {}, - } - } - } - for (self.shown_tracks) |maybe| { - const track = maybe orelse continue; - if (!track.active() or (track.effect != .slide and track.effect != .zoom and - track.effect != .vertical)) continue; - if (boxContainsCell(track.to, col, row)) return null; - } - return .{ .col = col, .row = row }; - } - - fn boxContainsCell(box: Box, col: u16, row: u16) bool { - const x = @as(f32, @floatFromInt(col)) + 0.5; - const y = @as(f32, @floatFromInt(row)) + 0.5; - return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h; - } - - pub fn cancel(self: *Presentation) void { - self.tracks = @splat(null); - self.closing_len = 0; - self.shown_tracks = @splat(null); - self.shown_closing_len = 0; - self.diff_state = .none; - self.pending = self.acknowledged; - } - - fn prepareDiff(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) !bool { - const count = surface.cells.len; - if (!self.previous_valid or - self.previous_cols != surface.cols or - self.previous_rows != surface.rows or - self.previous_cells.len != count) return false; - if (self.diffs.len != count) { - const next = try gpa.alloc(pardes.PanelCellDiff, count); - if (self.diffs.len > 0) gpa.free(self.diffs); - self.diffs = next; - } - for (self.diffs, self.previous_cells, surface.cells) |*diff, *old, *new| - diff.* = pardes.PanelCellDiff.between(old, new); - for (&self.tracks) |*slot| { - const track = if (slot.*) |*track| track else continue; - if (track.effect != .ascii) continue; - var longest: u16 = 1; - var row: u16 = 0; - while (row < surface.rows) : (row += 1) { - var col: u16 = 0; - while (col < surface.cols) : (col += 1) { - if (!boxContainsCell(track.to, col, row)) continue; - const index = @as(usize, row) * surface.cols + col; - switch (self.diffs[index]) { - .ascii => |diff| longest = @max(longest, diff.frameCount()), - .unchanged, .visual => {}, - } - } - } - track.frame_count = @max(track.frame_count, longest); - } - self.diff_state = .ready; - return true; - } - - pub fn submit(self: *Presentation, p: *Pardes, s: *pardes.Surface) !void { - self.submitted = @splat(null); - for (p.panes, 0..) |slot, id| { - const pane = slot orelse continue; - self.submitted[id] = .{ - .serial = pane.serial, - .box = panelBox(p.rects[id]), - }; - } - self.submitted_ready = true; - if (self.diff_state != .none and !try self.prepareDiff(p.gpa, s)) { - for (&self.tracks) |*slot| { - const track = slot.* orelse continue; - if (track.effect.needsPreviousGrid()) slot.* = null; - } - self.closing_len = 0; - self.diff_state = .none; - } - if (self.diff_state == .ready) { - s.previous_cells = self.previous_cells; - s.previous_body_layers = &self.previous_body_layers; - s.previous_tag_layers = &self.previous_tag_layers; - s.cell_diffs = self.diffs; - } - s.npanel_tracks = paintOrder( - &self.tracks, - self.closing[0..self.closing_len], - &s.panel_tracks, - ); - } - - pub fn animating(self: *const Presentation) bool { - for (self.tracks) |track| if (track != null and track.?.active()) return true; - for (self.closing[0..self.closing_len]) |track| - if (track.active()) return true; - return false; - } - - test "presentation cache allocation failures retain ownership and allow retry" { - var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{}); - const gpa = allocator.allocator(); - var state: Presentation = .{}; - defer state.deinit(gpa); - var cells: [3]pardes.Cell = @splat(.{}); - cells[0].default = false; - cells[0].text[0] = 'a'; - var surface: pardes.Surface = .{ .cols = 2, .rows = 1, .cells = cells[0..2] }; - state.submitted[0] = .{ .serial = 7, .box = .{ .x = 0, .y = 0, .w = 2, .h = 1 } }; - state.submitted_ready = true; - state.capturePrevious(gpa, &surface); - try std.testing.expect(state.previous_valid); - try std.testing.expectEqual(@as(u32, 7), state.previous_layout[0].?.serial); - const previous = state.previous_cells.ptr; - const allocated = allocator.allocations; - allocator.fail_index = allocator.alloc_index; - state.capturePrevious(gpa, &surface); - try std.testing.expect(state.previous_valid); - try std.testing.expectEqual(allocated, allocator.allocations); - try std.testing.expect(!allocator.has_induced_failure); - - surface.cols = 3; - surface.cells = &cells; - state.submitted[0].?.box.w = 3; - state.diff_state = .ready; - state.capturePrevious(gpa, &surface); - try std.testing.expect(allocator.has_induced_failure); - try std.testing.expect(!state.previous_valid); - try std.testing.expectEqual(previous, state.previous_cells.ptr); - try std.testing.expectEqual(@as(usize, 2), state.previous_cells.len); - try std.testing.expectEqual(@as(u8, 'a'), state.previous_cells[0].text[0]); - try std.testing.expect(state.previous_layout[0] == null); - try std.testing.expectEqual(.none, state.diff_state); - - allocator.fail_index = std.math.maxInt(usize); - state.capturePrevious(gpa, &surface); - try std.testing.expect(state.previous_valid); - try std.testing.expectEqual(@as(usize, 3), state.previous_cells.len); - try std.testing.expectEqual(@as(f32, 3), state.previous_layout[0].?.box.w); - state.diff_state = .requested; - allocator.fail_index = allocator.alloc_index; - try std.testing.expectError(error.OutOfMemory, state.prepareDiff(gpa, &surface)); - try std.testing.expectEqual(.requested, state.diff_state); - try std.testing.expectEqual(@as(usize, 0), state.diffs.len); - allocator.fail_index = std.math.maxInt(usize); - try std.testing.expect(try state.prepareDiff(gpa, &surface)); - try std.testing.expectEqual(.ready, state.diff_state); - try std.testing.expectEqual(@as(usize, 3), state.diffs.len); - for (state.diffs) |diff| try std.testing.expectEqual(.unchanged, diff); - } - - test "presentation submission failure preserves shown geometry until retry is acknowledged" { - var allocator: std.testing.FailingAllocator = .init(std.testing.allocator, .{}); - const p = try Pardes.init(allocator.allocator(), .{ .tty_only = true, .cols = 40, .rows = 12 }); - defer p.deinit(); - var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); - defer frame.deinit(); - _ = try p.render(frame.allocator()); - p.acknowledgePanelPresentation(&.{}); - const shown = p.presentation.shown; - const previous = p.presentation.previous_cells.ptr; - p.presentation.diff_state = .requested; - p.presentation.pending = true; - allocator.fail_index = allocator.alloc_index; - try std.testing.expectError(error.OutOfMemory, p.presentation.submit(p, &p.surface)); - try std.testing.expectEqualDeep(shown, p.presentation.shown); - try std.testing.expectEqual(previous, p.presentation.previous_cells.ptr); - try std.testing.expectEqual(.requested, p.presentation.diff_state); - try std.testing.expect(p.presentation.pending); - try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 5, 3) == null); - - allocator.fail_index = std.math.maxInt(usize); - try p.presentation.submit(p, &p.surface); - try std.testing.expectEqualDeep(shown, p.presentation.shown); - try std.testing.expect(p.presentation.pending); - p.acknowledgePanelPresentation(&.{}); - try std.testing.expect(!p.presentation.pending); - try std.testing.expectEqual(CellPosition{ .col = 5, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 5, 3).?); - } -}; - pub const column_weight_unit: u64 = 1 << 32; pub const max_column_weight: u64 = std.math.maxInt(u64) / MAX_COLS; @@ -1250,8 +600,6 @@ pub fn compute(p: *Pardes) void { } } -pub const ascii_max_movement_frames: u16 = 12; - test "collapse transfers height upward and restores the original layout" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 32 }); defer p.deinit(); @@ -1457,789 +805,6 @@ test "collapse dump restores folded state and retained expansion weight" { try std.testing.expectEqual(restored.screen_h - restored.rects[0].y, restored.rects[0].h); } -pub const Easing = enum(u8) { - linear, - smooth, - /// Quintic ease-in-out. It creeps at both ends and crosses the middle of - /// the distance fast, inside the same frame count a linear walk would use. - smoother, - in_cubic, - out_cubic, - out_back, -}; - -pub const Transition = enum(u8) { - // Numeric values are shared with the GUI shader ABI. - off = 0, - slide = 1, - zoom = 2, - dissolve = 3, - ascii = 4, - vertical = 5, - edges = 6, - fall = 7, - wave = 8, - curtain = 9, - scramble = 10, - typewriter = 11, - - pub fn easing(effect: Transition) Easing { - return switch (effect) { - .off, .dissolve, .wave => .smooth, - .slide, .vertical, .edges => .out_cubic, - .zoom => .out_back, - // Character walks and per-cell locks read best with a slow start, - // a fast middle, and a slow settle over their fixed frame count. - .ascii, .fall, .scramble => .smoother, - // A sweep and a typewriter are constant-rate by definition: easing - // their head would make the pass visibly hesitate mid-pane. - .curtain, .typewriter => .linear, - }; - } - - pub fn frames(effect: Transition) u16 { - return switch (effect) { - .off => 0, - .slide => 12, - .zoom => 14, - .dissolve => 10, - .ascii => ascii_max_movement_frames + 1, - .vertical => 12, - .edges, .curtain, .scramble => 12, - // Travelling motion needs a couple more samples than a lock or a - // rigid slide before it stops reading as a jump. - .fall, .wave, .typewriter => 14, - }; - } - - pub fn composedByCore(effect: Transition) bool { - return switch (effect) { - .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => true, - .off, .slide, .zoom, .dissolve, .vertical => false, - }; - } - - pub fn needsPreviousGrid(effect: Transition) bool { - return effect == .dissolve or effect == .vertical or effect.composedByCore(); - } - - pub fn lifecycleOnly(effect: Transition) bool { - return effect == .vertical; - } -}; - -pub const SceneEffect = struct { - crt: bool = false, - ripple: bool = false, - glitch: bool = false, -}; - -pub const Phase = enum(u8) { - opening = 0, - moving = 1, - /// Presentation-only content whose pane lifetime has already ended. - /// It is never a valid input target. - closing = 2, -}; - -pub const Box = extern struct { - x: f32 = 0, - y: f32 = 0, - w: f32 = 0, - h: f32 = 0, - - pub fn eql(a: Box, b: Box) bool { - return a.x == b.x and a.y == b.y and a.w == b.w and a.h == b.h; - } - - // A cell belongs to a fractional box when its center lies inside. - pub fn contains(box: Box, col: u16, row: u16) bool { - const x: f32 = @floatFromInt(col); - const y: f32 = @floatFromInt(row); - return x >= box.x and x < box.x + box.w and y >= box.y and y < box.y + box.h; - } -}; - -// Drawing and hit testing share this moving/opening/closing order. -pub fn paintOrder(live: []const ?Track, closing: []const Track, out: []Track) usize { - var len: usize = 0; - for ([_]Phase{ .moving, .opening }) |phase| for (live) |maybe| { - const track = maybe orelse continue; - if (!track.active() or track.phase != phase) continue; - if (len == out.len) return len; - out[len] = track; - len += 1; - }; - for (closing) |track| { - if (!track.active()) continue; - if (len == out.len) return len; - out[len] = track; - len += 1; - } - return len; -} - -/// One POD record is enough for every backend. `from` and `to` are logical -/// cell boxes; frontends convert them to pixels only at their render edge. -pub const Track = extern struct { - serial: u32 = 0, - pane: u8 = 0, - phase: Phase = .moving, - effect: Transition = .off, - _padding: u8 = 0, - frame: u16 = 0, - frame_count: u16 = 0, - from: Box = .{}, - to: Box = .{}, - - pub fn active(track: Track) bool { - return track.effect != .off and track.frame < track.frames(); - } - - pub fn frames(track: Track) u16 { - return if (track.frame_count != 0) track.frame_count else track.effect.frames(); - } - - pub fn amount(track: Track) f32 { - // Opening rises quickly and settles; closing reverses that motion and - // accelerates down out of the fixed clip. - if (track.phase == .closing and track.effect == .vertical) - return progressEased(.in_cubic, track.frames(), track.frame); - return progressEased(track.effect.easing(), track.frames(), track.frame); - } - - pub fn presented(track: Track) Box { - return lerpBox(track.from, track.to, track.amount()); - } - - pub fn visualBox(track: Track) Box { - return switch (track.effect) { - .slide, .zoom, .vertical => track.presented(), - .off, .dissolve => track.to, - // Every character effect stays inside the pane's final rectangle. - .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => track.to, - }; - } - - pub fn contentBox(track: Track) Box { - return if (track.phase == .closing) track.from else track.to; - } -}; - -pub fn openingBox(effect: Transition, target: Box, screen_width: u16) Box { - return switch (effect) { - .slide => blk: { - var from = target; - const middle = target.x + target.w * 0.5; - from.x = if (middle < @as(f32, @floatFromInt(screen_width)) * 0.5) - -target.w - else - @floatFromInt(screen_width); - break :blk from; - }, - .zoom => .{ - .x = target.x + target.w * 0.5, - .y = target.y + target.h * 0.5, - .w = 0, - .h = 0, - }, - .vertical => blk: { - var from = target; - from.y += target.h; - break :blk from; - }, - .off, .dissolve => target, - // Character effects own the glyphs inside a fixed rectangle, so their - // panel opens at exactly its final geometry. - .ascii, .edges, .fall, .wave, .curtain, .scramble, .typewriter => target, - }; -} - -pub fn closingBox(effect: Transition, source: Box) Box { - return switch (effect) { - .vertical => blk: { - var to = source; - to.y += source.h; - break :blk to; - }, - else => source, - }; -} - -pub fn sample(easing: Easing, raw: f32) f32 { - const t = std.math.clamp(raw, 0.0, 1.0); - return switch (easing) { - .linear => t, - .smooth => t * t * (3.0 - 2.0 * t), - .smoother => t * t * t * (t * (6.0 * t - 15.0) + 10.0), - .in_cubic => t * t * t, - .out_cubic => 1.0 - (1.0 - t) * (1.0 - t) * (1.0 - t), - // Robert Penner's ease-out-back polynomial. It intentionally travels - // a little past one before settling exactly on the endpoint. - .out_back => blk: { - const c1: f32 = 1.70158; - const c3 = c1 + 1.0; - const u = t - 1.0; - break :blk 1.0 + c3 * u * u * u + c1 * u * u; - }, - }; -} - -pub fn progress(effect: Transition, frame: u16) f32 { - return progressEased(effect.easing(), effect.frames(), frame); -} - -fn progressEased(easing: Easing, frames: u16, frame: u16) f32 { - if (frames <= 1 or frame >= frames - 1) return 1.0; - return sample(easing, @as(f32, @floatFromInt(frame)) / @as(f32, @floatFromInt(frames - 1))); -} - -pub fn lerpBox(from: Box, to: Box, t: f32) Box { - const u = @max(0.0, t); - return .{ - .x = from.x + (to.x - from.x) * u, - .y = from.y + (to.y - from.y) * u, - .w = @max(0.0, from.w + (to.w - from.w) * u), - .h = @max(0.0, from.h + (to.h - from.h) * u), - }; -} - -/// Stable cell noise shared by the TTY reveal and shader ports. Integer-only -/// hashing means resizing or repainting a frame does not make cells flicker. -pub fn cellNoise(serial: u32, col: u16, row: u16) f32 { - var x = serial ^ (@as(u32, col) *% 0x9e37_79b9) ^ (@as(u32, row) *% 0x85eb_ca6b); - x ^= x >> 16; - x *%= 0x7feb_352d; - x ^= x >> 15; - x *%= 0x846c_a68b; - x ^= x >> 16; - return @as(f32, @floatFromInt(x & 0xffff)) / 65535.0; -} - -/// Whether a changed dissolve cell has crossed from the frozen old grid to -/// the new one. Exact endpoints are part of the presentation contract. -pub fn dissolveRevealed(serial: u32, col: u16, row: u16, raw_progress: f32) bool { - const t = std.math.clamp(raw_progress, 0.0, 1.0); - if (t <= 0) return false; - if (t >= 1) return true; - return cellNoise(serial, col, row) < t; -} - -pub const CellArea = struct { - x0: u16 = 0, - y0: u16 = 0, - cols: u16 = 1, - rows: u16 = 1, - - pub fn of(box: Box) CellArea { - return .{ - .x0 = floorCell(box.x), - .y0 = floorCell(box.y), - .cols = ceilCell(box.w), - .rows = ceilCell(box.h), - }; - } -}; - -fn floorCell(value: f32) u16 { - return @intFromFloat(std.math.clamp(@floor(value), 0.0, @as(f32, std.math.maxInt(u16)))); -} - -fn ceilCell(value: f32) u16 { - return @intFromFloat(std.math.clamp(@ceil(value), 1.0, @as(f32, std.math.maxInt(u16)))); -} - -pub const CharSource = union(enum) { - /// Nothing has arrived here yet: keep the frozen old cell. - old, - at: Offset, - /// Paint this printable byte in the destination cell's own style, whatever - /// that cell holds — a caret marching over empty space is still a caret. - byte: u8, - churn: u8, - - pub const Offset = struct { cols: i32 = 0, rows: i32 = 0 }; - - pub const settled: CharSource = .{ .at = .{} }; -}; - -pub fn charSource(track: Track, col: u16, row: u16, area: CellArea) CharSource { - const t = track.amount(); - if (t >= 1.0) return .settled; - const w: f32 = @floatFromInt(area.cols); - const h: f32 = @floatFromInt(area.rows); - const c: f32 = @floatFromInt(col); - const r: f32 = @floatFromInt(row); - const remaining = 1.0 - t; - return switch (track.effect) { - .edges => blk: { - const travel = cellsOf(remaining * (w + 1.0)); - break :blk .{ .at = .{ .cols = if (row % 2 == 0) travel else -travel } }; - }, - // Columns rain down, each with its own stable head start, so the pane - // fills from the top and the last glyphs land at the bottom. - .fall => blk: { - const local = staggered(t, cellNoise(track.serial, col, 0) * 0.4); - if (local <= 0.0) break :blk .old; - break :blk .{ .at = .{ .rows = cellsOf((1.0 - local) * (h + 1.0)) } }; - }, - // A vertical ripple travels left to right and its amplitude decays, so - // the pane settles out of a wave instead of a fade. - .wave => .{ .at = .{ - .rows = cellsOf(remaining * @min(4.0, h) * @sin(c * 0.55 - t * 9.0)), - } }, - // A curtain of glyphs marches in from the right, column by column, left - // to right; each column still has a short slide of its own. - .curtain => blk: { - const lead = t * (w + 1.0) - c; - if (lead <= 0.0) break :blk .old; - break :blk .{ .at = .{ .cols = -cellsOf(@max(0.0, 3.0 - lead)) } }; - }, - // Every cell churns through printable ASCII and locks onto its final - // glyph at its own stable threshold: the pane resolves out of noise. - .scramble => blk: { - if (t >= cellNoise(track.serial, col, row) * 0.8) break :blk .settled; - const churn = cellNoise( - track.serial ^ (@as(u32, track.frame) *% 0x27d4_eb2f), - col, - row, - ); - break :blk .{ .churn = @intCast(33 + @min(93, @as(u32, @intFromFloat(churn * 94.0)))) }; - }, - // Reading-order reveal with a caret sitting on the write head. - .typewriter => blk: { - const head = t * w * h; - const index = r * w + c; - if (index + 1.0 <= head) break :blk .settled; - if (index <= head) break :blk .{ .byte = '_' }; - break :blk .old; - }, - // PanelAscii walks its own byte distance per cell, and the geometry - // effects never reach this path at all. - .off, .slide, .zoom, .dissolve, .vertical, .ascii => .settled, - }; -} - -fn cellsOf(distance: f32) i32 { - return @intFromFloat(@round(std.math.clamp(distance, -65535.0, 65535.0))); -} - -/// Remap track progress into one cell's own window. A stagger delays a glyph -/// without making the effect as a whole end after its last frame. -fn staggered(t: f32, delay: f32) f32 { - if (delay >= 1.0) return t; - return (t - delay) / (1.0 - delay); -} - -test "easing presets have exact endpoints and intended shapes" { - inline for (std.enums.values(Easing)) |easing| { - try std.testing.expectEqual(@as(f32, 0), sample(easing, 0)); - try std.testing.expectEqual(@as(f32, 1), sample(easing, 1)); - } - try std.testing.expectEqual(@as(f32, 0.5), sample(.linear, 0.5)); - try std.testing.expect(sample(.in_cubic, 0.5) < sample(.linear, 0.5)); - try std.testing.expect(sample(.out_cubic, 0.5) > sample(.linear, 0.5)); - try std.testing.expect(sample(.out_back, 0.8) > 1.0); - // Slow at both ends, fast through the middle, and symmetric about the - // halfway point: the same curve the integer byte walk reproduces. - try std.testing.expectEqual(@as(f32, 0.5), sample(.smoother, 0.5)); - try std.testing.expect(sample(.smoother, 0.15) < sample(.smooth, 0.15)); - try std.testing.expect(sample(.smoother, 0.85) > sample(.smooth, 0.85)); - try std.testing.expect(sample(.smoother, 0.6) - sample(.smoother, 0.4) > - sample(.linear, 0.6) - sample(.linear, 0.4)); -} - -test "transition progress completes exactly" { - inline for (std.enums.values(Transition)) |effect| { - try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames())); - if (effect.frames() > 0) - try std.testing.expectEqual(@as(f32, 1), progress(effect, effect.frames() - 1)); - try std.testing.expectEqual(@as(f32, 1), progress(effect, std.math.maxInt(u16))); - } - try std.testing.expectEqual(@as(f32, 1), progress(.off, 0)); - - const shrinking = lerpBox(.{ .w = 100, .h = 40 }, .{}, sample(.out_back, 0.8)); - try std.testing.expectEqual(@as(f32, 0), shrinking.w); - try std.testing.expectEqual(@as(f32, 0), shrinking.h); - const opening = lerpBox(.{}, .{ .w = 100, .h = 40 }, sample(.out_back, 0.8)); - try std.testing.expect(opening.w > 100); - try std.testing.expect(opening.h > 40); -} - -test "character effects are core-composed and settle on the canonical glyph" { - const box: Box = .{ .x = 4, .y = 2, .w = 20, .h = 6 }; - const area: CellArea = .of(box); - try std.testing.expectEqual(@as(u16, 4), area.x0); - try std.testing.expectEqual(@as(u16, 2), area.y0); - try std.testing.expectEqual(@as(u16, 20), area.cols); - try std.testing.expectEqual(@as(u16, 6), area.rows); - - inline for (std.enums.values(Transition)) |effect| { - if (comptime !effect.composedByCore()) continue; - // Core composition needs the frozen old grid for every glyph which has - // not arrived, so no character effect may animate without it. - try std.testing.expect(effect.needsPreviousGrid()); - if (comptime effect == .ascii) continue; // owns its own per-cell byte walk - - const last: Track = .{ .effect = effect, .frame = effect.frames() - 1, .to = box }; - const first: Track = .{ .effect = effect, .frame = 0, .to = box }; - var moving = false; - var row: u16 = 0; - while (row < area.rows) : (row += 1) { - var col: u16 = 0; - while (col < area.cols) : (col += 1) { - // The last active sample is the exact canonical grid: no cell - // is displaced, churning, or still frozen. - try std.testing.expectEqual(CharSource.settled, charSource(last, col, row, area)); - if (!std.meta.eql(CharSource.settled, charSource(first, col, row, area))) - moving = true; - } - } - try std.testing.expect(moving); - } -} - -test "each character effect moves glyphs along its own axis" { - const box: Box = .{ .w = 30, .h = 8 }; - const area: CellArea = .of(box); - - // Rows alternate which screen edge they come from, and every glyph in a row - // travels as one rigid slide: one offset, no vertical component. - var edges: Track = .{ .effect = .edges, .frame = 2, .to = box }; - const even = charSource(edges, 5, 0, area).at; - const odd = charSource(edges, 5, 1, area).at; - try std.testing.expect(even.cols > 0); - try std.testing.expectEqual(-even.cols, odd.cols); - try std.testing.expectEqual(@as(i32, 0), even.rows); - try std.testing.expectEqual(even, charSource(edges, 17, 0, area).at); - edges.frame = 5; - try std.testing.expect(charSource(edges, 5, 0, area).at.cols < even.cols); - - // Falling columns are vertical only, staggered, and sample from below the - // destination because the new text is still above the pane. - const fall: Track = .{ .effect = .fall, .frame = 4, .to = box }; - var falling = false; - var col: u16 = 0; - while (col < area.cols) : (col += 1) switch (charSource(fall, col, 0, area)) { - .old => {}, - .byte, .churn => return error.FallShouldNotChurn, - .at => |offset| { - try std.testing.expectEqual(@as(i32, 0), offset.cols); - try std.testing.expect(offset.rows >= 0); - if (offset.rows > 0) falling = true; - }, - }; - try std.testing.expect(falling); - - // The wave displaces rows both ways as it travels, and only rows. - const wave: Track = .{ .effect = .wave, .frame = 1, .to = box }; - var above = false; - var below = false; - col = 0; - while (col < area.cols) : (col += 1) { - const offset = charSource(wave, col, 3, area).at; - try std.testing.expectEqual(@as(i32, 0), offset.cols); - if (offset.rows < 0) above = true; - if (offset.rows > 0) below = true; - } - try std.testing.expect(above and below); - - // The curtain has a head: columns behind it hold the old grid, columns the - // head has passed are settled, and the head itself is still sliding. - const curtain: Track = .{ .effect = .curtain, .frame = 5, .to = box }; - try std.testing.expectEqual(CharSource.settled, charSource(curtain, 0, 0, area)); - try std.testing.expectEqual(CharSource{ .old = {} }, charSource(curtain, 29, 0, area)); - var sliding = false; - col = 0; - while (col < area.cols) : (col += 1) switch (charSource(curtain, col, 0, area)) { - .at => |offset| if (offset.cols < 0) { - sliding = true; - }, - .old, .byte, .churn => {}, - }; - try std.testing.expect(sliding); - - var scramble: Track = .{ .effect = .scramble, .frame = 3, .to = box }; - var churning: usize = 0; - var locked: usize = 0; - var changed = false; - col = 0; - while (col < area.cols) : (col += 1) switch (charSource(scramble, col, 0, area)) { - .churn => |byte| { - try std.testing.expect(byte >= ' ' and byte <= '~'); - churning += 1; - scramble.frame = 4; - switch (charSource(scramble, col, 0, area)) { - .churn => |next| changed = changed or next != byte, - .old, .at, .byte => {}, - } - scramble.frame = 3; - }, - .at => locked += 1, - .old, .byte => return error.ScrambleShouldNotFreeze, - }; - try std.testing.expect(churning > 0 and locked > 0 and changed); - - // The typewriter writes in reading order with a caret on its head. - const typewriter: Track = .{ .effect = .typewriter, .frame = 7, .to = box }; - try std.testing.expectEqual(CharSource.settled, charSource(typewriter, 0, 0, area)); - try std.testing.expectEqual( - CharSource{ .old = {} }, - charSource(typewriter, area.cols - 1, area.rows - 1, area), - ); - var carets: usize = 0; - var row: u16 = 0; - while (row < area.rows) : (row += 1) { - col = 0; - while (col < area.cols) : (col += 1) switch (charSource(typewriter, col, row, area)) { - .byte => |byte| { - try std.testing.expectEqual(@as(u8, '_'), byte); - carets += 1; - }, - .old, .at, .churn => {}, - }; - } - try std.testing.expectEqual(@as(usize, 1), carets); -} - -test "opening presets separate geometry and content transitions" { - const target: Box = .{ .x = 30, .y = 2, .w = 20, .h = 8 }; - try std.testing.expectEqual(target, openingBox(.ascii, target, 80)); - try std.testing.expectEqual(@as(f32, 0), openingBox(.zoom, target, 80).w); - try std.testing.expectEqual(@as(f32, 80), openingBox(.slide, target, 80).x); - try std.testing.expectEqual(@as(f32, target.y + target.h), openingBox(.vertical, target, 80).y); - try std.testing.expectEqual(@as(f32, target.y + target.h), closingBox(.vertical, target).y); - - var track: Track = .{ .effect = .slide, .from = target, .to = target }; - try std.testing.expect(track.active()); - track.frame = track.effect.frames(); - try std.testing.expect(!track.active()); - - track = .{ .effect = .dissolve, .frame = 3, .from = .{}, .to = target }; - try std.testing.expectEqual(target, track.visualBox()); - - var closing: Track = .{ - .phase = .closing, - .effect = .vertical, - .from = target, - .to = closingBox(.vertical, target), - }; - try std.testing.expectEqual(target, closing.contentBox()); - closing.frame = 2; - try std.testing.expect(closing.amount() < progress(.vertical, closing.frame)); -} - -test "dissolve has exact stable endpoints" { - for (0..64) |col| { - const x: u16 = @intCast(col); - try std.testing.expect(!dissolveRevealed(42, x, 7, 0)); - try std.testing.expect(dissolveRevealed(42, x, 7, 1)); - if (dissolveRevealed(42, x, 7, 0.25)) - try std.testing.expect(dissolveRevealed(42, x, 7, 0.75)); - } -} - -pub const Animation = struct { - pub const frame_ms: u32 = 16; - pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms; - pub const transition_steps: u16 = 10; - - pub fn Transition(comptime Value: type) type { - return struct { - const Self = @This(); - - from: Value, - to: Value, - displayed: Value, - step: u16 = transition_steps, - - pub fn init(value: Value) Self { - return .{ .from = value, .to = value, .displayed = value }; - } - - pub fn isActive(a: *const Self) bool { - return a.step < transition_steps; - } - - pub fn retarget(a: *Self, target: Value) void { - 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; - } - - pub fn advance(a: *Self) void { - if (!a.isActive()) return; - a.step += 1; - a.displayed = if (a.step == transition_steps) - a.to - else - Value.interpolate(a.from, a.to, a.step, transition_steps); - } - - pub fn snap(a: *Self, value: Value) void { - a.* = init(value); - } - }; - } - - pub fn Immediate(comptime Value: type) type { - return struct { - const Self = @This(); - - displayed: Value, - - pub fn init(value: Value) Self { - return .{ .displayed = value }; - } - - pub fn isActive(_: *const Self) bool { - return false; - } - - pub fn retarget(a: *Self, target: Value) void { - a.displayed = target; - } - - pub fn advance(_: *Self) void {} - - pub fn snap(a: *Self, value: Value) void { - a.displayed = value; - } - }; - } - - pub fn interpolateRgb(from: [3]u8, to: [3]u8, step: u16, steps: u16) [3]u8 { - if (step == 0) return from; - if (step >= steps) return to; - var out: [3]u8 = undefined; - for (&out, from, to) |*dst, a, b| { - const numerator = @as(u32, a) * (steps - step) + @as(u32, b) * step; - dst.* = @intCast((numerator + steps / 2) / steps); - } - return out; - } - - const TestColor = struct { - rgb: [3]u8, - - pub fn interpolate(from: TestColor, to: TestColor, step: u16, steps: u16) TestColor { - return .{ .rgb = interpolateRgb(from.rgb, to.rgb, step, steps) }; - } - }; - - test "Immediate lands where a completed Transition lands" { - const from: TestColor = .{ .rgb = .{ 240, 3, 90 } }; - const to: TestColor = .{ .rgb = .{ 5, 222, 90 } }; - - var faded = Animation.Transition(TestColor).init(from); - faded.retarget(to); - for (0..transition_steps) |_| faded.advance(); - - var instant = Immediate(TestColor).init(from); - try std.testing.expect(!instant.isActive()); - instant.retarget(to); - try std.testing.expectEqual(faded.displayed, instant.displayed); - - // Never active, so a frontend that renders only while something is animating stops immediately - // rather than spending ten frames discovering there is nothing to draw. - try std.testing.expect(!instant.isActive()); - instant.advance(); - try std.testing.expectEqual(to, instant.displayed); - - instant.snap(from); - try std.testing.expectEqual(from, instant.displayed); - } - - test "fixed-step interpolation has exact monotonic endpoints" { - const Tween = Animation.Transition(TestColor); - const from: TestColor = .{ .rgb = .{ 240, 3, 90 } }; - const to: TestColor = .{ .rgb = .{ 5, 222, 90 } }; - var tween = Tween.init(from); - tween.retarget(to); - try std.testing.expectEqual(from, tween.displayed); - - var previous = tween.displayed; - for (0..transition_steps) |_| { - tween.advance(); - try std.testing.expect(tween.displayed.rgb[0] <= previous.rgb[0]); - try std.testing.expect(tween.displayed.rgb[1] >= previous.rgb[1]); - try std.testing.expectEqual(@as(u8, 90), tween.displayed.rgb[2]); - previous = tween.displayed; - } - try std.testing.expect(!tween.isActive()); - try std.testing.expectEqual(to, tween.displayed); - tween.advance(); - try std.testing.expectEqual(to, tween.displayed); - } - - test "retarget starts at the currently displayed value" { - const Tween = Animation.Transition(TestColor); - const first: TestColor = .{ .rgb = .{ 0, 40, 200 } }; - const second: TestColor = .{ .rgb = .{ 200, 140, 0 } }; - const third: TestColor = .{ .rgb = .{ 20, 10, 250 } }; - var tween = Tween.init(first); - tween.retarget(second); - tween.advance(); - tween.advance(); - tween.advance(); - const on_screen = tween.displayed; - - tween.retarget(third); - try std.testing.expectEqual(on_screen, tween.from); - try std.testing.expectEqual(on_screen, tween.displayed); - try std.testing.expect(tween.isActive()); - for (0..transition_steps) |_| tween.advance(); - try std.testing.expectEqual(third, tween.displayed); - } -}; - -test "fractional panel inverse preserves compact header rows through slide and zoom" { - const layer: pardes.BodyLayer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 30, .h = 10 }, .cols = 30, .rows = 15, .context_rows = 4 }; - for ([_]Transition{ .slide, .zoom, .vertical }) |effect| { - var presentation: Presentation = .{}; - const track: Track = .{ .serial = 7, .effect = effect, .frame = 4, .frame_count = 10, .from = .{ .x = 2, .y = 2, .w = 20, .h = 8 }, .to = .{ .x = 3, .y = 2, .w = 32, .h = 12 } }; - presentation.shown_tracks[0] = track; - const shown = track.presented(); - for (0..4) |index| { - const row: u16 = @intCast(index); - const source_x: f32 = 5.3; - const source_y = (layer.rowTop(row, 20, 10) + 5) / 20; - const x = shown.x + (source_x - track.to.x) / track.to.w * shown.w; - const y = shown.y + (source_y - track.to.y) / track.to.h * shown.h; - const point = presentation.pointerFractional(80, 30, x, y).?; - try std.testing.expectApproxEqAbs(source_x, point.x, 0.0001); - try std.testing.expectApproxEqAbs(source_y, point.y, 0.0001); - const body_hit = layer.hitAt(point.x * 10, point.y * 20, 10, 20, 8, 10).?; - try std.testing.expectEqual(row, body_hit.row); - } - } -} - -test "presentation snapshots own compact body and tag rows across later edits" { - var state: Presentation = .{}; - defer state.deinit(std.testing.allocator); - var cells: [1]pardes.Cell = .{.{}}; - var body: [2]pardes.Cell = .{ .{}, .{} }; - body[0].text[0] = 'A'; - var surface: pardes.Surface = .{ .cols = 1, .rows = 1, .cells = &cells }; - surface.body_layers[0] = .{ .serial = 9, .cols = 1, .rows = 2, .context_rows = 1, .cells = &body }; - var tag: [1]pardes.Cell = .{.{}}; - tag[0].text[0] = 'T'; - surface.tag_layers[0] = .{ .kind = .pane, .serial = 9, .cols = 1, .cells = &tag }; - state.capturePrevious(std.testing.allocator, &surface); - try std.testing.expect(state.previous_valid); - tag[0].text[0] = 'U'; - try std.testing.expectEqual(@as(u8, 'T'), state.previous_tag_layers[0].cells[0].text[0]); - body[0].text[0] = 'B'; - surface.body_layers[0].context_rows = 0; - try std.testing.expectEqual(@as(u8, 'A'), state.previous_body_layers[0].cells[0].text[0]); - try std.testing.expectEqual(@as(u16, 1), state.previous_body_layers[0].context_rows); - state.capturePrevious(std.testing.allocator, &surface); - 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 "column reorder moves only crossed state and preserves weights" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 173, .rows = 20 }); defer p.deinit(); diff --git a/src/look.zig b/src/look.zig index d7e763d3..8f9e15f2 100644 --- a/src/look.zig +++ b/src/look.zig @@ -8,6 +8,7 @@ const pdf_enabled = @import("pardes_config").mupdf; const fs = @import("fs.zig"); const platform_has_fs = fs.platform_has_fs; const layout = @import("layout.zig"); +const animation = @import("animation.zig"); const modal = @import("modal.zig"); const filesystem = @import("fs.zig"); const panes = @import("panes.zig"); @@ -1367,13 +1368,13 @@ test "stationary Look hover follows only acknowledged panel samples" { defer p.deinit(); const pane = p.panes[0].?; const rect = p.rects[0]; - const target: layout.Box = .{ + const target: animation.Box = .{ .x = @floatFromInt(rect.x), .y = @floatFromInt(rect.y), .w = @floatFromInt(rect.w), .h = @floatFromInt(rect.h), }; - var track: layout.Track = .{ + var track: animation.Track = .{ .serial = pane.serial, .pane = 0, .phase = .moving, diff --git a/src/macos.zig b/src/macos.zig index 62858887..854b24f8 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -12,6 +12,7 @@ const colors = @import("colors.zig"); const look = @import("look.zig"); const message = pardes.Messages.Message; const layout = @import("layout.zig"); +const animation = @import("animation.zig"); const file_watch = @import("file_watch.zig"); const image = if (pardes.pdf_enabled) @import("image.zig") else struct {}; const host_io = @import("host_io.zig"); @@ -72,8 +73,8 @@ pub const Scene = extern struct { frame: u32 = 0, }; -pub const PanelBox = layout.Box; -pub const PanelTrack = layout.Track; +pub const PanelBox = animation.Box; +pub const PanelTrack = animation.Track; pub const Image = extern struct { serial: u32, @@ -117,7 +118,7 @@ const scene_frame_hz: u32 = 60; const scene_wrap_ns: u64 = 4096 * std.time.ns_per_s; -const max_tick_catch_up_ns: u64 = 4 * pardes.layout.Animation.frame_ns; +const max_tick_catch_up_ns: u64 = 4 * pardes.animation.Animation.frame_ns; const theme_watch_pane: u8 = @intCast(pardes.MAX_PANES); const watch_slot_count = pardes.MAX_PANES + 1; @@ -849,7 +850,7 @@ export fn pardes_should_quit() bool { return st.core.quit; } -fn encodeSceneEffects(effects: layout.SceneEffect) u32 { +fn encodeSceneEffects(effects: animation.SceneEffect) u32 { var flags: u32 = 0; if (effects.crt) flags |= scene_flag_crt; if (effects.ripple) flags |= scene_flag_ripple; @@ -869,18 +870,18 @@ const TickSpend = struct { elapsed_ns: u64, steps: u32, bank_ns: u64 }; fn spendTickTime(previous_ns: u64, now_ns: u64, bank_ns: u64) TickSpend { const measured = if (previous_ns == 0 or now_ns <= previous_ns) - pardes.layout.Animation.frame_ns + pardes.animation.Animation.frame_ns else now_ns - previous_ns; const elapsed = @min(measured, max_tick_catch_up_ns); var bank = bank_ns +| elapsed; var steps: u32 = 0; - while (bank >= pardes.layout.Animation.frame_ns) : (steps += 1) bank -= pardes.layout.Animation.frame_ns; + while (bank >= pardes.animation.Animation.frame_ns) : (steps += 1) bank -= pardes.animation.Animation.frame_ns; return .{ .elapsed_ns = elapsed, .steps = steps, .bank_ns = bank }; } test "the animation clock spends real time, not callbacks" { - const frame = pardes.layout.Animation.frame_ns; + const frame = pardes.animation.Animation.frame_ns; const expectEqual = std.testing.expectEqual; const first = spendTickTime(0, 999 * std.time.ns_per_s, 0); @@ -2844,21 +2845,21 @@ test "pardes.h matches the Zig boundary" { try expectEqual(@as(u32, c.PARDES_SCENE_CRT), scene_flag_crt); try expectEqual(@as(u32, c.PARDES_SCENE_RIPPLE), scene_flag_ripple); try expectEqual(@as(u32, c.PARDES_SCENE_GLITCH), scene_flag_glitch); - try expectEqual(@as(u8, c.PARDES_PANEL_OPENING), @intFromEnum(layout.Phase.opening)); - try expectEqual(@as(u8, c.PARDES_PANEL_MOVING), @intFromEnum(layout.Phase.moving)); - try expectEqual(@as(u8, c.PARDES_PANEL_CLOSING), @intFromEnum(layout.Phase.closing)); - try expectEqual(@as(u8, c.PARDES_PANEL_OFF), @intFromEnum(layout.Transition.off)); - try expectEqual(@as(u8, c.PARDES_PANEL_SLIDE), @intFromEnum(layout.Transition.slide)); - try expectEqual(@as(u8, c.PARDES_PANEL_ZOOM), @intFromEnum(layout.Transition.zoom)); - try expectEqual(@as(u8, c.PARDES_PANEL_DISSOLVE), @intFromEnum(layout.Transition.dissolve)); - try expectEqual(@as(u8, c.PARDES_PANEL_ASCII), @intFromEnum(layout.Transition.ascii)); - try expectEqual(@as(u8, c.PARDES_PANEL_VERTICAL), @intFromEnum(layout.Transition.vertical)); - try expectEqual(@as(u8, c.PARDES_PANEL_EDGES), @intFromEnum(layout.Transition.edges)); - try expectEqual(@as(u8, c.PARDES_PANEL_FALL), @intFromEnum(layout.Transition.fall)); - try expectEqual(@as(u8, c.PARDES_PANEL_WAVE), @intFromEnum(layout.Transition.wave)); - try expectEqual(@as(u8, c.PARDES_PANEL_CURTAIN), @intFromEnum(layout.Transition.curtain)); - try expectEqual(@as(u8, c.PARDES_PANEL_SCRAMBLE), @intFromEnum(layout.Transition.scramble)); - try expectEqual(@as(u8, c.PARDES_PANEL_TYPEWRITER), @intFromEnum(layout.Transition.typewriter)); + try expectEqual(@as(u8, c.PARDES_PANEL_OPENING), @intFromEnum(animation.Phase.opening)); + try expectEqual(@as(u8, c.PARDES_PANEL_MOVING), @intFromEnum(animation.Phase.moving)); + try expectEqual(@as(u8, c.PARDES_PANEL_CLOSING), @intFromEnum(animation.Phase.closing)); + try expectEqual(@as(u8, c.PARDES_PANEL_OFF), @intFromEnum(animation.Transition.off)); + try expectEqual(@as(u8, c.PARDES_PANEL_SLIDE), @intFromEnum(animation.Transition.slide)); + try expectEqual(@as(u8, c.PARDES_PANEL_ZOOM), @intFromEnum(animation.Transition.zoom)); + try expectEqual(@as(u8, c.PARDES_PANEL_DISSOLVE), @intFromEnum(animation.Transition.dissolve)); + try expectEqual(@as(u8, c.PARDES_PANEL_ASCII), @intFromEnum(animation.Transition.ascii)); + try expectEqual(@as(u8, c.PARDES_PANEL_VERTICAL), @intFromEnum(animation.Transition.vertical)); + try expectEqual(@as(u8, c.PARDES_PANEL_EDGES), @intFromEnum(animation.Transition.edges)); + try expectEqual(@as(u8, c.PARDES_PANEL_FALL), @intFromEnum(animation.Transition.fall)); + try expectEqual(@as(u8, c.PARDES_PANEL_WAVE), @intFromEnum(animation.Transition.wave)); + try expectEqual(@as(u8, c.PARDES_PANEL_CURTAIN), @intFromEnum(animation.Transition.curtain)); + try expectEqual(@as(u8, c.PARDES_PANEL_SCRAMBLE), @intFromEnum(animation.Transition.scramble)); + try expectEqual(@as(u8, c.PARDES_PANEL_TYPEWRITER), @intFromEnum(animation.Transition.typewriter)); try expectEqual(@as(u21, c.PARDES_KEY_ENTER), pardes.Key.enter); try expectEqual(@as(u21, c.PARDES_KEY_ESCAPE), pardes.Key.escape); diff --git a/src/mouse.zig b/src/mouse.zig index beb2e624..5ab6fe2b 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -7,6 +7,8 @@ const exec = @import("exec.zig"); const look = @import("look.zig"); const std = @import("std"); const layout = @import("layout.zig"); +const Presentation = @import("Presentation.zig"); +const animation = @import("animation.zig"); const modal = @import("modal.zig"); const ctlfs = @import("ninep/tree.zig"); const panes = @import("panes.zig"); @@ -1233,7 +1235,7 @@ test "pointer coordinates follow presented panel geometry" { defer p.deinit(); const pane = p.panes[0].?; - const moving: layout.Track = .{ + const moving: animation.Track = .{ .serial = pane.serial, .pane = 0, .phase = .moving, @@ -1244,13 +1246,13 @@ test "pointer coordinates follow presented panel geometry" { p.acknowledgePanelPresentation(&.{moving}); // One shared physical-to-logical map feeds all pointer gestures. The // selected sample is 26.25% across and 35% down the presented rectangle. - try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 21, .row = 7 }, p.presentation.pointer(p.screen_w, p.screen_h, 30, 8).?); + try std.testing.expectEqual(Presentation.CellPosition{ .col = 21, .row = 7 }, p.presentation.pointer(p.screen_w, p.screen_h, 30, 8).?); // Canonical cells covered only by the not-yet-arrived target are inert. try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 2, 2) == null); // Unrelated screen space remains in the ordinary grid coordinate system. - try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 100, .row = 10 }, p.presentation.pointer(p.screen_w, p.screen_h, 100, 10).?); + try std.testing.expectEqual(Presentation.CellPosition{ .col = 100, .row = 10 }, p.presentation.pointer(p.screen_w, p.screen_h, 100, 10).?); - p.acknowledgePanelPresentation(&.{layout.Track{ + p.acknowledgePanelPresentation(&.{animation.Track{ .serial = pane.serial, .pane = 0, .phase = .opening, @@ -1262,7 +1264,7 @@ test "pointer coordinates follow presented panel geometry" { // Tracks paint in pane-slot order inside a phase. The later slot is the // top quad and therefore owns an overlap, even though both are moving. - const overlap = [_]layout.Track{ .{ + const overlap = [_]animation.Track{ .{ .serial = pane.serial, .pane = 0, .phase = .moving, @@ -1280,7 +1282,7 @@ test "pointer coordinates follow presented panel geometry" { p.presentation.shown_tracks = @splat(null); p.presentation.shown_tracks[0] = overlap[0]; p.presentation.shown_tracks[1] = overlap[1]; - try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 65, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 25, 3).?); + try std.testing.expectEqual(Presentation.CellPosition{ .col = 65, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 25, 3).?); } test "queued pointer input is inert until a changed layout is presented" { @@ -1296,7 +1298,7 @@ test "queued pointer input is inert until a changed layout is presented" { try std.testing.expect(p.presentation.pending); try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 10, 4) == null); - var tracks: [MAX_PANES]layout.Track = undefined; + var tracks: [MAX_PANES]animation.Track = undefined; var len: usize = 0; for (p.presentation.tracks) |maybe| if (maybe) |track| { tracks[len] = track; @@ -1313,7 +1315,7 @@ test "pointer rejects panel content cells which have not materialized" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 12, .tty_only = true }); defer p.deinit(); const pane = p.panes[0].?; - const target: layout.Box = .{ .x = 4, .y = 2, .w = 32, .h = 4 }; + const target: animation.Box = .{ .x = 4, .y = 2, .w = 32, .h = 4 }; p.presentation.diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h); @memset(p.presentation.diffs, .unchanged); for (2..6) |row| { @@ -1328,9 +1330,9 @@ test "pointer rejects panel content cells which have not materialized" { } p.presentation.diff_state = .ready; - for ([_]layout.Transition{ .dissolve, .ascii }) |effect| { + for ([_]animation.Transition{ .dissolve, .ascii }) |effect| { const frame: u16 = if (effect == .dissolve) 3 else 5; - const track: layout.Track = .{ + const track: animation.Track = .{ .serial = pane.serial, .pane = 0, .phase = .opening, @@ -1349,7 +1351,7 @@ test "pointer rejects panel content cells which have not materialized" { const col: u16 = @intCast(4 + relative_col); const row: u16 = @intCast(2 + relative_row); const visible = switch (effect) { - .dissolve => layout.dissolveRevealed( + .dissolve => animation.dissolveRevealed( pane.serial, rcol, rrow, @@ -1364,7 +1366,7 @@ test "pointer rejects panel content cells which have not materialized" { const mapped = p.presentation.pointer(p.screen_w, p.screen_h, col, row); try std.testing.expectEqual(visible, mapped != null); if (mapped) |point| - try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = col, .row = row }, point); + try std.testing.expectEqual(Presentation.CellPosition{ .col = col, .row = row }, point); saw_visible = saw_visible or visible; saw_hidden = saw_hidden or !visible; }; @@ -1380,9 +1382,9 @@ test "unchanged content cells remain clickable through data effects" { p.presentation.diffs = try p.gpa.alloc(PanelCellDiff, @as(usize, p.screen_w) * p.screen_h); @memset(p.presentation.diffs, .unchanged); p.presentation.diff_state = .ready; - const box: layout.Box = .{ .x = 2, .y = 2, .w = 20, .h = 4 }; + const box: animation.Box = .{ .x = 2, .y = 2, .w = 20, .h = 4 }; - for ([_]layout.Transition{ .dissolve, .ascii }) |effect| { + for ([_]animation.Transition{ .dissolve, .ascii }) |effect| { p.acknowledgePanelPresentation(&.{.{ .serial = pane.serial, .pane = 0, @@ -1391,7 +1393,7 @@ test "unchanged content cells remain clickable through data effects" { .from = box, .to = box, }}); - try std.testing.expectEqual(layout.Presentation.CellPosition{ .col = 8, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 8, 3).?); + try std.testing.expectEqual(Presentation.CellPosition{ .col = 8, .row = 3 }, p.presentation.pointer(p.screen_w, p.screen_h, 8, 3).?); } } @@ -1400,7 +1402,7 @@ test "held drag follows acknowledged panels and balances an invisible release" { defer p.deinit(); const pane = p.panes[0].?; const target = layout.panelBox(p.rects[0]); - var track: layout.Track = .{ + var track: animation.Track = .{ .serial = pane.serial, .pane = 0, .phase = .moving, diff --git a/src/ninep/screen.zig b/src/ninep/screen.zig index bc2788ec..88947d0d 100644 --- a/src/ninep/screen.zig +++ b/src/ninep/screen.zig @@ -245,7 +245,7 @@ test "screen snapshots preserve rendered cells and styles until their handle is test "screen inspection preserves acknowledged presentation and the next real frame" { const gpa = testing.allocator; - for (std.enums.values(pardes.layout.Transition)) |transition| { + for (std.enums.values(pardes.animation.Transition)) |transition| { errdefer std.debug.print("screen inspection during {s}\n", .{@tagName(transition)}); const control = try withFile(gpa, "const value = 1;\n"); defer control.deinit(); diff --git a/src/pardes.zig b/src/pardes.zig index 6e78380a..bfd8a4f6 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1,5 +1,7 @@ const std = @import("std"); pub const layout = @import("layout.zig"); +pub const animation = @import("animation.zig"); +pub const Presentation = @import("Presentation.zig"); const uucode = @import("uucode"); const vaxis = @import("vaxis"); pub const modal = @import("modal.zig"); @@ -16,6 +18,7 @@ pub const mouse = @import("mouse.zig"); pub const normal = @import("normal.zig"); pub const edit = @import("edit.zig"); pub const body_layer = @import("body_layer.zig"); +pub const draw = @import("draw.zig"); pub const Messages = @import("Messages.zig"); pub const Pipe = @import("Pipe.zig"); pub const colors = @import("colors.zig"); @@ -284,7 +287,7 @@ pub fn topbarPaneBorderPixels(cell_h: u32, tagline_h: u32) u32 { return @min(@as(u32, config.gui_topbar_pane_border_px), spare * 2); } -pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?layout.Track) f32 { +pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?animation.Track) f32 { if (row < p.topBarHeight()) return 0; if (row < p.topBarHeight() + p.columnBarHeight()) { for (p.col_x[0..p.ncol], p.col_w[0..p.ncol]) |x, w| @@ -315,7 +318,7 @@ pub fn taglineOriginColForFrame(p: *const Pardes, col: u16, row: u16) f32 { } test "paint order is moving, then opening, then closing tombstones on top" { - const Track = layout.Track; + const Track = animation.Track; // Deliberately interleaved on the way in: the phases are what order the // output, not the slot they happened to occupy. const live = [_]?Track{ @@ -329,7 +332,7 @@ test "paint order is moving, then opening, then closing tombstones on top" { .{ .serial = 16, .pane = 2, .phase = .closing, .effect = .vertical }, }; var out: [8]Track = undefined; - const len = layout.paintOrder(&live, &closing, &out); + const len = animation.paintOrder(&live, &closing, &out); var serials: [8]u32 = undefined; for (out[0..len], 0..) |track, i| serials[i] = track.serial; @@ -338,7 +341,7 @@ test "paint order is moving, then opening, then closing tombstones on top" { // A host's array is fixed-size and the core's is not its business: writing // past it would be a buffer overrun in whichever shell had the smaller one. var tight: [2]Track = undefined; - try std.testing.expectEqual(@as(usize, 2), layout.paintOrder(&live, &closing, &tight)); + try std.testing.expectEqual(@as(usize, 2), animation.paintOrder(&live, &closing, &tight)); try std.testing.expectEqual(@as(u32, 12), tight[0].serial); try std.testing.expectEqual(@as(u32, 15), tight[1].serial); } @@ -458,6 +461,7 @@ test { _ = @import("colors.zig"); _ = @import("surface.zig"); _ = @import("body_layer.zig"); + _ = @import("draw.zig"); _ = @import("tag_layer.zig"); } @@ -2223,7 +2227,7 @@ test "Font keeps requested and acknowledged faces as separate plain state" { try std.testing.expectEqualStrings(installed[0].path, request); try std.testing.expect(p.settings.font.pending); try std.testing.expect(p.takeFontRequest() == null); - const fake_track: layout.Track = .{ + const fake_track: animation.Track = .{ .serial = p.panes[p.active].?.serial, .pane = @intCast(p.active), .effect = .slide, @@ -2329,7 +2333,7 @@ test "restored sessions animate layout changes after bootstrap" { source.dump_out.?, ); defer restored.deinit(); - try std.testing.expectEqual(layout.Transition.slide, restored.settings.panel_transition); + try std.testing.expectEqual(animation.Transition.slide, restored.settings.panel_transition); try std.testing.expect(!restored.animationActive()); var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); @@ -3284,8 +3288,8 @@ test "layout commits publish finite tracks only for changed panes" { try std.testing.expect(p.presentation.tracks[1] == null); try std.testing.expect(p.presentation.tracks[0] != null); try std.testing.expect(p.presentation.tracks[2] != null); - try std.testing.expectEqual(layout.Phase.moving, p.presentation.tracks[0].?.phase); - try std.testing.expectEqual(layout.Phase.opening, p.presentation.tracks[2].?.phase); + try std.testing.expectEqual(animation.Phase.moving, p.presentation.tracks[0].?.phase); + try std.testing.expectEqual(animation.Phase.opening, p.presentation.tracks[2].?.phase); try std.testing.expectEqual(untouched, p.rects[1]); try std.testing.expect(p.animationActive()); @@ -3294,7 +3298,7 @@ test "layout commits publish finite tracks only for changed panes" { const surface = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 2), surface.panelTracks().len); - for (0..layout.Transition.slide.frames()) |_| p.update(.tick); + for (0..animation.Transition.slide.frames()) |_| p.update(.tick); try std.testing.expect(!p.animationActive()); _ = frame.reset(.retain_capacity); try std.testing.expectEqual(@as(usize, 0), (try p.render(frame.allocator())).panelTracks().len); @@ -3363,7 +3367,7 @@ test "vertical close samples only a canonical baseline containing that pane" { _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); - for (0..layout.Transition.vertical.frames()) |_| p.update(.tick); + for (0..animation.Transition.vertical.frames()) |_| p.update(.tick); _ = frame.reset(.retain_capacity); const canonical = try p.render(frame.allocator()); try std.testing.expectEqual(@as(usize, 0), canonical.panelTracks().len); @@ -3452,7 +3456,7 @@ test "canonical fallback and transition toggle retire unpresented tracks" { p.acknowledgePanelPresentation(moving.panelTracks()); try std.testing.expect(moving.panelTracks().len > 0); exec.applySettingBuiltin(p, config.Runtime.find("PanelZoom").?, null); - try std.testing.expectEqual(layout.Transition.zoom, p.settings.panel_transition); + try std.testing.expectEqual(animation.Transition.zoom, p.settings.panel_transition); try std.testing.expect(p.presentation.pending); try std.testing.expect(p.presentation.pointer(p.screen_w, p.screen_h, 10, 4) == null); for (p.presentation.tracks) |track| try std.testing.expect(track == null); @@ -3506,11 +3510,11 @@ test "back-to-back layout commits retarget from the last presented boxes" { const target_c = p.presentation.tracks[0] orelse return error.MissingSecondRetarget; try std.testing.expect(target_c.from.eql(shown_a)); try std.testing.expect(target_c.to.eql(layout.panelBox(p.rects[0]))); - try std.testing.expectEqual(layout.Phase.moving, target_c.phase); + try std.testing.expectEqual(animation.Phase.moving, target_c.phase); _ = frame.reset(.retain_capacity); const latest = try p.render(frame.allocator()); - var submitted: ?layout.Track = null; + var submitted: ?animation.Track = null; for (latest.panelTracks()) |track| if (track.pane == 0) { submitted = track; break; @@ -3533,13 +3537,13 @@ test "an unpresented opening pane remains in the opening paint phase" { try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); const first = p.presentation.tracks[1] orelse return error.MissingOpeningTrack; - try std.testing.expectEqual(layout.Phase.opening, first.phase); + try std.testing.expectEqual(animation.Phase.opening, first.phase); _ = try p.newShell(2, ""); try std.testing.expect(layout.splitColumn(p, 1, 2, true)); p.sync(); const retargeted = p.presentation.tracks[1] orelse return error.MissingOpeningRetarget; - try std.testing.expectEqual(layout.Phase.opening, retargeted.phase); + try std.testing.expectEqual(animation.Phase.opening, retargeted.phase); try std.testing.expect(retargeted.from.eql(first.from)); } @@ -3577,7 +3581,7 @@ test "core composes ASCII bytes while visual-only cells pass through" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); - const presented = try p.composeAsciiTransitions(arena.allocator(), &surface); + const presented = try draw.composeAsciiTransitions(p, arena.allocator(), &surface); try std.testing.expect(presented != &surface); try std.testing.expectEqualStrings("C", presented.at(0, 0).grapheme()); try std.testing.expectEqualStrings("x", presented.at(1, 0).grapheme()); @@ -3612,7 +3616,7 @@ test "core composes character motion out of the new grid, not a fade" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); - const presented = try p.composeAsciiTransitions(arena.allocator(), &surface); + const presented = try draw.composeAsciiTransitions(p, arena.allocator(), &surface); try std.testing.expect(presented != &surface); var seen: [8]u8 = undefined; for (&seen, 0..) |*byte, col| byte.* = presented.at(@intCast(col), 0).text[0]; @@ -3620,8 +3624,8 @@ test "core composes character motion out of the new grid, not a fade" { try std.testing.expectEqualStrings("a", surface.at(0, 0).grapheme()); // The last active sample is the untouched canonical grid, with no copy. - surface.panel_tracks[0].frame = layout.Transition.edges.frames() - 1; - try std.testing.expect(try p.composeAsciiTransitions(arena.allocator(), &surface) == &surface); + surface.panel_tracks[0].frame = animation.Transition.edges.frames() - 1; + try std.testing.expect(try draw.composeAsciiTransitions(p, arena.allocator(), &surface) == &surface); } test "repeated non-dyadic column splits preserve every unrelated boundary" { @@ -3775,7 +3779,7 @@ pub const Pardes = struct { rects: [MAX_PANES]Rect = @splat(.{}), col_x: [MAX_COLS]u16 = undefined, col_w: [MAX_COLS]u16 = undefined, - presentation: layout.Presentation = .{}, + presentation: Presentation = .{}, active: usize = 0, jumps: [MAX_JUMPS]Loc = undefined, @@ -4643,7 +4647,7 @@ pub const Pardes = struct { pub fn pump(p: *Pardes, h: Host) !void { p.host = h; const v = h.vtable; - if (v.wait_input) |f| f(h.ctx, if (p.animationActive()) layout.Animation.frame_ms else 0); + if (v.wait_input) |f| f(h.ctx, if (p.animationActive()) animation.Animation.frame_ms else 0); while (p.nextQueued()) |ev| p.update(ev); while (p.nextEffect()) |e| p.perform(e); turn.settle(); @@ -5404,7 +5408,7 @@ pub const Pardes = struct { // ---- dumb undo/redo: whole-state snapshots, one per edit op ---- - pub fn acknowledgePanelPresentation(p: *Pardes, tracks: []const layout.Track) void { + pub fn acknowledgePanelPresentation(p: *Pardes, tracks: []const animation.Track) void { p.presentation.acknowledge(p, tracks); if (p.pointer_inside and p.drag != .none) { if (p.presentation.pointer(p.screen_w, p.screen_h, p.pointer_raw_col, p.pointer_raw_row)) |mapped| { @@ -5779,430 +5783,9 @@ pub const Pardes = struct { return null; } - // ---- render: build the canonical surface ---- + // ---- render: build the canonical surface (draw.zig) ---- - pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { - panes.File.refreshHighlights(p); - const s = &p.surface; - const ncells = @as(usize, p.screen_w) * p.screen_h; - if (s.cells.len != ncells) { - const cells = try p.gpa.alloc(Cell, ncells); - p.gpa.free(s.cells); - s.cells = cells; - } - s.cols = p.screen_w; - s.rows = p.screen_h; - s.cursor = null; - s.pointer_shape = .arrow; - if (comptime pdf_enabled) if (p.pdf_hover_preview) |preview| { - if (preview.probe.link != null and preview.pane < p.panes.len) { - if (p.panes[preview.pane]) |pane| { - if (pane.serial == preview.serial and panes.Pdf.paneNativeReady(p, pane)) - s.pointer_shape = .link; - } - } - }; - // A look-hover preview means the pointed word is a real Look or Exec - // operand: the same plumbing a click would act on. Hosts that model - // the pointer per-text (the macOS I-beam) read this as "target" and - // afford it the arrow instead. - if (p.look_hover_preview != null) s.pointer_shape = .target; - // Stale hover bits must not survive into a frame whose pointer has - // moved on: the ordinary content paint does not clear fields it - // never set. - for (s.cells) |*c| c.hover = false; - for (&s.body_layers) |*layer| layer.rows = 0; - for (&s.tag_layers) |*layer| layer.cols = 0; - s.nimages = 0; - s.npanel_tracks = 0; - s.previous_cells = &.{}; - s.previous_body_layers = &.{}; - s.previous_tag_layers = &.{}; - s.cell_diffs = &.{}; - const chrome = p.chromeTheme(); - const th = p.theme(); - // Separators have their own role; a scrollbar need not frame the page. - s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = chrome.border } }); - - for (&p.panes, 0..) |*slot, id| { - const pane = slot.* orelse continue; - pane.body_rows = 0; - pane.body_visible_rows = 0; - pane.context_row_limit = null; - s.body_layers[id].rows = 0; - Messages.collectNotices(p, pane, p.rects[id], id); - try p.renderPane(arena, pane, p.rects[id], id, id == p.active); - try body_layer.renderBodyLayer(p, arena, pane, p.rects[id], id); - } - for (&p.panes, 0..) |*slot, id| { - const pane = slot.* orelse continue; - if (pane.notices.len == 0) continue; - const r = p.rects[id]; - const tx = r.x + config.GUTTER; - const tw = r.w - config.GUTTER; - // Painted the way the sticky context headers above them are: the - // tagline font and the tagline's own colours, so a notice reads as - // a band belonging to the pane rather than as a line of body text - // that happens to sit at the bottom of it. - const msg_style: CellStyle = .{ - .fg = .{ .rgb = chrome.tag_fg }, - .bg = .{ .rgb = chrome.tag_bg }, - .font_role = .tagline, - }; - // An OVERLAY over the body's top rows, at the right edge, one - // chip to a row in the order collectNotices chose -- not a row - // taken out of the body the way a tree-sitter context header is. - // The chip is as wide as its own message and no wider, so the text - // beneath the rest of the row keeps reading as the file it is. - const first = p.bodyTop(pane, r); - var leader_buf: [16]u8 = undefined; - for (pane.notices.slice(), 0..) |kind, i| { - const row = first + @as(u16, pane.notices.row[i]); - // Every notice is painted on the canonical grid as well as - // emitted as a tag layer: the grid is what a terminal client - // draws, and a band it cannot see is a message it never gets. - // The GUI skips grid cells a tag layer covers, so nothing is - // drawn twice there. - const text = Messages.noticeText(p, pane, i, &leader_buf); - const chip = Messages.noticeCols(p, text, tw); - if (chip == 0) continue; - const cx = tx + tw - chip; - pane.notices.left[i] = cx; - // A message coming or going blends toward the page it sits - // on; a grid cannot move it by less than a row. - var style = msg_style; - if (Messages.noticeLife(pane, i)) |life| { - // No page colour to fade to (a terminal's own default): - // the text dissolves into its chip instead. - const alpha = Messages.messageMotion(p, life).alpha; - const page = th.bg orelse chrome.tag_bg; - style.fg = .{ .rgb = Messages.blendRgb(chrome.tag_fg, page, alpha) }; - style.bg = .{ .rgb = Messages.blendRgb(chrome.tag_bg, page, alpha) }; - } - s.fill(cx, row, chip, 1, .{ .bg = style.bg, .font_role = .tagline }); - // One column short of the chip's right edge: a prompt's caret - // sits AFTER its last character the moment it opens, and flush - // right would put it past the pane, off the grid, and past what - // the detached wire will encode -- which drops every frame for - // as long as the prompt is up. - const printed = Messages.printRight(s, cx, row, chip -| 1, text, style); - if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue; - // The cursor follows the text to wherever it landed; a caret - // in the part a narrow band dropped has nowhere to be. - const prompt_col = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); - if (prompt_col < printed.dropped) continue; - const caret = printed.left + (prompt_col - printed.dropped); - if (caret < tx + tw) - s.cursor = .{ .x = @intCast(caret), .y = row, .bar = pane.input.mode == .insert }; - } - } - - try body_layer.paintHeaders(p); - try body_layer.renderTagLayers(p, arena); - - switch (p.drag) { - .border_v => |d| { - if (d.cur_x < s.cols) { - var row: u16 = p.topBarHeight(); - while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎"); - } - if (d.corner) |k| if (d.cur_y < s.rows) { - var col = if (k.col == d.left_col) p.col_x[k.col] else d.cur_x +| 1; - const end = if (k.col == d.left_col) d.cur_x else p.col_x[k.col] +| p.col_w[k.col] -| 1; - while (col <= end and col < s.cols) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); - }; - }, - .border_h => |d| if (d.cur_y < s.rows) { - var col = p.col_x[d.col]; - while (col < p.col_x[d.col] + p.col_w[d.col]) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); - }, - .column_move => |d| rail: { - const source = layout.columnBySerial(p, d.serial) orelse break :rail; - // Where the column's left edge would land, reordered or not. - const edge: u16 = switch (layout.columnDrop(p, source, d.cur_x, d.grab)) { - .stay => break :rail, - .reorder => |target| if (target < source) p.col_x[target] else p.col_x[target] + p.col_w[target], - .edge => |x| x, - }; - // Leave the outside insertion rail visible above compact - // text layers. This clips presentation only, never reflows. - if (edge == s.cols) { - for (&s.body_layers) |*layer| if (layer.rows > 0 and layer.viewport.x + layer.viewport.w == s.cols) { - layer.viewport.w -|= 1; - }; - for (&s.tag_layers) |*layer| if (layer.cols > 0 and layer.viewport.x + layer.viewport.w == s.cols and layer.viewport.y >= p.topBarHeight()) { - layer.viewport.w -|= 1; - }; - } - // The rail runs in the seam cell left of the edge, where a - // border drag's does: the edge cell itself is a gutter, which - // the GUI paints as a scroll rail that swallowed the dashes. - // At the window's left edge there is no seam, so it runs just - // past that scroll rail instead. It takes the lit grip's - // color: this is where the grabbed column lands. - const x = @min(if (edge == 0) config.GUTTER - 1 else edge - 1, s.cols -| 1); - var row: u16 = p.topBarHeight(); - while (row < s.rows) : (row += 1) { - // Past the left edge's scroll rail the rail would cut - // through column 0's grip and pane boxes: leave them whole. - if (edge == 0 and s.at(x, row).style.font_role == .tagline) continue; - s.overlayDash(x, row, "╎"); - s.at(x, row).style.fg = .{ .rgb = chrome.column_box }; - } - }, - .move => |d| if (d.cur_x < s.cols) { - if (layout.movePlacement(p, d.id, d.cur_x, d.cur_y)) |placement| { - var col: u16 = p.col_x[placement.preview_col]; - while (col < p.col_x[placement.preview_col] + p.col_w[placement.preview_col]) : (col += 1) { - s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = chrome.lineno } }); - } - s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = chrome.lineno } }); - } - }, - .none, .select => if (p.pointer_inside) { - // hover hints on a pane's own trailing edge - var c: usize = 0; - while (c + 1 < p.ncol) : (c += 1) { - if (p.hover_col == p.col_x[c] + p.col_w[c] -| 1) { - var row: u16 = p.topBarHeight(); - while (row < s.rows) : (row += 1) s.overlayDash(p.hover_col, row, "╎"); - } - } - for (0..p.ncol) |cc| { - if (p.hover_col < p.col_x[cc] or p.hover_col >= p.col_x[cc] + p.col_w[cc]) continue; - // the same seam the h-handle hit test picks (see there) - if (mouse.seamIdxAt(p, cc, p.hover_row) != null) { - var col = p.col_x[cc]; - while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌"); - } - } - for (0..p.ncol -| 1) |cn| { - if (p.hover_col != p.col_x[cn] + p.col_w[cn] -| 1) continue; - if (mouse.seamIdxAt(p, cn, p.hover_row) != null) continue; - if (mouse.seamIdxAt(p, cn + 1, p.hover_row) == null) continue; - var col = p.col_x[cn + 1]; - while (col < p.col_x[cn + 1] + p.col_w[cn + 1]) : (col += 1) s.overlayDash(col, p.hover_row, "╌"); - } - }, - } - - // debug overlay: a small stats box drawn last, top-right ("Debug") - if (p.settings.debug) if (p.panes[p.active]) |at| { - var nlive: usize = 0; - for (p.panes) |slot| { - if (slot != null) nlive += 1; - } - var sb_off: usize = undefined; - var sb_total: usize = undefined; - if (at.file) |*f| { - sb_off = f.scroll; - sb_total = panes.File.nlines(p.gpa, f); - } else if (at.pdfPage()) |page| { - sb_off = page; - sb_total = if (comptime pdf_enabled) at.pdf.?.page_count else 0; - } else { - const sb = panes.Terminal.scrollbar(at); - sb_off = sb.offset; - sb_total = sb.total; - } - const effective_font = p.settings.font.effective_name.get(); - const font_name = if (effective_font.len == 0) "default" else effective_font; - var ov_buf: [256]u8 = undefined; - const text = std.fmt.bufPrint( - &ov_buf, - "pardes resizes={d}\ntheme {s}\nfont {s}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}\npinch {d:6.4}\ntscroll {d:7.4}", - .{ p.resize_count, p.theme().name, font_name[0..@min(font_name.len, 64)], nlive, p.ncol, p.active, at.cols, at.rows, sb_off, sb_total, p.ov_pinch_scale, p.ov_touch_scroll_delta }, - ) catch ""; - // wide enough for the longest theme name compiled in, which is the - // only row here whose width is not ours to choose - const ow: u16 = 38; - const oh: u16 = 10; - const ox: u16 = if (s.cols > ow) s.cols - ow else 0; - if (ox + ow <= s.cols and p.topBarHeight() + oh <= s.rows) { - // vaxis single_rounded border ring, default style - s.set(ox, p.topBarHeight(), "╭", .{}); - s.set(ox + ow - 1, p.topBarHeight(), "╮", .{}); - s.set(ox, p.topBarHeight() + oh - 1, "╰", .{}); - s.set(ox + ow - 1, p.topBarHeight() + oh - 1, "╯", .{}); - var bx = ox + 1; - while (bx < ox + ow - 1) : (bx += 1) { - s.set(bx, p.topBarHeight(), "─", .{}); - s.set(bx, p.topBarHeight() + oh - 1, "─", .{}); - } - var by = p.topBarHeight() + 1; - while (by < p.topBarHeight() + oh - 1) : (by += 1) { - s.set(ox, by, "│", .{}); - s.set(ox + ow - 1, by, "│", .{}); - } - s.fill(ox + 1, p.topBarHeight() + 1, ow - 2, oh - 2, .{ .bg = .{ .rgb = chrome.scroll_track } }); - var lines = std.mem.splitScalar(u8, text, '\n'); - var ly: u16 = p.topBarHeight() + 1; - while (lines.next()) |line| : (ly += 1) { - if (ly >= p.topBarHeight() + oh - 1) break; - _ = s.print(ox + 1, ly, ow - 2, line, .{ .fg = .{ .rgb = chrome.tag_fg } }); - } - } - }; - try p.presentation.submit(p, s); - return p.composeAsciiTransitions(arena, s); - } - - fn composeAsciiTransitions(p: *Pardes, arena: std.mem.Allocator, canonical: *Surface) !*Surface { - _ = p; - if (!canonical.hasPanelDiff()) return canonical; - var presented: ?*Surface = null; - for (canonical.panelTracks()) |track| { - if (!track.effect.composedByCore() or track.phase == .closing) continue; - const area = layout.CellArea.of(track.to); - const col_end = @min(canonical.cols, area.x0 +| area.cols); - const row_end = @min(canonical.rows, area.y0 +| area.rows); - var row: u16 = area.y0; - while (row < row_end) : (row += 1) { - var col: u16 = area.x0; - while (col < col_end) : (col += 1) { - const index = @as(usize, row) * canonical.cols + col; - const composed = composedCell(canonical, track, area, col, row, index) orelse - continue; - if (composed.visuallyEqual(&canonical.cells[index])) continue; - if (presented == null) { - const next = try arena.create(Surface); - next.* = canonical.*; - next.cells = try arena.dupe(Cell, canonical.cells); - presented = next; - } - presented.?.cells[index] = composed; - } - } - } - return presented orelse canonical; - } - - fn composedCell( - canonical: *const Surface, - track: layout.Track, - area: layout.CellArea, - col: u16, - row: u16, - index: usize, - ) ?Cell { - if (track.effect == .ascii) { - const diff = switch (canonical.cell_diffs[index]) { - .ascii => |diff| diff, - .unchanged, .visual => return null, - }; - const byte = diff.byteAt(track.frame); - if (byte == diff.to) return null; - if (track.frame == 0) return canonical.previous_cells[index]; - return withByte(canonical.cells[index], byte); - } - return switch (layout.charSource(track, col - area.x0, row - area.y0, area)) { - .old => canonical.previous_cells[index], - .byte => |byte| withByte(canonical.cells[index], byte), - // Churn belongs on the glyph, not on the pane's empty space, and it - // borrows whichever side of the diff actually has one. - .churn => |byte| blk: { - const new_cell = &canonical.cells[index]; - if (hasGlyph(new_cell)) break :blk withByte(new_cell.*, byte); - const old_cell = &canonical.previous_cells[index]; - if (hasGlyph(old_cell)) break :blk withByte(old_cell.*, byte); - break :blk old_cell.*; - }, - .at => |offset| blk: { - const source_col = @as(i32, col) + offset.cols; - const source_row = @as(i32, row) + offset.rows; - // A glyph whose source lies outside the pane has not arrived - // yet: the frozen old cell is the only truthful thing under it. - if (source_col < area.x0 or source_row < area.y0 or - source_col >= @as(i32, area.x0) + area.cols or - source_row >= @as(i32, area.y0) + area.rows or - source_col >= canonical.cols or source_row >= canonical.rows) - break :blk canonical.previous_cells[index]; - const source = @as(usize, @intCast(source_row)) * canonical.cols + - @as(usize, @intCast(source_col)); - break :blk canonical.cells[source]; - }, - }; - } - - fn withByte(cell: Cell, byte: u8) Cell { - var out = cell; - out.text[0] = byte; - out.len = 1; - out.default = false; - return out; - } - - /// Whether a cell shows a glyph a character effect can work on. An unpainted - /// cell and a space are both blank space, whatever style they carry. - fn hasGlyph(cell: *const Cell) bool { - return !cell.default and !(cell.len == 1 and cell.text[0] == ' '); - } - - fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void { - const tz = tracy.zone(@src(), "renderPane"); - defer tz.end(); - if (r.w <= config.GUTTER or r.h == 0) return; - const s = &p.surface; - const th = p.theme(); - const chrome = p.chromeTheme(); - const tx = r.x + config.GUTTER; // text area (tag + body), right of the gutter - const tw = r.w - config.GUTTER; - const tag_y = p.tagTop(pane, r); - const body_y = p.bodyTop(pane, r); - const pane_bg: Color = if (th.bg) |c| .{ .rgb = c } else .default; - - // text area resets to terminal-default cells (vaxis clear semantics); - // light themes paint the page over it. - { - // Two full passes over every cell in the pane, every frame. - const tz_clear = tracy.zone(@src(), "paneClear"); - defer tz_clear.end(); - s.clearRect(tx, r.y, tw, r.h); - if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } }); - } - - const box_bg = if (active) chrome.box else chrome.box_dim; - const box_lum = (@as(u16, box_bg[0]) * 3 + @as(u16, box_bg[1]) * 6 + @as(u16, box_bg[2])) / 10; - const box_ink: [3]u8 = if (box_lum > 140) .{ 0x00, 0x00, 0x00 } else .{ 0xff, 0xff, 0xff }; - const box_style: CellStyle = .{ - .fg = .{ .rgb = box_ink }, - .bg = .{ .rgb = box_bg }, - // Grips keep physical grid dimensions, independently of tag text. - .font_role = .tagline, - }; - s.fill(r.x, tag_y, config.GUTTER, pane.tag_rows, box_style); - s.set(r.x, tag_y, switch (pane.body.mode) { - .normal => config.tag_normal, - .insert => config.tag_insert, - .tty => config.tag_tty, - }, box_style); - - try body_layer.paintPaneTag(p, arena, pane, r, id, active, p.tagCapacity(r.w -| TAG_TEXT_INSET)); - - // Folded panes retain their body state, but neither body pixels nor - // its cursor belong outside the one-row tag rectangle. - if (pane.collapsed) return; - - if (comptime pdf_enabled) - if (pane.hasPdf() and panes.Pdf.draw(p, pane, r, id, tx, tw)) return; - - if (pane.image) |*iv| { - // Below the notice chips: a picture is drawn after the cells (the - // GUI's image pass, kitty's z-order), so a chip over it would be - // painted out. Text gets the overlay; a picture gives up the rows. - const shown = pane.notices.span(); - const image_h = (r.h -| pane.tag_rows) -| shown; - panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y + shown, tw, image_h); - // thumbless, but the same one column as the real scrollbar below — - // that is the whole point of drawing it, and like that one it runs - // past the notice bands so the gutter has no notch in it - s.fill(r.x, body_y, 1, r.h -| pane.tag_rows, .{ .bg = .{ .rgb = chrome.scroll_track } }); - s.fill(r.x + 1, body_y, 1, r.h -| pane.tag_rows, .{ .bg = pane_bg }); - return; - } - - try body_layer.renderBody(p, arena, pane, r, id, active, true); - } + pub const render = draw.render; pub fn appendImagePlace(p: *Pardes, place: ImagePlace) bool { const s = &p.surface; diff --git a/src/surface.zig b/src/surface.zig index a9e69cca..eef787b4 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -3,6 +3,7 @@ const pardes = @import("pardes.zig"); const std = @import("std"); const layout = @import("layout.zig"); +const animation = @import("animation.zig"); const uucode = @import("uucode"); const vaxis = @import("vaxis"); const panes = @import("panes.zig"); @@ -70,7 +71,7 @@ pub const AsciiDiff = struct { /// Long printable-byte walks complete in about the same time as the other /// panel effects. Extra distance is crossed by eased character skips. - pub const max_movement_frames = layout.ascii_max_movement_frames; + pub const max_movement_frames = animation.ascii_max_movement_frames; pub fn between(old: *const Cell, new: *const Cell) ?AsciiDiff { const from = old.printableAscii() orelse return null; @@ -341,7 +342,7 @@ pub const Surface = struct { cursor: ?struct { x: u16, y: u16, bar: bool = false } = null, images: []?ImagePlace = &.{}, nimages: usize = 0, - panel_tracks: [MAX_PANES * 2]layout.Track = undefined, + panel_tracks: [MAX_PANES * 2]animation.Track = undefined, npanel_tracks: usize = 0, previous_cells: []const Cell = &.{}, previous_body_layers: []const BodyLayer = &.{}, @@ -356,7 +357,7 @@ pub const Surface = struct { return &s.body_layers; } - pub fn panelTracks(s: *const Surface) []const layout.Track { + pub fn panelTracks(s: *const Surface) []const animation.Track { return s.panel_tracks[0..s.npanel_tracks]; } diff --git a/src/tty/panel_compositor.zig b/src/tty/panel_compositor.zig index 9363841a..67d3a6e6 100644 --- a/src/tty/panel_compositor.zig +++ b/src/tty/panel_compositor.zig @@ -8,9 +8,10 @@ const std = @import("std"); const pardes = @import("../pardes.zig"); const layout = @import("../layout.zig"); +const animation = @import("../animation.zig"); -const Box = layout.Box; -const Track = layout.Track; +const Box = animation.Box; +const Track = animation.Track; /// Kitty placements cannot be resampled through the character-grid transform. /// Keep their transmitted pixels cached, but omit the placement while its pane @@ -77,7 +78,7 @@ pub fn compose( // Stable layout motion first, newly opening panels above it, and closing // tombstones last. A closing pane no longer owns input or canonical cells, // but its frozen old content remains the top visual until it slides out. - for ([_]layout.Phase{ .moving, .opening, .closing }) |phase| { + for ([_]animation.Phase{ .moving, .opening, .closing }) |phase| { for (tracks) |track| { if (!drawable(source, track) or track.phase != phase) continue; switch (track.effect) { @@ -139,7 +140,7 @@ fn dissolve(out: *pardes.Surface, source: *const pardes.Surface, track: Track) v var x = area.x0; while (x < area.x1) : (x += 1) { if (!source.panelCellChanged(x, y)) continue; - if (layout.dissolveRevealed( + if (animation.dissolveRevealed( track.serial, x - area.x0, y - area.y0, @@ -339,7 +340,7 @@ test "TTY content effects never touch cells outside the published diff" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); - for ([_]layout.Transition{ .ascii, .dissolve }) |effect| { + for ([_]animation.Transition{ .ascii, .dissolve }) |effect| { const track: Track = .{ .serial = 17, .effect = effect, @@ -366,7 +367,7 @@ test "content transition without a diff snaps to canonical surface" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); - for ([_]layout.Transition{ .dissolve, .ascii, .vertical }) |effect| { + for ([_]animation.Transition{ .dissolve, .ascii, .vertical }) |effect| { const track: Track = .{ .effect = effect, .phase = .opening, @@ -391,7 +392,7 @@ test "exact transition endpoint preserves the canonical cursor" { }; const track: Track = .{ .effect = .ascii, - .frame = layout.Transition.ascii.frames() - 1, + .frame = animation.Transition.ascii.frames() - 1, .from = .{ .w = 1, .h = 1 }, .to = .{ .w = 1, .h = 1 }, }; @@ -431,7 +432,7 @@ test "vertical opening rises through a fixed old-grid clip" { .serial = 5, .phase = .opening, .effect = .vertical, - .from = layout.openingBox(.vertical, target, 3), + .from = animation.openingBox(.vertical, target, 3), .to = target, }; @@ -485,7 +486,7 @@ test "vertical closing drops frozen content over canonical cells" { .phase = .closing, .effect = .vertical, .from = old_box, - .to = layout.closingBox(.vertical, old_box), + .to = animation.closingBox(.vertical, old_box), }; const first = try compose(arena.allocator(), &surface, &.{track}, null); @@ -530,13 +531,13 @@ test "closing content paints after opening content regardless of track order" { .phase = .closing, .effect = .vertical, .from = box, - .to = layout.closingBox(.vertical, box), + .to = animation.closingBox(.vertical, box), }; const opening: Track = .{ .serial = 2, .phase = .opening, .effect = .ascii, - .frame = layout.Transition.ascii.frames() - 1, + .frame = animation.Transition.ascii.frames() - 1, .from = box, .to = box, }; @@ -691,8 +692,8 @@ test "active panel transition hides only its own native attachment" { const tracks = [_]Track{ .{ .serial = 41, .effect = .slide, .frame = 0 }, .{ .serial = 42, .effect = .ascii, .frame = 0 }, - .{ .serial = 43, .effect = .zoom, .frame = layout.Transition.zoom.frames() - 1 }, - .{ .serial = 44, .effect = .zoom, .frame = layout.Transition.zoom.frames() }, + .{ .serial = 43, .effect = .zoom, .frame = animation.Transition.zoom.frames() - 1 }, + .{ .serial = 44, .effect = .zoom, .frame = animation.Transition.zoom.frames() }, .{ .serial = 45, .phase = .opening, .effect = .vertical, .frame = 0 }, }; diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 32f9138f..66e551b1 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -899,7 +899,7 @@ const Shell = struct { caps_pending: bool = true, check_files: bool = false, in_paste: bool = false, - tracks: []const pardes.layout.Track = &.{}, + tracks: []const pardes.animation.Track = &.{}, fn of(ctx: ?*anyopaque) *Shell { return @ptrCast(@alignCast(ctx.?)); @@ -1532,7 +1532,7 @@ fn tickWatch(io: std.Io, loop: *Loop) void { while (true) { tick_request.waitUncancelable(io); tick_request.reset(); - var ts: libc.timespec = .{ .sec = 0, .nsec = @as(isize, pardes.layout.Animation.frame_ms) * std.time.ns_per_ms }; + var ts: libc.timespec = .{ .sec = 0, .nsec = @as(isize, pardes.animation.Animation.frame_ms) * std.time.ns_per_ms }; _ = libc.nanosleep(&ts, null); loop.postEvent(.tick) catch {}; } diff --git a/test/panes.zig b/test/panes.zig index d0551cfc..11fb750a 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -2,6 +2,7 @@ const std = @import("std"); const pardes = @import("pardes"); const panes = pardes.panes; const layout = pardes.layout; +const animation = pardes.animation; const Pardes = pardes.Pardes; const Pane = pardes.Pane; const Key = pardes.Key; @@ -70,7 +71,7 @@ const TagLayerTests = struct { try std.testing.expectEqualStrings("body\n", pane.file.?.content); p.update(.{ .key = .{ .cp = Key.escape } }); const rect = p.rects[0]; - const track: layout.Track = .{ + const track: animation.Track = .{ .serial = pane.serial, .pane = 0, .effect = .zoom, diff --git a/test/pdf.zig b/test/pdf.zig index 3a592451..ed5d6908 100644 --- a/test/pdf.zig +++ b/test/pdf.zig @@ -7,6 +7,7 @@ const image = pardes.image; const config = pardes.config; const builtins = pardes.builtins; const layout = pardes.layout; +const animation = pardes.animation; const dump = pardes.dump; const Pardes = pardes.Pardes; @@ -1399,7 +1400,7 @@ test "PDF tint and tinted theme changes rebuild every visible raster only" { // Chrome animation reads a separate palette. Its ticks must never disturb // the target-theme tint key or ask MuPDF for the same pixels again. try std.testing.expect(p.animationActive()); - for (0..layout.Animation.transition_steps) |_| { + for (0..animation.Animation.transition_steps) |_| { p.update(.tick); _ = frame.reset(.retain_capacity); const tick_surface = try p.render(frame.allocator()); -- cgit v1.3 From e0b5564f04aa0ae6666f29ef29c768d800fbabce Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 13:15:41 -0300 Subject: Step core animation by the shell's clock and sleep to the next wake A shell now answers Host.now, its monotonic clock in ns, and the pump advances core animation to it: one .tick per whole 16 ms frame since core time last stood still. The same animation therefore takes the same time at 60, 120 and 144 Hz, over ssh and after a slow frame (the GUI's re-armed clock ran them ~25% slow at 144 Hz; the tty's post-frame sleep drifted). nextWake() lists every core animation in one place: a frame from now while anything moves, the end of the wait while something only waits (a message lingering, the look-hover delay), null when idle. Shells sleep exactly that long, and a wait is jumped to its end in one step, so an 800 ms linger costs one frame instead of fifty. An overshoot under 1.5 ms after a step is let go: at 60 and 120 Hz every display frame takes exactly one step (a 16 ms frame against a 16.67 ms vsync would otherwise double-step every 24th). The six tick drivers are gone: tty's timer thread only times the wait, interruptibly (a newer, shorter request cuts short a sleep still timing a longer one); the GUI's AnimationClock, web's JS tick bank (pardes_tick now takes rAF's timestamp), the detached server's and the board's ticks; grid mode steps a virtual clock straight to each wake. PARDES_TEST_CLOCK, set by the snapshot harness, gives tty and the detached server the same virtual clock. Every stepped frame is drawn. The old pump never drew the last frame of a fade (a .tick asks for no frame, and the fade was over by the check), so a theme switch stopped at 9/10 of the way until the next input: theme.golden, themesel.golden and the GUI's acme-light scene move to the theme's true colours, and nothing else changes. The frozen previous grid is captured only while a panel transition is chosen, not on every idle frame. --- docs/render-pipeline.md | 2 +- src/Messages.zig | 81 ++- src/Presentation.zig | 13 +- src/animation.zig | 261 +++++----- src/colors.zig | 8 +- src/detached/server.zig | 21 +- src/esp32p4.zig | 15 +- src/exec.zig | 4 + src/gui/gui.zig | 86 +--- src/host_io.zig | 17 + src/macos.zig | 8 +- src/pardes.zig | 151 +++++- src/tty/tty.zig | 108 +++- src/web.zig | 36 +- src/web/app.mjs | 36 +- test/gui-goldens.txt | 6 +- test/gui_golden.py | 4 +- test/pdf.zig | 2 +- test/snapshot.zig | 3 + test/snapshots/theme.golden | 1066 ++++++++++++++++++++-------------------- test/snapshots/themesel.golden | 945 ++++++++++++++++++----------------- test/web_harness.mjs | 16 +- 22 files changed, 1557 insertions(+), 1332 deletions(-) diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index 2a679b8d..590a7e72 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -803,7 +803,7 @@ stripped. No visual change until stage 9 unless stated. |---|---|---| | 0 | GUI capture goldens: a handful of scenes rendered hidden in capture mode, PPM hashes checked in (same machine/driver) | the safety net the GUI stages need; machine- and driver-specific, so a LOCAL gate only, never CI | | 1 | pure moves: render → `src/draw.zig`; Presentation/animation out of layout.zig | no behaviour | -| 2a | clock plumbing: `now_ns` into pump/events, `nextWake`, `advance`, deadlines for linger/hover; all shells pass now and sleep to the wake; six tick drivers deleted; ack moves into pump; capturePrevious only when transitions are on. Animations still compute `frame = floor(elapsed / 16 ms)`, so every curve is identical | pacing is corrected (144 Hz no longer ~25% slow, ssh no longer drifts): a bug fix allowed in this phase. Unit tests step an injected clock by exactly 16 ms, so their numbers do not change. The snapshot harness runs the binary with `--test-clock` (each core-time step is exactly 16.67 ms per tick request, as today), so goldens see the same sequence and `stable` (test/snapshot.zig ~861) is unaffected by wall-time pacing | +| 2a | clock plumbing: `Host.now` (the shell's monotonic ns) and `Pardes.advance(now)`, which runs one `.tick` per whole 16 ms frame since core time last stood still; `nextWake()` lists every core animation and answers the next frame while anything moves, the wait's end while something only waits (message linger, look-hover delay), null when idle; the pump sleeps to it and draws only stepped frames. No shell posts `.tick` any more (tty's timer thread only wakes the wait; GUI's AnimationClock, web's JS tick bank and the detached server's and the board's ticks are gone; grid mode moves its virtual clock straight to the next wake). `acknowledgePanelPresentation` stays in the shells (revisit at stage 8: grid mode acks `&.{}` on purpose). capturePrevious only when transitions are on. `.tick` stays as the one-frame step `advance` and the unit tests use, so every curve and every asserted number is unchanged | pacing is corrected (144 Hz no longer ~25% slow, ssh no longer drifts): a bug fix allowed in this phase. PARDES_TEST_CLOCK (set by the snapshot harness): tty and the detached server answer a virtual clock that a timed-out wait moves exactly to the core's next wake, so goldens replay the same frame sequence on any machine load. Review fixes: tty's wake timer is interruptible (a newer, shorter request cuts short an older sleep); a wait (linger, hover delay) is jumped to its end in one step, never counted against the 240-frame catch-up cap; an overshoot under 1.5 ms after a step is let go, so 60 and 120 Hz take exactly one step per display frame. Known exceptions: the GUI still polls every 16 ms when idle (SDL events, gamepad, fs tick, smooth scroll depend on it; revisit at stage 8); a minimized GUI renders every stepped frame of a running animation (stage 8) | | 2b | (on `fx`, feel-reviewed) Tween/Spring curves, settle rules, OKLab crossfades, new durations from §8.1 | a look change, kept apart from 2a so either can be bisected | | 3 | paint functions take `s: *Surface`; delete the swap hack | mechanical, wide | | 4 | PLACE: Region list built once; renderPane/tags/notices read their rects from it | wait for tag→Text; the refactor is already replacing BOX_H with `pane.tag_rows` and `p.tagTop/bodyTop(pane, r)` — PLACE absorbs those into the region rects | diff --git a/src/Messages.zig b/src/Messages.zig index 97a2f939..7334de95 100644 --- a/src/Messages.zig +++ b/src/Messages.zig @@ -272,11 +272,35 @@ fn advanceLine(p: *const Pardes, life: *Pane.MessageLife) bool { return false; } -/// Is any message line moving (and so owed ticks)? -pub fn messagesAnimating(pane: *const Pane) bool { - if (pane.msg_len > 0 and pane.msg_life.phase != .shown) return true; - for (pane.msg_older[0..pane.msg_older_len]) |line| if (line.life.phase != .shown) return true; - return false; +/// Move every lingering message line `frames` along its linger at once: +/// how `advance` jumps a wait instead of stepping it frame by frame. +pub fn holdMessages(p: *Pardes, frames: u64) void { + const n: u16 = @intCast(@min(frames, std.math.maxInt(u16))); + for (p.panes) |slot| { + const pane = slot orelse continue; + if (pane.msg_len > 0 and pane.msg_life.phase == .lingering) pane.msg_life.frame +|= n; + for (pane.msg_older[0..pane.msg_older_len]) |*line| { + if (line.life.phase == .lingering) line.life.frame +|= n; + } + } +} + +/// Frames until this pane's message lines next change: 1 while a line +/// falls in or dissolves, the rest of its linger while one only waits, null +/// when every line just shows. +pub fn messageWake(p: *const Pardes, pane: *const Pane) ?u64 { + var soonest: ?u64 = null; + const linger = messageFrames(p.settings.message_linger_ms); + for (0..@as(usize, pane.msg_older_len) + 1) |i| { + const life = if (i < pane.msg_older_len) pane.msg_older[i].life else if (pane.msg_len > 0) pane.msg_life else continue; + const frames: u64 = switch (life.phase) { + .shown => continue, + .entering, .leaving => 1, + .lingering => @max(1, linger -| life.frame), + }; + soonest = if (soonest) |n| @min(n, frames) else frames; + } + return soonest; } /// How present a message is (1 = fully) and how far above its row it @@ -286,7 +310,7 @@ pub const MessageMotion = struct { alpha: f32 = 1, slide: f32 = 0 }; /// Animation frames a message timing lasts; at least one. pub fn messageFrames(ms: u16) u16 { - return @intCast(@max(1, (@as(u32, ms) + animation.Animation.frame_ms - 1) / animation.Animation.frame_ms)); + return @intCast(@max(1, (@as(u32, ms) + animation.frame_ms - 1) / animation.frame_ms)); } pub fn messageMotion(p: *const Pardes, life: Pane.MessageLife) MessageMotion { @@ -792,3 +816,48 @@ test "notices stack over the body's top rows without moving them" { for (p.surface.tagLayers()) |*layer| try std.testing.expect(layer.cols == 0 or layer.kind != .notice); } + +test "an idle core wants no wake and a lingering message sleeps to its linger's end" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const frame = animation.frame_ns; + p.advance(1_000_000); + try std.testing.expectEqual(@as(?u64, null), p.nextWake()); + const pane = p.panes[p.active].?; + try std.testing.expect(p.executeBuiltinLine(p.active, "Msg hello")); + // Falling in: a frame from now. + try std.testing.expectEqual(@as(?u64, p.stepped_ns + frame), p.nextWake()); + const fall = messageFrames(p.settings.message_fall_ms); + p.advance(p.stepped_ns + fall * frame); + try std.testing.expectEqual(Pane.MessagePhase.shown, pane.msg_life.phase); + try std.testing.expectEqual(@as(?u64, null), p.nextWake()); + // A key starts the 800 ms linger: the core sleeps to its end, not a + // frame at a time, and the frames it jumps draw nothing. + p.update(.{ .key = .{ .cp = 'x' } }); + try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase); + const linger = messageFrames(p.settings.message_linger_ms); + const due = p.stepped_ns + linger * frame; + try std.testing.expectEqual(@as(?u64, due), p.nextWake()); + p.needs_frame = false; + p.advance(due - frame); + try std.testing.expectEqual(@as(?u64, due), p.nextWake()); + try std.testing.expectEqual(Pane.MessagePhase.lingering, pane.msg_life.phase); + p.advance(due); + try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase); + try std.testing.expectEqual(@as(?u64, due + frame), p.nextWake()); + p.advance(due + 10 * std.time.ns_per_s); + try std.testing.expectEqual(@as(usize, 0), pane.msg_len); + try std.testing.expectEqual(@as(?u64, null), p.nextWake()); + + // A long linger ends when it says, jumped in one step, not stretched by + // the cap on catching up with moving frames. + try std.testing.expect(p.executeBuiltinLine(p.active, "MessageLinger 5000")); + try std.testing.expect(p.executeBuiltinLine(p.active, "Msg again")); + p.advance(p.stepped_ns + fall * frame); + p.update(.{ .key = .{ .cp = 'x' } }); + const long_due = p.stepped_ns + messageFrames(5000) * frame; + try std.testing.expectEqual(@as(?u64, long_due), p.nextWake()); + p.advance(long_due); + try std.testing.expectEqual(Pane.MessagePhase.leaving, pane.msg_life.phase); +} diff --git a/src/Presentation.zig b/src/Presentation.zig index fe8317dd..45b196c8 100644 --- a/src/Presentation.zig +++ b/src/Presentation.zig @@ -295,11 +295,18 @@ pub fn acknowledge(self: *Presentation, p: *Pardes, tracks: []const Track) void self.tracks = @splat(null); self.closing_len = 0; self.diff_state = .none; - self.capturePrevious(p.gpa, &p.surface); + // The frozen grid exists for transitions alone: with them off, an + // idle frame copies nothing. + if (p.settings.panel_transition != .off) { + self.capturePrevious(p.gpa, &p.surface); + } else { + self.previous_valid = false; + self.previous_layout = @splat(null); + } } } -fn capturePrevious(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) void { +pub fn capturePrevious(self: *Presentation, gpa: std.mem.Allocator, surface: *const pardes.Surface) void { self.diff_state = .none; const cells = surface.cells; if (cells.len == 0) { @@ -651,6 +658,8 @@ test "presentation submission failure preserves shown geometry until retry is ac defer p.deinit(); var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); defer frame.deinit(); + // The frozen grid is kept only while a transition is chosen. + p.settings.panel_transition = .slide; _ = try p.render(frame.allocator()); p.acknowledgePanelPresentation(&.{}); const shown = p.presentation.shown; diff --git a/src/animation.zig b/src/animation.zig index b190c1a1..d6ef1ec3 100644 --- a/src/animation.zig +++ b/src/animation.zig @@ -588,157 +588,158 @@ test "dissolve has exact stable endpoints" { } } -pub const Animation = struct { - pub const frame_ms: u32 = 16; - pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms; - pub const transition_steps: u16 = 10; - - pub fn Transition(comptime Value: type) type { - return struct { - const Self = @This(); - - from: Value, - to: Value, - displayed: Value, - step: u16 = transition_steps, - - pub fn init(value: Value) Self { - return .{ .from = value, .to = value, .displayed = value }; - } +pub const frame_ms: u32 = 16; +pub const frame_ns: u64 = frame_ms * std.time.ns_per_ms; +pub const transition_steps: u16 = 10; + +/// A displayed value that fades from one target to the next over +/// `transition_steps` frames; the chrome colours are one. +pub fn Fade(comptime Value: type) type { + return struct { + const Self = @This(); + + from: Value, + to: Value, + displayed: Value, + step: u16 = transition_steps, + + pub fn init(value: Value) Self { + return .{ .from = value, .to = value, .displayed = value }; + } - pub fn isActive(a: *const Self) bool { - return a.step < transition_steps; - } + pub fn isActive(a: *const Self) bool { + return a.step < transition_steps; + } - pub fn retarget(a: *Self, target: Value) void { - 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; - } + pub fn retarget(a: *Self, target: Value) void { + 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; + } - pub fn advance(a: *Self) void { - if (!a.isActive()) return; - a.step += 1; - a.displayed = if (a.step == transition_steps) - a.to - else - Value.interpolate(a.from, a.to, a.step, transition_steps); - } + pub fn advance(a: *Self) void { + if (!a.isActive()) return; + a.step += 1; + a.displayed = if (a.step == transition_steps) + a.to + else + Value.interpolate(a.from, a.to, a.step, transition_steps); + } - pub fn snap(a: *Self, value: Value) void { - a.* = init(value); - } - }; - } + pub fn snap(a: *Self, value: Value) void { + a.* = init(value); + } + }; +} - pub fn Immediate(comptime Value: type) type { - return struct { - const Self = @This(); +pub fn Immediate(comptime Value: type) type { + return struct { + const Self = @This(); - displayed: Value, + displayed: Value, - pub fn init(value: Value) Self { - return .{ .displayed = value }; - } + pub fn init(value: Value) Self { + return .{ .displayed = value }; + } - pub fn isActive(_: *const Self) bool { - return false; - } + pub fn isActive(_: *const Self) bool { + return false; + } - pub fn retarget(a: *Self, target: Value) void { - a.displayed = target; - } + pub fn retarget(a: *Self, target: Value) void { + a.displayed = target; + } - pub fn advance(_: *Self) void {} + pub fn advance(_: *Self) void {} - pub fn snap(a: *Self, value: Value) void { - a.displayed = value; - } - }; - } - - pub fn interpolateRgb(from: [3]u8, to: [3]u8, step: u16, steps: u16) [3]u8 { - if (step == 0) return from; - if (step >= steps) return to; - var out: [3]u8 = undefined; - for (&out, from, to) |*dst, a, b| { - const numerator = @as(u32, a) * (steps - step) + @as(u32, b) * step; - dst.* = @intCast((numerator + steps / 2) / steps); + pub fn snap(a: *Self, value: Value) void { + a.displayed = value; } - return out; + }; +} + +pub fn interpolateRgb(from: [3]u8, to: [3]u8, step: u16, steps: u16) [3]u8 { + if (step == 0) return from; + if (step >= steps) return to; + var out: [3]u8 = undefined; + for (&out, from, to) |*dst, a, b| { + const numerator = @as(u32, a) * (steps - step) + @as(u32, b) * step; + dst.* = @intCast((numerator + steps / 2) / steps); } + return out; +} - const TestColor = struct { - rgb: [3]u8, +const TestColor = struct { + rgb: [3]u8, - pub fn interpolate(from: TestColor, to: TestColor, step: u16, steps: u16) TestColor { - return .{ .rgb = interpolateRgb(from.rgb, to.rgb, step, steps) }; - } - }; + pub fn interpolate(from: TestColor, to: TestColor, step: u16, steps: u16) TestColor { + return .{ .rgb = interpolateRgb(from.rgb, to.rgb, step, steps) }; + } +}; - test "Immediate lands where a completed Transition lands" { - const from: TestColor = .{ .rgb = .{ 240, 3, 90 } }; - const to: TestColor = .{ .rgb = .{ 5, 222, 90 } }; +test "Immediate lands where a completed Transition lands" { + const from: TestColor = .{ .rgb = .{ 240, 3, 90 } }; + const to: TestColor = .{ .rgb = .{ 5, 222, 90 } }; - var faded = Animation.Transition(TestColor).init(from); - faded.retarget(to); - for (0..transition_steps) |_| faded.advance(); + var faded = Fade(TestColor).init(from); + faded.retarget(to); + for (0..transition_steps) |_| faded.advance(); - var instant = Immediate(TestColor).init(from); - try std.testing.expect(!instant.isActive()); - instant.retarget(to); - try std.testing.expectEqual(faded.displayed, instant.displayed); + var instant = Immediate(TestColor).init(from); + try std.testing.expect(!instant.isActive()); + instant.retarget(to); + try std.testing.expectEqual(faded.displayed, instant.displayed); - // Never active, so a frontend that renders only while something is animating stops immediately - // rather than spending ten frames discovering there is nothing to draw. - try std.testing.expect(!instant.isActive()); - instant.advance(); - try std.testing.expectEqual(to, instant.displayed); + // Never active, so a frontend that renders only while something is animating stops immediately + // rather than spending ten frames discovering there is nothing to draw. + try std.testing.expect(!instant.isActive()); + instant.advance(); + try std.testing.expectEqual(to, instant.displayed); - instant.snap(from); - try std.testing.expectEqual(from, instant.displayed); - } + instant.snap(from); + try std.testing.expectEqual(from, instant.displayed); +} - test "fixed-step interpolation has exact monotonic endpoints" { - const Tween = Animation.Transition(TestColor); - const from: TestColor = .{ .rgb = .{ 240, 3, 90 } }; - const to: TestColor = .{ .rgb = .{ 5, 222, 90 } }; - var tween = Tween.init(from); - tween.retarget(to); - try std.testing.expectEqual(from, tween.displayed); - - var previous = tween.displayed; - for (0..transition_steps) |_| { - tween.advance(); - try std.testing.expect(tween.displayed.rgb[0] <= previous.rgb[0]); - try std.testing.expect(tween.displayed.rgb[1] >= previous.rgb[1]); - try std.testing.expectEqual(@as(u8, 90), tween.displayed.rgb[2]); - previous = tween.displayed; - } - try std.testing.expect(!tween.isActive()); - try std.testing.expectEqual(to, tween.displayed); - tween.advance(); - try std.testing.expectEqual(to, tween.displayed); - } +test "fixed-step interpolation has exact monotonic endpoints" { + const Tween = Fade(TestColor); + const from: TestColor = .{ .rgb = .{ 240, 3, 90 } }; + const to: TestColor = .{ .rgb = .{ 5, 222, 90 } }; + var tween = Tween.init(from); + tween.retarget(to); + try std.testing.expectEqual(from, tween.displayed); - test "retarget starts at the currently displayed value" { - const Tween = Animation.Transition(TestColor); - const first: TestColor = .{ .rgb = .{ 0, 40, 200 } }; - const second: TestColor = .{ .rgb = .{ 200, 140, 0 } }; - const third: TestColor = .{ .rgb = .{ 20, 10, 250 } }; - var tween = Tween.init(first); - tween.retarget(second); - tween.advance(); - tween.advance(); + var previous = tween.displayed; + for (0..transition_steps) |_| { tween.advance(); - const on_screen = tween.displayed; - - tween.retarget(third); - try std.testing.expectEqual(on_screen, tween.from); - try std.testing.expectEqual(on_screen, tween.displayed); - try std.testing.expect(tween.isActive()); - for (0..transition_steps) |_| tween.advance(); - try std.testing.expectEqual(third, tween.displayed); + try std.testing.expect(tween.displayed.rgb[0] <= previous.rgb[0]); + try std.testing.expect(tween.displayed.rgb[1] >= previous.rgb[1]); + try std.testing.expectEqual(@as(u8, 90), tween.displayed.rgb[2]); + previous = tween.displayed; } -}; + try std.testing.expect(!tween.isActive()); + try std.testing.expectEqual(to, tween.displayed); + tween.advance(); + try std.testing.expectEqual(to, tween.displayed); +} + +test "retarget starts at the currently displayed value" { + const Tween = Fade(TestColor); + const first: TestColor = .{ .rgb = .{ 0, 40, 200 } }; + const second: TestColor = .{ .rgb = .{ 200, 140, 0 } }; + const third: TestColor = .{ .rgb = .{ 20, 10, 250 } }; + var tween = Tween.init(first); + tween.retarget(second); + tween.advance(); + tween.advance(); + tween.advance(); + const on_screen = tween.displayed; + + tween.retarget(third); + try std.testing.expectEqual(on_screen, tween.from); + try std.testing.expectEqual(on_screen, tween.displayed); + try std.testing.expect(tween.isActive()); + for (0..transition_steps) |_| tween.advance(); + try std.testing.expectEqual(third, tween.displayed); +} + diff --git a/src/colors.zig b/src/colors.zig index f814d55d..0065d5ef 100644 --- a/src/colors.zig +++ b/src/colors.zig @@ -186,15 +186,15 @@ pub const ChromeTheme = struct { pub fn interpolate(from: ChromeTheme, to: ChromeTheme, step: u16, steps: u16) ChromeTheme { var out: ChromeTheme = undefined; inline for (@typeInfo(ChromeTheme).@"struct".fields) |field| - @field(out, field.name) = animation.Animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps); + @field(out, field.name) = animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps); return out; } }; pub const ChromeAnimation = if (theme_animation) - animation.Animation.Transition(ChromeTheme) + animation.Fade(ChromeTheme) else - animation.Animation.Immediate(ChromeTheme); + animation.Immediate(ChromeTheme); pub const initial_chrome = ChromeTheme.fromTheme(&themes[0]); @@ -414,7 +414,7 @@ test "runtime theme changes animate chrome and retarget without a jump" { try std.testing.expectEqualStrings("acme", p.theme().name); try std.testing.expectEqual(midflight, p.chromeTheme().*); try std.testing.expect(p.animationActive()); - for (0..animation.Animation.transition_steps) |_| p.update(.tick); + for (0..animation.transition_steps) |_| p.update(.tick); try std.testing.expect(!p.animationActive()); try std.testing.expectEqual(ChromeTheme.fromTheme(p.theme()), p.chromeTheme().*); // Extra ticks are inert at the exact endpoint. diff --git a/src/detached/server.zig b/src/detached/server.zig index 5c0e1487..212c9a3d 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -140,6 +140,8 @@ pub const Session = struct { worker_gpa: std.mem.Allocator, io: std.Io, core: *pardes.Pardes, + /// PARDES_TEST_CLOCK's virtual time (host_io.testClock). + test_clock: ?u64 = null, listener: c_int = -1, path_buf: [sun_path_len]u8 = undefined, path_len: usize = 0, @@ -343,8 +345,14 @@ pub const Session = struct { return @ptrCast(@alignCast(ctx.?)); } + fn clockNow(ctx: ?*anyopaque) u64 { + const s = of(ctx); + return s.test_clock orelse host_io.monotonicNs(); + } + const vtable: host_io.Host.VTable = .{ .wait_input = waitInput, + .now = clockNow, .present = present, .poll_frame = pollFrame, .spawn = spawn, @@ -827,8 +835,9 @@ pub const Session = struct { pardes.turn.rest(); nap(if (timeout_ms == 0) 16 else timeout_ms); pardes.turn.wake(); - // An animating frame's wait is the frame: tick it. - if (timeout_ms != 0) s.core.update(.tick); + if (timeout_ms != 0) if (s.test_clock) |*virtual| { + virtual.* = @max(virtual.*, s.core.nextWake() orelse virtual.*); + }; return; } var timeout: c_int = if (timeout_ms == 0) -1 else @intCast(@min(timeout_ms, std.math.maxInt(c_int))); @@ -838,9 +847,10 @@ pub const Session = struct { const ready = libc.poll(&fds, @intCast(n), timeout); pardes.turn.wake(); if (ready > 0) s.dispatch(fds[0..n], src[0..n]); - // Animating, the frame's time is up: advance it. There is no other - // tick in a detached session, and without it an animation never ends. - if (timeout_ms != 0 and monotonicMs() -| now >= timeout_ms) s.core.update(.tick); + // The core's wake ran out; the pump advances it from `now`. + if (timeout_ms != 0 and monotonicMs() -| now >= timeout_ms) if (s.test_clock) |*virtual| { + virtual.* = @max(virtual.*, s.core.nextWake() orelse virtual.*); + }; _ = s.drainCompletions(true); s.expire(monotonicMs()); s.harvest(); @@ -1155,6 +1165,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options, name: []const u8) !void .cols = options.cols, .rows = options.rows, .prompt_rcs = host_io.Shell.prepare(), + .test_clock = host_io.testClock(), }; defer session.core.deinit(); defer session.deinit(); diff --git a/src/esp32p4.zig b/src/esp32p4.zig index 0d532e1e..c583de54 100644 --- a/src/esp32p4.zig +++ b/src/esp32p4.zig @@ -572,10 +572,9 @@ export fn pardes_esp32p4_tick(now_ms: u64) callconv(.c) void { dirty = true; } } - if (c.animationActive()) { - c.update(.tick); - dirty = true; - } + // The core steps its own animations by this clock (`Host.now`). + c.advance(now_ms * std.time.ns_per_ms); + if (c.needs_frame) dirty = true; } /// How long a lone ESC waits for a second byte before it counts as the Escape key. @@ -590,7 +589,7 @@ var esc_held_at: ?u64 = null; export fn pardes_esp32p4_wants_frame() callconv(.c) bool { const c = core orelse return false; - return dirty or c.animationActive(); + return dirty or c.needs_frame; } export fn pardes_esp32p4_render() callconv(.c) u32 { @@ -607,7 +606,11 @@ export fn pardes_esp32p4_quit() callconv(.c) bool { // ------------------------------------------------------------------------------------ the host -const pardes_host: pardes.Host.VTable = .{ .present = present, .gpio_toggle = gpioToggle }; +const pardes_host: pardes.Host.VTable = .{ .now = boardNow, .present = present, .gpio_toggle = gpioToggle }; + +fn boardNow(_: ?*anyopaque) u64 { + return last_now_ms * std.time.ns_per_ms; +} /// The `Gpio` word's one seam to the board. Nothing here knows what a pad is; it forwards, and /// answers false when the firmware brought none, which is what puts "gpio: NoPads" on the message diff --git a/src/exec.zig b/src/exec.zig index b16c600c..ad0f7b9b 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -502,6 +502,10 @@ pub fn applySettingBuiltin(p: *Pardes, setting: config.Runtime.Setting, arg: ?[] if (p.settings.column_tags != previous_column_tags) { if (p.header_column != null) tagline.exitHeader(p); } + // Idle frames keep no frozen grid while transitions are off; the one + // just drawn is the baseline the first transition starts from. + if (transition_changed and previous_transition == .off) + p.presentation.capturePrevious(p.gpa, &p.surface); if (transition_changed or tagline_metrics_changed or p.settings.column_tags != previous_column_tags) { p.abandonPanelAnimations(); } diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 0737bb63..a1e21dc0 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -200,55 +200,18 @@ const touch_click_flash_max_frames: u8 = 14; const touch_click_flash_vertices: usize = 1400; const touch_scroll_tick: f32 = 0.02; -const AnimationClock = struct { - next_ns: u64 = 0, - - fn due(clock: *AnimationClock, active: bool, now_ns: u64) bool { - if (!active) { - clock.next_ns = 0; - return false; - } - if (clock.next_ns == 0) { - clock.next_ns = now_ns +| pardes.animation.Animation.frame_ns; - return false; - } - if (now_ns < clock.next_ns) return false; - clock.next_ns = now_ns +| pardes.animation.Animation.frame_ns; - return true; - } -}; - -fn finishPresentedAnimationFrame( - clock: *AnimationClock, - core: *pardes.Pardes, - tracks: []const pardes.animation.Track, - now_ns: u64, -) void { - core.acknowledgePanelPresentation(tracks); - if (clock.due(core.animationActive(), now_ns)) core.update(.tick); -} - -test "GUI animation clock is active-only and cadence gated" { - var clock: AnimationClock = .{}; - try std.testing.expect(!clock.due(false, 100)); - try std.testing.expect(!clock.due(true, 100)); - try std.testing.expect(!clock.due(true, 100 + pardes.animation.Animation.frame_ns - 1)); - try std.testing.expect(clock.due(true, 100 + pardes.animation.Animation.frame_ns)); - try std.testing.expect(!clock.due(false, 100 + 2 * pardes.animation.Animation.frame_ns)); - try std.testing.expectEqual(@as(u64, 0), clock.next_ns); -} - test "a persistent scene presents a new panel's frame zero before advancing it" { const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer core.deinit(); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); - var clock: AnimationClock = .{}; + const frame = pardes.animation.frame_ns; core.settings.scene_effects.crt = true; + core.advance(100); const scene_frame = try core.render(arena.allocator()); - finishPresentedAnimationFrame(&clock, core, scene_frame.panelTracks(), 100); - try std.testing.expectEqual(@as(u64, 100 + pardes.animation.Animation.frame_ns), clock.next_ns); + core.acknowledgePanelPresentation(scene_frame.panelTracks()); + try std.testing.expectEqual(@as(?u64, 100 + frame), core.nextWake()); core.settings.panel_transition = .slide; core.update(.{ .command = "Newcol" }); @@ -258,12 +221,12 @@ test "a persistent scene presents a new panel's frame zero before advancing it" try std.testing.expect(track_count > 0); for (first.panelTracks()) |track| try std.testing.expectEqual(@as(u16, 0), track.frame); - finishPresentedAnimationFrame( - &clock, - core, - first.panelTracks(), - 100 + pardes.animation.Animation.frame_ns, - ); + core.acknowledgePanelPresentation(first.panelTracks()); + // Just short of a frame is no frame at all. + core.advance(100 + frame - 1); + _ = arena.reset(.retain_capacity); + for ((try core.render(arena.allocator())).panelTracks()) |track| try std.testing.expectEqual(@as(u16, 0), track.frame); + core.advance(100 + frame); _ = arena.reset(.retain_capacity); const second = try core.render(arena.allocator()); try std.testing.expectEqual(track_count, second.panelTracks().len); @@ -3742,7 +3705,6 @@ const Shell = struct { presented: bool = false, skipped_presents: u8 = 0, surface: ?*pardes.Surface = null, - animation_clock: AnimationClock = .{}, saw_event: bool = false, dump_err: ?anyerror = null, retired_shells: [pardes.MAX_PANES]RetiredShell = @splat(.{}), @@ -3832,6 +3794,7 @@ const Shell = struct { const vtable: pardes.Host.VTable = .{ .wait_input = waitInput, + .now = guiNow, .present = present, .post_present = postPresent, .poll_frame = pollFrame, @@ -3857,6 +3820,7 @@ const Shell = struct { const grid_vtable: pardes.Host.VTable = vt: { var v = vtable; v.wait_input = null; + v.now = null; v.poll_frame = gridPollFrame; v.present = gridPresent; v.post_present = gridPostPresent; @@ -4106,20 +4070,16 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void { fn postPresent(ctx: ?*anyopaque) void { const s = shellOf(ctx); const g = s.gui orelse return; - if (!s.presented) { - // No image (a minimized window): time still passes, so a lingering - // message or a hover wait runs out instead of redrawing forever. - if (s.animation_clock.due(s.core.animationActive(), c.SDL_GetTicksNS())) s.core.update(.tick); - return; - } + // No image (a minimized window) shows no tracks; time still passes and + // the pump advances it all the same. + if (!s.presented) return; const frame = s.surface orelse return; refreshPresentedPointer(g, s.core); - finishPresentedAnimationFrame( - &s.animation_clock, - s.core, - frame.panelTracks(), - c.SDL_GetTicksNS(), - ); + s.core.acknowledgePanelPresentation(frame.panelTracks()); +} + +fn guiNow(_: ?*anyopaque) u64 { + return c.SDL_GetTicksNS(); } fn gridPollFrame(ctx: ?*anyopaque) void { @@ -4129,8 +4089,10 @@ fn gridPollFrame(ctx: ?*anyopaque) void { if (f.tick().count != 0) s.saw_event = true; } pollCwds(s.core, s.ptys); - if (s.core.animationActive()) { - s.core.update(.tick); + // Grid time is virtual: each poll moves it straight to the core's + // next wake, a frame at a time while anything moves. + if (s.core.nextWake()) |due| { + s.core.advance(due); s.saw_event = true; } } diff --git a/src/host_io.zig b/src/host_io.zig index 6009c00f..f1a4eb98 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -12,6 +12,9 @@ pub const Host = struct { pub const VTable = struct { wait_input: ?*const fn (ctx: ?*anyopaque, timeout_ms: u32) void = null, + /// The shell's monotonic clock in ns: the only time the core sees. + /// Without one, core animation moves only by `.tick` events. + now: ?*const fn (ctx: ?*anyopaque) u64 = null, present: ?*const fn (ctx: ?*anyopaque, surface: *const pardes.Surface) void = null, post_present: ?*const fn (ctx: ?*anyopaque) void = null, poll_frame: ?*const fn (ctx: ?*anyopaque) void = null, @@ -39,6 +42,20 @@ pub const Host = struct { }; }; +/// The shells' monotonic clock in ns, the one `Host.now` answers with. +/// PARDES_TEST_CLOCK makes a shell answer a virtual clock instead, moved +/// only when a timed wait runs out -- straight to the core's next wake -- so +/// the snapshot harness sees the same frames whatever the machine's load. +pub fn monotonicNs() u64 { + var ts: libc.timespec = undefined; + if (libc.clock_gettime(.MONOTONIC, &ts) != 0) return 0; + return @as(u64, @intCast(ts.sec)) * std.time.ns_per_s + @as(u64, @intCast(ts.nsec)); +} + +pub fn testClock() ?u64 { + return if (libc.getenv("PARDES_TEST_CLOCK")) |v| (if (std.mem.span(v).len != 0) 0 else null) else null; +} + pub const Fallback = struct { pub const dump_path = "pardes.dump.zon"; diff --git a/src/macos.zig b/src/macos.zig index 854b24f8..fce0a53e 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -118,7 +118,7 @@ const scene_frame_hz: u32 = 60; const scene_wrap_ns: u64 = 4096 * std.time.ns_per_s; -const max_tick_catch_up_ns: u64 = 4 * pardes.animation.Animation.frame_ns; +const max_tick_catch_up_ns: u64 = 4 * pardes.animation.frame_ns; const theme_watch_pane: u8 = @intCast(pardes.MAX_PANES); const watch_slot_count = pardes.MAX_PANES + 1; @@ -870,18 +870,18 @@ const TickSpend = struct { elapsed_ns: u64, steps: u32, bank_ns: u64 }; fn spendTickTime(previous_ns: u64, now_ns: u64, bank_ns: u64) TickSpend { const measured = if (previous_ns == 0 or now_ns <= previous_ns) - pardes.animation.Animation.frame_ns + pardes.animation.frame_ns else now_ns - previous_ns; const elapsed = @min(measured, max_tick_catch_up_ns); var bank = bank_ns +| elapsed; var steps: u32 = 0; - while (bank >= pardes.animation.Animation.frame_ns) : (steps += 1) bank -= pardes.animation.Animation.frame_ns; + while (bank >= pardes.animation.frame_ns) : (steps += 1) bank -= pardes.animation.frame_ns; return .{ .elapsed_ns = elapsed, .steps = steps, .bank_ns = bank }; } test "the animation clock spends real time, not callbacks" { - const frame = pardes.animation.Animation.frame_ns; + const frame = pardes.animation.frame_ns; const expectEqual = std.testing.expectEqual; const first = spendTickTime(0, 999 * std.time.ns_per_s, 0); diff --git a/src/pardes.zig b/src/pardes.zig index bfd8a4f6..161a047c 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -3337,9 +3337,9 @@ test "vertical close samples only a canonical baseline containing that pane" { defer p.deinit(); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); + p.settings.panel_transition = .vertical; const boot = try p.render(frame.allocator()); p.acknowledgePanelPresentation(boot.panelTracks()); - p.settings.panel_transition = .vertical; _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); @@ -3360,9 +3360,9 @@ test "vertical close samples only a canonical baseline containing that pane" { defer p.deinit(); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); + p.settings.panel_transition = .vertical; const boot = try p.render(frame.allocator()); p.acknowledgePanelPresentation(boot.panelTracks()); - p.settings.panel_transition = .vertical; _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); @@ -3386,9 +3386,9 @@ test "previous-grid animation refreshes its mask and snaps overlapping layout ch defer p.deinit(); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); + p.settings.panel_transition = .vertical; const boot = try p.render(frame.allocator()); p.acknowledgePanelPresentation(boot.panelTracks()); - p.settings.panel_transition = .vertical; _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); @@ -3875,6 +3875,13 @@ pub const Pardes = struct { /// Set by a shell whose present was skipped (no swapchain image): the /// frame is still owed, and the next pump draws it again. present_skipped: bool = false, + /// Core time. `now_ns` is the shell's monotonic clock as `advance` was + /// last given it; `stepped_ns` is how far animation has been stepped + /// along it, in whole frames of `animation.frame_ns`. Idle, the two are + /// the same instant. + now_ns: u64 = 0, + stepped_ns: u64 = 0, + clock_started: bool = false, /// Builtins announce themselves on the message row. Off while startup /// configuration is applied: those lines are nobody clicking, and should /// not leave the row holding the name of the last one. @@ -4647,7 +4654,17 @@ pub const Pardes = struct { pub fn pump(p: *Pardes, h: Host) !void { p.host = h; const v = h.vtable; - if (v.wait_input) |f| f(h.ctx, if (p.animationActive()) animation.Animation.frame_ms else 0); + if (v.wait_input) |f| { + // Asleep until input or until core animation next needs a + // frame; 0 is no timeout at all. + const timeout: u32 = if (p.nextWake()) |due| wake: { + const now = if (v.now) |clock| clock(h.ctx) else p.now_ns; + const ms = ((due -| now) + std.time.ns_per_ms - 1) / std.time.ns_per_ms; + break :wake @intCast(std.math.clamp(ms, 1, std.math.maxInt(u32))); + } else 0; + f(h.ctx, timeout); + } + if (v.now) |clock| p.advance(clock(h.ctx)); while (p.nextQueued()) |ev| p.update(ev); while (p.nextEffect()) |e| p.perform(e); turn.settle(); @@ -4657,7 +4674,9 @@ pub const Pardes = struct { // Nothing to draw: do not spend a frame, and above all do not block on // the display for one. This is what keeps the frame rate governing // rendering and nothing else. - if (!p.needs_frame and !p.animationActive()) return; + // A shell with a clock draws what `advance` stepped; one without + // steps its own `.tick`s and draws while anything animates. + if (!p.needs_frame and (v.now != null or !p.animationActive())) return; _ = p.frame_arena.reset(.retain_capacity); const surface = try p.render(p.frame_arena.allocator()); if (v.present) |f| f(h.ctx, surface); @@ -5717,18 +5736,85 @@ pub const Pardes = struct { return &p.chrome_animation.displayed; } - /// Whether an idle frontend should schedule another tick. All frame-based - /// core work belongs in this one predicate so TTY and GUI mature it alike. - pub fn animationActive(p: *const Pardes) bool { - if (p.chrome_animation.isActive() or p.look_hover_wait != null) return true; + /// When core animation next needs a frame, on the `stepped_ns` + /// timeline: a frame from now while anything moves, the end of the + /// wait while something only waits (a message lingering, a look-hover + /// delay), null when nothing is animating. Every frame-based animation + /// the core owns is in this one list, which is what lets every shell + /// sleep exactly as long as nothing changes. + pub fn nextWake(p: *const Pardes) ?u64 { + const next = p.stepped_ns + animation.frame_ns; + if (p.chrome_animation.isActive() or p.presentation.animating()) return next; + const scene = p.settings.scene_effects; + if (scene.crt or scene.ripple or scene.glitch) return next; + var frames: ?u64 = null; + if (p.look_hover_wait) |waiting| { + const delay = config.look_preview_delay_frames orelse return next; + frames = @max(1, delay -| waiting.frames); + } for (p.panes) |slot| if (slot) |pane| { - if (Messages.messagesAnimating(pane)) return true; + const wait = Messages.messageWake(p, pane) orelse continue; + frames = if (frames) |soonest| @min(soonest, wait) else wait; }; - const scene = p.settings.scene_effects; - if (scene.crt or scene.ripple or scene.glitch) return true; - return p.presentation.animating(); + return if (frames) |n| p.stepped_ns + n * animation.frame_ns else null; } + /// For shells that only need a yes or no: macOS and the board, which + /// keep their own frame banks. + pub fn animationActive(p: *const Pardes) bool { + return p.nextWake() != null; + } + + /// Bring core animation up to `now_ns`, the shell's monotonic clock: one + /// `.tick` for every whole frame that has passed since it last stood + /// still, so an animation takes as long on a 144 Hz display, over ssh or + /// after a slow frame as it does anywhere. A wait (a message lingering, + /// the look-hover delay) is jumped to its end in one step and drawn + /// once. Idle, time is not banked: the next animation starts from now. + pub fn advance(p: *Pardes, now_ns: u64) void { + // The first time a shell's clock is seen, core time starts there. + const started = p.clock_started; + p.clock_started = true; + p.now_ns = now_ns; + if (!started) p.stepped_ns = now_ns; + const frame = animation.frame_ns; + var moving: u32 = 0; + while (p.nextWake()) |due| { + if (due > now_ns) break; + // Only waiting until `due`: its counters move there at once. + const held = (due - p.stepped_ns) / frame; + if (held > 1) { + Messages.holdMessages(p, held - 1); + if (p.look_hover_wait) |*waiting| waiting.frames +|= @intCast(@min(held - 1, std.math.maxInt(u16))); + p.stepped_ns += (held - 1) * frame; + } + // Back from a stall, the moving frames it missed are not all + // run at once: the rest are stepped from now, at their pace. + if (moving == max_catch_up_frames) { + p.stepped_ns = now_ns; + return; + } + moving += 1; + p.stepped_ns += frame; + p.update(.tick); + // A stepped frame is a frame to draw. (A `.tick` does not ask + // for one itself: the last frame of a fade used never to be + // drawn.) + p.needs_frame = true; + } else { + p.stepped_ns = now_ns; + return; + } + // A 60 Hz display frame is 16.67 ms and ours 16: without this, every + // 24th display frame would take two steps, a hitch in every fade. + // An overshoot this small is let go; a larger one (144 Hz lands 4.8 + // ms past) is kept, so the average rate stays right. + if (moving > 0 and now_ns - p.stepped_ns < vsync_slack_ns) p.stepped_ns = now_ns; + } + + pub const vsync_slack_ns = 1500 * std.time.ns_per_us; + pub const max_catch_up_frames = 240; + pub fn noteHaptic(p: *Pardes, pulse: Haptic) void { if (comptime platform != .macos) return; if (p.haptic == .look) return; @@ -6751,9 +6837,9 @@ test "board heap: transition grids fit the budget and the surface borrows its sn defer p.deinit(); var frame: std.heap.ArenaAllocator = .init(std.testing.allocator); defer frame.deinit(); + p.settings.panel_transition = .vertical; const boot = try p.render(frame.allocator()); p.acknowledgePanelPresentation(boot.panelTracks()); - p.settings.panel_transition = .vertical; _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); p.sync(); @@ -6864,3 +6950,40 @@ test "jump history terminal rectangle follows output and clears on reflow" { try std.testing.expect(pane.pointerSelection(pane.sel[0]) == null); try std.testing.expectEqual(.none, pane.sel[0].state); } + +test "core animation keeps wall time at 60, 120 and 144 Hz and across a stall" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + const frame = animation.frame_ns; + // Something that animates for as long as it is on. + p.settings.scene_effects.crt = true; + for ([_]u64{ 60, 120, 144 }) |hz| { + const start: u64 = 5 * std.time.ns_per_s; + p.clock_started = false; + p.advance(start); + var steps: u64 = 0; + var at: u64 = 1; + while (at <= hz) : (at += 1) { + const before = p.stepped_ns; + p.advance(start + at * std.time.ns_per_s / hz); + const taken = (p.stepped_ns - before + frame / 2) / frame; + // Never two steps in one display frame: that is a visible hitch. + try std.testing.expect(taken <= 1); + steps += taken; + } + switch (hz) { + // One step a display frame, every display frame. + 60 => try std.testing.expectEqual(@as(u64, 60), steps), + 120 => try std.testing.expectEqual(@as(u64, 60), steps), + // The frame's own rate: 62 a second, give or take one. + else => try std.testing.expect(steps >= 61 and steps <= 63), + } + } + // A stall of minutes is not minutes of frames. + const before = p.stepped_ns; + p.advance(before + 600 * std.time.ns_per_s); + try std.testing.expectEqual(before + 600 * std.time.ns_per_s, p.stepped_ns); + p.settings.scene_effects.crt = false; + p.advance(p.stepped_ns + frame); + try std.testing.expectEqual(@as(?u64, null), p.nextWake()); +} diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 66e551b1..6083f609 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -76,7 +76,8 @@ pub const Command = struct { pub var value: union(enum) { nop, quit, - tick, + /// A timed wait ran out: the core's next wake has come. + wake, key_press: vaxis.Key, pty_read: struct { id: usize, gen: u32, bytes: []u8 }, pty_eof: struct { id: usize, gen: u32 }, @@ -728,6 +729,7 @@ fn localSession( .paste_buf = paste_buf, .inotify_fd = file_watch.init(false), .fs = fs, + .test_clock = host_io.testClock(), }; pardes.lsp.setStatusSink(&sh, lspStatusSink); defer { @@ -775,7 +777,7 @@ fn localSession( try startInput(loop, input_cache); defer if (attached.* == null) stopInput(loop); (try std.Thread.spawn(.{}, winchWatch, .{ loop, vx, tty })).detach(); - (try std.Thread.spawn(.{}, tickWatch, .{ io, loop })).detach(); + (try std.Thread.spawn(.{}, wakeWatch, .{ io, loop })).detach(); if (fs) |f| try f.watch(loop, wakeFs); try vx.queryTerminalSend(tty.writer()); @@ -900,6 +902,10 @@ const Shell = struct { check_files: bool = false, in_paste: bool = false, tracks: []const pardes.animation.Track = &.{}, + /// PARDES_TEST_CLOCK's virtual time (host_io.testClock), and where the + /// running-out wait moves it: the core's next wake. + test_clock: ?u64 = null, + wake_to: u64 = 0, fn of(ctx: ?*anyopaque) *Shell { return @ptrCast(@alignCast(ctx.?)); @@ -911,12 +917,14 @@ const Shell = struct { const isolated_vtable: host_io.Host.VTable = .{ .wait_input = waitInput, + .now = clockNow, .present = present, .post_present = postPresent, }; const vtable: host_io.Host.VTable = .{ .wait_input = waitInput, + .now = clockNow, .present = present, .post_present = postPresent, .poll_frame = pollFrame, @@ -939,12 +947,20 @@ const Shell = struct { .pipe = pipe, }; + fn clockNow(ctx: ?*anyopaque) u64 { + const s = of(ctx); + return s.test_clock orelse host_io.monotonicNs(); + } + fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { const s = of(ctx); var batch: usize = 0; - // The wait is the 9P connections' turn with the core. Animating, the - // frame's tick arrives through the same queue (`tickWatch`). - if (timeout_ms != 0) tick_request.set(s.io); + // The wait is the 9P connections' turn with the core. A timeout (the + // core's next wake) arrives through the same queue (`wakeWatch`). + if (timeout_ms != 0) { + s.wake_to = s.core.nextWake() orelse 0; + requestWake(s.io, timeout_ms); + } pardes.turn.rest(); const first = s.loop.nextEvent() catch { pardes.turn.wake(); @@ -984,7 +1000,9 @@ const Shell = struct { defer tz_event.end(); switch (event) { .nop => {}, - .tick => core.update(.tick), + .wake => if (s.test_clock) |*virtual| { + virtual.* = @max(virtual.*, s.wake_to); + }, .quit => { core.quit = true; return true; @@ -1523,21 +1541,77 @@ fn winchWatch(loop: *Loop, vx: *vaxis.Vaxis, tty: *vaxis.Tty) void { } } -/// Set by an animating frame's wait: post one `.tick` a frame from now. The -/// tick joins the input queue, so a key or a click that lands inside the -/// frame is handled at once instead of after a blind sleep. -var tick_request: std.Io.Event = .unset; +/// A wait with a timeout: post one `.wake` when it runs out. The wake joins +/// the input queue, so a key or a click that lands first is handled at once +/// instead of after a blind sleep; vaxis's queue has no timed wait. Each +/// request bumps `wake_gen`, which cuts short a sleep still timing an +/// earlier, longer request (an 800 ms linger interrupted by a key that +/// starts a 16 ms fall) and times the new one instead. +var wake_gen: std.atomic.Value(u32) = .init(0); +var wake_ms: std.atomic.Value(u32) = .init(0); + +fn requestWake(io: std.Io, ms: u32) void { + wake_ms.store(ms, .release); + _ = wake_gen.fetchAdd(1, .acq_rel); + io.futexWake(u32, &wake_gen.raw, 1); +} + +fn wakeWatch(io: std.Io, loop: *Loop) void { + var seen = wake_gen.load(.acquire); + while (true) { + // Asleep until a request. + while (wake_gen.load(.acquire) == seen) io.futexWaitUncancelable(u32, &wake_gen.raw, seen); + seen = wake_gen.load(.acquire); + if (timeWake(io, &seen)) loop.postEvent(.wake) catch {}; + } +} -fn tickWatch(io: std.Io, loop: *Loop) void { +/// Sleep out the latest request; true when it ran its course, false when +/// none is pending. A newer request met on the way restarts the timing. +fn timeWake(io: std.Io, seen: *u32) bool { while (true) { - tick_request.waitUncancelable(io); - tick_request.reset(); - var ts: libc.timespec = .{ .sec = 0, .nsec = @as(isize, pardes.animation.Animation.frame_ms) * std.time.ns_per_ms }; - _ = libc.nanosleep(&ts, null); - loop.postEvent(.tick) catch {}; + const ms = wake_ms.load(.acquire); + const deadline = host_io.monotonicNs() + @as(u64, ms) * std.time.ns_per_ms; + while (true) { + const now = host_io.monotonicNs(); + if (now >= deadline) return true; + io.futexWaitTimeout(u32, &wake_gen.raw, seen.*, .{ .duration = .{ + .raw = .fromNanoseconds(@intCast(deadline - now)), + .clock = .awake, + } }) catch {}; + const gen = wake_gen.load(.acquire); + if (gen != seen.*) { + seen.* = gen; + break; + } + } } } +test "a short wake request cuts short a long one still sleeping" { + const io = std.testing.io; + var seen = wake_gen.load(.acquire); + requestWake(io, 800); + seen = wake_gen.load(.acquire); + const Timer = struct { + fn run(timer_io: std.Io, start_seen: u32, done: *std.atomic.Value(u64)) void { + var local = start_seen; + _ = timeWake(timer_io, &local); + done.store(host_io.monotonicNs(), .release); + } + }; + var done: std.atomic.Value(u64) = .init(0); + const start = host_io.monotonicNs(); + const thread = try std.Thread.spawn(.{}, Timer.run, .{ io, seen, &done }); + var pause: std.c.timespec = .{ .sec = 0, .nsec = 50 * std.time.ns_per_ms }; + _ = std.c.nanosleep(&pause, null); + requestWake(io, 16); + thread.join(); + // The 16 ms request lands about 66 ms in: well before the 800 ms one + // would have, however loaded the machine. + try std.testing.expect(done.load(.acquire) - start < 700 * std.time.ns_per_ms); +} + fn wakeFs(ctx: ?*anyopaque) void { const loop: *Loop = @ptrCast(@alignCast(ctx.?)); _ = loop.tryPostEvent(.fs_ready) catch {}; @@ -1822,7 +1896,7 @@ const Attach = struct { fn apply(a: *Attach, event: @TypeOf(Command.value)) ?AttachEnd { switch (event) { - .nop, .tick, .fs_ready, .lsp_done, .lsp_status, .pipe_done, .pty_read, .pty_eof, .files_changed => {}, + .nop, .wake, .fs_ready, .lsp_done, .lsp_status, .pipe_done, .pty_read, .pty_eof, .files_changed => {}, .quit => return .none, .focus_in => {}, .focus_out => return a.send(.pointer_leave), diff --git a/src/web.zig b/src/web.zig index b77d07a6..532e3794 100644 --- a/src/web.zig +++ b/src/web.zig @@ -1,7 +1,7 @@ //! Freestanding WebAssembly boundary for the DOM backend. //! //! JavaScript owns the clock and nothing else: it calls `pardes_tick` (one -//! `Pardes.pump`) and reads the packed cell buffer `present` left behind. The +//! `Pardes.pump`, at requestAnimationFrame's timestamp) and reads the packed cell buffer `present` left behind. The //! host vtable below is only what a page genuinely has — pixels, the //! clipboard, links, downloads. Processes, a real filesystem, a language //! backend and subprocess pipes are left null, so the core answers them itself @@ -106,6 +106,9 @@ const State = struct { frame_len: usize = 0, frame_cols: u16 = 0, frame_rows: u16 = 0, + /// requestAnimationFrame's timestamp as the last tick gave it, in ns: + /// the core's clock (`Host.now`). + now_ns: u64 = 0, fn init(cols: u16, rows: u16) !State { const allocs = pardes.memory.init(gpa); @@ -276,30 +279,19 @@ export fn pardes_pointer_leave() void { s.core.update(.pointer_leave); } -/// One iteration of the core's own loop: queued input, effects, render, -/// present. JS calls it once per animation frame, after spending that frame's -/// animation ticks. -export fn pardes_tick() void { +/// One iteration of the core's own loop: queued input, effects, the frames +/// of animation `timestamp_ms` (requestAnimationFrame's clock) has brought, +/// render, present. JS calls it once per display frame; a call with no +/// timestamp (NaN) leaves the clock where it was. +export fn pardes_tick(timestamp_ms: f64) void { const s = &(state orelse return); + if (std.math.isFinite(timestamp_ms) and timestamp_ms >= 0) + s.now_ns = @intFromFloat(timestamp_ms * std.time.ns_per_ms); s.core.pump(host(s)) catch |err| { last_error = @intFromError(err); }; } -/// Spend one fixed 60 Hz animation step, the only writer of animated time in -/// this host. Separate from the pump for the reason the AppKit shell keeps them -/// separate: if every input pump also advanced the transition, a burst of keys -/// would collapse a ten-frame fade into one display frame. -export fn pardes_animation_tick() void { - const s = &(state orelse return); - s.core.update(.tick); -} - -export fn pardes_animating() u32 { - const s = &(state orelse return 0); - return @intFromBool(s.core.animationActive()); -} - /// The Exit builtin, or the last pane closing. JS stops its frame loop. export fn pardes_should_quit() u32 { const s = &(state orelse return 0); @@ -462,7 +454,13 @@ extern "pardes" fn host_download( bytes_len: usize, ) callconv(.c) void; +fn now(ctx: ?*anyopaque) u64 { + const s: *State = @ptrCast(@alignCast(ctx.?)); + return s.now_ns; +} + const vtable: pardes.Host.VTable = .{ + .now = now, .present = present, .write_file = writeFile, .write_dump = writeDump, diff --git a/src/web/app.mjs b/src/web/app.mjs index 11d8f228..280239d5 100644 --- a/src/web/app.mjs +++ b/src/web/app.mjs @@ -1,7 +1,5 @@ const CELL_SIZE = 20; const ATTR_TAGLINE = 1 << 7; -const ANIMATION_FRAME_MS = 1000 / 60; -const MAX_ANIMATION_TICKS = 4; const DEFAULT_FG = "#cccccc"; const DEFAULT_BG = "#121212"; const decoder = new TextDecoder(); @@ -325,7 +323,6 @@ export class PardesRuntime { this.running = false; this.pointer = null; this.frameHandle = 0; - this.animationClock = { last: null, lag: 0 }; this.bindEvents(); } @@ -338,7 +335,6 @@ export class PardesRuntime { this.wasm.pardes_tick(); this.renderer.render(); this.running = true; - this.animationClock = { last: null, lag: 0 }; this.frameHandle = requestAnimationFrame((timestamp) => this.frame(timestamp)); this.root.focus({ preventScroll: true }); } @@ -355,13 +351,9 @@ export class PardesRuntime { this.resize(); this.root.style.setProperty("--pardes-tagline-font-size", `${this.measuredTaglinePercent}%`); } - // JS owns the animation clock; the pump that follows applies those ticks, - // runs the core's effects and presents one frame however fast the display - // is. Both halves are needed: an idle core still has input and effects. - for (let ticks = animationTicks(this.animationClock, timestamp); ticks > 0; ticks -= 1) { - this.wasm.pardes_animation_tick(); - } - this.wasm.pardes_tick(); + // The core's clock is this timestamp: it steps its own animations + // by it, however fast the display refreshes. + this.wasm.pardes_tick(timestamp); if (this.wasm.pardes_should_quit()) this.running = false; this.renderer.render(); this.frameHandle = requestAnimationFrame((nextTimestamp) => this.frame(nextTimestamp)); @@ -559,28 +551,6 @@ export class PardesRuntime { } } -/// Spend monotonic requestAnimationFrame time as fixed 60 Hz core ticks. A -/// high-refresh display therefore renders repeated states rather than making -/// frame-count transitions run twice as fast. Long suspension is capped so a -/// returning tab cannot execute an unbounded loop before drawing. -export function animationTicks(clock, timestamp) { - if (!Number.isFinite(timestamp)) return 0; - if (clock.last === null || timestamp < clock.last) { - clock.last = timestamp; - clock.lag = 0; - return 0; - } - const elapsed = Math.min(timestamp - clock.last, ANIMATION_FRAME_MS * MAX_ANIMATION_TICKS); - clock.last = timestamp; - clock.lag += elapsed; - const ticks = Math.min(MAX_ANIMATION_TICKS, Math.floor((clock.lag + 1e-7) / ANIMATION_FRAME_MS)); - clock.lag -= ticks * ANIMATION_FRAME_MS; - return ticks; -} - -/// The four things the core asks the page for, as the module's imports. The -/// holder exists because the import object has to be built before the instance -/// whose memory those imports read. export function browserHost() { const host = { wasm: null }; const view = (ptr, len) => new Uint8Array(host.wasm.memory.buffer, ptr, len); diff --git a/test/gui-goldens.txt b/test/gui-goldens.txt index 5531b578..73aa1f86 100644 --- a/test/gui-goldens.txt +++ b/test/gui-goldens.txt @@ -3,8 +3,8 @@ 03-notice 4d59205fe6b89a4589e94c68521257f21b40917c0198ea20248925687663d9f5 04-context-rows-mid-thumb 7129ee318975f858c430b1e610b26ca756d5bb37553338c0dc6caab2290de2a4 05-body-selection 39103f3747aae07a12b5151e7e377430b88b96e504670ac2f2d0272197eb827b -06-tag-focus-selection 2bedec24842109aa2cc302226d53a994ff547791ad54d5781fe48d8eaac179ac -07-prompt-notice 6d6eda3f3a74d3ff048cb0e433e3794ca061e356184ff62a4878395c58482727 +06-tag-focus-selection 6a7f50e292f4ede92df24a817218818b93a01fd15fcf39467cf06c3772077834 +07-prompt-notice d5cdacd74ae6d29834f0b09a607bfce5a2ca8a549c259be50b76a57a2d2d3271 08-wide-glyph-edges 123a182b82a2a8c455291a5c89ccfa28e1f8ce9566172ca404c0b9c71dd33b48 09-collapsed-focus-tint ae66076fdfb0cab9a906b052df257deee5ba91a6274712d52a76d8a7adc5b415 10-tagbottom dd8ba0d3ecabf3420d000b0c9591a57278179cbd2a9060f849d283f3881d1847 @@ -12,5 +12,5 @@ 12-multiline-tag 1b9ccd331814dc76111470f18448b1ca192e1edc3d79ea6eee19a6a78a4b1831 13-image de18edee2ab2e0f2d4bf4162d86e3d8a1852fccfc344830491fa1a359095d4ea 14-theme-ink 6e733d4212373c90b860b2e41102ced8719b07480591b5da3905267c8358db18 -15-theme-acme-light 39f9256b7c8ed5187cd8d2cc3840db274efcecad263e519c4f9c79eb9efea19c +15-theme-acme-light e099dc1a351de8efa63ecd0ec0899636ea31b722a945eb40137a18abd87a82e2 16-debug e97ce74b7a3d4a08f6a529b1f0de111d62e5532c829a6b1633b966dd45218f55 diff --git a/test/gui_golden.py b/test/gui_golden.py index 6586af3a..1252014c 100644 --- a/test/gui_golden.py +++ b/test/gui_golden.py @@ -89,7 +89,9 @@ def run(args): name = 'gui-golden' config = root / 'config/pardes' config.mkdir(parents=True) - (config / 'init').write_text('Shell /bin/sh\nTheme forge\n') + # No linger: a dismissed message is gone, not caught part way, when a + # scene settles. + (config / 'init').write_text('Shell /bin/sh\nTheme forge\nMessageLinger 0\n') source = root / 'shapes.zig' source.write_bytes(ZIG) notes = root / 'notes.txt' diff --git a/test/pdf.zig b/test/pdf.zig index ed5d6908..93418727 100644 --- a/test/pdf.zig +++ b/test/pdf.zig @@ -1400,7 +1400,7 @@ test "PDF tint and tinted theme changes rebuild every visible raster only" { // Chrome animation reads a separate palette. Its ticks must never disturb // the target-theme tint key or ask MuPDF for the same pixels again. try std.testing.expect(p.animationActive()); - for (0..animation.Animation.transition_steps) |_| { + for (0..animation.transition_steps) |_| { p.update(.tick); _ = frame.reset(.retain_capacity); const tick_surface = try p.render(frame.allocator()); diff --git a/test/snapshot.zig b/test/snapshot.zig index bdabb56e..53553657 100644 --- a/test/snapshot.zig +++ b/test/snapshot.zig @@ -579,6 +579,9 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const // views' durations (lsp_zls.zig hideTime) and the transient message row's // time of day (message.zig stamp) _ = setenv("PARDES_NOTIME", "1", 1); + // A virtual core clock: animation moves a frame per timed-out wait, + // whatever the machine's load (host_io.testClock). + _ = setenv("PARDES_TEST_CLOCK", "1", 1); // The protocol client must be HERMETIC under the harness: `.rs` files // speak to the deterministic mock when the build handed one over, and // every other spec is disabled outright — a script that opened a stray diff --git a/test/snapshots/theme.golden b/test/snapshots/theme.golden index 51bb77ff..02750663 100644 --- a/test/snapshots/theme.golden +++ b/test/snapshots/theme.golden @@ -53,178 +53,174 @@ |30: 0-0 d,#608269, 1-1 d,#0d1410, 2-49 #e5eee5,#0d1410, 50-50 d,#608269, 51-51 d,#0d1410, 52-99 #e5eee5,#0d1410, == snap dusk grid=100x31 cursor=4,5 == style dusk grid=100x31 -|0: 0-50 #c4b7c4,#2b262f, 51-59 #fff0eb,#654053, 60-70 #c4b7c4,#2b262f, 71-99 d,#2b262f, -|1: 0-1 #ffffff,#77795e, 2-2 d,#39303a, 3-34 #d0c3d0,#39303a, 35-49 d,#39303a, 50-51 #ffffff,#77795e, 52-52 d,#2b262f, 53-84 #c4b7c4,#2b262f, 85-99 d,#2b262f, -|2: 0-1 #000000,#e7b491, 2-2 d,#39303a, 3-29 #d0c3d0,#39303a, 30-32 #a7d3c7,#39303a, 33-49 #d0c3d0,#39303a, 50-51 #ffffff,#705667, 52-52 d,#2b262f, 53-79 #c4b7c4,#2b262f, 80-82 #9cc7bb,#2b262f, 83-99 #c4b7c4,#2b262f, -|3: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|4: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|5: 0-0 d,#89697a, 1-49 d,#39323b, 50-50 d,#89697a, 51-99 d,#39323b, -|6: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|7: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|8: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|9: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|10: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|11: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|12: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|13: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|14: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|15: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|16: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|17: 0-1 #ffffff,#705667, 2-2 d,#2b262f, 3-29 #c4b7c4,#2b262f, 30-32 #9cc7bb,#2b262f, 33-49 #c4b7c4,#2b262f, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|18: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|19: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|20: 0-0 d,#89697a, 1-49 d,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|21: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|22: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|23: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|24: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|25: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|26: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|27: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|28: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|29: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, -|30: 0-0 d,#89697a, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#89697a, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|0: 0-50 #c6b6c5,#2c2630, 51-59 #fff0eb,#654053, 60-70 #c6b6c5,#2c2630, 71-99 d,#2c2630, +|1: 0-1 #ffffff,#757a5f, 2-2 d,#3b2f3b, 3-34 #d2c2d1,#3b2f3b, 35-49 d,#3b2f3b, 50-51 #ffffff,#757a5f, 52-52 d,#2c2630, 53-84 #c6b6c5,#2c2630, 85-99 d,#2c2630, +|2: 0-1 #000000,#edb092, 2-2 d,#3b2f3b, 3-29 #d2c2d1,#3b2f3b, 30-32 #a1d5c4,#3b2f3b, 33-49 #d2c2d1,#3b2f3b, 50-51 #ffffff,#745469, 52-52 d,#2c2630, 53-79 #c6b6c5,#2c2630, 80-82 #96c9b8,#2c2630, 83-99 #c6b6c5,#2c2630, +|3: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|4: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|5: 0-0 d,#8e667c, 1-49 d,#39323b, 50-50 d,#8e667c, 51-99 d,#39323b, +|6: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|7: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|8: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|9: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|10: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|11: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|12: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|13: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|14: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|15: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|16: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|17: 0-1 #ffffff,#745469, 2-2 d,#2c2630, 3-29 #c6b6c5,#2c2630, 30-32 #96c9b8,#2c2630, 33-49 #c6b6c5,#2c2630, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|18: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|19: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|20: 0-0 d,#8e667c, 1-49 d,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|21: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|22: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|23: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|24: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|25: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|26: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|27: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|28: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|29: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, +|30: 0-0 d,#8e667c, 1-1 d,#39323b, 2-49 #c3b6c1,#39323b, 50-50 d,#8e667c, 51-51 d,#39323b, 52-99 #c3b6c1,#39323b, == snap ink grid=100x31 cursor=4,5 == style ink grid=100x31 -|0: 0-50 #d4dfed,#17222c, 51-59 #ffffff,#1b547c, 60-70 #d4dfed,#17222c, 71-99 d,#17222c, -|1: 0-1 #ffffff,#8c7d5b, 2-2 d,#253341, 3-34 #e0eaf5,#253341, 35-49 d,#253341, 50-51 #ffffff,#8c7d5b, 52-52 d,#17222c, 53-84 #d4dfed,#17222c, 85-99 d,#17222c, -|2: 0-1 #000000,#90d4f4, 2-2 d,#253341, 3-29 #e0eaf5,#253341, 30-32 #f6e8b9,#253341, 33-49 #e0eaf5,#253341, 50-51 #ffffff,#61758c, 52-52 d,#17222c, 53-79 #d4dfed,#17222c, 80-82 #ecdead,#17222c, 83-99 #d4dfed,#17222c, -|3: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|4: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|5: 0-0 d,#87a1bb, 1-49 d,#000000, 50-50 d,#87a1bb, 51-99 d,#000000, -|6: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|7: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|8: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|9: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|10: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|11: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|12: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|13: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|14: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|15: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|16: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|17: 0-1 #ffffff,#61758c, 2-2 d,#17222c, 3-29 #d4dfed,#17222c, 30-32 #ecdead,#17222c, 33-49 #d4dfed,#17222c, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|18: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|19: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|20: 0-0 d,#87a1bb, 1-49 d,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|21: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|22: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|23: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|24: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|25: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|26: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|27: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|28: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|29: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, -|30: 0-0 d,#87a1bb, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#87a1bb, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|0: 0-50 #d5e4f1,#15212c, 51-59 #ffffff,#1b547c, 60-70 #d5e4f1,#15212c, 71-99 d,#15212c, +|1: 0-1 #ffffff,#8f7d5b, 2-2 d,#223342, 3-34 #e1eef9,#223342, 35-49 d,#223342, 50-51 #ffffff,#8f7d5b, 52-52 d,#15212c, 53-84 #d5e4f1,#15212c, 85-99 d,#15212c, +|2: 0-1 #000000,#86d8ff, 2-2 d,#223342, 3-29 #e1eef9,#223342, 30-32 #ffeab8,#223342, 33-49 #e1eef9,#223342, 50-51 #ffffff,#5f7990, 52-52 d,#15212c, 53-79 #d5e4f1,#15212c, 80-82 #f5e0ac,#15212c, 83-99 #d5e4f1,#15212c, +|3: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|4: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|5: 0-0 d,#86a8c2, 1-49 d,#000000, 50-50 d,#86a8c2, 51-99 d,#000000, +|6: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|7: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|8: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|9: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|10: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|11: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|12: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|13: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|14: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|15: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|16: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|17: 0-1 #ffffff,#5f7990, 2-2 d,#15212c, 3-29 #d5e4f1,#15212c, 30-32 #f5e0ac,#15212c, 33-49 #d5e4f1,#15212c, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|18: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|19: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|20: 0-0 d,#86a8c2, 1-49 d,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|21: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|22: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|23: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|24: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|25: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|26: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|27: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|28: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|29: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, +|30: 0-0 d,#86a8c2, 1-1 d,#000000, 2-49 #f3f7fc,#000000, 50-50 d,#86a8c2, 51-51 d,#000000, 52-99 #f3f7fc,#000000, == snap paper grid=100x31 cursor=4,5 == style paper grid=100x31 -|0: 0-50 #5f6f69,#d3d6d1, 51-59 #203e31,#c5e2d0, 60-70 #5f6f69,#d3d6d1, 71-99 d,#d3d6d1, -|1: 0-1 #000000,#b89e7b, 2-2 d,#c9d3cd, 3-34 #596963,#c9d3cd, 35-49 d,#c9d3cd, 50-51 #000000,#b89e7b, 52-52 d,#d3d6d1, 53-84 #5f6f69,#d3d6d1, 85-99 d,#d3d6d1, -|2: 0-1 #ffffff,#3e7b68, 2-2 d,#c9d3cd, 3-29 #596963,#c9d3cd, 30-32 #785580,#c9d3cd, 33-49 #596963,#c9d3cd, 50-51 #000000,#8aa194, 52-52 d,#d3d6d1, 53-79 #5f6f69,#d3d6d1, 80-82 #7f5b87,#d3d6d1, 83-99 #5f6f69,#d3d6d1, -|3: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|4: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|5: 0-0 d,#869e90, 1-49 d,#f5f3ed, 50-50 d,#869e90, 51-99 d,#f5f3ed, -|6: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|7: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|8: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|9: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|10: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|11: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|12: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|13: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|14: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|15: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|16: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|17: 0-1 #000000,#8aa194, 2-2 d,#d3d6d1, 3-29 #5f6f69,#d3d6d1, 30-32 #7f5b87,#d3d6d1, 33-49 #5f6f69,#d3d6d1, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|18: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|19: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|20: 0-0 d,#869e90, 1-49 d,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|21: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|22: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|23: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|24: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|25: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|26: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|27: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|28: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|29: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, -|30: 0-0 d,#869e90, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869e90, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|0: 0-50 #52625a,#e8eae3, 51-59 #203e31,#c5e2d0, 60-70 #52625a,#e8eae3, 71-99 d,#e8eae3, +|1: 0-1 #000000,#bda27e, 2-2 d,#dce5dc, 3-34 #4a5a52,#dce5dc, 35-49 d,#dce5dc, 50-51 #000000,#bda27e, 52-52 d,#e8eae3, 53-84 #52625a,#e8eae3, 85-99 d,#e8eae3, +|2: 0-1 #ffffff,#367157, 2-2 d,#dce5dc, 3-29 #4a5a52,#dce5dc, 30-32 #69447a,#dce5dc, 33-49 #4a5a52,#dce5dc, 50-51 #000000,#8fa594, 52-52 d,#e8eae3, 53-79 #52625a,#e8eae3, 80-82 #724c83,#e8eae3, 83-99 #52625a,#e8eae3, +|3: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|4: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|5: 0-0 d,#869d8a, 1-49 d,#f5f3ed, 50-50 d,#869d8a, 51-99 d,#f5f3ed, +|6: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|7: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|8: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|9: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|10: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|11: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|12: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|13: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|14: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|15: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|16: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|17: 0-1 #000000,#8fa594, 2-2 d,#e8eae3, 3-29 #52625a,#e8eae3, 30-32 #724c83,#e8eae3, 33-49 #52625a,#e8eae3, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|18: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|19: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|20: 0-0 d,#869d8a, 1-49 d,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|21: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|22: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|23: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|24: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|25: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|26: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|27: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|28: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|29: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, +|30: 0-0 d,#869d8a, 1-1 d,#f5f3ed, 2-49 #303b3b,#f5f3ed, 50-50 d,#869d8a, 51-51 d,#f5f3ed, 52-99 #303b3b,#f5f3ed, == snap daybreak grid=100x31 cursor=4,5 == style daybreak grid=100x31 -|0: 0-50 #2d495e,#edf1f5, 51-59 #10364f,#b9dffb, 60-70 #2d495e,#edf1f5, 71-99 d,#edf1f5, -|1: 0-1 #000000,#b89b7a, 2-2 d,#dfeaf1, 3-34 #254156,#dfeaf1, 35-49 d,#dfeaf1, 50-51 #000000,#b89b7a, 52-52 d,#edf1f5, 53-84 #2d495e,#edf1f5, 85-99 d,#edf1f5, -|2: 0-1 #ffffff,#056392, 2-2 d,#dfeaf1, 3-29 #254156,#dfeaf1, 30-32 #542862,#dfeaf1, 33-49 #254156,#dfeaf1, 50-51 #000000,#7d9fb4, 52-52 d,#edf1f5, 53-79 #2d495e,#edf1f5, 80-82 #5c306a,#edf1f5, 83-99 #2d495e,#edf1f5, -|3: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|4: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|5: 0-0 d,#69879e, 1-49 d,#ffffff, 50-50 d,#69879e, 51-99 d,#ffffff, -|6: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|7: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|8: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|9: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|10: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|11: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|12: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|13: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|14: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|15: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|16: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|17: 0-1 #000000,#7d9fb4, 2-2 d,#edf1f5, 3-29 #2d495e,#edf1f5, 30-32 #5c306a,#edf1f5, 33-49 #2d495e,#edf1f5, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|18: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|19: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|20: 0-0 d,#69879e, 1-49 d,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|21: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|22: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|23: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|24: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|25: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|26: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|27: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|28: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|29: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, -|30: 0-0 d,#69879e, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#69879e, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|0: 0-50 #29465e,#edf2f7, 51-59 #10364f,#b9dffb, 60-70 #29465e,#edf2f7, 71-99 d,#edf2f7, +|1: 0-1 #000000,#b79a7a, 2-2 d,#dfeaf3, 3-34 #213e56,#dfeaf3, 35-49 d,#dfeaf3, 50-51 #000000,#b79a7a, 52-52 d,#edf2f7, 53-84 #29465e,#edf2f7, 85-99 d,#edf2f7, +|2: 0-1 #ffffff,#006198, 2-2 d,#dfeaf3, 3-29 #213e56,#dfeaf3, 30-32 #52255f,#dfeaf3, 33-49 #213e56,#dfeaf3, 50-51 #000000,#7b9eb7, 52-52 d,#edf2f7, 53-79 #29465e,#edf2f7, 80-82 #5a2d67,#edf2f7, 83-99 #29465e,#edf2f7, +|3: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|4: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|5: 0-0 d,#6685a0, 1-49 d,#ffffff, 50-50 d,#6685a0, 51-99 d,#ffffff, +|6: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|7: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|8: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|9: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|10: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|11: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|12: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|13: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|14: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|15: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|16: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|17: 0-1 #000000,#7b9eb7, 2-2 d,#edf2f7, 3-29 #29465e,#edf2f7, 30-32 #5a2d67,#edf2f7, 33-49 #29465e,#edf2f7, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|18: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|19: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|20: 0-0 d,#6685a0, 1-49 d,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|21: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|22: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|23: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|24: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|25: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|26: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|27: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|28: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|29: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, +|30: 0-0 d,#6685a0, 1-1 d,#ffffff, 2-49 #142d40,#ffffff, 50-50 d,#6685a0, 51-51 d,#ffffff, 52-99 #142d40,#ffffff, == snap atelier grid=100x31 cursor=4,5 == style atelier grid=100x31 -|0: 0-50 #3b5d56,#e5f0ee, 51-59 #35492d,#edf2a5, 60-70 #3b5d56,#e5f0ee, 71-99 d,#e5f0ee, -|1: 0-1 #000000,#bca380, 2-2 d,#d4e7e1, 3-34 #33554e,#d4e7e1, 35-49 d,#d4e7e1, 50-51 #000000,#bca380, 52-52 d,#e5f0ee, 53-84 #3b5d56,#e5f0ee, 85-99 d,#e5f0ee, -|2: 0-1 #ffffff,#317f70, 2-2 d,#d4e7e1, 3-29 #33554e,#d4e7e1, 30-32 #633c6a,#d4e7e1, 33-49 #33554e,#d4e7e1, 50-51 #000000,#92b7a4, 52-52 d,#e5f0ee, 53-79 #3b5d56,#e5f0ee, 80-82 #6c4473,#e5f0ee, 83-99 #3b5d56,#e5f0ee, -|3: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|4: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|5: 0-0 d,#99aa7a, 1-49 d,#fffdeb, 50-50 d,#99aa7a, 51-99 d,#fffdeb, -|6: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|7: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|8: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|9: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|10: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|11: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|12: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|13: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|14: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|15: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|16: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|17: 0-1 #000000,#92b7a4, 2-2 d,#e5f0ee, 3-29 #3b5d56,#e5f0ee, 30-32 #6c4473,#e5f0ee, 33-49 #3b5d56,#e5f0ee, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|18: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|19: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|20: 0-0 d,#99aa7a, 1-49 d,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|21: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|22: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|23: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|24: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|25: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|26: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|27: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|28: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|29: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -|30: 0-0 d,#99aa7a, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#99aa7a, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, -== snap colors-off grid=100x31 cursor=4,5 -|3: ls Colors ls -== style colors-off grid=100x31 |0: 0-50 #3d6055,#e4f0ed, 51-59 #35492d,#edf2a5, 60-70 #3d6055,#e4f0ed, 71-99 d,#e4f0ed, |1: 0-1 #000000,#bca481, 2-2 d,#d3e7df, 3-34 #35584d,#d3e7df, 35-49 d,#d3e7df, 50-51 #000000,#bca481, 52-52 d,#e4f0ed, 53-84 #3d6055,#e4f0ed, 85-99 d,#e4f0ed, |2: 0-1 #ffffff,#36826b, 2-2 d,#d3e7df, 3-29 #35584d,#d3e7df, 30-32 #653f6b,#d3e7df, 33-49 #35584d,#d3e7df, 50-51 #000000,#95baa2, 52-52 d,#e4f0ed, 53-79 #3d6055,#e4f0ed, 80-82 #6e4774,#e4f0ed, 83-99 #3d6055,#e4f0ed, +|3: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|4: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|5: 0-0 d,#9fae76, 1-49 d,#fffdeb, 50-50 d,#9fae76, 51-99 d,#fffdeb, +|6: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|7: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|8: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|9: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|10: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|11: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|12: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|13: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|14: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|15: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|16: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|17: 0-1 #000000,#95baa2, 2-2 d,#e4f0ed, 3-29 #3d6055,#e4f0ed, 30-32 #6e4774,#e4f0ed, 33-49 #3d6055,#e4f0ed, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|18: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|19: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|20: 0-0 d,#9fae76, 1-49 d,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|21: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|22: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|23: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|24: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|25: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|26: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|27: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|28: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|29: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +|30: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-49 #293f35,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-99 #293f35,#fffdeb, +== snap colors-off grid=100x31 cursor=4,5 +|3: ls Colors ls +== style colors-off grid=100x31 |3: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-3 #293f35,#fffdeb, 4-41 d,#fffdeb, 42-42 d,#e4f0ed, 43-48 #3d6055,#e4f0ed, 49-49 d,#e4f0ed, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-53 #293f35,#fffdeb, 54-99 d,#fffdeb, |4: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-6 #293f35,#fffdeb, 7-49 d,#fffdeb, 50-50 d,#9fae76, 51-51 d,#fffdeb, 52-56 #293f35,#fffdeb, 57-99 d,#fffdeb, -|5: 0-0 d,#9fae76, 1-49 d,#fffdeb, 50-50 d,#9fae76, 51-99 d,#fffdeb, |6: 0-0 d,#9fae76, 1-49 d,#fffdeb, 50-50 d,#9fae76, 51-99 d,#fffdeb, |7: 0-0 d,#9fae76, 1-49 d,#fffdeb, 50-50 d,#9fae76, 51-99 d,#fffdeb, |8: 0-0 d,#9fae76, 1-49 d,#fffdeb, 50-50 d,#9fae76, 51-99 d,#fffdeb, @@ -253,240 +249,240 @@ == snap forge grid=100x31 cursor=4,5 |3: ls ls == style forge grid=100x31 -|0: 0-50 #b7bfc6,#35393e, 51-59 #e2eef4,#1c5172, 60-70 #b7bfc6,#35393e, 71-99 d,#35393e, -|1: 0-1 #ffffff,#727f6a, 2-2 d,#3e4650, 3-34 #c1c9d0,#3e4650, 35-49 d,#3e4650, 50-51 #ffffff,#727f6a, 52-52 d,#35393e, 53-84 #b7bfc6,#35393e, 85-99 d,#35393e, -|2: 0-1 #000000,#92bde9, 2-2 d,#3e4650, 3-29 #c1c9d0,#3e4650, 30-32 #e9bda1,#3e4650, 33-49 #c1c9d0,#3e4650, 50-51 #ffffff,#5b6d7f, 52-52 d,#35393e, 53-79 #b7bfc6,#35393e, 80-82 #dfb397,#35393e, 83-99 #b7bfc6,#35393e, -|3: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|4: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|5: 0-0 d,#758592, 1-49 d,#0c0c0e, 50-50 d,#758592, 51-99 d,#0c0c0e, -|6: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|7: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|8: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|9: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|10: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|11: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|12: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|13: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|14: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|15: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|16: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|17: 0-1 #ffffff,#5b6d7f, 2-2 d,#35393e, 3-29 #b7bfc6,#35393e, 30-32 #dfb397,#35393e, 33-49 #b7bfc6,#35393e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|18: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|19: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|20: 0-0 d,#758592, 1-49 d,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|21: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|22: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|23: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|24: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|25: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|26: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|27: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|28: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|29: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, -|30: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#758592, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|0: 0-50 #c5c9d2,#22252b, 51-59 #e2eef4,#1c5172, 60-70 #c5c9d2,#22252b, 71-99 d,#22252b, +|1: 0-1 #ffffff,#6a7b67, 2-2 d,#2d3440, 3-34 #d1d5de,#2d3440, 35-49 d,#2d3440, 50-51 #ffffff,#6a7b67, 52-52 d,#22252b, 53-84 #c5c9d2,#22252b, 85-99 d,#22252b, +|2: 0-1 #000000,#9cc3f7, 2-2 d,#2d3440, 3-29 #d1d5de,#2d3440, 30-32 #f8cba7,#2d3440, 33-49 #d1d5de,#2d3440, 50-51 #ffffff,#55647b, 52-52 d,#22252b, 53-79 #c5c9d2,#22252b, 80-82 #ecbf9b,#22252b, 83-99 #c5c9d2,#22252b, +|3: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|4: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|5: 0-0 d,#708095, 1-49 d,#0c0c0e, 50-50 d,#708095, 51-99 d,#0c0c0e, +|6: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|7: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|8: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|9: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|10: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|11: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|12: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|13: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|14: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|15: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|16: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|17: 0-1 #ffffff,#55647b, 2-2 d,#22252b, 3-29 #c5c9d2,#22252b, 30-32 #ecbf9b,#22252b, 33-49 #c5c9d2,#22252b, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|18: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|19: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|20: 0-0 d,#708095, 1-49 d,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|21: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|22: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|23: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|24: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|25: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|26: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|27: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|28: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|29: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, +|30: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-49 #e5e5e5,#0c0c0e, 50-50 d,#708095, 51-51 d,#0c0c0e, 52-99 #e5e5e5,#0c0c0e, == snap lagoon grid=100x31 cursor=4,5 == style lagoon grid=100x31 -|0: 0-50 #bcd6cc,#28413d, 51-59 #e6f2f3,#325f6c, 60-70 #bcd6cc,#28413d, 71-99 d,#28413d, -|1: 0-1 #ffffff,#957763, 2-2 d,#33544c, 3-34 #c8e2d8,#33544c, 35-49 d,#33544c, 50-51 #ffffff,#957763, 52-52 d,#28413d, 53-84 #bcd6cc,#28413d, 85-99 d,#28413d, -|2: 0-1 #000000,#84cebd, 2-2 d,#33544c, 3-29 #c8e2d8,#33544c, 30-32 #f1cef0,#33544c, 33-49 #c8e2d8,#33544c, 50-51 #ffffff,#548073, 52-52 d,#28413d, 53-79 #bcd6cc,#28413d, 80-82 #e5c2e4,#28413d, 83-99 #bcd6cc,#28413d, -|3: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|4: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|5: 0-0 d,#6e988c, 1-49 d,#34464c, 50-50 d,#6e988c, 51-99 d,#34464c, -|6: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|7: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|8: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|9: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|10: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|11: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|12: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|13: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|14: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|15: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|16: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|17: 0-1 #ffffff,#548073, 2-2 d,#28413d, 3-29 #bcd6cc,#28413d, 30-32 #e5c2e4,#28413d, 33-49 #bcd6cc,#28413d, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|18: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|19: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|20: 0-0 d,#6e988c, 1-49 d,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|21: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|22: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|23: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|24: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|25: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|26: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|27: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|28: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|29: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, -|30: 0-0 d,#6e988c, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e988c, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|0: 0-50 #bbd7cb,#29443f, 51-59 #e6f2f3,#325f6c, 60-70 #bbd7cb,#29443f, 71-99 d,#29443f, +|1: 0-1 #ffffff,#9a7662, 2-2 d,#34574d, 3-34 #c7e3d7,#34574d, 35-49 d,#34574d, 50-51 #ffffff,#9a7662, 52-52 d,#29443f, 53-84 #bbd7cb,#29443f, 85-99 d,#29443f, +|2: 0-1 #000000,#81cfb7, 2-2 d,#34574d, 3-29 #c7e3d7,#34574d, 30-32 #f0cef8,#34574d, 33-49 #c7e3d7,#34574d, 50-51 #ffffff,#548372, 52-52 d,#29443f, 53-79 #bbd7cb,#29443f, 80-82 #e4c2ec,#29443f, 83-99 #bbd7cb,#29443f, +|3: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|4: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|5: 0-0 d,#6e9b8b, 1-49 d,#34464c, 50-50 d,#6e9b8b, 51-99 d,#34464c, +|6: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|7: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|8: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|9: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|10: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|11: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|12: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|13: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|14: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|15: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|16: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|17: 0-1 #ffffff,#548372, 2-2 d,#29443f, 3-29 #bbd7cb,#29443f, 30-32 #e4c2ec,#29443f, 33-49 #bbd7cb,#29443f, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|18: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|19: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|20: 0-0 d,#6e9b8b, 1-49 d,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|21: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|22: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|23: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|24: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|25: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|26: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|27: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|28: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|29: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, +|30: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-49 #bac9ce,#34464c, 50-50 d,#6e9b8b, 51-51 d,#34464c, 52-99 #bac9ce,#34464c, == snap solarium grid=100x31 cursor=4,5 == style solarium grid=100x31 -|0: 0-50 #c6d4be,#144043, 51-59 #eee8d5,#1f5c64, 60-70 #c6d4be,#144043, 71-99 d,#144043, -|1: 0-1 #ffffff,#3e7a94, 2-2 d,#234f50, 3-34 #d2e0ca,#234f50, 35-49 d,#234f50, 50-51 #ffffff,#3e7a94, 52-52 d,#144043, 53-84 #c6d4be,#144043, 85-99 d,#144043, -|2: 0-1 #000000,#d7c36e, 2-2 d,#234f50, 3-29 #d2e0ca,#234f50, 30-32 #f5cded,#234f50, 33-49 #d2e0ca,#234f50, 50-51 #ffffff,#477f76, 52-52 d,#144043, 53-79 #c6d4be,#144043, 80-82 #e9c1e1,#144043, 83-99 #c6d4be,#144043, -|3: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|4: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|5: 0-0 d,#66978c, 1-49 d,#173e45, 50-50 d,#66978c, 51-99 d,#173e45, -|6: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|7: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|8: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|9: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|10: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|11: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|12: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|13: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|14: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|15: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|16: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|17: 0-1 #ffffff,#477f76, 2-2 d,#144043, 3-29 #c6d4be,#144043, 30-32 #e9c1e1,#144043, 33-49 #c6d4be,#144043, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|18: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|19: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|20: 0-0 d,#66978c, 1-49 d,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|21: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|22: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|23: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|24: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|25: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|26: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|27: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|28: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|29: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, -|30: 0-0 d,#66978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#66978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|0: 0-50 #c7d4bd,#123f43, 51-59 #eee8d5,#1f5c64, 60-70 #c7d4bd,#123f43, 71-99 d,#123f43, +|1: 0-1 #ffffff,#347a9a, 2-2 d,#214e50, 3-34 #d3e0c9,#214e50, 35-49 d,#214e50, 50-51 #ffffff,#347a9a, 52-52 d,#123f43, 53-84 #c7d4bd,#123f43, 85-99 d,#123f43, +|2: 0-1 #000000,#e1c266, 2-2 d,#214e50, 3-29 #d3e0c9,#214e50, 30-32 #f5cdec,#214e50, 33-49 #d3e0c9,#214e50, 50-51 #ffffff,#467f76, 52-52 d,#123f43, 53-79 #c7d4bd,#123f43, 80-82 #e9c1e0,#123f43, 83-99 #c7d4bd,#123f43, +|3: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|4: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|5: 0-0 d,#65978c, 1-49 d,#173e45, 50-50 d,#65978c, 51-99 d,#173e45, +|6: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|7: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|8: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|9: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|10: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|11: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|12: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|13: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|14: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|15: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|16: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|17: 0-1 #ffffff,#467f76, 2-2 d,#123f43, 3-29 #c7d4bd,#123f43, 30-32 #e9c1e0,#123f43, 33-49 #c7d4bd,#123f43, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|18: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|19: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|20: 0-0 d,#65978c, 1-49 d,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|21: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|22: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|23: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|24: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|25: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|26: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|27: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|28: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|29: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, +|30: 0-0 d,#65978c, 1-1 d,#173e45, 2-49 #a7b7ae,#173e45, 50-50 d,#65978c, 51-51 d,#173e45, 52-99 #a7b7ae,#173e45, == snap spectrum grid=100x31 cursor=4,5 == style spectrum grid=100x31 -|0: 0-50 #dbd8bb,#373932, 51-59 #fcfcfa,#513f60, 60-70 #dbd8bb,#373932, 71-99 d,#373932, -|1: 0-1 #ffffff,#6a6796, 2-2 d,#494839, 3-34 #e7e4c7,#494839, 35-49 d,#494839, 50-51 #ffffff,#6a6796, 52-52 d,#373932, 53-84 #dbd8bb,#373932, 85-99 d,#373932, -|2: 0-1 #000000,#fcd666, 2-2 d,#494839, 3-29 #e7e4c7,#494839, 30-32 #dae1fd,#494839, 33-49 #e7e4c7,#494839, 50-51 #ffffff,#8a8453, 52-52 d,#373932, 53-79 #dbd8bb,#373932, 80-82 #c9d5fc,#373932, 83-99 #dbd8bb,#373932, -|3: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|4: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|5: 0-0 d,#8f8e72, 1-49 d,#100e11, 50-50 d,#8f8e72, 51-99 d,#100e11, -|6: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|7: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|8: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|9: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|10: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|11: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|12: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|13: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|14: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|15: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|16: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|17: 0-1 #ffffff,#8a8453, 2-2 d,#373932, 3-29 #dbd8bb,#373932, 30-32 #c9d5fc,#373932, 33-49 #dbd8bb,#373932, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|18: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|19: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|20: 0-0 d,#8f8e72, 1-49 d,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|21: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|22: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|23: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|24: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|25: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|26: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|27: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|28: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|29: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, -|30: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#8f8e72, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|0: 0-50 #ddd8bb,#3b3830, 51-59 #fcfcfa,#513f60, 60-70 #ddd8bb,#3b3830, 71-99 d,#3b3830, +|1: 0-1 #ffffff,#706596, 2-2 d,#4d4736, 3-34 #e9e4c7,#4d4736, 35-49 d,#4d4736, 50-51 #ffffff,#706596, 52-52 d,#3b3830, 53-84 #ddd8bb,#3b3830, 85-99 d,#3b3830, +|2: 0-1 #000000,#ffd866, 2-2 d,#4d4736, 3-29 #e9e4c7,#4d4736, 30-32 #d7e3ff,#4d4736, 33-49 #e9e4c7,#4d4736, 50-51 #ffffff,#92844f, 52-52 d,#3b3830, 53-79 #ddd8bb,#3b3830, 80-82 #c5d7ff,#3b3830, 83-99 #ddd8bb,#3b3830, +|3: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|4: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|5: 0-0 d,#948d6f, 1-49 d,#100e11, 50-50 d,#948d6f, 51-99 d,#100e11, +|6: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|7: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|8: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|9: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|10: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|11: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|12: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|13: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|14: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|15: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|16: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|17: 0-1 #ffffff,#92844f, 2-2 d,#3b3830, 3-29 #ddd8bb,#3b3830, 30-32 #c5d7ff,#3b3830, 33-49 #ddd8bb,#3b3830, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|18: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|19: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|20: 0-0 d,#948d6f, 1-49 d,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|21: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|22: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|23: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|24: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|25: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|26: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|27: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|28: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|29: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, +|30: 0-0 d,#948d6f, 1-1 d,#100e11, 2-49 #fcfcfa,#100e11, 50-50 d,#948d6f, 51-51 d,#100e11, 52-99 #fcfcfa,#100e11, == snap harvest grid=100x31 cursor=4,5 == style harvest grid=100x31 -|0: 0-50 #d6d8b8,#353b2e, 51-59 #f3f2cc,#4f5834, 60-70 #d6d8b8,#353b2e, 71-99 d,#353b2e, -|1: 0-1 #ffffff,#975948, 2-2 d,#434c38, 3-34 #e2e4c4,#434c38, 35-49 d,#434c38, 50-51 #ffffff,#975948, 52-52 d,#353b2e, 53-84 #d6d8b8,#353b2e, 85-99 d,#353b2e, -|2: 0-1 #000000,#dcc080, 2-2 d,#434c38, 3-29 #e2e4c4,#434c38, 30-32 #c7e7f8,#434c38, 33-49 #e2e4c4,#434c38, 50-51 #ffffff,#7d7f4e, 52-52 d,#353b2e, 53-79 #d6d8b8,#353b2e, 80-82 #bbdbed,#353b2e, 83-99 #d6d8b8,#353b2e, -|3: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|4: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|5: 0-0 d,#898e60, 1-49 d,#100e0b, 50-50 d,#898e60, 51-99 d,#100e0b, -|6: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|7: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|8: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|9: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|10: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|11: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|12: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|13: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|14: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|15: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|16: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|17: 0-1 #ffffff,#7d7f4e, 2-2 d,#353b2e, 3-29 #d6d8b8,#353b2e, 30-32 #bbdbed,#353b2e, 33-49 #d6d8b8,#353b2e, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|18: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|19: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|20: 0-0 d,#898e60, 1-49 d,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|21: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|22: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|23: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|24: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|25: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|26: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|27: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|28: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|29: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, -|30: 0-0 d,#898e60, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#898e60, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|0: 0-50 #d5d8b8,#343b2e, 51-59 #f3f2cc,#4f5834, 60-70 #d5d8b8,#343b2e, 71-99 d,#343b2e, +|1: 0-1 #ffffff,#9b583f, 2-2 d,#424d38, 3-34 #e1e4c4,#424d38, 35-49 d,#424d38, 50-51 #ffffff,#9b583f, 52-52 d,#343b2e, 53-84 #d5d8b8,#343b2e, 85-99 d,#343b2e, +|2: 0-1 #000000,#d8bd83, 2-2 d,#424d38, 3-29 #e1e4c4,#424d38, 30-32 #c5e7f7,#424d38, 33-49 #e1e4c4,#424d38, 50-51 #ffffff,#7b7e4e, 52-52 d,#343b2e, 53-79 #d5d8b8,#343b2e, 80-82 #badbeb,#343b2e, 83-99 #d5d8b8,#343b2e, +|3: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|4: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|5: 0-0 d,#888e5e, 1-49 d,#100e0b, 50-50 d,#888e5e, 51-99 d,#100e0b, +|6: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|7: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|8: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|9: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|10: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|11: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|12: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|13: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|14: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|15: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|16: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|17: 0-1 #ffffff,#7b7e4e, 2-2 d,#343b2e, 3-29 #d5d8b8,#343b2e, 30-32 #badbeb,#343b2e, 33-49 #d5d8b8,#343b2e, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|18: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|19: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|20: 0-0 d,#888e5e, 1-49 d,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|21: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|22: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|23: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|24: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|25: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|26: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|27: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|28: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|29: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, +|30: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-49 #f3f2cc,#100e0b, 50-50 d,#888e5e, 51-51 d,#100e0b, 52-99 #f3f2cc,#100e0b, == snap clay grid=100x31 cursor=4,5 == style clay grid=100x31 -|0: 0-50 #d7d6b6,#343f35, 51-59 #fbf1c7,#505c38, 60-70 #d7d6b6,#343f35, 71-99 d,#343f35, -|1: 0-1 #ffffff,#895d61, 2-2 d,#424f3f, 3-34 #e3e2c2,#424f3f, 35-49 d,#424f3f, 50-51 #ffffff,#895d61, 52-52 d,#343f35, 53-84 #d7d6b6,#343f35, 85-99 d,#343f35, -|2: 0-1 #000000,#92c776, 2-2 d,#424f3f, 3-29 #e3e2c2,#424f3f, 30-32 #b7eced,#424f3f, 33-49 #e3e2c2,#424f3f, 50-51 #ffffff,#6a7c4d, 52-52 d,#343f35, 53-79 #d7d6b6,#343f35, 80-82 #ace0e1,#343f35, 83-99 #d7d6b6,#343f35, -|3: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|4: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|5: 0-0 d,#8c9366, 1-49 d,#10100e, 50-50 d,#8c9366, 51-99 d,#10100e, -|6: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|7: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|8: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|9: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|10: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|11: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|12: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|13: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|14: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|15: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|16: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|17: 0-1 #ffffff,#6a7c4d, 2-2 d,#343f35, 3-29 #d7d6b6,#343f35, 30-32 #ace0e1,#343f35, 33-49 #d7d6b6,#343f35, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|18: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|19: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|20: 0-0 d,#8c9366, 1-49 d,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|21: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|22: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|23: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|24: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|25: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|26: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|27: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|28: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|29: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, -|30: 0-0 d,#8c9366, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9366, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|0: 0-50 #d7d6b6,#343f36, 51-59 #fbf1c7,#505c38, 60-70 #d7d6b6,#343f36, 71-99 d,#343f36, +|1: 0-1 #ffffff,#875e65, 2-2 d,#424f40, 3-34 #e3e2c2,#424f40, 35-49 d,#424f40, 50-51 #ffffff,#875e65, 52-52 d,#343f36, 53-84 #d7d6b6,#343f36, 85-99 d,#343f36, +|2: 0-1 #000000,#8ac874, 2-2 d,#424f40, 3-29 #e3e2c2,#424f40, 30-32 #b5ecec,#424f40, 33-49 #e3e2c2,#424f40, 50-51 #ffffff,#687c4d, 52-52 d,#343f36, 53-79 #d7d6b6,#343f36, 80-82 #aae0e0,#343f36, 83-99 #d7d6b6,#343f36, +|3: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|4: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|5: 0-0 d,#8c9467, 1-49 d,#10100e, 50-50 d,#8c9467, 51-99 d,#10100e, +|6: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|7: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|8: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|9: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|10: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|11: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|12: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|13: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|14: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|15: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|16: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|17: 0-1 #ffffff,#687c4d, 2-2 d,#343f36, 3-29 #d7d6b6,#343f36, 30-32 #aae0e0,#343f36, 33-49 #d7d6b6,#343f36, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|18: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|19: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|20: 0-0 d,#8c9467, 1-49 d,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|21: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|22: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|23: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|24: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|25: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|26: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|27: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|28: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|29: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, +|30: 0-0 d,#8c9467, 1-1 d,#10100e, 2-49 #f3e5bc,#10100e, 50-50 d,#8c9467, 51-51 d,#10100e, 52-99 #f3e5bc,#10100e, == snap forge-black grid=100x31 cursor=4,5 == style forge-black grid=100x31 -|0: 0-50 #c7cacf,#24282c, 51-59 #e2eef4,#1c5172, 60-70 #c7cacf,#24282c, 71-99 d,#24282c, -|1: 0-1 #ffffff,#6d7867, 2-2 d,#2f3740, 3-34 #d3d6db,#2f3740, 35-49 d,#2f3740, 50-51 #ffffff,#6d7867, 52-52 d,#24282c, 53-84 #c7cacf,#24282c, 85-99 d,#24282c, -|2: 0-1 #000000,#9ac4ea, 2-2 d,#2f3740, 3-29 #d3d6db,#2f3740, 30-32 #f1ceae,#2f3740, 33-49 #d3d6db,#2f3740, 50-51 #ffffff,#576676, 52-52 d,#24282c, 53-79 #c7cacf,#24282c, 80-82 #e5c2a2,#24282c, 83-99 #c7cacf,#24282c, -|3: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|4: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|5: 0-0 d,#738290, 1-49 d,#000000, 50-50 d,#738290, 51-99 d,#000000, -|6: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|7: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|8: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|9: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|10: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|11: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|12: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|13: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|14: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|15: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|16: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|17: 0-1 #ffffff,#576676, 2-2 d,#24282c, 3-29 #c7cacf,#24282c, 30-32 #e5c2a2,#24282c, 33-49 #c7cacf,#24282c, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|18: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|19: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|20: 0-0 d,#738290, 1-49 d,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|21: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|22: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|23: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|24: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|25: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|26: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|27: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|28: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|29: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, -|30: 0-0 d,#738290, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#738290, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|0: 0-50 #c5c9d2,#22252b, 51-59 #e2eef4,#1c5172, 60-70 #c5c9d2,#22252b, 71-99 d,#22252b, +|1: 0-1 #ffffff,#6a7b67, 2-2 d,#2d3440, 3-34 #d1d5de,#2d3440, 35-49 d,#2d3440, 50-51 #ffffff,#6a7b67, 52-52 d,#22252b, 53-84 #c5c9d2,#22252b, 85-99 d,#22252b, +|2: 0-1 #000000,#9cc3f7, 2-2 d,#2d3440, 3-29 #d1d5de,#2d3440, 30-32 #f8cba7,#2d3440, 33-49 #d1d5de,#2d3440, 50-51 #ffffff,#55647b, 52-52 d,#22252b, 53-79 #c5c9d2,#22252b, 80-82 #ecbf9b,#22252b, 83-99 #c5c9d2,#22252b, +|3: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|4: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|5: 0-0 d,#708095, 1-49 d,#000000, 50-50 d,#708095, 51-99 d,#000000, +|6: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|7: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|8: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|9: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|10: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|11: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|12: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|13: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|14: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|15: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|16: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|17: 0-1 #ffffff,#55647b, 2-2 d,#22252b, 3-29 #c5c9d2,#22252b, 30-32 #ecbf9b,#22252b, 33-49 #c5c9d2,#22252b, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|18: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|19: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|20: 0-0 d,#708095, 1-49 d,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|21: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|22: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|23: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|24: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|25: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|26: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|27: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|28: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|29: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, +|30: 0-0 d,#708095, 1-1 d,#000000, 2-49 #f6f6f6,#000000, 50-50 d,#708095, 51-51 d,#000000, 52-99 #f6f6f6,#000000, == snap forge-soft grid=100x31 cursor=4,5 == style forge-soft grid=100x31 -|0: 0-50 #c5c9d2,#292c33, 51-59 #e2eef4,#1c5172, 60-70 #c5c9d2,#292c33, 71-99 d,#292c33, -|1: 0-1 #ffffff,#6d7f6c, 2-2 d,#363d48, 3-34 #d1d5de,#363d48, 35-49 d,#363d48, 50-51 #ffffff,#6d7f6c, 52-52 d,#292c33, 53-84 #c5c9d2,#292c33, 85-99 d,#292c33, -|2: 0-1 #000000,#9cc3f7, 2-2 d,#363d48, 3-29 #d1d5de,#363d48, 30-32 #f8cba7,#363d48, 33-49 #d1d5de,#363d48, 50-51 #ffffff,#55647b, 52-52 d,#292c33, 53-79 #c5c9d2,#292c33, 80-82 #ecbf9b,#292c33, 83-99 #c5c9d2,#292c33, +|0: 0-50 #c5c9d2,#2a2d34, 51-59 #e2eef4,#1c5172, 60-70 #c5c9d2,#2a2d34, 71-99 d,#2a2d34, +|1: 0-1 #ffffff,#6d7f6c, 2-2 d,#373e49, 3-34 #d1d5de,#373e49, 35-49 d,#373e49, 50-51 #ffffff,#6d7f6c, 52-52 d,#2a2d34, 53-84 #c5c9d2,#2a2d34, 85-99 d,#2a2d34, +|2: 0-1 #000000,#9cc3f7, 2-2 d,#373e49, 3-29 #d1d5de,#373e49, 30-32 #f8cba7,#373e49, 33-49 #d1d5de,#373e49, 50-51 #ffffff,#55647b, 52-52 d,#2a2d34, 53-79 #c5c9d2,#2a2d34, 80-82 #ecbf9b,#2a2d34, 83-99 #c5c9d2,#2a2d34, |3: 0-0 d,#708095, 1-1 d,#35383e, 2-49 #bfc3cc,#35383e, 50-50 d,#708095, 51-51 d,#35383e, 52-99 #bfc3cc,#35383e, |4: 0-0 d,#708095, 1-1 d,#35383e, 2-49 #bfc3cc,#35383e, 50-50 d,#708095, 51-51 d,#35383e, 52-99 #bfc3cc,#35383e, |5: 0-0 d,#708095, 1-49 d,#35383e, 50-50 d,#708095, 51-99 d,#35383e, @@ -501,7 +497,7 @@ |14: 0-0 d,#708095, 1-1 d,#35383e, 2-49 #bfc3cc,#35383e, 50-50 d,#708095, 51-51 d,#35383e, 52-99 #bfc3cc,#35383e, |15: 0-0 d,#708095, 1-1 d,#35383e, 2-49 #bfc3cc,#35383e, 50-50 d,#708095, 51-51 d,#35383e, 52-99 #bfc3cc,#35383e, |16: 0-0 d,#708095, 1-1 d,#35383e, 2-49 #bfc3cc,#35383e, 50-50 d,#708095, 51-51 d,#35383e, 52-99 #bfc3cc,#35383e, -|17: 0-1 #ffffff,#55647b, 2-2 d,#292c33, 3-29 #c5c9d2,#292c33, 30-32 #ecbf9b,#292c33, 33-49 #c5c9d2,#292c33, 50-50 d,#708095, 51-51 d,#35383e, 52-99 #bfc3cc,#35383e, +|17: 0-1 #ffffff,#55647b, 2-2 d,#2a2d34, 3-29 #c5c9d2,#2a2d34, 30-32 #ecbf9b,#2a2d34, 33-49 #c5c9d2,#2a2d34, 50-50 d,#708095, 51-51 d,#35383e, 52-99 #bfc3cc,#35383e, |18: 0-0 d,#708095, 1-1 d,#35383e, 2-49 #bfc3cc,#35383e, 50-50 d,#708095, 51-51 d,#35383e, 52-99 #bfc3cc,#35383e, |19: 0-0 d,#708095, 1-1 d,#35383e, 2-49 #bfc3cc,#35383e, 50-50 d,#708095, 51-51 d,#35383e, 52-99 #bfc3cc,#35383e, |20: 0-0 d,#708095, 1-49 d,#35383e, 50-50 d,#708095, 51-51 d,#35383e, 52-99 #bfc3cc,#35383e, @@ -517,166 +513,166 @@ |30: 0-0 d,#708095, 1-1 d,#35383e, 2-49 #bfc3cc,#35383e, 50-50 d,#708095, 51-51 d,#35383e, 52-99 #bfc3cc,#35383e, == snap orchard-black grid=100x31 cursor=4,5 == style orchard-black grid=100x31 -|0: 0-50 #b4c4bb,#1e2926, 51-59 #f5f7ee,#3c614a, 60-70 #b4c4bb,#1e2926, 71-99 d,#1e2926, -|1: 0-1 #ffffff,#857557, 2-2 d,#283730, 3-34 #c0d0c7,#283730, 35-49 d,#283730, 50-51 #ffffff,#857557, 52-52 d,#1e2926, 53-84 #b4c4bb,#1e2926, 85-99 d,#1e2926, -|2: 0-1 #000000,#abd195, 2-2 d,#283730, 3-29 #c0d0c7,#283730, 30-32 #e2bedf,#283730, 33-49 #c0d0c7,#283730, 50-51 #ffffff,#4a665a, 52-52 d,#1e2926, 53-79 #b4c4bb,#1e2926, 80-82 #d6b2d3,#1e2926, 83-99 #b4c4bb,#1e2926, -|3: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|4: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|5: 0-0 d,#62826d, 1-49 d,#000000, 50-50 d,#62826d, 51-99 d,#000000, -|6: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|7: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|8: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|9: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|10: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|11: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|12: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|13: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|14: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|15: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|16: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|17: 0-1 #ffffff,#4a665a, 2-2 d,#1e2926, 3-29 #b4c4bb,#1e2926, 30-32 #d6b2d3,#1e2926, 33-49 #b4c4bb,#1e2926, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|18: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|19: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|20: 0-0 d,#62826d, 1-49 d,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|21: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|22: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|23: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|24: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|25: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|26: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|27: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|28: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|29: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, -|30: 0-0 d,#62826d, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#62826d, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|0: 0-50 #b2c3b8,#1d2824, 51-59 #f5f7ee,#3c614a, 60-70 #b2c3b8,#1d2824, 71-99 d,#1d2824, +|1: 0-1 #ffffff,#887455, 2-2 d,#26362d, 3-34 #becfc4,#26362d, 35-49 d,#26362d, 50-51 #ffffff,#887455, 52-52 d,#1d2824, 53-84 #b2c3b8,#1d2824, 85-99 d,#1d2824, +|2: 0-1 #000000,#add38a, 2-2 d,#26362d, 3-29 #becfc4,#26362d, 30-32 #dfbce5,#26362d, 33-49 #becfc4,#26362d, 50-51 #ffffff,#496656, 52-52 d,#1d2824, 53-79 #b2c3b8,#1d2824, 80-82 #d3b0d9,#1d2824, 83-99 #b2c3b8,#1d2824, +|3: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|4: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|5: 0-0 d,#608269, 1-49 d,#000000, 50-50 d,#608269, 51-99 d,#000000, +|6: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|7: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|8: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|9: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|10: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|11: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|12: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|13: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|14: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|15: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|16: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|17: 0-1 #ffffff,#496656, 2-2 d,#1d2824, 3-29 #b2c3b8,#1d2824, 30-32 #d3b0d9,#1d2824, 33-49 #b2c3b8,#1d2824, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|18: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|19: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|20: 0-0 d,#608269, 1-49 d,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|21: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|22: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|23: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|24: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|25: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|26: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|27: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|28: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|29: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, +|30: 0-0 d,#608269, 1-1 d,#000000, 2-49 #f2f8ef,#000000, 50-50 d,#608269, 51-51 d,#000000, 52-99 #f2f8ef,#000000, == snap helix grid=100x31 cursor=4,5 == style helix grid=100x31 -|0: 0-50 #979998,#131414, 51-59 #080808,#80a0ff, 60-70 #979998,#131414, 71-99 d,#131414, -|1: 0-1 #ffffff,#837048, 2-2 d,#141615, 3-34 #989a99,#141615, 35-49 d,#141615, 50-51 #ffffff,#837048, 52-52 d,#131414, 53-84 #979998,#131414, 85-99 d,#131414, -|2: 0-1 #ffffff,#6a6d66, 2-2 d,#141615, 3-29 #989a99,#141615, 30-32 #9c989c,#141615, 33-49 #989a99,#141615, 50-51 #ffffff,#2a2c2b, 52-52 d,#131414, 53-79 #979998,#131414, 80-82 #9a979b,#131414, 83-99 #979998,#131414, -|3: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|4: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|5: 0-0 d,#474a48, 1-49 d,#080808, 50-50 d,#474a48, 51-99 d,#080808, -|6: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|7: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|8: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|9: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|10: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|11: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|12: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|13: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|14: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|15: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|16: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|17: 0-1 #ffffff,#2a2c2b, 2-2 d,#131414, 3-29 #979998,#131414, 30-32 #9a979b,#131414, 33-49 #979998,#131414, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|18: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|19: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|20: 0-0 d,#474a48, 1-49 d,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|21: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|22: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|23: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|24: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|25: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|26: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|27: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|28: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|29: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, -|30: 0-0 d,#474a48, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#474a48, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|0: 0-50 #949494,#121212, 51-59 #080808,#80a0ff, 60-70 #949494,#121212, 71-99 d,#121212, +|1: 0-1 #ffffff,#826f46, 2-2 d,#121212, 3-34 #949494,#121212, 35-49 d,#121212, 50-51 #ffffff,#826f46, 52-52 d,#121212, 53-84 #949494,#121212, 85-99 d,#121212, +|2: 0-1 #ffffff,#626262, 2-2 d,#121212, 3-49 #949494,#121212, 50-51 #ffffff,#262626, 52-52 d,#121212, 53-99 #949494,#121212, +|3: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|4: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|5: 0-0 d,#444444, 1-49 d,#080808, 50-50 d,#444444, 51-99 d,#080808, +|6: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|7: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|8: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|9: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|10: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|11: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|12: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|13: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|14: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|15: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|16: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|17: 0-1 #ffffff,#262626, 2-2 d,#121212, 3-49 #949494,#121212, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|18: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|19: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|20: 0-0 d,#444444, 1-49 d,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|21: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|22: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|23: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|24: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|25: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|26: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|27: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|28: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|29: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, +|30: 0-0 d,#444444, 1-1 d,#080808, 2-49 #bdbdbd,#080808, 50-50 d,#444444, 51-51 d,#080808, 52-99 #bdbdbd,#080808, == snap dark grid=100x31 cursor=4,5 == style dark grid=100x31 -|0: 0-50 #969696,#29283a, 51-59 #d8c8a8,#513c17, 60-70 #969696,#29283a, 71-99 d,#29283a, -|1: 0-1 #ffffff,#86794d, 2-2 d,#29283a, 3-34 #969696,#29283a, 35-49 d,#29283a, 50-51 #ffffff,#86794d, 52-52 d,#29283a, 53-84 #969696,#29283a, 85-99 d,#29283a, -|2: 0-1 #ffffff,#6643d3, 2-2 d,#29283a, 3-49 #969696,#29283a, 50-51 #ffffff,#343853, 52-52 d,#29283a, 53-99 #969696,#29283a, -|3: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|4: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|5: 0-0 d,#515151, 1-49 d,d, 50-50 d,#515151, 51-99 d,d, -|6: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|7: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|8: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|9: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|10: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|11: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|12: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|13: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|14: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|15: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|16: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|17: 0-1 #ffffff,#343853, 2-2 d,#29283a, 3-49 #969696,#29283a, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|18: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|19: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|20: 0-0 d,#515151, 1-49 d,d, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|21: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|22: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|23: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|24: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|25: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|26: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|27: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|28: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|29: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, -|30: 0-0 d,#515151, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#515151, 51-51 d,d, 52-99 #969696,#2c2a3e, +|0: 0-50 #969696,#2c2a3e, 51-59 #d8c8a8,#513c17, 60-70 #969696,#2c2a3e, 71-99 d,#2c2a3e, +|1: 0-1 #ffffff,#867a4e, 2-2 d,#2c2a3e, 3-34 #969696,#2c2a3e, 35-49 d,#2c2a3e, 50-51 #ffffff,#867a4e, 52-52 d,#2c2a3e, 53-84 #969696,#2c2a3e, 85-99 d,#2c2a3e, +|2: 0-1 #ffffff,#663fe0, 2-2 d,#2c2a3e, 3-49 #969696,#2c2a3e, 50-51 #ffffff,#353a58, 52-52 d,#2c2a3e, 53-99 #969696,#2c2a3e, +|3: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|4: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|5: 0-0 d,#525252, 1-49 d,d, 50-50 d,#525252, 51-99 d,d, +|6: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|7: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|8: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|9: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|10: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|11: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|12: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|13: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|14: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|15: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|16: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|17: 0-1 #ffffff,#353a58, 2-2 d,#2c2a3e, 3-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|18: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|19: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|20: 0-0 d,#525252, 1-49 d,d, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|21: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|22: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|23: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|24: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|25: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|26: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|27: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|28: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|29: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, +|30: 0-0 d,#525252, 1-1 d,d, 2-49 #969696,#2c2a3e, 50-50 d,#525252, 51-51 d,d, 52-99 #969696,#2c2a3e, == snap acme grid=100x31 cursor=4,5 == style acme grid=100x31 -|0: 0-50 #0f0f0f,#d7eaec, 51-59 #000000,#fafa92, 60-70 #0f0f0f,#d7eaec, 71-99 d,#d7eaec, -|1: 0-1 #000000,#bfa37a, 2-2 d,#d7eaec, 3-34 #0f0f0f,#d7eaec, 35-49 d,#d7eaec, 50-51 #000000,#bfa37a, 52-52 d,#d7eaec, 53-84 #0f0f0f,#d7eaec, 85-99 d,#d7eaec, -|2: 0-1 #ffffff,#663fe0, 2-2 d,#d7eaec, 3-49 #0f0f0f,#d7eaec, 50-51 #ffffff,#353a58, 52-52 d,#d7eaec, 53-99 #0f0f0f,#d7eaec, -|3: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|4: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|5: 0-0 d,#eeeedb, 1-49 d,#ffffea, 50-50 d,#eeeedb, 51-99 d,#ffffea, -|6: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|7: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|8: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|9: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|10: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|11: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|12: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|13: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|14: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|15: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|16: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|17: 0-1 #ffffff,#353a58, 2-2 d,#d7eaec, 3-49 #0f0f0f,#d7eaec, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|18: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|19: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|20: 0-0 d,#eeeedb, 1-49 d,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|21: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|22: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|23: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|24: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|25: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|26: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|27: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|28: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|29: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, -|30: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-49 #000000,#ffffea, 50-50 d,#eeeedb, 51-51 d,#ffffea, 52-99 #000000,#ffffea, +|0: 0-50 #000000,#eaffff, 51-59 #000000,#fafa92, 60-70 #000000,#eaffff, 71-99 d,#eaffff, +|1: 0-1 #000000,#c5a87f, 2-2 d,#eaffff, 3-34 #000000,#eaffff, 35-49 d,#eaffff, 50-51 #000000,#c5a87f, 52-52 d,#eaffff, 53-84 #000000,#eaffff, 85-99 d,#eaffff, +|2: 0-1 #ffffff,#663fe0, 2-2 d,#eaffff, 3-49 #000000,#eaffff, 50-51 #ffffff,#353a58, 52-52 d,#eaffff, 53-99 #000000,#eaffff, +|3: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|4: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|5: 0-99 d,#ffffea, +|6: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|7: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|8: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|9: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|10: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|11: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|12: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|13: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|14: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|15: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|16: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|17: 0-1 #ffffff,#353a58, 2-2 d,#eaffff, 3-49 #000000,#eaffff, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|18: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|19: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|20: 0-51 d,#ffffea, 52-99 #000000,#ffffea, +|21: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|22: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|23: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|24: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|25: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|26: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|27: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|28: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|29: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, +|30: 0-1 d,#ffffea, 2-49 #000000,#ffffea, 50-51 d,#ffffea, 52-99 #000000,#ffffea, == snap generated grid=100x31 cursor=4,5 == style generated grid=100x31 -|0: 0-50 #cacaca,#323c43, 51-59 #e0e0e0,#303840, 60-70 #cacaca,#323c43, 71-99 d,#323c43, -|1: 0-1 #ffffff,#5b8094, 2-2 d,#323c43, 3-34 #cacaca,#323c43, 35-49 d,#323c43, 50-51 #ffffff,#5b8094, 52-52 d,#323c43, 53-84 #cacaca,#323c43, 85-99 d,#323c43, -|2: 0-1 #000000,#f0966d, 2-2 d,#323c43, 3-49 #cacaca,#323c43, 50-51 #ffffff,#313842, 52-52 d,#323c43, 53-99 #cacaca,#323c43, -|3: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|4: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|5: 0-0 d,#454c51, 1-49 d,#161e26, 50-50 d,#454c51, 51-99 d,#161e26, -|6: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|7: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|8: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|9: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|10: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|11: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|12: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|13: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|14: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|15: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|16: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|17: 0-1 #ffffff,#313842, 2-2 d,#323c43, 3-49 #cacaca,#323c43, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|18: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|19: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|20: 0-0 d,#454c51, 1-49 d,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|21: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|22: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|23: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|24: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|25: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|26: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|27: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|28: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|29: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, -|30: 0-0 d,#454c51, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#454c51, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|0: 0-50 #e0e0e0,#1e262e, 51-59 #e0e0e0,#303840, 60-70 #e0e0e0,#1e262e, 71-99 d,#1e262e, +|1: 0-1 #ffffff,#4f7b96, 2-2 d,#1e262e, 3-34 #e0e0e0,#1e262e, 35-49 d,#1e262e, 50-51 #ffffff,#4f7b96, 52-52 d,#1e262e, 53-84 #e0e0e0,#1e262e, 85-99 d,#1e262e, +|2: 0-1 #000000,#ffa060, 2-2 d,#1e262e, 3-49 #e0e0e0,#1e262e, 50-51 #ffffff,#303840, 52-52 d,#1e262e, 53-99 #e0e0e0,#1e262e, +|3: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|4: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|5: 0-0 d,#303840, 1-49 d,#161e26, 50-50 d,#303840, 51-99 d,#161e26, +|6: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|7: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|8: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|9: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|10: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|11: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|12: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|13: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|14: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|15: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|16: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|17: 0-1 #ffffff,#303840, 2-2 d,#1e262e, 3-49 #e0e0e0,#1e262e, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|18: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|19: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|20: 0-0 d,#303840, 1-49 d,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|21: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|22: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|23: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|24: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|25: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|26: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|27: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|28: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|29: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, +|30: 0-0 d,#303840, 1-1 d,#161e26, 2-49 #e0e0e0,#161e26, 50-50 d,#303840, 51-51 d,#161e26, 52-99 #e0e0e0,#161e26, diff --git a/test/snapshots/themesel.golden b/test/snapshots/themesel.golden index b3100e27..97d3ed41 100644 --- a/test/snapshots/themesel.golden +++ b/test/snapshots/themesel.golden @@ -83,169 +83,169 @@ |30: 0-0 d,#152019, 1-1 d,#0d1410, 2-6 #59675c,#0d1410, 7-16 #e5eee5,#0d1410, 17-99 d,#0d1410, == snap step-dusk grid=100x31 cursor=7,20 == style step-dusk grid=100x31 -|0: 0-70 #c4b7c4,#2b262f, 71-99 d,#2b262f, -|1: 0-1 #ffffff,#77795e, 2-2 d,#39303a, 3-34 #d0c3d0,#39303a, 35-99 d,#39303a, -|2: 0-1 #ffffff,#705667, 2-2 d,#2b262f, 3-32 #c4b7c4,#2b262f, 33-37 #9cc7bb,#2b262f, 38-59 #c4b7c4,#2b262f, 60-99 d,#2b262f, -|3: 0-0 d,#89697a, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-12 #c3b6c1,#39323b, 13-99 d,#39323b, -|4: 0-0 d,#89697a, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-12 #c3b6c1,#39323b, 13-99 d,#39323b, -|5: 0-0 d,#89697a, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-12 #c3b6c1,#39323b, 13-99 d,#39323b, -|6: 0-0 d,#89697a, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-12 #c3b6c1,#39323b, 13-99 d,#39323b, -|7: 0-0 d,#39333c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-12 #c3b6c1,#39323b, 13-99 d,#39323b, -|8: 0-0 d,#39333c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-12 #c3b6c1,#39323b, 13-99 d,#39323b, -|9: 0-0 d,#39333c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-12 #c3b6c1,#39323b, 13-99 d,#39323b, -|10: 0-0 d,#39333c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-12 #c3b6c1,#39323b, 13-99 d,#39323b, -|11: 0-0 d,#39333c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-12 #c3b6c1,#39323b, 13-99 d,#39323b, -|12: 0-0 d,#39333c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-13 #c3b6c1,#39323b, 14-99 d,#39323b, -|13: 0-0 d,#39333c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-13 #c3b6c1,#39323b, 14-99 d,#39323b, -|14: 0-0 d,#39333c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-13 #c3b6c1,#39323b, 14-99 d,#39323b, -|15: 0-0 d,#39333c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-13 #c3b6c1,#39323b, 14-99 d,#39323b, -|16: 0-0 d,#39333c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-13 #c3b6c1,#39323b, 14-99 d,#39323b, -|17: 0-1 #000000,#e7b491, 2-2 d,#39303a, 3-32 #d0c3d0,#39303a, 33-39 #a7d3c7,#39303a, 40-61 #d0c3d0,#39303a, 62-99 d,#39303a, -|18: 0-0 d,#89697a, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-21 #c3b6c1,#39323b, 22-92 d,#39323b, 93-93 d,#2b262f, 94-98 #c4b7c4,#2b262f, 99-99 d,#2b262f, -|19: 0-0 d,#39333c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-19 #c3b6c1,#39323b, 20-99 d,#39323b, -|20: 0-0 d,#39333c, 1-1 d,#39323b, 2-6 #8a7986,#39323b,b 7-16 #c3b6c1,#39323b, 17-99 d,#39323b, -|21: 0-0 d,#39333c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-15 #c3b6c1,#39323b, 16-99 d,#39323b, -|22: 0-0 d,#39333c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-17 #c3b6c1,#39323b, 18-99 d,#39323b, -|23: 0-0 d,#39333c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-20 #c3b6c1,#39323b, 21-99 d,#39323b, -|24: 0-0 d,#39333c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-19 #c3b6c1,#39323b, 20-99 d,#39323b, -|25: 0-0 d,#39333c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-17 #c3b6c1,#39323b, 18-99 d,#39323b, -|26: 0-0 d,#39333c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-18 #c3b6c1,#39323b, 19-99 d,#39323b, -|27: 0-0 d,#39333c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-20 #c3b6c1,#39323b, 21-99 d,#39323b, -|28: 0-0 d,#39333c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-20 #c3b6c1,#39323b, 21-99 d,#39323b, -|29: 0-0 d,#39333c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-19 #c3b6c1,#39323b, 20-99 d,#39323b, -|30: 0-0 d,#39333c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-16 #c3b6c1,#39323b, 17-99 d,#39323b, +|0: 0-70 #c6b6c5,#2c2630, 71-99 d,#2c2630, +|1: 0-1 #ffffff,#757a5f, 2-2 d,#3b2f3b, 3-34 #d2c2d1,#3b2f3b, 35-99 d,#3b2f3b, +|2: 0-1 #ffffff,#745469, 2-2 d,#2c2630, 3-32 #c6b6c5,#2c2630, 33-37 #96c9b8,#2c2630, 38-59 #c6b6c5,#2c2630, 60-99 d,#2c2630, +|3: 0-0 d,#8e667c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-12 #c3b6c1,#39323b, 13-99 d,#39323b, +|4: 0-0 d,#8e667c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-12 #c3b6c1,#39323b, 13-99 d,#39323b, +|5: 0-0 d,#8e667c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-12 #c3b6c1,#39323b, 13-99 d,#39323b, +|6: 0-0 d,#8e667c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-12 #c3b6c1,#39323b, 13-99 d,#39323b, +|7: 0-0 d,#3d3540, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-12 #c3b6c1,#39323b, 13-99 d,#39323b, +|8: 0-0 d,#3d3540, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-12 #c3b6c1,#39323b, 13-99 d,#39323b, +|9: 0-0 d,#3d3540, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-12 #c3b6c1,#39323b, 13-99 d,#39323b, +|10: 0-0 d,#3d3540, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-12 #c3b6c1,#39323b, 13-99 d,#39323b, +|11: 0-0 d,#3d3540, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-12 #c3b6c1,#39323b, 13-99 d,#39323b, +|12: 0-0 d,#3d3540, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-13 #c3b6c1,#39323b, 14-99 d,#39323b, +|13: 0-0 d,#3d3540, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-13 #c3b6c1,#39323b, 14-99 d,#39323b, +|14: 0-0 d,#3d3540, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-13 #c3b6c1,#39323b, 14-99 d,#39323b, +|15: 0-0 d,#3d3540, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-13 #c3b6c1,#39323b, 14-99 d,#39323b, +|16: 0-0 d,#3d3540, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-13 #c3b6c1,#39323b, 14-99 d,#39323b, +|17: 0-1 #000000,#edb092, 2-2 d,#3b2f3b, 3-32 #d2c2d1,#3b2f3b, 33-39 #a1d5c4,#3b2f3b, 40-61 #d2c2d1,#3b2f3b, 62-99 d,#3b2f3b, +|18: 0-0 d,#8e667c, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-21 #c3b6c1,#39323b, 22-92 d,#39323b, 93-93 d,#2c2630, 94-98 #c6b6c5,#2c2630, 99-99 d,#2c2630, +|19: 0-0 d,#3d3540, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-19 #c3b6c1,#39323b, 20-99 d,#39323b, +|20: 0-0 d,#3d3540, 1-1 d,#39323b, 2-6 #8a7986,#39323b,b 7-16 #c3b6c1,#39323b, 17-99 d,#39323b, +|21: 0-0 d,#3d3540, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-15 #c3b6c1,#39323b, 16-99 d,#39323b, +|22: 0-0 d,#3d3540, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-17 #c3b6c1,#39323b, 18-99 d,#39323b, +|23: 0-0 d,#3d3540, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-20 #c3b6c1,#39323b, 21-99 d,#39323b, +|24: 0-0 d,#3d3540, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-19 #c3b6c1,#39323b, 20-99 d,#39323b, +|25: 0-0 d,#3d3540, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-17 #c3b6c1,#39323b, 18-99 d,#39323b, +|26: 0-0 d,#3d3540, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-18 #c3b6c1,#39323b, 19-99 d,#39323b, +|27: 0-0 d,#3d3540, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-20 #c3b6c1,#39323b, 21-99 d,#39323b, +|28: 0-0 d,#3d3540, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-20 #c3b6c1,#39323b, 21-99 d,#39323b, +|29: 0-0 d,#3d3540, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-19 #c3b6c1,#39323b, 20-99 d,#39323b, +|30: 0-0 d,#3d3540, 1-1 d,#39323b, 2-6 #82717e,#39323b, 7-16 #c3b6c1,#39323b, 17-99 d,#39323b, == snap step-atelier grid=100x31 cursor=7,24 == style step-atelier grid=100x31 -|0: 0-70 #4b6960,#d2dcda, 71-99 d,#d2dcda, -|1: 0-1 #000000,#b5a07e, 2-2 d,#c4d5cf, 3-34 #45635a,#c4d5cf, 35-99 d,#c4d5cf, -|2: 0-1 #000000,#92b09c, 2-2 d,#d2dcda, 3-32 #4b6960,#d2dcda, 33-37 #72547b,#d2dcda, 38-59 #4b6960,#d2dcda, 60-99 d,#d2dcda, -|3: 0-0 d,#9da777, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-12 #293f35,#fffdeb, 13-99 d,#fffdeb, -|4: 0-0 d,#9da777, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-12 #293f35,#fffdeb, 13-99 d,#fffdeb, -|5: 0-0 d,#9da777, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-12 #293f35,#fffdeb, 13-99 d,#fffdeb, -|6: 0-0 d,#9da777, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-12 #293f35,#fffdeb, 13-99 d,#fffdeb, -|7: 0-0 d,#dcdac1, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-12 #293f35,#fffdeb, 13-99 d,#fffdeb, -|8: 0-0 d,#dcdac1, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-12 #293f35,#fffdeb, 13-99 d,#fffdeb, -|9: 0-0 d,#dcdac1, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-12 #293f35,#fffdeb, 13-99 d,#fffdeb, -|10: 0-0 d,#dcdac1, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-12 #293f35,#fffdeb, 13-99 d,#fffdeb, -|11: 0-0 d,#dcdac1, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-12 #293f35,#fffdeb, 13-99 d,#fffdeb, -|12: 0-0 d,#dcdac1, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-13 #293f35,#fffdeb, 14-99 d,#fffdeb, -|13: 0-0 d,#dcdac1, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-13 #293f35,#fffdeb, 14-99 d,#fffdeb, -|14: 0-0 d,#dcdac1, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-13 #293f35,#fffdeb, 14-99 d,#fffdeb, -|15: 0-0 d,#dcdac1, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-13 #293f35,#fffdeb, 14-99 d,#fffdeb, -|16: 0-0 d,#dcdac1, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-13 #293f35,#fffdeb, 14-99 d,#fffdeb, -|17: 0-1 #ffffff,#48876f, 2-2 d,#c4d5cf, 3-32 #45635a,#c4d5cf, 33-39 #6b4e74,#c4d5cf, 40-61 #45635a,#c4d5cf, 62-99 d,#c4d5cf, -|18: 0-0 d,#9da777, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-21 #293f35,#fffdeb, 22-92 d,#fffdeb, 93-93 d,#d2dcda, 94-98 #4b6960,#d2dcda, 99-99 d,#d2dcda, -|19: 0-0 d,#dcdac1, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-19 #293f35,#fffdeb, 20-99 d,#fffdeb, -|20: 0-0 d,#dcdac1, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-16 #293f35,#fffdeb, 17-99 d,#fffdeb, -|21: 0-0 d,#dcdac1, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-15 #293f35,#fffdeb, 16-99 d,#fffdeb, -|22: 0-0 d,#dcdac1, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-17 #293f35,#fffdeb, 18-99 d,#fffdeb, -|23: 0-0 d,#dcdac1, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-20 #293f35,#fffdeb, 21-99 d,#fffdeb, -|24: 0-0 d,#dcdac1, 1-1 d,#fffdeb, 2-6 #889078,#fffdeb,b 7-19 #293f35,#fffdeb, 20-99 d,#fffdeb, -|25: 0-0 d,#dcdac1, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-17 #293f35,#fffdeb, 18-99 d,#fffdeb, -|26: 0-0 d,#dcdac1, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-18 #293f35,#fffdeb, 19-99 d,#fffdeb, -|27: 0-0 d,#dcdac1, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-20 #293f35,#fffdeb, 21-99 d,#fffdeb, -|28: 0-0 d,#dcdac1, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-20 #293f35,#fffdeb, 21-99 d,#fffdeb, -|29: 0-0 d,#dcdac1, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-19 #293f35,#fffdeb, 20-99 d,#fffdeb, -|30: 0-0 d,#dcdac1, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-16 #293f35,#fffdeb, 17-99 d,#fffdeb, +|0: 0-70 #3d6055,#e4f0ed, 71-99 d,#e4f0ed, +|1: 0-1 #000000,#bca481, 2-2 d,#d3e7df, 3-34 #35584d,#d3e7df, 35-99 d,#d3e7df, +|2: 0-1 #000000,#95baa2, 2-2 d,#e4f0ed, 3-32 #3d6055,#e4f0ed, 33-37 #6e4774,#e4f0ed, 38-59 #3d6055,#e4f0ed, 60-99 d,#e4f0ed, +|3: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-12 #293f35,#fffdeb, 13-99 d,#fffdeb, +|4: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-12 #293f35,#fffdeb, 13-99 d,#fffdeb, +|5: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-12 #293f35,#fffdeb, 13-99 d,#fffdeb, +|6: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-12 #293f35,#fffdeb, 13-99 d,#fffdeb, +|7: 0-0 d,#eeeccf, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-12 #293f35,#fffdeb, 13-99 d,#fffdeb, +|8: 0-0 d,#eeeccf, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-12 #293f35,#fffdeb, 13-99 d,#fffdeb, +|9: 0-0 d,#eeeccf, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-12 #293f35,#fffdeb, 13-99 d,#fffdeb, +|10: 0-0 d,#eeeccf, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-12 #293f35,#fffdeb, 13-99 d,#fffdeb, +|11: 0-0 d,#eeeccf, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-12 #293f35,#fffdeb, 13-99 d,#fffdeb, +|12: 0-0 d,#eeeccf, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-13 #293f35,#fffdeb, 14-99 d,#fffdeb, +|13: 0-0 d,#eeeccf, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-13 #293f35,#fffdeb, 14-99 d,#fffdeb, +|14: 0-0 d,#eeeccf, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-13 #293f35,#fffdeb, 14-99 d,#fffdeb, +|15: 0-0 d,#eeeccf, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-13 #293f35,#fffdeb, 14-99 d,#fffdeb, +|16: 0-0 d,#eeeccf, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-13 #293f35,#fffdeb, 14-99 d,#fffdeb, +|17: 0-1 #ffffff,#36826b, 2-2 d,#d3e7df, 3-32 #35584d,#d3e7df, 33-39 #653f6b,#d3e7df, 40-61 #35584d,#d3e7df, 62-99 d,#d3e7df, +|18: 0-0 d,#9fae76, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-21 #293f35,#fffdeb, 22-92 d,#fffdeb, 93-93 d,#e4f0ed, 94-98 #3d6055,#e4f0ed, 99-99 d,#e4f0ed, +|19: 0-0 d,#eeeccf, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-19 #293f35,#fffdeb, 20-99 d,#fffdeb, +|20: 0-0 d,#eeeccf, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-16 #293f35,#fffdeb, 17-99 d,#fffdeb, +|21: 0-0 d,#eeeccf, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-15 #293f35,#fffdeb, 16-99 d,#fffdeb, +|22: 0-0 d,#eeeccf, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-17 #293f35,#fffdeb, 18-99 d,#fffdeb, +|23: 0-0 d,#eeeccf, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-20 #293f35,#fffdeb, 21-99 d,#fffdeb, +|24: 0-0 d,#eeeccf, 1-1 d,#fffdeb, 2-6 #889078,#fffdeb,b 7-19 #293f35,#fffdeb, 20-99 d,#fffdeb, +|25: 0-0 d,#eeeccf, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-17 #293f35,#fffdeb, 18-99 d,#fffdeb, +|26: 0-0 d,#eeeccf, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-18 #293f35,#fffdeb, 19-99 d,#fffdeb, +|27: 0-0 d,#eeeccf, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-20 #293f35,#fffdeb, 21-99 d,#fffdeb, +|28: 0-0 d,#eeeccf, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-20 #293f35,#fffdeb, 21-99 d,#fffdeb, +|29: 0-0 d,#eeeccf, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-19 #293f35,#fffdeb, 20-99 d,#fffdeb, +|30: 0-0 d,#eeeccf, 1-1 d,#fffdeb, 2-6 #909880,#fffdeb, 7-16 #293f35,#fffdeb, 17-99 d,#fffdeb, == snap step-forge grid=100x31 cursor=7,25 == style step-forge grid=100x31 -|0: 0-70 #b7bfc6,#35393e, 71-99 d,#35393e, -|1: 0-1 #ffffff,#727f6a, 2-2 d,#3e4650, 3-34 #c1c9d0,#3e4650, 35-99 d,#3e4650, -|2: 0-1 #ffffff,#5b6d7f, 2-2 d,#35393e, 3-32 #b7bfc6,#35393e, 33-37 #dfb397,#35393e, 38-59 #b7bfc6,#35393e, 60-99 d,#35393e, -|3: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-12 #e5e5e5,#0c0c0e, 13-99 d,#0c0c0e, -|4: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-12 #e5e5e5,#0c0c0e, 13-99 d,#0c0c0e, -|5: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-12 #e5e5e5,#0c0c0e, 13-99 d,#0c0c0e, -|6: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-12 #e5e5e5,#0c0c0e, 13-99 d,#0c0c0e, -|7: 0-0 d,#2f3132, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-12 #e5e5e5,#0c0c0e, 13-99 d,#0c0c0e, -|8: 0-0 d,#2f3132, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-12 #e5e5e5,#0c0c0e, 13-99 d,#0c0c0e, -|9: 0-0 d,#2f3132, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-12 #e5e5e5,#0c0c0e, 13-99 d,#0c0c0e, -|10: 0-0 d,#2f3132, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-12 #e5e5e5,#0c0c0e, 13-99 d,#0c0c0e, -|11: 0-0 d,#2f3132, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-12 #e5e5e5,#0c0c0e, 13-99 d,#0c0c0e, -|12: 0-0 d,#2f3132, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-13 #e5e5e5,#0c0c0e, 14-99 d,#0c0c0e, -|13: 0-0 d,#2f3132, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-13 #e5e5e5,#0c0c0e, 14-99 d,#0c0c0e, -|14: 0-0 d,#2f3132, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-13 #e5e5e5,#0c0c0e, 14-99 d,#0c0c0e, -|15: 0-0 d,#2f3132, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-13 #e5e5e5,#0c0c0e, 14-99 d,#0c0c0e, -|16: 0-0 d,#2f3132, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-13 #e5e5e5,#0c0c0e, 14-99 d,#0c0c0e, -|17: 0-1 #000000,#92bde9, 2-2 d,#3e4650, 3-32 #c1c9d0,#3e4650, 33-39 #e9bda1,#3e4650, 40-61 #c1c9d0,#3e4650, 62-99 d,#3e4650, -|18: 0-0 d,#758592, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-21 #e5e5e5,#0c0c0e, 22-92 d,#0c0c0e, 93-93 d,#35393e, 94-98 #b7bfc6,#35393e, 99-99 d,#35393e, -|19: 0-0 d,#2f3132, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-19 #e5e5e5,#0c0c0e, 20-99 d,#0c0c0e, -|20: 0-0 d,#2f3132, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-16 #e5e5e5,#0c0c0e, 17-99 d,#0c0c0e, -|21: 0-0 d,#2f3132, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-15 #e5e5e5,#0c0c0e, 16-99 d,#0c0c0e, -|22: 0-0 d,#2f3132, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-17 #e5e5e5,#0c0c0e, 18-99 d,#0c0c0e, -|23: 0-0 d,#2f3132, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-20 #e5e5e5,#0c0c0e, 21-99 d,#0c0c0e, -|24: 0-0 d,#2f3132, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-19 #e5e5e5,#0c0c0e, 20-99 d,#0c0c0e, -|25: 0-0 d,#2f3132, 1-1 d,#0c0c0e, 2-6 #6a6d74,#0c0c0e,b 7-17 #e5e5e5,#0c0c0e, 18-99 d,#0c0c0e, -|26: 0-0 d,#2f3132, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-18 #e5e5e5,#0c0c0e, 19-99 d,#0c0c0e, -|27: 0-0 d,#2f3132, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-20 #e5e5e5,#0c0c0e, 21-99 d,#0c0c0e, -|28: 0-0 d,#2f3132, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-20 #e5e5e5,#0c0c0e, 21-99 d,#0c0c0e, -|29: 0-0 d,#2f3132, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-19 #e5e5e5,#0c0c0e, 20-99 d,#0c0c0e, -|30: 0-0 d,#2f3132, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-16 #e5e5e5,#0c0c0e, 17-99 d,#0c0c0e, +|0: 0-70 #c5c9d2,#22252b, 71-99 d,#22252b, +|1: 0-1 #ffffff,#6a7b67, 2-2 d,#2d3440, 3-34 #d1d5de,#2d3440, 35-99 d,#2d3440, +|2: 0-1 #ffffff,#55647b, 2-2 d,#22252b, 3-32 #c5c9d2,#22252b, 33-37 #ecbf9b,#22252b, 38-59 #c5c9d2,#22252b, 60-99 d,#22252b, +|3: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-12 #e5e5e5,#0c0c0e, 13-99 d,#0c0c0e, +|4: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-12 #e5e5e5,#0c0c0e, 13-99 d,#0c0c0e, +|5: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-12 #e5e5e5,#0c0c0e, 13-99 d,#0c0c0e, +|6: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-12 #e5e5e5,#0c0c0e, 13-99 d,#0c0c0e, +|7: 0-0 d,#1a1c21, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-12 #e5e5e5,#0c0c0e, 13-99 d,#0c0c0e, +|8: 0-0 d,#1a1c21, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-12 #e5e5e5,#0c0c0e, 13-99 d,#0c0c0e, +|9: 0-0 d,#1a1c21, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-12 #e5e5e5,#0c0c0e, 13-99 d,#0c0c0e, +|10: 0-0 d,#1a1c21, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-12 #e5e5e5,#0c0c0e, 13-99 d,#0c0c0e, +|11: 0-0 d,#1a1c21, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-12 #e5e5e5,#0c0c0e, 13-99 d,#0c0c0e, +|12: 0-0 d,#1a1c21, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-13 #e5e5e5,#0c0c0e, 14-99 d,#0c0c0e, +|13: 0-0 d,#1a1c21, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-13 #e5e5e5,#0c0c0e, 14-99 d,#0c0c0e, +|14: 0-0 d,#1a1c21, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-13 #e5e5e5,#0c0c0e, 14-99 d,#0c0c0e, +|15: 0-0 d,#1a1c21, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-13 #e5e5e5,#0c0c0e, 14-99 d,#0c0c0e, +|16: 0-0 d,#1a1c21, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-13 #e5e5e5,#0c0c0e, 14-99 d,#0c0c0e, +|17: 0-1 #000000,#9cc3f7, 2-2 d,#2d3440, 3-32 #d1d5de,#2d3440, 33-39 #f8cba7,#2d3440, 40-61 #d1d5de,#2d3440, 62-99 d,#2d3440, +|18: 0-0 d,#708095, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-21 #e5e5e5,#0c0c0e, 22-92 d,#0c0c0e, 93-93 d,#22252b, 94-98 #c5c9d2,#22252b, 99-99 d,#22252b, +|19: 0-0 d,#1a1c21, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-19 #e5e5e5,#0c0c0e, 20-99 d,#0c0c0e, +|20: 0-0 d,#1a1c21, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-16 #e5e5e5,#0c0c0e, 17-99 d,#0c0c0e, +|21: 0-0 d,#1a1c21, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-15 #e5e5e5,#0c0c0e, 16-99 d,#0c0c0e, +|22: 0-0 d,#1a1c21, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-17 #e5e5e5,#0c0c0e, 18-99 d,#0c0c0e, +|23: 0-0 d,#1a1c21, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-20 #e5e5e5,#0c0c0e, 21-99 d,#0c0c0e, +|24: 0-0 d,#1a1c21, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-19 #e5e5e5,#0c0c0e, 20-99 d,#0c0c0e, +|25: 0-0 d,#1a1c21, 1-1 d,#0c0c0e, 2-6 #6a6d74,#0c0c0e,b 7-17 #e5e5e5,#0c0c0e, 18-99 d,#0c0c0e, +|26: 0-0 d,#1a1c21, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-18 #e5e5e5,#0c0c0e, 19-99 d,#0c0c0e, +|27: 0-0 d,#1a1c21, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-20 #e5e5e5,#0c0c0e, 21-99 d,#0c0c0e, +|28: 0-0 d,#1a1c21, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-20 #e5e5e5,#0c0c0e, 21-99 d,#0c0c0e, +|29: 0-0 d,#1a1c21, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-19 #e5e5e5,#0c0c0e, 20-99 d,#0c0c0e, +|30: 0-0 d,#1a1c21, 1-1 d,#0c0c0e, 2-6 #62656c,#0c0c0e, 7-16 #e5e5e5,#0c0c0e, 17-99 d,#0c0c0e, == snap step-lagoon grid=100x31 cursor=7,26 == style step-lagoon grid=100x31 -|0: 0-70 #bcd6cc,#28413d, 71-99 d,#28413d, -|1: 0-1 #ffffff,#957763, 2-2 d,#33544c, 3-34 #c8e2d8,#33544c, 35-99 d,#33544c, -|2: 0-1 #ffffff,#548073, 2-2 d,#28413d, 3-32 #bcd6cc,#28413d, 33-37 #e5c2e4,#28413d, 38-59 #bcd6cc,#28413d, 60-99 d,#28413d, -|3: 0-0 d,#6e988c, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-12 #bac9ce,#34464c, 13-99 d,#34464c, -|4: 0-0 d,#6e988c, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-12 #bac9ce,#34464c, 13-99 d,#34464c, -|5: 0-0 d,#6e988c, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-12 #bac9ce,#34464c, 13-99 d,#34464c, -|6: 0-0 d,#6e988c, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-12 #bac9ce,#34464c, 13-99 d,#34464c, -|7: 0-0 d,#37474b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-12 #bac9ce,#34464c, 13-99 d,#34464c, -|8: 0-0 d,#37474b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-12 #bac9ce,#34464c, 13-99 d,#34464c, -|9: 0-0 d,#37474b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-12 #bac9ce,#34464c, 13-99 d,#34464c, -|10: 0-0 d,#37474b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-12 #bac9ce,#34464c, 13-99 d,#34464c, -|11: 0-0 d,#37474b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-12 #bac9ce,#34464c, 13-99 d,#34464c, -|12: 0-0 d,#37474b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-13 #bac9ce,#34464c, 14-99 d,#34464c, -|13: 0-0 d,#37474b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-13 #bac9ce,#34464c, 14-99 d,#34464c, -|14: 0-0 d,#37474b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-13 #bac9ce,#34464c, 14-99 d,#34464c, -|15: 0-0 d,#37474b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-13 #bac9ce,#34464c, 14-99 d,#34464c, -|16: 0-0 d,#37474b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-13 #bac9ce,#34464c, 14-99 d,#34464c, -|17: 0-1 #000000,#84cebd, 2-2 d,#33544c, 3-32 #c8e2d8,#33544c, 33-39 #f1cef0,#33544c, 40-61 #c8e2d8,#33544c, 62-99 d,#33544c, -|18: 0-0 d,#6e988c, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-21 #bac9ce,#34464c, 22-92 d,#34464c, 93-93 d,#28413d, 94-98 #bcd6cc,#28413d, 99-99 d,#28413d, -|19: 0-0 d,#37474b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-19 #bac9ce,#34464c, 20-99 d,#34464c, -|20: 0-0 d,#37474b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-16 #bac9ce,#34464c, 17-99 d,#34464c, -|21: 0-0 d,#37474b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-15 #bac9ce,#34464c, 16-99 d,#34464c, -|22: 0-0 d,#37474b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-17 #bac9ce,#34464c, 18-99 d,#34464c, -|23: 0-0 d,#37474b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-20 #bac9ce,#34464c, 21-99 d,#34464c, -|24: 0-0 d,#37474b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-19 #bac9ce,#34464c, 20-99 d,#34464c, -|25: 0-0 d,#37474b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-17 #bac9ce,#34464c, 18-99 d,#34464c, -|26: 0-0 d,#37474b, 1-1 d,#34464c, 2-6 #83999c,#34464c,b 7-18 #bac9ce,#34464c, 19-99 d,#34464c, -|27: 0-0 d,#37474b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-20 #bac9ce,#34464c, 21-99 d,#34464c, -|28: 0-0 d,#37474b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-20 #bac9ce,#34464c, 21-99 d,#34464c, -|29: 0-0 d,#37474b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-19 #bac9ce,#34464c, 20-99 d,#34464c, -|30: 0-0 d,#37474b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-16 #bac9ce,#34464c, 17-99 d,#34464c, +|0: 0-70 #bbd7cb,#29443f, 71-99 d,#29443f, +|1: 0-1 #ffffff,#9a7662, 2-2 d,#34574d, 3-34 #c7e3d7,#34574d, 35-99 d,#34574d, +|2: 0-1 #ffffff,#548372, 2-2 d,#29443f, 3-32 #bbd7cb,#29443f, 33-37 #e4c2ec,#29443f, 38-59 #bbd7cb,#29443f, 60-99 d,#29443f, +|3: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-12 #bac9ce,#34464c, 13-99 d,#34464c, +|4: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-12 #bac9ce,#34464c, 13-99 d,#34464c, +|5: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-12 #bac9ce,#34464c, 13-99 d,#34464c, +|6: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-12 #bac9ce,#34464c, 13-99 d,#34464c, +|7: 0-0 d,#3a4c50, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-12 #bac9ce,#34464c, 13-99 d,#34464c, +|8: 0-0 d,#3a4c50, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-12 #bac9ce,#34464c, 13-99 d,#34464c, +|9: 0-0 d,#3a4c50, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-12 #bac9ce,#34464c, 13-99 d,#34464c, +|10: 0-0 d,#3a4c50, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-12 #bac9ce,#34464c, 13-99 d,#34464c, +|11: 0-0 d,#3a4c50, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-12 #bac9ce,#34464c, 13-99 d,#34464c, +|12: 0-0 d,#3a4c50, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-13 #bac9ce,#34464c, 14-99 d,#34464c, +|13: 0-0 d,#3a4c50, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-13 #bac9ce,#34464c, 14-99 d,#34464c, +|14: 0-0 d,#3a4c50, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-13 #bac9ce,#34464c, 14-99 d,#34464c, +|15: 0-0 d,#3a4c50, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-13 #bac9ce,#34464c, 14-99 d,#34464c, +|16: 0-0 d,#3a4c50, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-13 #bac9ce,#34464c, 14-99 d,#34464c, +|17: 0-1 #000000,#81cfb7, 2-2 d,#34574d, 3-32 #c7e3d7,#34574d, 33-39 #f0cef8,#34574d, 40-61 #c7e3d7,#34574d, 62-99 d,#34574d, +|18: 0-0 d,#6e9b8b, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-21 #bac9ce,#34464c, 22-92 d,#34464c, 93-93 d,#29443f, 94-98 #bbd7cb,#29443f, 99-99 d,#29443f, +|19: 0-0 d,#3a4c50, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-19 #bac9ce,#34464c, 20-99 d,#34464c, +|20: 0-0 d,#3a4c50, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-16 #bac9ce,#34464c, 17-99 d,#34464c, +|21: 0-0 d,#3a4c50, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-15 #bac9ce,#34464c, 16-99 d,#34464c, +|22: 0-0 d,#3a4c50, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-17 #bac9ce,#34464c, 18-99 d,#34464c, +|23: 0-0 d,#3a4c50, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-20 #bac9ce,#34464c, 21-99 d,#34464c, +|24: 0-0 d,#3a4c50, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-19 #bac9ce,#34464c, 20-99 d,#34464c, +|25: 0-0 d,#3a4c50, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-17 #bac9ce,#34464c, 18-99 d,#34464c, +|26: 0-0 d,#3a4c50, 1-1 d,#34464c, 2-6 #83999c,#34464c,b 7-18 #bac9ce,#34464c, 19-99 d,#34464c, +|27: 0-0 d,#3a4c50, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-20 #bac9ce,#34464c, 21-99 d,#34464c, +|28: 0-0 d,#3a4c50, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-20 #bac9ce,#34464c, 21-99 d,#34464c, +|29: 0-0 d,#3a4c50, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-19 #bac9ce,#34464c, 20-99 d,#34464c, +|30: 0-0 d,#3a4c50, 1-1 d,#34464c, 2-6 #7b9194,#34464c, 7-16 #bac9ce,#34464c, 17-99 d,#34464c, == snap step-solarium grid=100x31 cursor=7,27 == style step-solarium grid=100x31 -|0: 0-70 #c6d4be,#144043, 71-99 d,#144043, -|1: 0-1 #ffffff,#3e7a94, 2-2 d,#234f50, 3-34 #d2e0ca,#234f50, 35-99 d,#234f50, -|2: 0-1 #ffffff,#477f76, 2-2 d,#144043, 3-32 #c6d4be,#144043, 33-37 #e9c1e1,#144043, 38-59 #c6d4be,#144043, 60-99 d,#144043, -|3: 0-0 d,#66978c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-12 #a7b7ae,#173e45, 13-99 d,#173e45, -|4: 0-0 d,#66978c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-12 #a7b7ae,#173e45, 13-99 d,#173e45, -|5: 0-0 d,#66978c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-12 #a7b7ae,#173e45, 13-99 d,#173e45, -|6: 0-0 d,#66978c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-12 #a7b7ae,#173e45, 13-99 d,#173e45, -|7: 0-0 d,#1f464c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-12 #a7b7ae,#173e45, 13-99 d,#173e45, -|8: 0-0 d,#1f464c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-12 #a7b7ae,#173e45, 13-99 d,#173e45, -|9: 0-0 d,#1f464c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-12 #a7b7ae,#173e45, 13-99 d,#173e45, -|10: 0-0 d,#1f464c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-12 #a7b7ae,#173e45, 13-99 d,#173e45, -|11: 0-0 d,#1f464c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-12 #a7b7ae,#173e45, 13-99 d,#173e45, -|12: 0-0 d,#1f464c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-13 #a7b7ae,#173e45, 14-99 d,#173e45, -|13: 0-0 d,#1f464c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-13 #a7b7ae,#173e45, 14-99 d,#173e45, -|14: 0-0 d,#1f464c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-13 #a7b7ae,#173e45, 14-99 d,#173e45, -|15: 0-0 d,#1f464c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-13 #a7b7ae,#173e45, 14-99 d,#173e45, -|16: 0-0 d,#1f464c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-13 #a7b7ae,#173e45, 14-99 d,#173e45, -|17: 0-1 #000000,#d7c36e, 2-2 d,#234f50, 3-32 #d2e0ca,#234f50, 33-39 #f5cded,#234f50, 40-61 #d2e0ca,#234f50, 62-99 d,#234f50, -|18: 0-0 d,#66978c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-21 #a7b7ae,#173e45, 22-92 d,#173e45, 93-93 d,#144043, 94-98 #c6d4be,#144043, 99-99 d,#144043, -|19: 0-0 d,#1f464c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-19 #a7b7ae,#173e45, 20-99 d,#173e45, -|20: 0-0 d,#1f464c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-16 #a7b7ae,#173e45, 17-99 d,#173e45, -|21: 0-0 d,#1f464c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-15 #a7b7ae,#173e45, 16-99 d,#173e45, -|22: 0-0 d,#1f464c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-17 #a7b7ae,#173e45, 18-99 d,#173e45, -|23: 0-0 d,#1f464c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-20 #a7b7ae,#173e45, 21-99 d,#173e45, -|24: 0-0 d,#1f464c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-19 #a7b7ae,#173e45, 20-99 d,#173e45, -|25: 0-0 d,#1f464c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-17 #a7b7ae,#173e45, 18-99 d,#173e45, -|26: 0-0 d,#1f464c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-18 #a7b7ae,#173e45, 19-99 d,#173e45, -|27: 0-0 d,#1f464c, 1-1 d,#173e45, 2-6 #6c8684,#173e45,b 7-20 #a7b7ae,#173e45, 21-99 d,#173e45, -|28: 0-0 d,#1f464c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-20 #a7b7ae,#173e45, 21-99 d,#173e45, -|29: 0-0 d,#1f464c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-19 #a7b7ae,#173e45, 20-99 d,#173e45, -|30: 0-0 d,#1f464c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-16 #a7b7ae,#173e45, 17-99 d,#173e45, +|0: 0-70 #c7d4bd,#123f43, 71-99 d,#123f43, +|1: 0-1 #ffffff,#347a9a, 2-2 d,#214e50, 3-34 #d3e0c9,#214e50, 35-99 d,#214e50, +|2: 0-1 #ffffff,#467f76, 2-2 d,#123f43, 3-32 #c7d4bd,#123f43, 33-37 #e9c1e0,#123f43, 38-59 #c7d4bd,#123f43, 60-99 d,#123f43, +|3: 0-0 d,#65978c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-12 #a7b7ae,#173e45, 13-99 d,#173e45, +|4: 0-0 d,#65978c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-12 #a7b7ae,#173e45, 13-99 d,#173e45, +|5: 0-0 d,#65978c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-12 #a7b7ae,#173e45, 13-99 d,#173e45, +|6: 0-0 d,#65978c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-12 #a7b7ae,#173e45, 13-99 d,#173e45, +|7: 0-0 d,#1c454c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-12 #a7b7ae,#173e45, 13-99 d,#173e45, +|8: 0-0 d,#1c454c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-12 #a7b7ae,#173e45, 13-99 d,#173e45, +|9: 0-0 d,#1c454c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-12 #a7b7ae,#173e45, 13-99 d,#173e45, +|10: 0-0 d,#1c454c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-12 #a7b7ae,#173e45, 13-99 d,#173e45, +|11: 0-0 d,#1c454c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-12 #a7b7ae,#173e45, 13-99 d,#173e45, +|12: 0-0 d,#1c454c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-13 #a7b7ae,#173e45, 14-99 d,#173e45, +|13: 0-0 d,#1c454c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-13 #a7b7ae,#173e45, 14-99 d,#173e45, +|14: 0-0 d,#1c454c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-13 #a7b7ae,#173e45, 14-99 d,#173e45, +|15: 0-0 d,#1c454c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-13 #a7b7ae,#173e45, 14-99 d,#173e45, +|16: 0-0 d,#1c454c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-13 #a7b7ae,#173e45, 14-99 d,#173e45, +|17: 0-1 #000000,#e1c266, 2-2 d,#214e50, 3-32 #d3e0c9,#214e50, 33-39 #f5cdec,#214e50, 40-61 #d3e0c9,#214e50, 62-99 d,#214e50, +|18: 0-0 d,#65978c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-21 #a7b7ae,#173e45, 22-92 d,#173e45, 93-93 d,#123f43, 94-98 #c7d4bd,#123f43, 99-99 d,#123f43, +|19: 0-0 d,#1c454c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-19 #a7b7ae,#173e45, 20-99 d,#173e45, +|20: 0-0 d,#1c454c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-16 #a7b7ae,#173e45, 17-99 d,#173e45, +|21: 0-0 d,#1c454c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-15 #a7b7ae,#173e45, 16-99 d,#173e45, +|22: 0-0 d,#1c454c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-17 #a7b7ae,#173e45, 18-99 d,#173e45, +|23: 0-0 d,#1c454c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-20 #a7b7ae,#173e45, 21-99 d,#173e45, +|24: 0-0 d,#1c454c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-19 #a7b7ae,#173e45, 20-99 d,#173e45, +|25: 0-0 d,#1c454c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-17 #a7b7ae,#173e45, 18-99 d,#173e45, +|26: 0-0 d,#1c454c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-18 #a7b7ae,#173e45, 19-99 d,#173e45, +|27: 0-0 d,#1c454c, 1-1 d,#173e45, 2-6 #6c8684,#173e45,b 7-20 #a7b7ae,#173e45, 21-99 d,#173e45, +|28: 0-0 d,#1c454c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-20 #a7b7ae,#173e45, 21-99 d,#173e45, +|29: 0-0 d,#1c454c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-19 #a7b7ae,#173e45, 20-99 d,#173e45, +|30: 0-0 d,#1c454c, 1-1 d,#173e45, 2-6 #647e7c,#173e45, 7-16 #a7b7ae,#173e45, 17-99 d,#173e45, == snap step-spectrum grid=100x31 cursor=7,27 |18: 2 Theme orchard Theme |19: 3 Theme dusk @@ -261,37 +261,37 @@ |29: 13 Theme clay |30: 14 Theme forge_black == style step-spectrum grid=100x31 -|0: 0-70 #dbd8bb,#373932, 71-99 d,#373932, -|1: 0-1 #ffffff,#6a6796, 2-2 d,#494839, 3-34 #e7e4c7,#494839, 35-99 d,#494839, -|2: 0-1 #ffffff,#8a8453, 2-2 d,#373932, 3-32 #dbd8bb,#373932, 33-37 #c9d5fc,#373932, 38-59 #dbd8bb,#373932, 60-99 d,#373932, -|3: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-12 #fcfcfa,#100e11, 13-99 d,#100e11, -|4: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-12 #fcfcfa,#100e11, 13-99 d,#100e11, -|5: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-12 #fcfcfa,#100e11, 13-99 d,#100e11, -|6: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-12 #fcfcfa,#100e11, 13-99 d,#100e11, -|7: 0-0 d,#212127, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-12 #fcfcfa,#100e11, 13-99 d,#100e11, -|8: 0-0 d,#212127, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-12 #fcfcfa,#100e11, 13-99 d,#100e11, -|9: 0-0 d,#212127, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-12 #fcfcfa,#100e11, 13-99 d,#100e11, -|10: 0-0 d,#212127, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-12 #fcfcfa,#100e11, 13-99 d,#100e11, -|11: 0-0 d,#212127, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-12 #fcfcfa,#100e11, 13-99 d,#100e11, -|12: 0-0 d,#212127, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-13 #fcfcfa,#100e11, 14-99 d,#100e11, -|13: 0-0 d,#212127, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-13 #fcfcfa,#100e11, 14-99 d,#100e11, -|14: 0-0 d,#212127, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-13 #fcfcfa,#100e11, 14-99 d,#100e11, -|15: 0-0 d,#212127, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-13 #fcfcfa,#100e11, 14-99 d,#100e11, -|16: 0-0 d,#212127, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-13 #fcfcfa,#100e11, 14-99 d,#100e11, -|17: 0-1 #000000,#fcd666, 2-2 d,#494839, 3-32 #e7e4c7,#494839, 33-39 #dae1fd,#494839, 40-61 #e7e4c7,#494839, 62-99 d,#494839, -|18: 0-0 d,#8f8e72, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-19 #fcfcfa,#100e11, 20-92 d,#100e11, 93-93 d,#373932, 94-98 #dbd8bb,#373932, 99-99 d,#373932, -|19: 0-0 d,#212127, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-16 #fcfcfa,#100e11, 17-99 d,#100e11, -|20: 0-0 d,#212127, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-15 #fcfcfa,#100e11, 16-99 d,#100e11, -|21: 0-0 d,#212127, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-17 #fcfcfa,#100e11, 18-99 d,#100e11, -|22: 0-0 d,#212127, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-20 #fcfcfa,#100e11, 21-99 d,#100e11, -|23: 0-0 d,#212127, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-19 #fcfcfa,#100e11, 20-99 d,#100e11, -|24: 0-0 d,#212127, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-17 #fcfcfa,#100e11, 18-99 d,#100e11, -|25: 0-0 d,#212127, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-18 #fcfcfa,#100e11, 19-99 d,#100e11, -|26: 0-0 d,#212127, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-20 #fcfcfa,#100e11, 21-99 d,#100e11, -|27: 0-0 d,#212127, 1-1 d,#100e11, 2-6 #706973,#100e11,b 7-20 #fcfcfa,#100e11, 21-99 d,#100e11, -|28: 0-0 d,#212127, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-19 #fcfcfa,#100e11, 20-99 d,#100e11, -|29: 0-0 d,#212127, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-16 #fcfcfa,#100e11, 17-99 d,#100e11, -|30: 0-0 d,#212127, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-23 #fcfcfa,#100e11, 24-99 d,#100e11, +|0: 0-70 #ddd8bb,#3b3830, 71-99 d,#3b3830, +|1: 0-1 #ffffff,#706596, 2-2 d,#4d4736, 3-34 #e9e4c7,#4d4736, 35-99 d,#4d4736, +|2: 0-1 #ffffff,#92844f, 2-2 d,#3b3830, 3-32 #ddd8bb,#3b3830, 33-37 #c5d7ff,#3b3830, 38-59 #ddd8bb,#3b3830, 60-99 d,#3b3830, +|3: 0-0 d,#948d6f, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-12 #fcfcfa,#100e11, 13-99 d,#100e11, +|4: 0-0 d,#948d6f, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-12 #fcfcfa,#100e11, 13-99 d,#100e11, +|5: 0-0 d,#948d6f, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-12 #fcfcfa,#100e11, 13-99 d,#100e11, +|6: 0-0 d,#948d6f, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-12 #fcfcfa,#100e11, 13-99 d,#100e11, +|7: 0-0 d,#211d23, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-12 #fcfcfa,#100e11, 13-99 d,#100e11, +|8: 0-0 d,#211d23, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-12 #fcfcfa,#100e11, 13-99 d,#100e11, +|9: 0-0 d,#211d23, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-12 #fcfcfa,#100e11, 13-99 d,#100e11, +|10: 0-0 d,#211d23, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-12 #fcfcfa,#100e11, 13-99 d,#100e11, +|11: 0-0 d,#211d23, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-12 #fcfcfa,#100e11, 13-99 d,#100e11, +|12: 0-0 d,#211d23, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-13 #fcfcfa,#100e11, 14-99 d,#100e11, +|13: 0-0 d,#211d23, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-13 #fcfcfa,#100e11, 14-99 d,#100e11, +|14: 0-0 d,#211d23, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-13 #fcfcfa,#100e11, 14-99 d,#100e11, +|15: 0-0 d,#211d23, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-13 #fcfcfa,#100e11, 14-99 d,#100e11, +|16: 0-0 d,#211d23, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-13 #fcfcfa,#100e11, 14-99 d,#100e11, +|17: 0-1 #000000,#ffd866, 2-2 d,#4d4736, 3-32 #e9e4c7,#4d4736, 33-39 #d7e3ff,#4d4736, 40-61 #e9e4c7,#4d4736, 62-99 d,#4d4736, +|18: 0-0 d,#948d6f, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-19 #fcfcfa,#100e11, 20-92 d,#100e11, 93-93 d,#3b3830, 94-98 #ddd8bb,#3b3830, 99-99 d,#3b3830, +|19: 0-0 d,#211d23, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-16 #fcfcfa,#100e11, 17-99 d,#100e11, +|20: 0-0 d,#211d23, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-15 #fcfcfa,#100e11, 16-99 d,#100e11, +|21: 0-0 d,#211d23, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-17 #fcfcfa,#100e11, 18-99 d,#100e11, +|22: 0-0 d,#211d23, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-20 #fcfcfa,#100e11, 21-99 d,#100e11, +|23: 0-0 d,#211d23, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-19 #fcfcfa,#100e11, 20-99 d,#100e11, +|24: 0-0 d,#211d23, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-17 #fcfcfa,#100e11, 18-99 d,#100e11, +|25: 0-0 d,#211d23, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-18 #fcfcfa,#100e11, 19-99 d,#100e11, +|26: 0-0 d,#211d23, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-20 #fcfcfa,#100e11, 21-99 d,#100e11, +|27: 0-0 d,#211d23, 1-1 d,#100e11, 2-6 #706973,#100e11,b 7-20 #fcfcfa,#100e11, 21-99 d,#100e11, +|28: 0-0 d,#211d23, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-19 #fcfcfa,#100e11, 20-99 d,#100e11, +|29: 0-0 d,#211d23, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-16 #fcfcfa,#100e11, 17-99 d,#100e11, +|30: 0-0 d,#211d23, 1-1 d,#100e11, 2-6 #68616b,#100e11, 7-23 #fcfcfa,#100e11, 24-99 d,#100e11, == snap step-harvest grid=100x31 cursor=7,27 |18: 3 Theme dusk Theme |19: 4 Theme ink @@ -307,37 +307,37 @@ |29: 14 Theme forge_black |30: 15 Theme forge_soft == style step-harvest grid=100x31 -|0: 0-70 #d6d8b8,#353b2e, 71-99 d,#353b2e, -|1: 0-1 #ffffff,#975948, 2-2 d,#434c38, 3-34 #e2e4c4,#434c38, 35-99 d,#434c38, -|2: 0-1 #ffffff,#7d7f4e, 2-2 d,#353b2e, 3-32 #d6d8b8,#353b2e, 33-37 #bbdbed,#353b2e, 38-59 #d6d8b8,#353b2e, 60-99 d,#353b2e, -|3: 0-0 d,#898e60, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-12 #f3f2cc,#100e0b, 13-99 d,#100e0b, -|4: 0-0 d,#898e60, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-12 #f3f2cc,#100e0b, 13-99 d,#100e0b, -|5: 0-0 d,#898e60, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-12 #f3f2cc,#100e0b, 13-99 d,#100e0b, -|6: 0-0 d,#898e60, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-12 #f3f2cc,#100e0b, 13-99 d,#100e0b, -|7: 0-0 d,#201d18, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-12 #f3f2cc,#100e0b, 13-99 d,#100e0b, -|8: 0-0 d,#201d18, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-12 #f3f2cc,#100e0b, 13-99 d,#100e0b, -|9: 0-0 d,#201d18, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-12 #f3f2cc,#100e0b, 13-99 d,#100e0b, -|10: 0-0 d,#201d18, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-12 #f3f2cc,#100e0b, 13-99 d,#100e0b, -|11: 0-0 d,#201d18, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-12 #f3f2cc,#100e0b, 13-99 d,#100e0b, -|12: 0-0 d,#201d18, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-13 #f3f2cc,#100e0b, 14-99 d,#100e0b, -|13: 0-0 d,#201d18, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-13 #f3f2cc,#100e0b, 14-99 d,#100e0b, -|14: 0-0 d,#201d18, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-13 #f3f2cc,#100e0b, 14-99 d,#100e0b, -|15: 0-0 d,#201d18, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-13 #f3f2cc,#100e0b, 14-99 d,#100e0b, -|16: 0-0 d,#201d18, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-13 #f3f2cc,#100e0b, 14-99 d,#100e0b, -|17: 0-1 #000000,#dcc080, 2-2 d,#434c38, 3-32 #e2e4c4,#434c38, 33-39 #c7e7f8,#434c38, 40-61 #e2e4c4,#434c38, 62-99 d,#434c38, -|18: 0-0 d,#898e60, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-16 #f3f2cc,#100e0b, 17-92 d,#100e0b, 93-93 d,#353b2e, 94-98 #d6d8b8,#353b2e, 99-99 d,#353b2e, -|19: 0-0 d,#201d18, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-15 #f3f2cc,#100e0b, 16-99 d,#100e0b, -|20: 0-0 d,#201d18, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-17 #f3f2cc,#100e0b, 18-99 d,#100e0b, -|21: 0-0 d,#201d18, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-20 #f3f2cc,#100e0b, 21-99 d,#100e0b, -|22: 0-0 d,#201d18, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-19 #f3f2cc,#100e0b, 20-99 d,#100e0b, -|23: 0-0 d,#201d18, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-17 #f3f2cc,#100e0b, 18-99 d,#100e0b, -|24: 0-0 d,#201d18, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-18 #f3f2cc,#100e0b, 19-99 d,#100e0b, -|25: 0-0 d,#201d18, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-20 #f3f2cc,#100e0b, 21-99 d,#100e0b, -|26: 0-0 d,#201d18, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-20 #f3f2cc,#100e0b, 21-99 d,#100e0b, -|27: 0-0 d,#201d18, 1-1 d,#100e0b, 2-6 #726c5e,#100e0b,b 7-19 #f3f2cc,#100e0b, 20-99 d,#100e0b, -|28: 0-0 d,#201d18, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-16 #f3f2cc,#100e0b, 17-99 d,#100e0b, -|29: 0-0 d,#201d18, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-23 #f3f2cc,#100e0b, 24-99 d,#100e0b, -|30: 0-0 d,#201d18, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-22 #f3f2cc,#100e0b, 23-99 d,#100e0b, +|0: 0-70 #d5d8b8,#343b2e, 71-99 d,#343b2e, +|1: 0-1 #ffffff,#9b583f, 2-2 d,#424d38, 3-34 #e1e4c4,#424d38, 35-99 d,#424d38, +|2: 0-1 #ffffff,#7b7e4e, 2-2 d,#343b2e, 3-32 #d5d8b8,#343b2e, 33-37 #badbeb,#343b2e, 38-59 #d5d8b8,#343b2e, 60-99 d,#343b2e, +|3: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-12 #f3f2cc,#100e0b, 13-99 d,#100e0b, +|4: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-12 #f3f2cc,#100e0b, 13-99 d,#100e0b, +|5: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-12 #f3f2cc,#100e0b, 13-99 d,#100e0b, +|6: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-12 #f3f2cc,#100e0b, 13-99 d,#100e0b, +|7: 0-0 d,#201d17, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-12 #f3f2cc,#100e0b, 13-99 d,#100e0b, +|8: 0-0 d,#201d17, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-12 #f3f2cc,#100e0b, 13-99 d,#100e0b, +|9: 0-0 d,#201d17, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-12 #f3f2cc,#100e0b, 13-99 d,#100e0b, +|10: 0-0 d,#201d17, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-12 #f3f2cc,#100e0b, 13-99 d,#100e0b, +|11: 0-0 d,#201d17, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-12 #f3f2cc,#100e0b, 13-99 d,#100e0b, +|12: 0-0 d,#201d17, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-13 #f3f2cc,#100e0b, 14-99 d,#100e0b, +|13: 0-0 d,#201d17, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-13 #f3f2cc,#100e0b, 14-99 d,#100e0b, +|14: 0-0 d,#201d17, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-13 #f3f2cc,#100e0b, 14-99 d,#100e0b, +|15: 0-0 d,#201d17, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-13 #f3f2cc,#100e0b, 14-99 d,#100e0b, +|16: 0-0 d,#201d17, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-13 #f3f2cc,#100e0b, 14-99 d,#100e0b, +|17: 0-1 #000000,#d8bd83, 2-2 d,#424d38, 3-32 #e1e4c4,#424d38, 33-39 #c5e7f7,#424d38, 40-61 #e1e4c4,#424d38, 62-99 d,#424d38, +|18: 0-0 d,#888e5e, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-16 #f3f2cc,#100e0b, 17-92 d,#100e0b, 93-93 d,#343b2e, 94-98 #d5d8b8,#343b2e, 99-99 d,#343b2e, +|19: 0-0 d,#201d17, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-15 #f3f2cc,#100e0b, 16-99 d,#100e0b, +|20: 0-0 d,#201d17, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-17 #f3f2cc,#100e0b, 18-99 d,#100e0b, +|21: 0-0 d,#201d17, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-20 #f3f2cc,#100e0b, 21-99 d,#100e0b, +|22: 0-0 d,#201d17, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-19 #f3f2cc,#100e0b, 20-99 d,#100e0b, +|23: 0-0 d,#201d17, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-17 #f3f2cc,#100e0b, 18-99 d,#100e0b, +|24: 0-0 d,#201d17, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-18 #f3f2cc,#100e0b, 19-99 d,#100e0b, +|25: 0-0 d,#201d17, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-20 #f3f2cc,#100e0b, 21-99 d,#100e0b, +|26: 0-0 d,#201d17, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-20 #f3f2cc,#100e0b, 21-99 d,#100e0b, +|27: 0-0 d,#201d17, 1-1 d,#100e0b, 2-6 #726c5e,#100e0b,b 7-19 #f3f2cc,#100e0b, 20-99 d,#100e0b, +|28: 0-0 d,#201d17, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-16 #f3f2cc,#100e0b, 17-99 d,#100e0b, +|29: 0-0 d,#201d17, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-23 #f3f2cc,#100e0b, 24-99 d,#100e0b, +|30: 0-0 d,#201d17, 1-1 d,#100e0b, 2-6 #6a6456,#100e0b, 7-22 #f3f2cc,#100e0b, 23-99 d,#100e0b, == snap step-clay grid=100x31 cursor=7,27 |18: 4 Theme ink Theme |19: 5 Theme paper @@ -353,13 +353,13 @@ |29: 15 Theme forge_soft |30: 16 Theme orchard_black == style step-clay grid=100x31 -|0: 0-70 #d7d6b6,#343f35, 71-99 d,#343f35, -|1: 0-1 #ffffff,#895d61, 2-2 d,#424f3f, 3-34 #e3e2c2,#424f3f, 35-99 d,#424f3f, -|2: 0-1 #ffffff,#6a7c4d, 2-2 d,#343f35, 3-32 #d7d6b6,#343f35, 33-37 #ace0e1,#343f35, 38-59 #d7d6b6,#343f35, 60-99 d,#343f35, -|3: 0-0 d,#8c9366, 1-1 d,#10100e, 2-6 #696455,#10100e, 7-12 #f3e5bc,#10100e, 13-99 d,#10100e, -|4: 0-0 d,#8c9366, 1-1 d,#10100e, 2-6 #696455,#10100e, 7-12 #f3e5bc,#10100e, 13-99 d,#10100e, -|5: 0-0 d,#8c9366, 1-1 d,#10100e, 2-6 #696455,#10100e, 7-12 #f3e5bc,#10100e, 13-99 d,#10100e, -|6: 0-0 d,#8c9366, 1-1 d,#10100e, 2-6 #696455,#10100e, 7-12 #f3e5bc,#10100e, 13-99 d,#10100e, +|0: 0-70 #d7d6b6,#343f36, 71-99 d,#343f36, +|1: 0-1 #ffffff,#875e65, 2-2 d,#424f40, 3-34 #e3e2c2,#424f40, 35-99 d,#424f40, +|2: 0-1 #ffffff,#687c4d, 2-2 d,#343f36, 3-32 #d7d6b6,#343f36, 33-37 #aae0e0,#343f36, 38-59 #d7d6b6,#343f36, 60-99 d,#343f36, +|3: 0-0 d,#8c9467, 1-1 d,#10100e, 2-6 #696455,#10100e, 7-12 #f3e5bc,#10100e, 13-99 d,#10100e, +|4: 0-0 d,#8c9467, 1-1 d,#10100e, 2-6 #696455,#10100e, 7-12 #f3e5bc,#10100e, 13-99 d,#10100e, +|5: 0-0 d,#8c9467, 1-1 d,#10100e, 2-6 #696455,#10100e, 7-12 #f3e5bc,#10100e, 13-99 d,#10100e, +|6: 0-0 d,#8c9467, 1-1 d,#10100e, 2-6 #696455,#10100e, 7-12 #f3e5bc,#10100e, 13-99 d,#10100e, |7: 0-0 d,#222119, 1-1 d,#10100e, 2-6 #696455,#10100e, 7-12 #f3e5bc,#10100e, 13-99 d,#10100e, |8: 0-0 d,#222119, 1-1 d,#10100e, 2-6 #696455,#10100e, 7-12 #f3e5bc,#10100e, 13-99 d,#10100e, |9: 0-0 d,#222119, 1-1 d,#10100e, 2-6 #696455,#10100e, 7-12 #f3e5bc,#10100e, 13-99 d,#10100e, @@ -370,8 +370,8 @@ |14: 0-0 d,#222119, 1-1 d,#10100e, 2-6 #696455,#10100e, 7-13 #f3e5bc,#10100e, 14-99 d,#10100e, |15: 0-0 d,#222119, 1-1 d,#10100e, 2-6 #696455,#10100e, 7-13 #f3e5bc,#10100e, 14-99 d,#10100e, |16: 0-0 d,#222119, 1-1 d,#10100e, 2-6 #696455,#10100e, 7-13 #f3e5bc,#10100e, 14-99 d,#10100e, -|17: 0-1 #000000,#92c776, 2-2 d,#424f3f, 3-32 #e3e2c2,#424f3f, 33-39 #b7eced,#424f3f, 40-61 #e3e2c2,#424f3f, 62-99 d,#424f3f, -|18: 0-0 d,#8c9366, 1-1 d,#10100e, 2-6 #696455,#10100e, 7-15 #f3e5bc,#10100e, 16-92 d,#10100e, 93-93 d,#343f35, 94-98 #d7d6b6,#343f35, 99-99 d,#343f35, +|17: 0-1 #000000,#8ac874, 2-2 d,#424f40, 3-32 #e3e2c2,#424f40, 33-39 #b5ecec,#424f40, 40-61 #e3e2c2,#424f40, 62-99 d,#424f40, +|18: 0-0 d,#8c9467, 1-1 d,#10100e, 2-6 #696455,#10100e, 7-15 #f3e5bc,#10100e, 16-92 d,#10100e, 93-93 d,#343f36, 94-98 #d7d6b6,#343f36, 99-99 d,#343f36, |19: 0-0 d,#222119, 1-1 d,#10100e, 2-6 #696455,#10100e, 7-17 #f3e5bc,#10100e, 18-99 d,#10100e, |20: 0-0 d,#222119, 1-1 d,#10100e, 2-6 #696455,#10100e, 7-20 #f3e5bc,#10100e, 21-99 d,#10100e, |21: 0-0 d,#222119, 1-1 d,#10100e, 2-6 #696455,#10100e, 7-19 #f3e5bc,#10100e, 20-99 d,#10100e, @@ -399,37 +399,37 @@ |29: 16 Theme orchard_black |30: 17 == style step-forge-black grid=100x31 -|0: 0-70 #c7cacf,#24282c, 71-99 d,#24282c, -|1: 0-1 #ffffff,#6d7867, 2-2 d,#2f3740, 3-34 #d3d6db,#2f3740, 35-99 d,#2f3740, -|2: 0-1 #ffffff,#576676, 2-2 d,#24282c, 3-32 #c7cacf,#24282c, 33-37 #e5c2a2,#24282c, 38-59 #c7cacf,#24282c, 60-99 d,#24282c, -|3: 0-0 d,#738290, 1-1 d,#000000, 2-6 #62656c,#000000, 7-12 #f6f6f6,#000000, 13-99 d,#000000, -|4: 0-0 d,#738290, 1-1 d,#000000, 2-6 #62656c,#000000, 7-12 #f6f6f6,#000000, 13-99 d,#000000, -|5: 0-0 d,#738290, 1-1 d,#000000, 2-6 #62656c,#000000, 7-12 #f6f6f6,#000000, 13-99 d,#000000, -|6: 0-0 d,#738290, 1-1 d,#000000, 2-6 #62656c,#000000, 7-12 #f6f6f6,#000000, 13-99 d,#000000, -|7: 0-0 d,#15171a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-12 #f6f6f6,#000000, 13-99 d,#000000, -|8: 0-0 d,#15171a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-12 #f6f6f6,#000000, 13-99 d,#000000, -|9: 0-0 d,#15171a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-12 #f6f6f6,#000000, 13-99 d,#000000, -|10: 0-0 d,#15171a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-12 #f6f6f6,#000000, 13-99 d,#000000, -|11: 0-0 d,#15171a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-12 #f6f6f6,#000000, 13-99 d,#000000, -|12: 0-0 d,#15171a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-13 #f6f6f6,#000000, 14-99 d,#000000, -|13: 0-0 d,#15171a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-13 #f6f6f6,#000000, 14-99 d,#000000, -|14: 0-0 d,#15171a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-13 #f6f6f6,#000000, 14-99 d,#000000, -|15: 0-0 d,#15171a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-13 #f6f6f6,#000000, 14-99 d,#000000, -|16: 0-0 d,#15171a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-13 #f6f6f6,#000000, 14-99 d,#000000, -|17: 0-1 #000000,#9ac4ea, 2-2 d,#2f3740, 3-32 #d3d6db,#2f3740, 33-39 #f1ceae,#2f3740, 40-61 #d3d6db,#2f3740, 62-99 d,#2f3740, -|18: 0-0 d,#738290, 1-1 d,#000000, 2-6 #62656c,#000000, 7-17 #f6f6f6,#000000, 18-92 d,#000000, 93-93 d,#24282c, 94-98 #c7cacf,#24282c, 99-99 d,#24282c, -|19: 0-0 d,#15171a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-20 #f6f6f6,#000000, 21-99 d,#000000, -|20: 0-0 d,#15171a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-19 #f6f6f6,#000000, 20-99 d,#000000, -|21: 0-0 d,#15171a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-17 #f6f6f6,#000000, 18-99 d,#000000, -|22: 0-0 d,#15171a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-18 #f6f6f6,#000000, 19-99 d,#000000, -|23: 0-0 d,#15171a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-20 #f6f6f6,#000000, 21-99 d,#000000, -|24: 0-0 d,#15171a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-20 #f6f6f6,#000000, 21-99 d,#000000, -|25: 0-0 d,#15171a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-19 #f6f6f6,#000000, 20-99 d,#000000, -|26: 0-0 d,#15171a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-16 #f6f6f6,#000000, 17-99 d,#000000, -|27: 0-0 d,#15171a, 1-1 d,#000000, 2-6 #6a6d74,#000000,b 7-23 #f6f6f6,#000000, 24-99 d,#000000, -|28: 0-0 d,#15171a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-22 #f6f6f6,#000000, 23-99 d,#000000, -|29: 0-0 d,#15171a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-25 #f6f6f6,#000000, 26-99 d,#000000, -|30: 0-0 d,#15171a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-99 d,#000000, +|0: 0-70 #c5c9d2,#22252b, 71-99 d,#22252b, +|1: 0-1 #ffffff,#6a7b67, 2-2 d,#2d3440, 3-34 #d1d5de,#2d3440, 35-99 d,#2d3440, +|2: 0-1 #ffffff,#55647b, 2-2 d,#22252b, 3-32 #c5c9d2,#22252b, 33-37 #ecbf9b,#22252b, 38-59 #c5c9d2,#22252b, 60-99 d,#22252b, +|3: 0-0 d,#708095, 1-1 d,#000000, 2-6 #62656c,#000000, 7-12 #f6f6f6,#000000, 13-99 d,#000000, +|4: 0-0 d,#708095, 1-1 d,#000000, 2-6 #62656c,#000000, 7-12 #f6f6f6,#000000, 13-99 d,#000000, +|5: 0-0 d,#708095, 1-1 d,#000000, 2-6 #62656c,#000000, 7-12 #f6f6f6,#000000, 13-99 d,#000000, +|6: 0-0 d,#708095, 1-1 d,#000000, 2-6 #62656c,#000000, 7-12 #f6f6f6,#000000, 13-99 d,#000000, +|7: 0-0 d,#14161a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-12 #f6f6f6,#000000, 13-99 d,#000000, +|8: 0-0 d,#14161a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-12 #f6f6f6,#000000, 13-99 d,#000000, +|9: 0-0 d,#14161a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-12 #f6f6f6,#000000, 13-99 d,#000000, +|10: 0-0 d,#14161a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-12 #f6f6f6,#000000, 13-99 d,#000000, +|11: 0-0 d,#14161a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-12 #f6f6f6,#000000, 13-99 d,#000000, +|12: 0-0 d,#14161a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-13 #f6f6f6,#000000, 14-99 d,#000000, +|13: 0-0 d,#14161a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-13 #f6f6f6,#000000, 14-99 d,#000000, +|14: 0-0 d,#14161a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-13 #f6f6f6,#000000, 14-99 d,#000000, +|15: 0-0 d,#14161a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-13 #f6f6f6,#000000, 14-99 d,#000000, +|16: 0-0 d,#14161a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-13 #f6f6f6,#000000, 14-99 d,#000000, +|17: 0-1 #000000,#9cc3f7, 2-2 d,#2d3440, 3-32 #d1d5de,#2d3440, 33-39 #f8cba7,#2d3440, 40-61 #d1d5de,#2d3440, 62-99 d,#2d3440, +|18: 0-0 d,#708095, 1-1 d,#000000, 2-6 #62656c,#000000, 7-17 #f6f6f6,#000000, 18-92 d,#000000, 93-93 d,#22252b, 94-98 #c5c9d2,#22252b, 99-99 d,#22252b, +|19: 0-0 d,#14161a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-20 #f6f6f6,#000000, 21-99 d,#000000, +|20: 0-0 d,#14161a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-19 #f6f6f6,#000000, 20-99 d,#000000, +|21: 0-0 d,#14161a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-17 #f6f6f6,#000000, 18-99 d,#000000, +|22: 0-0 d,#14161a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-18 #f6f6f6,#000000, 19-99 d,#000000, +|23: 0-0 d,#14161a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-20 #f6f6f6,#000000, 21-99 d,#000000, +|24: 0-0 d,#14161a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-20 #f6f6f6,#000000, 21-99 d,#000000, +|25: 0-0 d,#14161a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-19 #f6f6f6,#000000, 20-99 d,#000000, +|26: 0-0 d,#14161a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-16 #f6f6f6,#000000, 17-99 d,#000000, +|27: 0-0 d,#14161a, 1-1 d,#000000, 2-6 #6a6d74,#000000,b 7-23 #f6f6f6,#000000, 24-99 d,#000000, +|28: 0-0 d,#14161a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-22 #f6f6f6,#000000, 23-99 d,#000000, +|29: 0-0 d,#14161a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-25 #f6f6f6,#000000, 26-99 d,#000000, +|30: 0-0 d,#14161a, 1-1 d,#000000, 2-6 #62656c,#000000, 7-99 d,#000000, == snap step-forge-soft grid=100x31 cursor=7,27 |18: 6 Theme daybreak Theme |19: 7 Theme atelier @@ -445,37 +445,37 @@ |29: 17 |30: 18 # Legacy and imported themes == style step-forge-soft grid=100x31 -|0: 0-70 #c5c9d2,#292c33, 71-99 d,#292c33, -|1: 0-1 #ffffff,#6d7f6c, 2-2 d,#363d48, 3-34 #d1d5de,#363d48, 35-99 d,#363d48, -|2: 0-1 #ffffff,#55647b, 2-2 d,#292c33, 3-32 #c5c9d2,#292c33, 33-37 #ecbf9b,#292c33, 38-59 #c5c9d2,#292c33, 60-99 d,#292c33, +|0: 0-70 #c5c9d2,#2a2d34, 71-99 d,#2a2d34, +|1: 0-1 #ffffff,#6d7f6c, 2-2 d,#373e49, 3-34 #d1d5de,#373e49, 35-99 d,#373e49, +|2: 0-1 #ffffff,#55647b, 2-2 d,#2a2d34, 3-32 #c5c9d2,#2a2d34, 33-37 #ecbf9b,#2a2d34, 38-59 #c5c9d2,#2a2d34, 60-99 d,#2a2d34, |3: 0-0 d,#708095, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-12 #bfc3cc,#35383e, 13-99 d,#35383e, |4: 0-0 d,#708095, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-12 #bfc3cc,#35383e, 13-99 d,#35383e, |5: 0-0 d,#708095, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-12 #bfc3cc,#35383e, 13-99 d,#35383e, |6: 0-0 d,#708095, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-12 #bfc3cc,#35383e, 13-99 d,#35383e, -|7: 0-0 d,#393c43, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-12 #bfc3cc,#35383e, 13-99 d,#35383e, -|8: 0-0 d,#393c43, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-12 #bfc3cc,#35383e, 13-99 d,#35383e, -|9: 0-0 d,#393c43, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-12 #bfc3cc,#35383e, 13-99 d,#35383e, -|10: 0-0 d,#393c43, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-12 #bfc3cc,#35383e, 13-99 d,#35383e, -|11: 0-0 d,#393c43, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-12 #bfc3cc,#35383e, 13-99 d,#35383e, -|12: 0-0 d,#393c43, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-13 #bfc3cc,#35383e, 14-99 d,#35383e, -|13: 0-0 d,#393c43, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-13 #bfc3cc,#35383e, 14-99 d,#35383e, -|14: 0-0 d,#393c43, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-13 #bfc3cc,#35383e, 14-99 d,#35383e, -|15: 0-0 d,#393c43, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-13 #bfc3cc,#35383e, 14-99 d,#35383e, -|16: 0-0 d,#393c43, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-13 #bfc3cc,#35383e, 14-99 d,#35383e, -|17: 0-1 #000000,#9cc3f7, 2-2 d,#363d48, 3-32 #d1d5de,#363d48, 33-39 #f8cba7,#363d48, 40-61 #d1d5de,#363d48, 62-99 d,#363d48, -|18: 0-0 d,#708095, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-20 #bfc3cc,#35383e, 21-92 d,#35383e, 93-93 d,#292c33, 94-98 #c5c9d2,#292c33, 99-99 d,#292c33, -|19: 0-0 d,#393c43, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-19 #bfc3cc,#35383e, 20-99 d,#35383e, -|20: 0-0 d,#393c43, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-17 #bfc3cc,#35383e, 18-99 d,#35383e, -|21: 0-0 d,#393c43, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-18 #bfc3cc,#35383e, 19-99 d,#35383e, -|22: 0-0 d,#393c43, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-20 #bfc3cc,#35383e, 21-99 d,#35383e, -|23: 0-0 d,#393c43, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-20 #bfc3cc,#35383e, 21-99 d,#35383e, -|24: 0-0 d,#393c43, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-19 #bfc3cc,#35383e, 20-99 d,#35383e, -|25: 0-0 d,#393c43, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-16 #bfc3cc,#35383e, 17-99 d,#35383e, -|26: 0-0 d,#393c43, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-23 #bfc3cc,#35383e, 24-99 d,#35383e, -|27: 0-0 d,#393c43, 1-1 d,#35383e, 2-6 #8e9199,#35383e,b 7-22 #bfc3cc,#35383e, 23-99 d,#35383e, -|28: 0-0 d,#393c43, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-25 #bfc3cc,#35383e, 26-99 d,#35383e, -|29: 0-0 d,#393c43, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-99 d,#35383e, -|30: 0-0 d,#393c43, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-34 #bfc3cc,#35383e, 35-99 d,#35383e, +|7: 0-0 d,#3d4047, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-12 #bfc3cc,#35383e, 13-99 d,#35383e, +|8: 0-0 d,#3d4047, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-12 #bfc3cc,#35383e, 13-99 d,#35383e, +|9: 0-0 d,#3d4047, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-12 #bfc3cc,#35383e, 13-99 d,#35383e, +|10: 0-0 d,#3d4047, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-12 #bfc3cc,#35383e, 13-99 d,#35383e, +|11: 0-0 d,#3d4047, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-12 #bfc3cc,#35383e, 13-99 d,#35383e, +|12: 0-0 d,#3d4047, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-13 #bfc3cc,#35383e, 14-99 d,#35383e, +|13: 0-0 d,#3d4047, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-13 #bfc3cc,#35383e, 14-99 d,#35383e, +|14: 0-0 d,#3d4047, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-13 #bfc3cc,#35383e, 14-99 d,#35383e, +|15: 0-0 d,#3d4047, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-13 #bfc3cc,#35383e, 14-99 d,#35383e, +|16: 0-0 d,#3d4047, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-13 #bfc3cc,#35383e, 14-99 d,#35383e, +|17: 0-1 #000000,#9cc3f7, 2-2 d,#373e49, 3-32 #d1d5de,#373e49, 33-39 #f8cba7,#373e49, 40-61 #d1d5de,#373e49, 62-99 d,#373e49, +|18: 0-0 d,#708095, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-20 #bfc3cc,#35383e, 21-92 d,#35383e, 93-93 d,#2a2d34, 94-98 #c5c9d2,#2a2d34, 99-99 d,#2a2d34, +|19: 0-0 d,#3d4047, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-19 #bfc3cc,#35383e, 20-99 d,#35383e, +|20: 0-0 d,#3d4047, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-17 #bfc3cc,#35383e, 18-99 d,#35383e, +|21: 0-0 d,#3d4047, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-18 #bfc3cc,#35383e, 19-99 d,#35383e, +|22: 0-0 d,#3d4047, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-20 #bfc3cc,#35383e, 21-99 d,#35383e, +|23: 0-0 d,#3d4047, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-20 #bfc3cc,#35383e, 21-99 d,#35383e, +|24: 0-0 d,#3d4047, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-19 #bfc3cc,#35383e, 20-99 d,#35383e, +|25: 0-0 d,#3d4047, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-16 #bfc3cc,#35383e, 17-99 d,#35383e, +|26: 0-0 d,#3d4047, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-23 #bfc3cc,#35383e, 24-99 d,#35383e, +|27: 0-0 d,#3d4047, 1-1 d,#35383e, 2-6 #8e9199,#35383e,b 7-22 #bfc3cc,#35383e, 23-99 d,#35383e, +|28: 0-0 d,#3d4047, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-25 #bfc3cc,#35383e, 26-99 d,#35383e, +|29: 0-0 d,#3d4047, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-99 d,#35383e, +|30: 0-0 d,#3d4047, 1-1 d,#35383e, 2-6 #868991,#35383e, 7-34 #bfc3cc,#35383e, 35-99 d,#35383e, == snap step-orchard-black grid=100x31 cursor=7,27 |18: 7 Theme atelier Theme |19: 8 Theme forge @@ -491,37 +491,37 @@ |29: 18 # Legacy and imported themes |30: 19 Theme helix == style step-orchard-black grid=100x31 -|0: 0-70 #b4c4bb,#1e2926, 71-99 d,#1e2926, -|1: 0-1 #ffffff,#857557, 2-2 d,#283730, 3-34 #c0d0c7,#283730, 35-99 d,#283730, -|2: 0-1 #ffffff,#4a665a, 2-2 d,#1e2926, 3-32 #b4c4bb,#1e2926, 33-37 #d6b2d3,#1e2926, 38-59 #b4c4bb,#1e2926, 60-99 d,#1e2926, -|3: 0-0 d,#62826d, 1-1 d,#000000, 2-6 #59675c,#000000, 7-12 #f2f8ef,#000000, 13-99 d,#000000, -|4: 0-0 d,#62826d, 1-1 d,#000000, 2-6 #59675c,#000000, 7-12 #f2f8ef,#000000, 13-99 d,#000000, -|5: 0-0 d,#62826d, 1-1 d,#000000, 2-6 #59675c,#000000, 7-12 #f2f8ef,#000000, 13-99 d,#000000, -|6: 0-0 d,#62826d, 1-1 d,#000000, 2-6 #59675c,#000000, 7-12 #f2f8ef,#000000, 13-99 d,#000000, -|7: 0-0 d,#151c18, 1-1 d,#000000, 2-6 #59675c,#000000, 7-12 #f2f8ef,#000000, 13-99 d,#000000, -|8: 0-0 d,#151c18, 1-1 d,#000000, 2-6 #59675c,#000000, 7-12 #f2f8ef,#000000, 13-99 d,#000000, -|9: 0-0 d,#151c18, 1-1 d,#000000, 2-6 #59675c,#000000, 7-12 #f2f8ef,#000000, 13-99 d,#000000, -|10: 0-0 d,#151c18, 1-1 d,#000000, 2-6 #59675c,#000000, 7-12 #f2f8ef,#000000, 13-99 d,#000000, -|11: 0-0 d,#151c18, 1-1 d,#000000, 2-6 #59675c,#000000, 7-12 #f2f8ef,#000000, 13-99 d,#000000, -|12: 0-0 d,#151c18, 1-1 d,#000000, 2-6 #59675c,#000000, 7-13 #f2f8ef,#000000, 14-99 d,#000000, -|13: 0-0 d,#151c18, 1-1 d,#000000, 2-6 #59675c,#000000, 7-13 #f2f8ef,#000000, 14-99 d,#000000, -|14: 0-0 d,#151c18, 1-1 d,#000000, 2-6 #59675c,#000000, 7-13 #f2f8ef,#000000, 14-99 d,#000000, -|15: 0-0 d,#151c18, 1-1 d,#000000, 2-6 #59675c,#000000, 7-13 #f2f8ef,#000000, 14-99 d,#000000, -|16: 0-0 d,#151c18, 1-1 d,#000000, 2-6 #59675c,#000000, 7-13 #f2f8ef,#000000, 14-99 d,#000000, -|17: 0-1 #000000,#abd195, 2-2 d,#283730, 3-32 #c0d0c7,#283730, 33-39 #e2bedf,#283730, 40-61 #c0d0c7,#283730, 62-99 d,#283730, -|18: 0-0 d,#62826d, 1-1 d,#000000, 2-6 #59675c,#000000, 7-19 #f2f8ef,#000000, 20-92 d,#000000, 93-93 d,#1e2926, 94-98 #b4c4bb,#1e2926, 99-99 d,#1e2926, -|19: 0-0 d,#151c18, 1-1 d,#000000, 2-6 #59675c,#000000, 7-17 #f2f8ef,#000000, 18-99 d,#000000, -|20: 0-0 d,#151c18, 1-1 d,#000000, 2-6 #59675c,#000000, 7-18 #f2f8ef,#000000, 19-99 d,#000000, -|21: 0-0 d,#151c18, 1-1 d,#000000, 2-6 #59675c,#000000, 7-20 #f2f8ef,#000000, 21-99 d,#000000, -|22: 0-0 d,#151c18, 1-1 d,#000000, 2-6 #59675c,#000000, 7-20 #f2f8ef,#000000, 21-99 d,#000000, -|23: 0-0 d,#151c18, 1-1 d,#000000, 2-6 #59675c,#000000, 7-19 #f2f8ef,#000000, 20-99 d,#000000, -|24: 0-0 d,#151c18, 1-1 d,#000000, 2-6 #59675c,#000000, 7-16 #f2f8ef,#000000, 17-99 d,#000000, -|25: 0-0 d,#151c18, 1-1 d,#000000, 2-6 #59675c,#000000, 7-23 #f2f8ef,#000000, 24-99 d,#000000, -|26: 0-0 d,#151c18, 1-1 d,#000000, 2-6 #59675c,#000000, 7-22 #f2f8ef,#000000, 23-99 d,#000000, -|27: 0-0 d,#151c18, 1-1 d,#000000, 2-6 #616f64,#000000,b 7-25 #f2f8ef,#000000, 26-99 d,#000000, -|28: 0-0 d,#151c18, 1-1 d,#000000, 2-6 #59675c,#000000, 7-99 d,#000000, -|29: 0-0 d,#151c18, 1-1 d,#000000, 2-6 #59675c,#000000, 7-34 #f2f8ef,#000000, 35-99 d,#000000, -|30: 0-0 d,#151c18, 1-1 d,#000000, 2-6 #59675c,#000000, 7-17 #f2f8ef,#000000, 18-99 d,#000000, +|0: 0-70 #b2c3b8,#1d2824, 71-99 d,#1d2824, +|1: 0-1 #ffffff,#887455, 2-2 d,#26362d, 3-34 #becfc4,#26362d, 35-99 d,#26362d, +|2: 0-1 #ffffff,#496656, 2-2 d,#1d2824, 3-32 #b2c3b8,#1d2824, 33-37 #d3b0d9,#1d2824, 38-59 #b2c3b8,#1d2824, 60-99 d,#1d2824, +|3: 0-0 d,#608269, 1-1 d,#000000, 2-6 #59675c,#000000, 7-12 #f2f8ef,#000000, 13-99 d,#000000, +|4: 0-0 d,#608269, 1-1 d,#000000, 2-6 #59675c,#000000, 7-12 #f2f8ef,#000000, 13-99 d,#000000, +|5: 0-0 d,#608269, 1-1 d,#000000, 2-6 #59675c,#000000, 7-12 #f2f8ef,#000000, 13-99 d,#000000, +|6: 0-0 d,#608269, 1-1 d,#000000, 2-6 #59675c,#000000, 7-12 #f2f8ef,#000000, 13-99 d,#000000, +|7: 0-0 d,#111813, 1-1 d,#000000, 2-6 #59675c,#000000, 7-12 #f2f8ef,#000000, 13-99 d,#000000, +|8: 0-0 d,#111813, 1-1 d,#000000, 2-6 #59675c,#000000, 7-12 #f2f8ef,#000000, 13-99 d,#000000, +|9: 0-0 d,#111813, 1-1 d,#000000, 2-6 #59675c,#000000, 7-12 #f2f8ef,#000000, 13-99 d,#000000, +|10: 0-0 d,#111813, 1-1 d,#000000, 2-6 #59675c,#000000, 7-12 #f2f8ef,#000000, 13-99 d,#000000, +|11: 0-0 d,#111813, 1-1 d,#000000, 2-6 #59675c,#000000, 7-12 #f2f8ef,#000000, 13-99 d,#000000, +|12: 0-0 d,#111813, 1-1 d,#000000, 2-6 #59675c,#000000, 7-13 #f2f8ef,#000000, 14-99 d,#000000, +|13: 0-0 d,#111813, 1-1 d,#000000, 2-6 #59675c,#000000, 7-13 #f2f8ef,#000000, 14-99 d,#000000, +|14: 0-0 d,#111813, 1-1 d,#000000, 2-6 #59675c,#000000, 7-13 #f2f8ef,#000000, 14-99 d,#000000, +|15: 0-0 d,#111813, 1-1 d,#000000, 2-6 #59675c,#000000, 7-13 #f2f8ef,#000000, 14-99 d,#000000, +|16: 0-0 d,#111813, 1-1 d,#000000, 2-6 #59675c,#000000, 7-13 #f2f8ef,#000000, 14-99 d,#000000, +|17: 0-1 #000000,#add38a, 2-2 d,#26362d, 3-32 #becfc4,#26362d, 33-39 #dfbce5,#26362d, 40-61 #becfc4,#26362d, 62-99 d,#26362d, +|18: 0-0 d,#608269, 1-1 d,#000000, 2-6 #59675c,#000000, 7-19 #f2f8ef,#000000, 20-92 d,#000000, 93-93 d,#1d2824, 94-98 #b2c3b8,#1d2824, 99-99 d,#1d2824, +|19: 0-0 d,#111813, 1-1 d,#000000, 2-6 #59675c,#000000, 7-17 #f2f8ef,#000000, 18-99 d,#000000, +|20: 0-0 d,#111813, 1-1 d,#000000, 2-6 #59675c,#000000, 7-18 #f2f8ef,#000000, 19-99 d,#000000, +|21: 0-0 d,#111813, 1-1 d,#000000, 2-6 #59675c,#000000, 7-20 #f2f8ef,#000000, 21-99 d,#000000, +|22: 0-0 d,#111813, 1-1 d,#000000, 2-6 #59675c,#000000, 7-20 #f2f8ef,#000000, 21-99 d,#000000, +|23: 0-0 d,#111813, 1-1 d,#000000, 2-6 #59675c,#000000, 7-19 #f2f8ef,#000000, 20-99 d,#000000, +|24: 0-0 d,#111813, 1-1 d,#000000, 2-6 #59675c,#000000, 7-16 #f2f8ef,#000000, 17-99 d,#000000, +|25: 0-0 d,#111813, 1-1 d,#000000, 2-6 #59675c,#000000, 7-23 #f2f8ef,#000000, 24-99 d,#000000, +|26: 0-0 d,#111813, 1-1 d,#000000, 2-6 #59675c,#000000, 7-22 #f2f8ef,#000000, 23-99 d,#000000, +|27: 0-0 d,#111813, 1-1 d,#000000, 2-6 #616f64,#000000,b 7-25 #f2f8ef,#000000, 26-99 d,#000000, +|28: 0-0 d,#111813, 1-1 d,#000000, 2-6 #59675c,#000000, 7-99 d,#000000, +|29: 0-0 d,#111813, 1-1 d,#000000, 2-6 #59675c,#000000, 7-34 #f2f8ef,#000000, 35-99 d,#000000, +|30: 0-0 d,#111813, 1-1 d,#000000, 2-6 #59675c,#000000, 7-17 #f2f8ef,#000000, 18-99 d,#000000, == snap step-helix grid=100x31 cursor=7,27 |18: 10 Theme solarium Theme |19: 11 Theme spectrum @@ -537,37 +537,37 @@ |29: 21 Theme acme |30: 22 Theme acid == style step-helix grid=100x31 -|0: 0-70 #979998,#131414, 71-99 d,#131414, -|1: 0-1 #ffffff,#837048, 2-2 d,#141615, 3-34 #989a99,#141615, 35-99 d,#141615, -|2: 0-1 #ffffff,#2a2c2b, 2-2 d,#131414, 3-32 #979998,#131414, 33-37 #9a979b,#131414, 38-59 #979998,#131414, 60-99 d,#131414, -|3: 0-0 d,#474a48, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-99 d,#080808, -|4: 0-0 d,#474a48, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-99 d,#080808, -|5: 0-0 d,#474a48, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-99 d,#080808, -|6: 0-0 d,#474a48, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-99 d,#080808, -|7: 0-0 d,#121312, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-99 d,#080808, -|8: 0-0 d,#121312, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-99 d,#080808, -|9: 0-0 d,#121312, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-99 d,#080808, -|10: 0-0 d,#121312, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-99 d,#080808, -|11: 0-0 d,#121312, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-99 d,#080808, -|12: 0-0 d,#121312, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-99 d,#080808, -|13: 0-0 d,#121312, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-99 d,#080808, -|14: 0-0 d,#121312, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-99 d,#080808, -|15: 0-0 d,#121312, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-99 d,#080808, -|16: 0-0 d,#121312, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-99 d,#080808, -|17: 0-1 #ffffff,#6a6d66, 2-2 d,#141615, 3-32 #989a99,#141615, 33-39 #9c989c,#141615, 40-61 #989a99,#141615, 62-99 d,#141615, -|18: 0-0 d,#474a48, 1-1 d,#080808, 2-6 #626262,#080808, 7-20 #bdbdbd,#080808, 21-92 d,#080808, 93-93 d,#131414, 94-98 #979998,#131414, 99-99 d,#131414, -|19: 0-0 d,#121312, 1-1 d,#080808, 2-6 #626262,#080808, 7-20 #bdbdbd,#080808, 21-99 d,#080808, -|20: 0-0 d,#121312, 1-1 d,#080808, 2-6 #626262,#080808, 7-19 #bdbdbd,#080808, 20-99 d,#080808, -|21: 0-0 d,#121312, 1-1 d,#080808, 2-6 #626262,#080808, 7-16 #bdbdbd,#080808, 17-99 d,#080808, -|22: 0-0 d,#121312, 1-1 d,#080808, 2-6 #626262,#080808, 7-23 #bdbdbd,#080808, 24-99 d,#080808, -|23: 0-0 d,#121312, 1-1 d,#080808, 2-6 #626262,#080808, 7-22 #bdbdbd,#080808, 23-99 d,#080808, -|24: 0-0 d,#121312, 1-1 d,#080808, 2-6 #626262,#080808, 7-25 #bdbdbd,#080808, 26-99 d,#080808, -|25: 0-0 d,#121312, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808, -|26: 0-0 d,#121312, 1-1 d,#080808, 2-6 #626262,#080808, 7-34 #bdbdbd,#080808, 35-99 d,#080808, -|27: 0-0 d,#121312, 1-1 d,#080808, 2-6 #626262,#080808,b 7-17 #bdbdbd,#080808, 18-99 d,#080808, -|28: 0-0 d,#121312, 1-1 d,#080808, 2-6 #626262,#080808, 7-16 #bdbdbd,#080808, 17-99 d,#080808, -|29: 0-0 d,#121312, 1-1 d,#080808, 2-6 #626262,#080808, 7-16 #bdbdbd,#080808, 17-99 d,#080808, -|30: 0-0 d,#121312, 1-1 d,#080808, 2-6 #626262,#080808, 7-16 #bdbdbd,#080808, 17-99 d,#080808, +|0: 0-70 #949494,#121212, 71-99 d,#121212, +|1: 0-1 #ffffff,#826f46, 2-2 d,#121212, 3-34 #949494,#121212, 35-99 d,#121212, +|2: 0-1 #ffffff,#262626, 2-2 d,#121212, 3-59 #949494,#121212, 60-99 d,#121212, +|3: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-99 d,#080808, +|4: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-99 d,#080808, +|5: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-99 d,#080808, +|6: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-99 d,#080808, +|7: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-99 d,#080808, +|8: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-99 d,#080808, +|9: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-99 d,#080808, +|10: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-99 d,#080808, +|11: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-99 d,#080808, +|12: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-99 d,#080808, +|13: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-99 d,#080808, +|14: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-99 d,#080808, +|15: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-99 d,#080808, +|16: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-99 d,#080808, +|17: 0-1 #ffffff,#626262, 2-2 d,#121212, 3-61 #949494,#121212, 62-99 d,#121212, +|18: 0-0 d,#444444, 1-1 d,#080808, 2-6 #626262,#080808, 7-20 #bdbdbd,#080808, 21-92 d,#080808, 93-93 d,#121212, 94-98 #949494,#121212, 99-99 d,#121212, +|19: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-20 #bdbdbd,#080808, 21-99 d,#080808, +|20: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-19 #bdbdbd,#080808, 20-99 d,#080808, +|21: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-16 #bdbdbd,#080808, 17-99 d,#080808, +|22: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-23 #bdbdbd,#080808, 24-99 d,#080808, +|23: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-22 #bdbdbd,#080808, 23-99 d,#080808, +|24: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-25 #bdbdbd,#080808, 26-99 d,#080808, +|25: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-99 d,#080808, +|26: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-34 #bdbdbd,#080808, 35-99 d,#080808, +|27: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808,b 7-17 #bdbdbd,#080808, 18-99 d,#080808, +|28: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-16 #bdbdbd,#080808, 17-99 d,#080808, +|29: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-16 #bdbdbd,#080808, 17-99 d,#080808, +|30: 0-0 d,#121212, 1-1 d,#080808, 2-6 #626262,#080808, 7-16 #bdbdbd,#080808, 17-99 d,#080808, == snap step-dark grid=100x31 cursor=7,27 |18: 11 Theme spectrum Theme |19: 12 Theme harvest @@ -583,37 +583,37 @@ |29: 22 Theme acid |30: 23 Theme acme_helix == style step-dark grid=100x31 -|0: 0-70 #969696,#29283a, 71-99 d,#29283a, -|1: 0-1 #ffffff,#86794d, 2-2 d,#29283a, 3-34 #969696,#29283a, 35-99 d,#29283a, -|2: 0-1 #ffffff,#343853, 2-2 d,#29283a, 3-59 #969696,#29283a, 60-99 d,#29283a, -|3: 0-0 d,#515151, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|4: 0-0 d,#515151, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|5: 0-0 d,#515151, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|6: 0-0 d,#515151, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|7: 0-0 d,#282828, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|8: 0-0 d,#282828, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|9: 0-0 d,#282828, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|10: 0-0 d,#282828, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|11: 0-0 d,#282828, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|12: 0-0 d,#282828, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|13: 0-0 d,#282828, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|14: 0-0 d,#282828, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|15: 0-0 d,#282828, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|16: 0-0 d,#282828, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|17: 0-1 #ffffff,#6643d3, 2-2 d,#29283a, 3-61 #969696,#29283a, 62-99 d,#29283a, -|18: 0-0 d,#515151, 1-1 d,d, 2-6 #5a5a5a,d, 7-92 d,d, 93-93 d,#29283a, 94-98 #969696,#29283a, 99-99 d,#29283a, -|19: 0-0 d,#282828, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|20: 0-0 d,#282828, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|21: 0-0 d,#282828, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|22: 0-0 d,#282828, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|23: 0-0 d,#282828, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|24: 0-0 d,#282828, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|25: 0-0 d,#282828, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|26: 0-0 d,#282828, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|27: 0-0 d,#282828, 1-1 d,d, 2-6 #5a5a5a,d,b 7-99 d,d, -|28: 0-0 d,#282828, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|29: 0-0 d,#282828, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, -|30: 0-0 d,#282828, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|0: 0-70 #969696,#2c2a3e, 71-99 d,#2c2a3e, +|1: 0-1 #ffffff,#867a4e, 2-2 d,#2c2a3e, 3-34 #969696,#2c2a3e, 35-99 d,#2c2a3e, +|2: 0-1 #ffffff,#353a58, 2-2 d,#2c2a3e, 3-59 #969696,#2c2a3e, 60-99 d,#2c2a3e, +|3: 0-0 d,#525252, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|4: 0-0 d,#525252, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|5: 0-0 d,#525252, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|6: 0-0 d,#525252, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|7: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|8: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|9: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|10: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|11: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|12: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|13: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|14: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|15: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|16: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|17: 0-1 #ffffff,#663fe0, 2-2 d,#2c2a3e, 3-61 #969696,#2c2a3e, 62-99 d,#2c2a3e, +|18: 0-0 d,#525252, 1-1 d,d, 2-6 #5a5a5a,d, 7-92 d,d, 93-93 d,#2c2a3e, 94-98 #969696,#2c2a3e, 99-99 d,#2c2a3e, +|19: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|20: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|21: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|22: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|23: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|24: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|25: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|26: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|27: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d,b 7-99 d,d, +|28: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|29: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, +|30: 0-0 d,#2a2a2a, 1-1 d,d, 2-6 #5a5a5a,d, 7-99 d,d, == snap step-acme grid=100x31 cursor=7,27 |18: 12 Theme harvest Theme |19: 13 Theme clay @@ -629,37 +629,37 @@ |29: 23 Theme acme_helix |30: 24 Theme adwaita_dark == style step-acme grid=100x31 -|0: 0-70 #0f0f0f,#d7eaec, 71-99 d,#d7eaec, -|1: 0-1 #000000,#bfa37a, 2-2 d,#d7eaec, 3-34 #0f0f0f,#d7eaec, 35-99 d,#d7eaec, -|2: 0-1 #ffffff,#353a58, 2-2 d,#d7eaec, 3-59 #0f0f0f,#d7eaec, 60-99 d,#d7eaec, -|3: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, -|4: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, -|5: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, -|6: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, -|7: 0-0 d,#8e8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, -|8: 0-0 d,#8e8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, -|9: 0-0 d,#8e8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, -|10: 0-0 d,#8e8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, -|11: 0-0 d,#8e8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, -|12: 0-0 d,#8e8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, -|13: 0-0 d,#8e8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, -|14: 0-0 d,#8e8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, -|15: 0-0 d,#8e8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, -|16: 0-0 d,#8e8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, -|17: 0-1 #ffffff,#663fe0, 2-2 d,#d7eaec, 3-61 #0f0f0f,#d7eaec, 62-99 d,#d7eaec, -|18: 0-0 d,#eeeedb, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-19 #000000,#ffffea, 20-92 d,#ffffea, 93-93 d,#d7eaec, 94-98 #0f0f0f,#d7eaec, 99-99 d,#d7eaec, -|19: 0-0 d,#8e8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-16 #000000,#ffffea, 17-99 d,#ffffea, -|20: 0-0 d,#8e8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-23 #000000,#ffffea, 24-99 d,#ffffea, -|21: 0-0 d,#8e8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-22 #000000,#ffffea, 23-99 d,#ffffea, -|22: 0-0 d,#8e8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-25 #000000,#ffffea, 26-99 d,#ffffea, -|23: 0-0 d,#8e8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-99 d,#ffffea, -|24: 0-0 d,#8e8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-34 #000000,#ffffea, 35-99 d,#ffffea, -|25: 0-0 d,#8e8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-17 #000000,#ffffea, 18-99 d,#ffffea, -|26: 0-0 d,#8e8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-16 #000000,#ffffea, 17-99 d,#ffffea, -|27: 0-0 d,#8e8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea,b 7-16 #000000,#ffffea, 17-99 d,#ffffea, -|28: 0-0 d,#8e8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-16 #000000,#ffffea, 17-99 d,#ffffea, -|29: 0-0 d,#8e8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-22 #000000,#ffffea, 23-99 d,#ffffea, -|30: 0-0 d,#8e8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-24 #000000,#ffffea, 25-99 d,#ffffea, +|0: 0-70 #000000,#eaffff, 71-99 d,#eaffff, +|1: 0-1 #000000,#c5a87f, 2-2 d,#eaffff, 3-34 #000000,#eaffff, 35-99 d,#eaffff, +|2: 0-1 #ffffff,#353a58, 2-2 d,#eaffff, 3-59 #000000,#eaffff, 60-99 d,#eaffff, +|3: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, +|4: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, +|5: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, +|6: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, +|7: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, +|8: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, +|9: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, +|10: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, +|11: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, +|12: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, +|13: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, +|14: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, +|15: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, +|16: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, +|17: 0-1 #ffffff,#663fe0, 2-2 d,#eaffff, 3-61 #000000,#eaffff, 62-99 d,#eaffff, +|18: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-19 #000000,#ffffea, 20-92 d,#ffffea, 93-93 d,#eaffff, 94-98 #000000,#eaffff, 99-99 d,#eaffff, +|19: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-16 #000000,#ffffea, 17-99 d,#ffffea, +|20: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-23 #000000,#ffffea, 24-99 d,#ffffea, +|21: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-22 #000000,#ffffea, 23-99 d,#ffffea, +|22: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-25 #000000,#ffffea, 26-99 d,#ffffea, +|23: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-99 d,#ffffea, +|24: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-34 #000000,#ffffea, 35-99 d,#ffffea, +|25: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-17 #000000,#ffffea, 18-99 d,#ffffea, +|26: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-16 #000000,#ffffea, 17-99 d,#ffffea, +|27: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea,b 7-16 #000000,#ffffea, 17-99 d,#ffffea, +|28: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-16 #000000,#ffffea, 17-99 d,#ffffea, +|29: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-22 #000000,#ffffea, 23-99 d,#ffffea, +|30: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-24 #000000,#ffffea, 25-99 d,#ffffea, == snap step-generated grid=100x31 cursor=7,27 |18: 13 Theme clay Theme |19: 14 Theme forge_black @@ -675,70 +675,70 @@ |29: 24 Theme adwaita_dark |30: 25 Theme adwaita_light == style step-generated grid=100x31 -|0: 0-70 #cacaca,#323c43, 71-99 d,#323c43, -|1: 0-1 #ffffff,#5b8094, 2-2 d,#323c43, 3-34 #cacaca,#323c43, 35-99 d,#323c43, -|2: 0-1 #ffffff,#313842, 2-2 d,#323c43, 3-59 #cacaca,#323c43, 60-99 d,#323c43, -|3: 0-0 d,#454c51, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, -|4: 0-0 d,#454c51, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, -|5: 0-0 d,#454c51, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, -|6: 0-0 d,#454c51, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, -|7: 0-0 d,#2a3231, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, -|8: 0-0 d,#2a3231, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, -|9: 0-0 d,#2a3231, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, -|10: 0-0 d,#2a3231, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, -|11: 0-0 d,#2a3231, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, -|12: 0-0 d,#2a3231, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-13 #e0e0e0,#161e26, 14-99 d,#161e26, -|13: 0-0 d,#2a3231, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-13 #e0e0e0,#161e26, 14-99 d,#161e26, -|14: 0-0 d,#2a3231, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-13 #e0e0e0,#161e26, 14-99 d,#161e26, -|15: 0-0 d,#2a3231, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-13 #e0e0e0,#161e26, 14-99 d,#161e26, -|16: 0-0 d,#2a3231, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-13 #e0e0e0,#161e26, 14-99 d,#161e26, -|17: 0-1 #000000,#f0966d, 2-2 d,#323c43, 3-61 #cacaca,#323c43, 62-99 d,#323c43, -|18: 0-0 d,#454c51, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-16 #e0e0e0,#161e26, 17-92 d,#161e26, 93-93 d,#323c43, 94-98 #cacaca,#323c43, 99-99 d,#323c43, -|19: 0-0 d,#2a3231, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-23 #e0e0e0,#161e26, 24-99 d,#161e26, -|20: 0-0 d,#2a3231, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-22 #e0e0e0,#161e26, 23-99 d,#161e26, -|21: 0-0 d,#2a3231, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-25 #e0e0e0,#161e26, 26-99 d,#161e26, -|22: 0-0 d,#2a3231, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-99 d,#161e26, -|23: 0-0 d,#2a3231, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-34 #e0e0e0,#161e26, 35-99 d,#161e26, -|24: 0-0 d,#2a3231, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-17 #e0e0e0,#161e26, 18-99 d,#161e26, -|25: 0-0 d,#2a3231, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-16 #e0e0e0,#161e26, 17-99 d,#161e26, -|26: 0-0 d,#2a3231, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-16 #e0e0e0,#161e26, 17-99 d,#161e26, -|27: 0-0 d,#2a3231, 1-1 d,#161e26, 2-6 #303840,#161e26,b 7-16 #e0e0e0,#161e26, 17-99 d,#161e26, -|28: 0-0 d,#2a3231, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-22 #e0e0e0,#161e26, 23-99 d,#161e26, -|29: 0-0 d,#2a3231, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-24 #e0e0e0,#161e26, 25-99 d,#161e26, -|30: 0-0 d,#2a3231, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-25 #e0e0e0,#161e26, 26-99 d,#161e26, +|0: 0-70 #e0e0e0,#1e262e, 71-99 d,#1e262e, +|1: 0-1 #ffffff,#4f7b96, 2-2 d,#1e262e, 3-34 #e0e0e0,#1e262e, 35-99 d,#1e262e, +|2: 0-1 #ffffff,#303840, 2-2 d,#1e262e, 3-59 #e0e0e0,#1e262e, 60-99 d,#1e262e, +|3: 0-0 d,#303840, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, +|4: 0-0 d,#303840, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, +|5: 0-0 d,#303840, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, +|6: 0-0 d,#303840, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, +|7: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, +|8: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, +|9: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, +|10: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, +|11: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-12 #e0e0e0,#161e26, 13-99 d,#161e26, +|12: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-13 #e0e0e0,#161e26, 14-99 d,#161e26, +|13: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-13 #e0e0e0,#161e26, 14-99 d,#161e26, +|14: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-13 #e0e0e0,#161e26, 14-99 d,#161e26, +|15: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-13 #e0e0e0,#161e26, 14-99 d,#161e26, +|16: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-13 #e0e0e0,#161e26, 14-99 d,#161e26, +|17: 0-1 #000000,#ffa060, 2-2 d,#1e262e, 3-61 #e0e0e0,#1e262e, 62-99 d,#1e262e, +|18: 0-0 d,#303840, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-16 #e0e0e0,#161e26, 17-92 d,#161e26, 93-93 d,#1e262e, 94-98 #e0e0e0,#1e262e, 99-99 d,#1e262e, +|19: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-23 #e0e0e0,#161e26, 24-99 d,#161e26, +|20: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-22 #e0e0e0,#161e26, 23-99 d,#161e26, +|21: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-25 #e0e0e0,#161e26, 26-99 d,#161e26, +|22: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-99 d,#161e26, +|23: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-34 #e0e0e0,#161e26, 35-99 d,#161e26, +|24: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-17 #e0e0e0,#161e26, 18-99 d,#161e26, +|25: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-16 #e0e0e0,#161e26, 17-99 d,#161e26, +|26: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-16 #e0e0e0,#161e26, 17-99 d,#161e26, +|27: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26,b 7-16 #e0e0e0,#161e26, 17-99 d,#161e26, +|28: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-22 #e0e0e0,#161e26, 23-99 d,#161e26, +|29: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-24 #e0e0e0,#161e26, 25-99 d,#161e26, +|30: 0-0 d,#1e262e, 1-1 d,#161e26, 2-6 #303840,#161e26, 7-25 #e0e0e0,#161e26, 26-99 d,#161e26, == snap step-back grid=100x31 cursor=7,26 == style step-back grid=100x31 -|0: 0-70 #161616,#d6e9ea, 71-99 d,#d6e9ea, -|1: 0-1 #000000,#b9a481, 2-2 d,#d6e9ea, 3-34 #161616,#d6e9ea, 35-99 d,#d6e9ea, -|2: 0-1 #ffffff,#353a56, 2-2 d,#d6e9ea, 3-59 #161616,#d6e9ea, 60-99 d,#d6e9ea, -|3: 0-0 d,#eaebd9, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, -|4: 0-0 d,#eaebd9, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, -|5: 0-0 d,#eaebd9, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, -|6: 0-0 d,#eaebd9, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, -|7: 0-0 d,#8d8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, -|8: 0-0 d,#8d8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, -|9: 0-0 d,#8d8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, -|10: 0-0 d,#8d8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, -|11: 0-0 d,#8d8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, -|12: 0-0 d,#8d8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, -|13: 0-0 d,#8d8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, -|14: 0-0 d,#8d8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, -|15: 0-0 d,#8d8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, -|16: 0-0 d,#8d8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, -|17: 0-1 #ffffff,#7549d3, 2-2 d,#d6e9ea, 3-61 #161616,#d6e9ea, 62-99 d,#d6e9ea, -|18: 0-0 d,#eaebd9, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-16 #000000,#ffffea, 17-92 d,#ffffea, 93-93 d,#d6e9ea, 94-98 #161616,#d6e9ea, 99-99 d,#d6e9ea, -|19: 0-0 d,#8d8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-23 #000000,#ffffea, 24-99 d,#ffffea, -|20: 0-0 d,#8d8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-22 #000000,#ffffea, 23-99 d,#ffffea, -|21: 0-0 d,#8d8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-25 #000000,#ffffea, 26-99 d,#ffffea, -|22: 0-0 d,#8d8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-99 d,#ffffea, -|23: 0-0 d,#8d8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-34 #000000,#ffffea, 35-99 d,#ffffea, -|24: 0-0 d,#8d8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-17 #000000,#ffffea, 18-99 d,#ffffea, -|25: 0-0 d,#8d8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-16 #000000,#ffffea, 17-99 d,#ffffea, -|26: 0-0 d,#8d8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea,b 7-16 #000000,#ffffea, 17-99 d,#ffffea, -|27: 0-0 d,#8d8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-16 #000000,#ffffea, 17-99 d,#ffffea, -|28: 0-0 d,#8d8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-22 #000000,#ffffea, 23-99 d,#ffffea, -|29: 0-0 d,#8d8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-24 #000000,#ffffea, 25-99 d,#ffffea, -|30: 0-0 d,#8d8e49, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-25 #000000,#ffffea, 26-99 d,#ffffea, +|0: 0-70 #000000,#eaffff, 71-99 d,#eaffff, +|1: 0-1 #000000,#c5a87f, 2-2 d,#eaffff, 3-34 #000000,#eaffff, 35-99 d,#eaffff, +|2: 0-1 #ffffff,#353a58, 2-2 d,#eaffff, 3-59 #000000,#eaffff, 60-99 d,#eaffff, +|3: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, +|4: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, +|5: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, +|6: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, +|7: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, +|8: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, +|9: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, +|10: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, +|11: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, +|12: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, +|13: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, +|14: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, +|15: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, +|16: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-13 #000000,#ffffea, 14-99 d,#ffffea, +|17: 0-1 #ffffff,#663fe0, 2-2 d,#eaffff, 3-61 #000000,#eaffff, 62-99 d,#eaffff, +|18: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-16 #000000,#ffffea, 17-92 d,#ffffea, 93-93 d,#eaffff, 94-98 #000000,#eaffff, 99-99 d,#eaffff, +|19: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-23 #000000,#ffffea, 24-99 d,#ffffea, +|20: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-22 #000000,#ffffea, 23-99 d,#ffffea, +|21: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-25 #000000,#ffffea, 26-99 d,#ffffea, +|22: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-99 d,#ffffea, +|23: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-34 #000000,#ffffea, 35-99 d,#ffffea, +|24: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-17 #000000,#ffffea, 18-99 d,#ffffea, +|25: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-16 #000000,#ffffea, 17-99 d,#ffffea, +|26: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea,b 7-16 #000000,#ffffea, 17-99 d,#ffffea, +|27: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-16 #000000,#ffffea, 17-99 d,#ffffea, +|28: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-22 #000000,#ffffea, 23-99 d,#ffffea, +|29: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-24 #000000,#ffffea, 25-99 d,#ffffea, +|30: 0-0 d,#99994c, 1-1 d,#ffffea, 2-6 #999980,#ffffea, 7-25 #000000,#ffffea, 26-99 d,#ffffea, == snap tail grid=100x31 cursor=7,29 |18: 235 Theme wolf_alabaster_dark_bg |19: 236 Theme wolf_alabaster_dark_mono @@ -769,13 +769,8 @@ |29: 27 line 27 |30: 28 line 28 == style picked grid=100x31 -|0: 0-70 #000000,#eaffff, 71-99 d,#eaffff, -|1: 0-1 #000000,#c5a87f, 2-2 d,#eaffff, 3-34 #000000,#eaffff, 35-99 d,#eaffff, |2: 0-1 #ffffff,#663fe0, 2-2 d,#eaffff, 3-59 #000000,#eaffff, 60-99 d,#eaffff, |3: 0-1 d,#ffffea, 2-6 #999980,#ffffea,b 7-12 #000000,#ffffea, 13-99 d,#ffffea, -|4: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, -|5: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, -|6: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, |7: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, |8: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, |9: 0-1 d,#ffffea, 2-6 #999980,#ffffea, 7-12 #000000,#ffffea, 13-99 d,#ffffea, diff --git a/test/web_harness.mjs b/test/web_harness.mjs index 90dd9a19..402e41f4 100644 --- a/test/web_harness.mjs +++ b/test/web_harness.mjs @@ -17,7 +17,7 @@ async function main() { const webDir = path.resolve(process.argv[2]); const wasmBytes = await fs.readFile(path.join(webDir, "pardes.wasm")); const module = await WebAssembly.compile(wasmBytes); - const { DomRenderer, animationTicks, browserHost } = await import(pathToFileURL(path.join(webDir, "app.mjs"))); + const { DomRenderer, browserHost } = await import(pathToFileURL(path.join(webDir, "app.mjs"))); assert.deepEqual( [...new Set(WebAssembly.Module.imports(module).map((entry) => entry.module))], ["pardes"], @@ -40,7 +40,7 @@ async function main() { for (const name of [ "memory", "pardes_init", "pardes_frame", "pardes_frame_ptr", "pardes_key", "pardes_mouse", "pardes_pointer_leave", "pardes_pointer_shape", "pardes_gui_tagline_font_percent", - "pardes_paste", "pardes_tick", "pardes_animation_tick", "pardes_animating", + "pardes_paste", "pardes_tick", "pardes_should_quit", "pardes_deinit", ]) assert.ok(wasm[name], `missing wasm export ${name}`); @@ -85,18 +85,6 @@ async function main() { "DOM did not use the build-time tagline percentage", ); - for (const hz of [60, 120, 144]) { - const clock = { last: null, lag: 0 }; - let ticks = 0; - for (let frame = 0; frame <= hz; frame += 1) { - ticks += animationTicks(clock, frame * 1000 / hz); - } - assert.equal(ticks, 60, `${hz} Hz rAF did not produce 60 core ticks per second`); - } - const stalled = { last: null, lag: 0 }; - assert.equal(animationTicks(stalled, 0), 0); - assert.equal(animationTicks(stalled, 60_000), 4, "suspended tab catch-up was not bounded"); - const firstCell = renderer.cells[0]; const second = draw(); assert.equal(renderer.cells[0], firstCell, "stable frames should reuse DOM nodes"); -- cgit v1.3 From e11048c20a50b67841e648a4d5ddd7490bc9c9b7 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 13:24:06 -0300 Subject: Paint into a target Surface instead of swapping the frame's out The painters of a pane's body and tag (renderBody and its selection, gutter, syntax, ANSI and wrap-marker passes; paintPaneTag; paintHeaders; renderPane; the image and PDF grid paths) take the Surface they paint as a parameter. The body layer and the tag layers paint straight into their own temporary surfaces, and the std.mem.swap of p.surface around those calls is gone. No behaviour change. --- src/File.zig | 13 +++++++------ src/Terminal.zig | 4 ++-- src/body_layer.zig | 38 ++++++++++++++++---------------------- src/draw.zig | 32 ++++++++++++-------------------- src/image.zig | 5 +++-- src/pdf_view.zig | 7 ++++--- 6 files changed, 44 insertions(+), 55 deletions(-) diff --git a/src/File.zig b/src/File.zig index af538385..26585f77 100644 --- a/src/File.zig +++ b/src/File.zig @@ -6,6 +6,7 @@ const vaxis = @import("vaxis"); const pardes = @import("pardes.zig"); const config = @import("config.zig"); const Pardes = pardes.Pardes; +const Surface = pardes.Surface; const modal = @import("modal.zig"); const filesystem = @import("fs.zig"); const syntax = @import("syntax.zig"); @@ -1124,8 +1125,7 @@ fn fillBody(dst: ?[]u8, pane: *Pane, f: *State, width: usize, record_wrap: bool) return written; } -pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, active: bool) void { - const s = &p.surface; +pub fn drawGutter(p: *Pardes, s: *Surface, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, active: bool) void { const prefix_width = gutterWidth(pane); const goff = pane.scroll(); const gcur = Terminal.gridCursor(pane); @@ -1176,11 +1176,10 @@ fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle { /// syntax colors: recolor each content cell from its tree-sitter style byte; /// content starts after the lineno gutter -pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *State, r: pardes.Rect, tx: u16, tw: u16, body_h: u16) void { +pub fn recolorSyntax(p: *Pardes, s: *Surface, pane: *Pane, f: *State, r: pardes.Rect, tx: u16, tw: u16, body_h: u16) void { const highlights = if (f.mini) |mini| mini.colors else f.highlights; const highlight_start = if (f.mini != null) 0 else f.highlight_start; if (highlights.len == 0 and f.output == null) return; - const s = &p.surface; const prefix_width = gutterWidth(pane); const tz_recolor = tracy.zone(@src(), "synRecolor"); defer tz_recolor.end(); @@ -1255,6 +1254,7 @@ pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *State, r: pardes.Rect, tx: u16 pub fn drawWrapMarkers( p: *Pardes, + s: *Surface, pane: *const Pane, r: pardes.Rect, tx: u16, @@ -1268,7 +1268,7 @@ pub fn drawWrapMarkers( var row: u16 = 0; while (row + 1 < pane.wrap_n and row + 1 < body_h) : (row += 1) { if (pane.wrap_line[row + 1] != pane.wrap_line[row]) continue; - p.surface.set(tx + tw - 1, body_y + row, config.wrap_marker, .{ + s.set(tx + tw - 1, body_y + row, config.wrap_marker, .{ .fg = .{ .rgb = marker_fg }, .bg = pane_bg, }); @@ -1277,6 +1277,7 @@ pub fn drawWrapMarkers( pub fn paintWordSelection( p: *Pardes, + s: *Surface, pane: *Pane, r: pardes.Rect, row: i32, @@ -1301,7 +1302,7 @@ pub fn paintWordSelection( const c1 = @as(i32, prefix_width) + displayEndOffset(pane, row, here.at, hi - 1); var col = @max(@as(i32, prefix_width), c0); while (col <= c1 and col < @as(i32, tw)) : (col += 1) { - const cell = p.surface.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(vr))); + const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(vr))); cell.default = false; cell.style.bg = .{ .rgb = bg }; } diff --git a/src/Terminal.zig b/src/Terminal.zig index cd292e5f..62a77ca8 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -5,6 +5,7 @@ const std = @import("std"); const pardes = @import("pardes.zig"); const config = @import("config.zig"); const Pardes = pardes.Pardes; +const Surface = pardes.Surface; const modal = @import("modal.zig"); const tracy = @import("tracy.zig"); const dump = @import("dump.zig"); @@ -1756,11 +1757,10 @@ const Anchor = struct { } }; -pub fn recolorAnsi(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, body: []const u8) void { +pub fn recolorAnsi(p: *Pardes, s: *Surface, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, body_h: u16, body: []const u8) void { // No emulator, no ANSI cells: the whole pass — and the 256-colour theme // projection behind it — is compiled out. if (comptime !enabled) return; - const s = &p.surface; if (pane.terminal == null) return; const body_y = p.bodyTop(pane, r); var filtered_storage: FilteredColors = undefined; diff --git a/src/body_layer.zig b/src/body_layer.zig index aa52957c..db4d9524 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -145,7 +145,7 @@ test "compact context borders separate source gaps and preserve multiline header // ---- drawing a pane body into the surface ---- -fn paintTerminalSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.PointerRow, bg: [3]u8, fg: ?[3]u8) void { +fn paintTerminalSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: []const Pane.PointerRow, bg: [3]u8, fg: ?[3]u8) void { const body = bodyText(p, p.scratch.allocator(), pane) catch return; const raw = pane.body.mode == .tty; const tx = r.x + config.GUTTER; @@ -178,7 +178,7 @@ fn paintTerminalSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.P var col = panes.File.rawDisplayCol(line, lo); const end = panes.File.rawDisplayCol(line, hi); while (col < end and col < width) : (col += 1) { - const cell = p.surface.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(visible))); + const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(visible))); cell.default = false; cell.style.bg = .{ .rgb = bg }; if (fg) |ink| cell.style.fg = .{ .rgb = ink }; @@ -187,8 +187,8 @@ fn paintTerminalSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.P } } -fn paintSourceSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.PointerRow, bg: [3]u8, fg: ?[3]u8) void { - if (pane.isTerminal()) return paintTerminalSelection(p, pane, r, rows, bg, fg); +fn paintSourceSelection(p: *Pardes, s: *Surface, pane: *Pane, r: Rect, rows: []const Pane.PointerRow, bg: [3]u8, fg: ?[3]u8) void { + if (pane.isTerminal()) return paintTerminalSelection(p, s, pane, r, rows, bg, fg); const terminal_lines = if (pane.file == null) panes.Terminal.cursorLines(p, pane) catch return else &.{}; const tx = r.x + config.GUTTER; const width = r.w -| config.GUTTER; @@ -209,7 +209,7 @@ fn paintSourceSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.Poi var col = prefix + @as(i32, @intCast(panes.File.rawDisplayCol(line[start..], lo - start))); const last = prefix + @as(i32, @intCast(panes.File.rawDisplayCol(line[start..], hi - start))); while (col < last and col < width) : (col += 1) { - const cell = p.surface.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(visible))); + const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(visible))); cell.default = false; cell.style.bg = .{ .rgb = bg }; if (fg) |ink| cell.style.fg = .{ .rgb = ink }; @@ -220,6 +220,7 @@ fn paintSourceSelection(p: *Pardes, pane: *Pane, r: Rect, rows: []const Pane.Poi pub fn paintPointerSelection( p: *Pardes, + s: *Surface, pane: *Pane, r: Rect, tx: u16, @@ -230,8 +231,7 @@ pub fn paintPointerSelection( bg: [3]u8, fg: ?[3]u8, ) void { - if (pane.pointerSelection(sl)) |rows| return paintSourceSelection(p, pane, r, rows, bg, fg); - const s = &p.surface; + if (pane.pointerSelection(sl)) |rows| return paintSourceSelection(p, s, pane, r, rows, bg, fg); const r0 = @min(sl.r0, sl.r1); const r1 = @max(sl.r0, sl.r1); const c0 = @max(0, @min(sl.c0, sl.c1)); @@ -278,13 +278,8 @@ pub fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rec const temporary_cells = try arena.alloc(Cell, @as(usize, r.w) * (logical_rows + pane.tag_rows)); @memset(temporary_cells, .{}); var temporary: Surface = .{ .cols = r.w, .rows = @intCast(logical_rows + pane.tag_rows), .cells = temporary_cells }; - std.mem.swap(Surface, &p.surface, &temporary); const synthetic: Rect = .{ .x = 0, .y = 0, .w = r.w, .h = @intCast(logical_rows + pane.tag_rows) }; - renderBody(p, arena, pane, synthetic, id, id == p.active, false) catch |err| { - std.mem.swap(Surface, &p.surface, &temporary); - return err; - }; - std.mem.swap(Surface, &p.surface, &temporary); + try renderBody(p, &temporary, arena, pane, synthetic, id, id == p.active, false); const first_row: usize = if (p.settings.tag_bottom) 0 else pane.tag_rows; for (0..logical_rows) |row| { const start = (row + first_row) * r.w + config.GUTTER; @@ -304,8 +299,7 @@ pub fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rec } } -pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, scrollbar: bool) !void { - const s = &p.surface; +pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, scrollbar: bool) !void { const th = p.theme(); const chrome = p.chromeTheme(); const tx = r.x + config.GUTTER; @@ -343,12 +337,12 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id const tz_color = tracy.zone(@src(), "paneRecolor"); // File layout is independent of whether its producer requests colors. if (pane.file) |*f| { - panes.File.drawGutter(p, pane, r, tx, tw, body_h, active); + panes.File.drawGutter(p, s, pane, r, tx, tw, body_h, active); if (p.settings.colors and pane.colorAlgo() != .none) - panes.File.recolorSyntax(p, pane, f, r, tx, tw, body_h); - panes.File.drawWrapMarkers(p, pane, r, tx, tw, body_h, pane_bg); + panes.File.recolorSyntax(p, s, pane, f, r, tx, tw, body_h); + panes.File.drawWrapMarkers(p, s, pane, r, tx, tw, body_h, pane_bg); } else if (pane.isTerminal() and p.settings.colors) { - panes.Terminal.recolorAnsi(p, pane, r, tx, tw, body_h, body); + panes.Terminal.recolorAnsi(p, s, pane, r, tx, tw, body_h, body); } tz_color.end(); @@ -366,9 +360,9 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id // macOS composes its glass overlay over exactly these. const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); if (preview.file_word) |word| - panes.File.paintWordSelection(p, pane, r, word.row, word.lo, word.hi, preview_bg) + panes.File.paintWordSelection(p, s, pane, r, word.row, word.lo, word.hi, preview_bg) else if (preview.sel) |sel| - paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); + paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); } } const modal_hover = if (p.look_hover_preview) |preview| @@ -381,7 +375,7 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id // A select sweep over the tag is the tag's selection, painted there // in the tag's own selection colours. if (b == pardes.sel_slot and @min(sl.r0, sl.r1) < pane.tag_rows) continue; - paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sl, sel_btn[b], th.sel_fg); + paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sl, sel_btn[b], th.sel_fg); } // modal line selection (helix `x`): whole body rows, normal mode only if ((pane.body.mode == .normal or modal_hover) and pane.body.msel.active) { diff --git a/src/draw.zig b/src/draw.zig index 5db6da30..d84f2033 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -90,7 +90,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { pane.context_row_limit = null; s.body_layers[id].rows = 0; Messages.collectNotices(p, pane, p.rects[id], id); - try renderPane(p, arena, pane, p.rects[id], id, id == p.active); + try renderPane(p, s, arena, pane, p.rects[id], id, id == p.active); try body_layer.renderBodyLayer(p, arena, pane, p.rects[id], id); } for (&p.panes, 0..) |*slot, id| { @@ -156,7 +156,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { } } - try paintHeaders(p); + try paintHeaders(p, s); try renderTagLayers(p, arena); switch (p.drag) { @@ -397,11 +397,10 @@ fn hasGlyph(cell: *const Cell) bool { return !cell.default and !(cell.len == 1 and cell.text[0] == ' '); } -fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void { +fn renderPane(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void { const tz = tracy.zone(@src(), "renderPane"); defer tz.end(); if (r.w <= config.GUTTER or r.h == 0) return; - const s = &p.surface; const th = p.theme(); const chrome = p.chromeTheme(); const tx = r.x + config.GUTTER; // text area (tag + body), right of the gutter @@ -436,14 +435,14 @@ fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: us .tty => config.tag_tty, }, box_style); - try paintPaneTag(p, arena, pane, r, id, active, p.tagCapacity(r.w -| TAG_TEXT_INSET)); + try paintPaneTag(p, s, arena, pane, r, id, active, p.tagCapacity(r.w -| TAG_TEXT_INSET)); // Folded panes retain their body state, but neither body pixels nor // its cursor belong outside the one-row tag rectangle. if (pane.collapsed) return; if (comptime pdf_enabled) - if (pane.hasPdf() and panes.Pdf.draw(p, pane, r, id, tx, tw)) return; + if (pane.hasPdf() and panes.Pdf.draw(p, s, pane, r, id, tx, tw)) return; if (pane.image) |*iv| { // Below the notice chips: a picture is drawn after the cells (the @@ -451,7 +450,7 @@ fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: us // painted out. Text gets the overlay; a picture gives up the rows. const shown = pane.notices.span(); const image_h = (r.h -| pane.tag_rows) -| shown; - panes.Image.draw(p, iv, @intCast(id), pane.serial, tx, body_y + shown, tw, image_h); + panes.Image.draw(p, s, iv, @intCast(id), pane.serial, tx, body_y + shown, tw, image_h); // thumbless, but the same one column as the real scrollbar below — // that is the whole point of drawing it, and like that one it runs // past the notice bands so the gutter has no notch in it @@ -460,14 +459,13 @@ fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: us return; } - try body_layer.renderBody(p, arena, pane, r, id, active, true); + try body_layer.renderBody(p, s, arena, pane, r, id, active, true); } /// The workspace and column tags as the grid shows them: their text, the /// word under the pointer and, in the one being edited, its selection and /// caret. Their tag layers are drawn by renderTagLayers. -pub fn paintHeaders(p: *Pardes) !void { - const s = &p.surface; +pub fn paintHeaders(p: *Pardes, s: *Surface) !void { const chrome = p.chromeTheme(); const th = p.theme(); // global tagbar: full width, top rows — unless a native shell has @@ -623,12 +621,7 @@ pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { const storage = try arena.alloc(Cell, pitch * rows); @memset(storage, .{}); var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = rows, .cells = storage }; - std.mem.swap(Surface, &p.surface, &temporary); - paintPaneTag(p, arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, id == p.active, p.tagCapacity(r.w - TAG_TEXT_INSET)) catch |err| { - std.mem.swap(Surface, &p.surface, &temporary); - return err; - }; - std.mem.swap(Surface, &p.surface, &temporary); + try paintPaneTag(p, &temporary, arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, id == p.active, p.tagCapacity(r.w - TAG_TEXT_INSET)); // A layer to a line: the first in the pane's own slot, the rest // in the slots kept for the lines of taller tags. for (0..rows) |line| { @@ -791,8 +784,7 @@ fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: T } } -pub fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void { - const s = &p.surface; +pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void { const th = p.theme(); const chrome = p.chromeTheme(); const tx = r.x + @min(TAG_TEXT_INSET, r.w); @@ -867,7 +859,7 @@ pub fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, if (p.look_hover_preview) |preview| { if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) { const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); - body_layer.paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); + body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); }; } // The tag's own selections, while it has the keyboard, a line at a @@ -908,6 +900,6 @@ pub fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, for (pane.sel, 0..) |selection, button| { if (selection.state == .none or (selection.state == .done and button != 0)) continue; if (@min(selection.r0, selection.r1) >= rows) continue; - body_layer.paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg); + body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg); } } diff --git a/src/image.zig b/src/image.zig index 09f049ca..071a2e2a 100644 --- a/src/image.zig +++ b/src/image.zig @@ -941,6 +941,7 @@ fn ensureGrid(p: *pardes.Pardes, state: *State, cols: u16, rows: u16) void { pub fn draw( p: *pardes.Pardes, + s: *pardes.Surface, state: *State, pane_id: u8, serial: u32, @@ -982,7 +983,7 @@ pub fn draw( }; const sx = x + @as(u16, @intCast(offx + cx)); const sy = y + @as(u16, @intCast(offy + cy)); - if (sx < p.surface.cols and sy < p.surface.rows) - p.surface.set(sx, sy, cell.glyph[0..cell.glyph_len], .{ .fg = fg, .bg = bg }); + if (sx < s.cols and sy < s.rows) + s.set(sx, sy, cell.glyph[0..cell.glyph_len], .{ .fg = fg, .bg = bg }); }; } diff --git a/src/pdf_view.zig b/src/pdf_view.zig index b2fd5866..78d3de27 100644 --- a/src/pdf_view.zig +++ b/src/pdf_view.zig @@ -2542,6 +2542,7 @@ pub fn horizontalWheel(core: *pardes.Pardes, pane: *pardes.Pane, direction: i8) pub fn draw( core: *pardes.Pardes, + s: *pardes.Surface, pane: *pardes.Pane, rect: pardes.Rect, pane_id: usize, @@ -2622,8 +2623,8 @@ pub fn draw( const theme = core.theme(); const pane_bg: pardes.Color = if (theme.bg) |color| .{ .rgb = color } else .default; // The rail runs past the notice bands; only the thumb tracks the text. - core.surface.fill(rect.x, body_y, 1, body_h, .{ .bg = .{ .rgb = chrome.scroll_track } }); - core.surface.fill(rect.x + 1, body_y, 1, body_h, .{ .bg = pane_bg }); + s.fill(rect.x, body_y, 1, body_h, .{ .bg = .{ .rgb = chrome.scroll_track } }); + s.fill(rect.x + 1, body_y, 1, body_h, .{ .bg = pane_bg }); const track_h: usize = body_h; const total = @max(@as(u64, 1), state.document_height); @@ -2641,7 +2642,7 @@ pub fn draw( )); var y = pos; while (y < track_h and y < pos + len) : (y += 1) - core.surface.fill( + s.fill( rect.x, body_y + @as(u16, @intCast(y)), 1, -- cgit v1.3 From 29e734cf91ffe5f4cca8b40a54aad91fff464a82 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 13:42:03 -0300 Subject: Place every part of the frame once, before painting it draw.place builds the Surface's region list at the head of render: the page; each pane's grip, tag rows, body, rail with its thumb, and notice chips; each column's grip and tag; the workspace tag. The painters read their geometry from it instead of working it out again: the grid's notice chips and the notice layers share one chip rectangle, the rails (file, terminal, image, PDF page) paint the placed thumb (a native PDF measures its own into the region as it places its pages), and the body, tag, notice and column layers take their viewports from their regions. A collapsed pane places only its grip and tag. The column-move rail's one-cell clip of the layers under it is now a region field set in place, not a mutation of the built layers. No behaviour change; a test checks every region against the old geometry, the painted rail and the layer viewports across tag bottom, column tags, notices, a collapsed pane and a multi-line tag. --- src/body_layer.zig | 37 ++------ src/draw.zig | 258 ++++++++++++++++++++++++++++++++++++++++++++--------- src/pardes.zig | 1 + src/pdf_view.zig | 25 ++---- src/surface.zig | 57 ++++++++++++ 5 files changed, 291 insertions(+), 87 deletions(-) diff --git a/src/body_layer.zig b/src/body_layer.zig index db4d9524..2b6ae773 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -279,7 +279,7 @@ pub fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rec @memset(temporary_cells, .{}); var temporary: Surface = .{ .cols = r.w, .rows = @intCast(logical_rows + pane.tag_rows), .cells = temporary_cells }; const synthetic: Rect = .{ .x = 0, .y = 0, .w = r.w, .h = @intCast(logical_rows + pane.tag_rows) }; - try renderBody(p, &temporary, arena, pane, synthetic, id, id == p.active, false); + try renderBody(p, &temporary, arena, pane, synthetic, id, id == p.active, null); const first_row: usize = if (p.settings.tag_bottom) 0 else pane.tag_rows; for (0..logical_rows) |row| { const start = (row + first_row) * r.w + config.GUTTER; @@ -287,7 +287,8 @@ pub fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rec } layer.pane = @intCast(id); layer.serial = pane.serial; - layer.viewport = .{ .x = r.x + config.GUTTER, .y = p.bodyTop(pane, r), .w = width, .h = viewport_rows }; + const body = p.surface.region(.body, id).?; + layer.viewport = .{ .x = body.rect.x, .y = body.rect.y, .w = body.rect.w - body.clip, .h = body.rect.h }; layer.cols = width; layer.rows = @intCast(logical_rows); layer.context_rows = pane.context_rows; @@ -299,7 +300,7 @@ pub fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rec } } -pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, scrollbar: bool) !void { +pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, rail: ?pardes.Region) !void { const th = p.theme(); const chrome = p.chromeTheme(); const tx = r.x + config.GUTTER; @@ -501,34 +502,14 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane } } - if (scrollbar and body_h > 0) { + if (rail) |bar| if (bar.rect.h > 0) { // The rail covers the notice bands too: nothing else paints those // two gutter columns, so a gap there keeps the frame-wide border // fill and reads as a notch cut out of the scrollbar. - const rail_h = r.h -| pane.tag_rows; - s.fill(r.x, body_y, 1, rail_h, .{ .bg = .{ .rgb = chrome.scroll_track } }); - s.fill(r.x + 1, body_y, 1, rail_h, .{ .bg = pane_bg }); - const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |*f| .{ - .total = panes.File.nlines(p.gpa, f), - .offset = f.scroll, - .len = pane.rows, - } else if (pane.pdfPage()) |page| .{ - .total = if (comptime pdf_enabled) pane.pdf.?.page_count else 1, - .offset = page, - .len = 1, - } else blk: { - const gsb = panes.Terminal.scrollbar(pane); - break :blk .{ .total = gsb.total, .offset = gsb.offset, .len = gsb.len }; - }; - const track_h: usize = body_h; - const total = if (sb.total == 0) 1 else sb.total; - const len = @max(1, (track_h * @max(1, sb.len)) / total); - const pos = (track_h * sb.offset) / total; - var sy: usize = pos; - while (sy < track_h and sy < pos + len) : (sy += 1) { - s.fill(r.x, body_y + @as(u16, @intCast(sy)), 1, 1, .{ .bg = .{ .rgb = chrome.scroll_thumb } }); - } - } + s.fill(bar.rect.x, bar.rect.y, 1, bar.rect.h, .{ .bg = .{ .rgb = chrome.scroll_track } }); + s.fill(bar.rect.x + 1, bar.rect.y, 1, bar.rect.h, .{ .bg = pane_bg }); + s.fill(bar.rect.x, bar.rect.y + bar.thumb_y, 1, bar.thumb_h, .{ .bg = .{ .rgb = chrome.scroll_thumb } }); + }; } pub fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 { diff --git a/src/draw.zig b/src/draw.zig index d84f2033..e87e0511 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -18,6 +18,7 @@ const tagline = @import("tagline.zig"); const body_layer = @import("body_layer.zig"); const Messages = @import("Messages.zig"); const Color = @import("surface.zig").Color; +const FontRole = @import("surface.zig").FontRole; const CellStyle = @import("surface.zig").CellStyle; const Cell = @import("surface.zig").Cell; const Surface = @import("surface.zig").Surface; @@ -25,6 +26,7 @@ const Pardes = pardes.Pardes; const Pane = panes.Pane; const Text = panes.Text; const Rect = layout.Rect; +const Region = @import("surface.zig").Region; const TagKind = @import("tag_layer.zig").Kind; const pdf_enabled = panes.Pdf.enabled; const wordBoundsAtCol = pardes.wordBoundsAtCol; @@ -40,6 +42,104 @@ const NOTICE_LAYER_BASE = pardes.NOTICE_LAYER_BASE; const TAG_LINE_LAYER_BASE = pardes.TAG_LINE_LAYER_BASE; const HEADER_LINE_LAYER_BASE = pardes.HEADER_LINE_LAYER_BASE; +/// Where every part of the frame goes, decided once before anything is +/// painted: the page; each pane's grip, tag, body, rail (with its thumb) +/// and notice chips; each column's grip and tag; the workspace tag. The +/// painters below and every shell read these rectangles. +pub fn place(p: *Pardes, s: *Surface) void { + s.nregions = 0; + const add = struct { + fn region(surface: *Surface, r: Region) void { + std.debug.assert(surface.nregions < surface.regions.len); + surface.regions[surface.nregions] = r; + surface.nregions += 1; + } + }.region; + add(s, .{ .kind = .page, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = s.rows } }); + for (&p.panes, 0..) |*slot, id| { + const pane = slot.* orelse continue; + Messages.collectNotices(p, pane, p.rects[id], id); + const r = p.rects[id]; + if (r.w <= config.GUTTER or r.h == 0) continue; + const owner: u8 = @intCast(id); + const active = id == p.active; + const tag_y = p.tagTop(pane, r); + const body_y = p.bodyTop(pane, r); + add(s, .{ .kind = .grip, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = tag_y, .w = config.GUTTER, .h = pane.tag_rows } }); + add(s, .{ .kind = .tag, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = tag_y, .w = r.w - config.GUTTER, .h = pane.tag_rows } }); + // A folded pane is its tag rows and nothing else. + if (pane.collapsed) continue; + const body_h = r.h -| pane.tag_rows; + add(s, .{ .kind = .body, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = body_y, .w = r.w - config.GUTTER, .h = body_h } }); + var rail: Region = .{ .kind = .rail, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = body_y, .w = config.GUTTER, .h = body_h } }; + // An image's rail has no thumb; a native PDF's is measured as its + // pages are placed (Pdf.draw); every other pane's is here. + if (pane.image == null and body_h > 0) { + const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |*f| .{ + .total = panes.File.nlines(p.gpa, f), + .offset = f.scroll, + .len = pane.rows, + } else if (pane.pdfPage()) |page| .{ + .total = if (comptime pdf_enabled) pane.pdf.?.page_count else 1, + .offset = page, + .len = 1, + } else blk: { + const gsb = panes.Terminal.scrollbar(pane); + break :blk .{ .total = gsb.total, .offset = gsb.offset, .len = gsb.len }; + }; + const track_h: usize = body_h; + const total = if (sb.total == 0) 1 else sb.total; + const len = @max(1, (track_h * @max(1, sb.len)) / total); + const pos = (track_h * sb.offset) / total; + rail.thumb_y = @intCast(@min(pos, track_h)); + rail.thumb_h = @intCast(@min(len, track_h - rail.thumb_y)); + } + add(s, rail); + // Each notice a chip at the right edge, one to a row in the order + // collectNotices chose, as wide as its own message and no wider, so + // the text beneath the rest of the row keeps reading as the file. + if (r.h <= pane.tag_rows) continue; + const tx = r.x + config.GUTTER; + const tw = r.w - config.GUTTER; + var leader_buf: [16]u8 = undefined; + for (0..pane.notices.len) |i| { + const text = Messages.noticeText(p, pane, i, &leader_buf); + const chip = Messages.noticeCols(p, text, tw); + if (chip == 0) continue; + pane.notices.left[i] = tx + tw - chip; + add(s, .{ .kind = .notice, .owner = owner, .serial = pane.serial, .active = active, .notice = @intCast(i), .rect = .{ .x = tx + tw - chip, .y = body_y + @as(u16, pane.notices.row[i]), .w = chip, .h = BOX_H } }); + } + } + const column_rows = p.columnBarHeight(); + if (column_rows > 0) for (0..p.ncol) |c| { + if (p.col_w[c] == 0) continue; + const active = if (layout.findPane(p, p.active)) |found| found.col == c else false; + const inset = @min(TAG_TEXT_INSET, p.col_w[c]); + add(s, .{ .kind = .column_grip, .owner = @intCast(c), .serial = p.col_serial[c], .active = active, .rect = .{ .x = p.col_x[c], .y = p.topBarHeight(), .w = @min(config.GUTTER, p.col_w[c]), .h = column_rows } }); + add(s, .{ .kind = .column_tag, .owner = @intCast(c), .serial = p.col_serial[c], .active = active, .rect = .{ .x = p.col_x[c] + inset, .y = p.topBarHeight(), .w = p.col_w[c] - inset, .h = column_rows } }); + }; + if (p.settings.workspace_tag) + add(s, .{ .kind = .workspace_tag, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = p.topBarHeight() } }); + // A column carried to the window's right edge shows where it lands as a + // rail in the last cell: the layers under it stop a cell short. + if (p.drag == .column_move) rail: { + const d = p.drag.column_move; + const source = layout.columnBySerial(p, d.serial) orelse break :rail; + const edge: u16 = switch (layout.columnDrop(p, source, d.cur_x, d.grab)) { + .stay => break :rail, + .reorder => |target| if (target < source) p.col_x[target] else p.col_x[target] + p.col_w[target], + .edge => |x| x, + }; + if (edge != s.cols) break :rail; + for (s.regions[0..s.nregions]) |*region| switch (region.kind) { + .body, .tag, .notice, .column_tag => if (region.rect.x + region.rect.w == s.cols) { + region.clip = 1; + }, + else => {}, + }; + } +} + pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { panes.File.refreshHighlights(p); const s = &p.surface; @@ -80,8 +180,10 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { s.cell_diffs = &.{}; const chrome = p.chromeTheme(); const th = p.theme(); + place(p, s); // Separators have their own role; a scrollbar need not frame the page. - s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = chrome.border } }); + const whole = s.regions[0].rect; + s.fill(whole.x, whole.y, whole.w, whole.h, .{ .bg = .{ .rgb = chrome.border } }); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; @@ -89,13 +191,15 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { pane.body_visible_rows = 0; pane.context_row_limit = null; s.body_layers[id].rows = 0; - Messages.collectNotices(p, pane, p.rects[id], id); try renderPane(p, s, arena, pane, p.rects[id], id, id == p.active); try body_layer.renderBodyLayer(p, arena, pane, p.rects[id], id); } - for (&p.panes, 0..) |*slot, id| { - const pane = slot.* orelse continue; - if (pane.notices.len == 0) continue; + for (s.regionList()) |region| { + if (region.kind != .notice) continue; + const id: usize = region.owner; + const pane = p.panes[id].?; + const i: usize = region.notice; + const kind = pane.notices.kinds[i]; const r = p.rects[id]; const tx = r.x + config.GUTTER; const tw = r.w - config.GUTTER; @@ -108,25 +212,20 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline, }; - // An OVERLAY over the body's top rows, at the right edge, one - // chip to a row in the order collectNotices chose -- not a row - // taken out of the body the way a tree-sitter context header is. - // The chip is as wide as its own message and no wider, so the text - // beneath the rest of the row keeps reading as the file it is. - const first = p.bodyTop(pane, r); + // An OVERLAY over the body's top rows, at the right edge (placed + // by `place`) -- not a row taken out of the body the way a + // tree-sitter context header is. var leader_buf: [16]u8 = undefined; - for (pane.notices.slice(), 0..) |kind, i| { - const row = first + @as(u16, pane.notices.row[i]); + { + const row = region.rect.y; // Every notice is painted on the canonical grid as well as // emitted as a tag layer: the grid is what a terminal client // draws, and a band it cannot see is a message it never gets. // The GUI skips grid cells a tag layer covers, so nothing is // drawn twice there. const text = Messages.noticeText(p, pane, i, &leader_buf); - const chip = Messages.noticeCols(p, text, tw); - if (chip == 0) continue; - const cx = tx + tw - chip; - pane.notices.left[i] = cx; + const chip = region.rect.w; + const cx = region.rect.x; // A message coming or going blends toward the page it sits // on; a grid cannot move it by less than a row. var style = msg_style; @@ -183,16 +282,8 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { .reorder => |target| if (target < source) p.col_x[target] else p.col_x[target] + p.col_w[target], .edge => |x| x, }; - // Leave the outside insertion rail visible above compact - // text layers. This clips presentation only, never reflows. - if (edge == s.cols) { - for (&s.body_layers) |*layer| if (layer.rows > 0 and layer.viewport.x + layer.viewport.w == s.cols) { - layer.viewport.w -|= 1; - }; - for (&s.tag_layers) |*layer| if (layer.cols > 0 and layer.viewport.x + layer.viewport.w == s.cols and layer.viewport.y >= p.topBarHeight()) { - layer.viewport.w -|= 1; - }; - } + // The layers under an outside rail stop a cell short of it: + // `place` clipped them. // The rail runs in the seam cell left of the edge, where a // border drag's does: the edge cell itself is a gutter, which // the GUI paints as a scroll rail that swallowed the dashes. @@ -441,8 +532,9 @@ fn renderPane(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: // its cursor belong outside the one-row tag rectangle. if (pane.collapsed) return; + const rail = s.region(.rail, id).?; if (comptime pdf_enabled) - if (pane.hasPdf() and panes.Pdf.draw(p, s, pane, r, id, tx, tw)) return; + if (pane.hasPdf() and panes.Pdf.draw(p, s, pane, r, id, tx, tw, rail)) return; if (pane.image) |*iv| { // Below the notice chips: a picture is drawn after the cells (the @@ -454,12 +546,12 @@ fn renderPane(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: // thumbless, but the same one column as the real scrollbar below — // that is the whole point of drawing it, and like that one it runs // past the notice bands so the gutter has no notch in it - s.fill(r.x, body_y, 1, r.h -| pane.tag_rows, .{ .bg = .{ .rgb = chrome.scroll_track } }); - s.fill(r.x + 1, body_y, 1, r.h -| pane.tag_rows, .{ .bg = pane_bg }); + s.fill(rail.rect.x, rail.rect.y, 1, rail.rect.h, .{ .bg = .{ .rgb = chrome.scroll_track } }); + s.fill(rail.rect.x + 1, rail.rect.y, 1, rail.rect.h, .{ .bg = pane_bg }); return; } - try body_layer.renderBody(p, s, arena, pane, r, id, active, true); + try body_layer.renderBody(p, s, arena, pane, r, id, active, rail.*); } /// The workspace and column tags as the grid shows them: their text, the @@ -608,6 +700,7 @@ pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { const pane = slot orelse continue; const r = p.rects[id]; if (r.w <= TAG_TEXT_INSET or r.h == 0) continue; + const region = p.surface.region(.tag, id).?; const tag = try tagline.tagText(p, arena, pane); const rows = pane.tag_rows; // As wide as the widest line it shows, and never narrower than @@ -632,7 +725,7 @@ pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { layer.id = @intCast(id); layer.serial = pane.serial; layer.line = @intCast(line); - layer.viewport = .{ .x = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r) + @as(u16, @intCast(line)), .w = r.w - TAG_TEXT_INSET, .h = BOX_H }; + layer.viewport = .{ .x = region.rect.x + TAG_GAP, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w - TAG_GAP - region.clip, .h = BOX_H }; layer.cols = columns; layer.bg = if (id == p.active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor| @@ -659,18 +752,19 @@ pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { // GUI's "skip the cells a tag layer covers" leaves no stub behind. // A prompt is one of them too: left on the grid, a pixel shell drew // its tagline glyphs one to a BODY cell, spaced out like a banner. - for (p.panes, 0..) |slot, id| { - const pane = slot orelse continue; + for (p.surface.regionList()) |region| { + if (region.kind != .notice) continue; + const id: usize = region.owner; + const pane = p.panes[id].?; + const i: usize = region.notice; + const kind = pane.notices.kinds[i]; const r = p.rects[id]; - if (r.w <= TAG_TEXT_INSET or r.h <= pane.tag_rows) continue; - const first = p.bodyTop(pane, r); + if (r.w <= TAG_TEXT_INSET) continue; var leader_buf: [16]u8 = undefined; - for (pane.notices.slice(), 0..) |kind, i| { + { const text = Messages.noticeText(p, pane, i, &leader_buf); if (text.len == 0) continue; - const cx = pane.notices.left[i]; - if (cx == std.math.maxInt(u16) or cx >= r.x + r.w) continue; - const chip = r.x + r.w - cx; + const chip = region.rect.w; // Right aligned inside the chip, a blank cell short of its // edge: the same one the grid pass leaves for a prompt caret. // Wider than the chip, the text loses its head, as on the grid. @@ -693,11 +787,13 @@ pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { const page = p.theme().bg orelse chrome.tag_bg; const layer_index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i; try renderHeaderLayer(p, arena, layer_index, .notice, @intCast(id), 0, .{ - .x = cx, - .y = first + @as(u16, pane.notices.row[i]), + .x = region.rect.x, + .y = region.rect.y, .w = chip, .h = BOX_H, }, line, Messages.blendRgb(chrome.tag_bg, page, fade), Messages.blendRgb(chrome.tag_fg, page, fade)); + // Laid out at its full width; only shown short of the rail. + p.surface.tag_layers[layer_index].viewport.w -= region.clip; p.surface.tag_layers[layer_index].slide = motion.slide; p.surface.tag_layers[layer_index].fade = fade; // The caret rides the band, where the prompt's glyphs are, @@ -713,9 +809,11 @@ pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { if (p.col_w[column] == 0) continue; const active = if (layout.findPane(p, p.active)) |found| found.col == column and p.settings.focus_tint else false; const top = if (p.header_focus and p.header_column == column) p.header_top else 0; + const region = p.surface.region(.column_tag, column) orelse continue; for (0..p.columnBarHeight()) |line| { const index = if (line == 0) MAX_PANES + column else HEADER_LINE_LAYER_BASE + column * (MAX_TAG_ROWS - 1) + line - 1; - try renderHeaderLayer(p, arena, index, .column, @intCast(column), @intCast(line), .{ .x = p.col_x[column] + @min(TAG_TEXT_INSET, p.col_w[column]), .y = p.topBarHeight() + @as(u16, @intCast(line)), .w = p.col_w[column] -| TAG_TEXT_INSET, .h = COLBAR_H }, modal.lineSlice(tagline.columnTag(p, column), line + top), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg); + try renderHeaderLayer(p, arena, index, .column, @intCast(column), @intCast(line), .{ .x = region.rect.x, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w, .h = COLBAR_H }, modal.lineSlice(tagline.columnTag(p, column), line + top), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg); + p.surface.tag_layers[index].viewport.w -= region.clip; } }; } @@ -903,3 +1001,77 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg); } } + +test "place puts every part where the painters and layers put it, in every layout" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 }); + defer p.deinit(); + _ = try p.setTestFile("placed\n" ** 80); + p.newScratchColumn(0); + p.newScratchBelow(0); + p.sync(); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + for (0..16) |step| { + p.settings.tag_bottom = step & 1 != 0; + p.settings.column_tags = step & 2 != 0; + if (step & 4 != 0) try std.testing.expect(p.executeBuiltinLine(p.active, "Msg a placed notice")); + if (step == 8) try std.testing.expect(p.executeBuiltinLine(1, "Collapse")); + if (step == 12) try std.testing.expect(p.executeBuiltinLine(1, "Collapse")); + if (step == 10) { + const pane = p.panes[p.active].?; + pane.tag.own = try std.testing.allocator.dupe(u8, "Second\nthird"); + } + p.sync(); + _ = arena.reset(.retain_capacity); + const s = try p.render(arena.allocator()); + try std.testing.expectEqual(Region.Kind.page, s.regions[0].kind); + for (p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + const r = p.rects[id]; + if (r.w <= config.GUTTER or r.h == 0) continue; + const grip = s.region(.grip, id).?; + try std.testing.expectEqual(Rect{ .x = r.x, .y = p.tagTop(pane, r), .w = config.GUTTER, .h = pane.tag_rows }, grip.rect); + // The grid's grip cell is the grip's. + try std.testing.expectEqual(FontRole.tagline, s.at(grip.rect.x, grip.rect.y).style.font_role); + const tag = s.region(.tag, id).?; + try std.testing.expectEqual(Rect{ .x = r.x + config.GUTTER, .y = p.tagTop(pane, r), .w = r.w - config.GUTTER, .h = pane.tag_rows }, tag.rect); + if (r.w > TAG_TEXT_INSET) for (0..pane.tag_rows) |line| { + const layer = &s.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1]; + try std.testing.expectEqual(Rect{ .x = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r) + @as(u16, @intCast(line)), .w = r.w - TAG_TEXT_INSET, .h = BOX_H }, layer.viewport); + }; + if (pane.collapsed) { + try std.testing.expect(s.region(.body, id) == null); + try std.testing.expect(s.region(.rail, id) == null); + continue; + } + const body = s.region(.body, id).?; + try std.testing.expectEqual(Rect{ .x = r.x + config.GUTTER, .y = p.bodyTop(pane, r), .w = r.w - config.GUTTER, .h = r.h - pane.tag_rows }, body.rect); + const rail = s.region(.rail, id).?; + // The painted thumb is exactly the placed one. + for (0..rail.rect.h) |row| { + const y = rail.rect.y + @as(u16, @intCast(row)); + const thumb = row >= rail.thumb_y and row < rail.thumb_y + rail.thumb_h; + const want = if (thumb) p.chromeTheme().scroll_thumb else p.chromeTheme().scroll_track; + try std.testing.expectEqual(Color{ .rgb = want }, s.at(rail.rect.x, y).style.bg); + } + var notices: usize = 0; + for (s.regionList()) |region| { + if (region.kind != .notice or region.owner != id) continue; + notices += 1; + try std.testing.expectEqual(pane.notices.left[region.notice], region.rect.x); + try std.testing.expectEqual(r.x + r.w, region.rect.x + region.rect.w); + try std.testing.expectEqual(p.bodyTop(pane, r) + pane.notices.row[region.notice], region.rect.y); + try std.testing.expectEqual(region.rect, s.tag_layers[NOTICE_LAYER_BASE + id * Pane.Notices.max + region.notice].viewport); + } + try std.testing.expectEqual(@as(usize, pane.notices.len), notices); + } + for (0..p.ncol) |c| { + const region = s.region(.column_tag, c); + if (p.columnBarHeight() == 0) { + try std.testing.expect(region == null); + continue; + } + try std.testing.expectEqual(Rect{ .x = p.col_x[c] + TAG_TEXT_INSET, .y = p.topBarHeight(), .w = p.col_w[c] - TAG_TEXT_INSET, .h = p.columnBarHeight() }, region.?.rect); + } + } +} diff --git a/src/pardes.zig b/src/pardes.zig index 161a047c..6946e123 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -38,6 +38,7 @@ pub const NativePlacement = @import("surface.zig").NativePlacement; pub const ImagePatch = @import("surface.zig").ImagePatch; pub const ImagePlace = @import("surface.zig").ImagePlace; pub const Surface = @import("surface.zig").Surface; +pub const Region = @import("surface.zig").Region; pub const builtins = @import("builtins.zig"); const limits = memory.limits; const selection_pipe = @import("selection_pipe.zig"); diff --git a/src/pdf_view.zig b/src/pdf_view.zig index 78d3de27..13c02231 100644 --- a/src/pdf_view.zig +++ b/src/pdf_view.zig @@ -2548,6 +2548,7 @@ pub fn draw( pane_id: usize, text_x: u16, text_width: u16, + rail: *pardes.Region, ) bool { if (comptime !enabled) return false; if (!core.native_images or rect.h <= pane.tag_rows) return false; @@ -2617,16 +2618,12 @@ pub fn draw( // This frame has spent the motion used to choose its raster band. state.scroll_travel = 0; - const body_y = core.bodyTop(pane, rect); - const body_h = rect.h -| pane.tag_rows; const chrome = core.chromeTheme(); const theme = core.theme(); const pane_bg: pardes.Color = if (theme.bg) |color| .{ .rgb = color } else .default; - // The rail runs past the notice bands; only the thumb tracks the text. - s.fill(rect.x, body_y, 1, body_h, .{ .bg = .{ .rgb = chrome.scroll_track } }); - s.fill(rect.x + 1, body_y, 1, body_h, .{ .bg = pane_bg }); - - const track_h: usize = body_h; + // The rail runs past the notice bands; only the thumb tracks the text, + // and it is measured here, in document pixels, for `place`'s region. + const track_h: usize = rail.rect.h; const total = @max(@as(u64, 1), state.document_height); const len = @max( @as(usize, 1), @@ -2640,15 +2637,11 @@ pub fn draw( @as(u64, track_h -| 1), @as(u64, track_h) * offset / total, )); - var y = pos; - while (y < track_h and y < pos + len) : (y += 1) - s.fill( - rect.x, - body_y + @as(u16, @intCast(y)), - 1, - 1, - .{ .bg = .{ .rgb = chrome.scroll_thumb } }, - ); + rail.thumb_y = @intCast(pos); + rail.thumb_h = @intCast(@min(len, track_h -| pos)); + s.fill(rail.rect.x, rail.rect.y, 1, rail.rect.h, .{ .bg = .{ .rgb = chrome.scroll_track } }); + s.fill(rail.rect.x + 1, rail.rect.y, 1, rail.rect.h, .{ .bg = pane_bg }); + s.fill(rail.rect.x, rail.rect.y + rail.thumb_y, 1, rail.thumb_h, .{ .bg = .{ .rgb = chrome.scroll_thumb } }); return true; } diff --git a/src/surface.zig b/src/surface.zig index eef787b4..b19c24c7 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -324,6 +324,51 @@ test "pixel attachment fit and pan do not invalidate cached pixels" { try std.testing.expect(key.eql(place.cacheKey())); } +/// One part of the frame and where it is, placed once by `draw.place` +/// before anything is painted: every painter and every shell reads the +/// geometry here instead of working it out again. `rect` is in grid cells +/// and is the hit-test geometry. +pub const Region = struct { + pub const Kind = enum(u8) { + /// The frame-wide border fill every other part is painted over. + page, + /// A pane's grip: the mode mark at the head of its tag rows. + grip, + /// A pane's tag rows, right of its grip. + tag, + /// A pane's body, right of its rail: absent while it is collapsed. + body, + /// The scroll rail down a pane's left edge, beside its body. + rail, + /// A transient band over the body's top rows (a message, a prompt). + notice, + column_grip, + column_tag, + workspace_tag, + }; + pub const none = std.math.maxInt(u8); + + kind: Kind, + /// The pane slot, or the column, the part belongs to; `none` for the + /// page and the workspace tag. + owner: u8 = none, + serial: u32 = 0, + rect: layout.Rect, + /// The pane or column has the keyboard's focus. + active: bool = false, + /// A rail's thumb: rows from the rail's top, and how many. Zero rows is + /// a rail with no thumb (an image). + thumb_y: u16 = 0, + thumb_h: u16 = 0, + /// A notice's index in its pane's `notices`. + notice: u8 = 0, + /// Cells the column-move rail takes off the right edge while it runs + /// there: layers and pixel shells stop short of them, the grid does not. + clip: u16 = 0, +}; + +pub const MAX_REGIONS = 1 + MAX_PANES * (4 + panes.Pane.Notices.max) + pardes.MAX_COLS * 2 + 1; + /// The canonical interface: what a frame of pardes IS. The tty shell writes /// these cells to vaxis one-to-one; the SDL shells rasterize them. pub const Surface = struct { @@ -337,6 +382,8 @@ pub const Surface = struct { tag_layers: [MAX_TAG_LAYERS]TagLayer = @splat(.{}), cols: u16 = 0, rows: u16 = 0, + regions: [MAX_REGIONS]Region = undefined, + nregions: u16 = 0, cells: []Cell = &.{}, /// bar: draw an insert-style thin cursor instead of the block cursor: ?struct { x: u16, y: u16, bar: bool = false } = null, @@ -357,6 +404,16 @@ pub const Surface = struct { return &s.body_layers; } + pub fn regionList(s: *const Surface) []const Region { + return s.regions[0..s.nregions]; + } + + /// The first part of `kind` that `owner` has: its tag, its rail. + pub fn region(s: *Surface, kind: Region.Kind, owner: usize) ?*Region { + for (s.regions[0..s.nregions]) |*r| if (r.kind == kind and r.owner == owner) return r; + return null; + } + pub fn panelTracks(s: *const Surface) []const animation.Track { return s.panel_tracks[0..s.npanel_tracks]; } -- cgit v1.3 From 05ca060fc91930f0e931f39dcf56729ec71543dd Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:26:39 -0300 Subject: Every helix-golf example is a differential case, one per step of its walkthrough The ten examples on helix-golf are imported as 118 cases: each numbered step of an example's walkthrough is the case whose keys are the command up to and including that step, so the first mismatch names the step where pardes and helix part. Six more restart at a step that begins with %, from helix's own text there, so a later step is tested even when an earlier one misses. zig build hxgolf compares all 124 with goldens from hx-harness. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + build.zig | 7 +++ docs/helix-keys.md | 13 +++++ test/hxcases/golf-goldens.jsonl | 124 ++++++++++++++++++++++++++++++++++++++++ test/hxcases/golf.jsonl | 124 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 269 insertions(+) create mode 100644 test/hxcases/golf-goldens.jsonl create mode 100644 test/hxcases/golf.jsonl diff --git a/README.md b/README.md index 8899d68a..c82bd310 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ zig build hxdiff-test comparator, allocation and CLI regression checks zig build hxdiff-live compare against a freshly run hx-harness zig build hxdiff-update update reference results only after comparison passes zig build hxparity file-pane vs pty-pane editing parity +zig build hxgolf every helix-golf example, step by step, against helix zig build mupdf-check compile, link, render and search docs/design.pdf zig build web-snap browser highlighting and touch interactions zig build web-driver-test browser driver timeouts and cleanup diff --git a/build.zig b/build.zig index 666e792d..334d3705 100644 --- a/build.zig +++ b/build.zig @@ -1097,6 +1097,13 @@ pub fn build(b: *std.Build) void { const hxparity_step = b.step("hxparity", "run the file-vs-pty editing parity suite (-- [--waivers w.jsonl] ...)"); hxparity_step.dependOn(&run_hxparity.step); + // every helix-golf example, one case per step of its walkthrough + const run_hxgolf = b.addRunArtifact(hxdiff); + run_hxgolf.has_side_effects = true; + run_hxgolf.addArgs(&.{ "--strict", "test/hxcases/golf.jsonl", "test/hxcases/golf-goldens.jsonl" }); + run_hxgolf.setCwd(b.path(".")); + b.step("hxgolf", "compare the helix-golf examples against their Helix results").dependOn(&run_hxgolf.step); + const lspbench = b.addExecutable(.{ .name = "pardes-lspbench", .root_module = b.createModule(.{ diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 41f9b364..84ee5124 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -404,6 +404,19 @@ Files (all in `test/hxcases/`): scrolloff band — no text differs), and four case texts a pty cannot hold verbatim (a literal TAB the emulator expands, a file with no trailing newline, an all-whitespace last row the dump trims). +- `golf.jsonl` + `golf-goldens.jsonl` — every example on helix-golf + (github.com/nik-rev/helix-golf, imported at d78c18c): one case per numbered + step of each walkthrough, its keys being the command up to and including + that step, so the first failing case names the step that diverges. A + step that starts over with `%` also gets `-fromNN` cases replaying the rest + of the command from helix's own text there, so a later step is still + tested when an earlier one misses. The goldens are + `hx-harness test/hxcases/golf.jsonl`; `zig build hxgolf` runs them. All ten examples reproduce their published result in hx 25.07.1 under + the site's own conditions (a file of the example's language, auto-pairs + on). Under the harness pins two do not: `csv_to_sql` needs auto-pairs to + close its `VALUES (`, and `invert_dictionary_2` needs 25.07's + `Alt-(` (rotate within groups of count) where the harness's newer + helix rotates by count. - `smoke.jsonl` — 20 cases referenced by nothing in the tree: no build step, no script. Either wire it up or delete it. diff --git a/test/hxcases/golf-goldens.jsonl b/test/hxcases/golf-goldens.jsonl new file mode 100644 index 00000000..d2d77302 --- /dev/null +++ b/test/hxcases/golf-goldens.jsonl @@ -0,0 +1,124 @@ +{"name":"golf-csv-to-sql-01","text":"id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n","mode":"normal","cursor":{"row":2,"col":33},"anchor":{"row":0,"col":0}} +{"name":"golf-csv-to-sql-02","text":"id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n","mode":"normal","cursor":{"row":0,"col":28},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":28},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":28},"anchor":{"row":1,"col":0}},{"cursor":{"row":2,"col":32},"anchor":{"row":2,"col":0}}],"primary":0} +{"name":"golf-csv-to-sql-03","text":"id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n","mode":"normal","cursor":{"row":0,"col":28},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":28},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":28},"anchor":{"row":1,"col":0}},{"cursor":{"row":2,"col":32},"anchor":{"row":2,"col":0}}],"primary":0} +{"name":"golf-csv-to-sql-04","text":"id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":10},"anchor":{"row":0,"col":10}},{"cursor":{"row":0,"col":17},"anchor":{"row":0,"col":17}},{"cursor":{"row":0,"col":28},"anchor":{"row":0,"col":28}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":10},"anchor":{"row":1,"col":10}},{"cursor":{"row":1,"col":17},"anchor":{"row":1,"col":17}},{"cursor":{"row":1,"col":28},"anchor":{"row":1,"col":28}},{"cursor":{"row":2,"col":3},"anchor":{"row":2,"col":3}},{"cursor":{"row":2,"col":4},"anchor":{"row":2,"col":4}},{"cursor":{"row":2,"col":11},"anchor":{"row":2,"col":11}},{"cursor":{"row":2,"col":12},"anchor":{"row":2,"col":12}},{"cursor":{"row":2,"col":19},"anchor":{"row":2,"col":19}},{"cursor":{"row":2,"col":20},"anchor":{"row":2,"col":20}},{"cursor":{"row":2,"col":31},"anchor":{"row":2,"col":31}},{"cursor":{"row":2,"col":32},"anchor":{"row":2,"col":32}}],"primary":0} +{"name":"golf-csv-to-sql-05","text":"id ,Item ,cost ,location \nid ,Item ,cost ,location \nid ,Item ,cost ,location \n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":9},"anchor":{"row":0,"col":9}},{"cursor":{"row":0,"col":15},"anchor":{"row":0,"col":15}},{"cursor":{"row":0,"col":25},"anchor":{"row":0,"col":25}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}},{"cursor":{"row":1,"col":15},"anchor":{"row":1,"col":15}},{"cursor":{"row":1,"col":25},"anchor":{"row":1,"col":25}},{"cursor":{"row":2,"col":3},"anchor":{"row":2,"col":3}},{"cursor":{"row":2,"col":9},"anchor":{"row":2,"col":9}},{"cursor":{"row":2,"col":15},"anchor":{"row":2,"col":15}},{"cursor":{"row":2,"col":25},"anchor":{"row":2,"col":25}}],"primary":0} +{"name":"golf-csv-to-sql-06","text":"id ,Item ,cost ,location \nid ,Item ,cost ,location \nid ,Item ,cost ,location \n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":0,"col":7},"anchor":{"row":0,"col":7}},{"cursor":{"row":0,"col":13},"anchor":{"row":0,"col":13}},{"cursor":{"row":0,"col":23},"anchor":{"row":0,"col":23}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}},{"cursor":{"row":1,"col":7},"anchor":{"row":1,"col":7}},{"cursor":{"row":1,"col":13},"anchor":{"row":1,"col":13}},{"cursor":{"row":1,"col":23},"anchor":{"row":1,"col":23}},{"cursor":{"row":2,"col":1},"anchor":{"row":2,"col":1}},{"cursor":{"row":2,"col":7},"anchor":{"row":2,"col":7}},{"cursor":{"row":2,"col":13},"anchor":{"row":2,"col":13}},{"cursor":{"row":2,"col":23},"anchor":{"row":2,"col":23}}],"primary":0} +{"name":"golf-csv-to-sql-07","text":"id ,Item ,cost ,location \nid ,Item ,cost ,location \nid ,Item ,cost ,location \n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":1},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":1}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":7}},{"cursor":{"row":0,"col":10},"anchor":{"row":0,"col":13}},{"cursor":{"row":0,"col":16},"anchor":{"row":0,"col":23}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":1}},{"cursor":{"row":1,"col":4},"anchor":{"row":1,"col":7}},{"cursor":{"row":1,"col":10},"anchor":{"row":1,"col":13}},{"cursor":{"row":1,"col":16},"anchor":{"row":1,"col":23}},{"cursor":{"row":2,"col":0},"anchor":{"row":2,"col":1}},{"cursor":{"row":2,"col":4},"anchor":{"row":2,"col":7}},{"cursor":{"row":2,"col":10},"anchor":{"row":2,"col":13}},{"cursor":{"row":2,"col":16},"anchor":{"row":2,"col":23}}],"primary":0} +{"name":"golf-csv-to-sql-08","text":"`id` ,`Item` ,`cost` ,`location` \n`id` ,`Item` ,`cost` ,`location` \n`id` ,`Item` ,`cost` ,`location` \n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":11}},{"cursor":{"row":0,"col":14},"anchor":{"row":0,"col":19}},{"cursor":{"row":0,"col":22},"anchor":{"row":0,"col":31}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":6},"anchor":{"row":1,"col":11}},{"cursor":{"row":1,"col":14},"anchor":{"row":1,"col":19}},{"cursor":{"row":1,"col":22},"anchor":{"row":1,"col":31}},{"cursor":{"row":2,"col":0},"anchor":{"row":2,"col":3}},{"cursor":{"row":2,"col":6},"anchor":{"row":2,"col":11}},{"cursor":{"row":2,"col":14},"anchor":{"row":2,"col":19}},{"cursor":{"row":2,"col":22},"anchor":{"row":2,"col":31}}],"primary":0} +{"name":"golf-csv-to-sql-09","text":"`id` ,`item` ,`cost` ,`location` \n`id` ,`item` ,`cost` ,`location` \n`id` ,`item` ,`cost` ,`location` \n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":11}},{"cursor":{"row":0,"col":14},"anchor":{"row":0,"col":19}},{"cursor":{"row":0,"col":22},"anchor":{"row":0,"col":31}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":6},"anchor":{"row":1,"col":11}},{"cursor":{"row":1,"col":14},"anchor":{"row":1,"col":19}},{"cursor":{"row":1,"col":22},"anchor":{"row":1,"col":31}},{"cursor":{"row":2,"col":0},"anchor":{"row":2,"col":3}},{"cursor":{"row":2,"col":6},"anchor":{"row":2,"col":11}},{"cursor":{"row":2,"col":14},"anchor":{"row":2,"col":19}},{"cursor":{"row":2,"col":22},"anchor":{"row":2,"col":31}}],"primary":0} +{"name":"golf-csv-to-sql-10","text":"`id` ,`item` ,`cost` ,`location` \n`id` ,`item` ,`cost` ,`location` \n`id` ,`item` ,`cost` ,`location` \n","mode":"normal","cursor":{"row":0,"col":31},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":31},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":31},"anchor":{"row":1,"col":0}},{"cursor":{"row":2,"col":31},"anchor":{"row":2,"col":0}}],"primary":0} +{"name":"golf-csv-to-sql-11","text":"(`id` ,`item` ,`cost` ,`location`) \n(`id` ,`item` ,`cost` ,`location`) \n(`id` ,`item` ,`cost` ,`location`) \n","mode":"normal","cursor":{"row":0,"col":33},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":33},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":33},"anchor":{"row":1,"col":0}},{"cursor":{"row":2,"col":33},"anchor":{"row":2,"col":0}}],"primary":0} +{"name":"golf-csv-to-sql-12","text":"(`id` ,`item` ,`cost` ,`location`) \n(`id` ,`item` ,`cost` ,`location`) \n(`id` ,`item` ,`cost` ,`location`) \n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}},{"cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}}],"primary":0} +{"name":"golf-csv-to-sql-13","text":"INSERT INTO `database(`id` ,`item` ,`cost` ,`location`) \nINSERT INTO `database(`id` ,`item` ,`cost` ,`location`) \nINSERT INTO `database(`id` ,`item` ,`cost` ,`location`) \n","mode":"insert","cursor":{"row":0,"col":21},"anchor":{"row":0,"col":21},"sels":[{"cursor":{"row":0,"col":21},"anchor":{"row":0,"col":21}},{"cursor":{"row":1,"col":21},"anchor":{"row":1,"col":21}},{"cursor":{"row":2,"col":21},"anchor":{"row":2,"col":21}}],"primary":0} +{"name":"golf-csv-to-sql-14","text":"INSERT INTO `database(`id` ,`item` ,`cost` ,`location`) \nINSERT INTO `database(`id` ,`item` ,`cost` ,`location`) \nINSERT INTO `database(`id` ,`item` ,`cost` ,`location`) \n","mode":"normal","cursor":{"row":0,"col":21},"anchor":{"row":0,"col":21},"sels":[{"cursor":{"row":0,"col":21},"anchor":{"row":0,"col":21}},{"cursor":{"row":1,"col":21},"anchor":{"row":1,"col":21}},{"cursor":{"row":2,"col":21},"anchor":{"row":2,"col":21}}],"primary":0} +{"name":"golf-csv-to-sql-15","text":"INSERT INTO `database(.`table`id` ,`item` ,`cost` ,`location`) \nINSERT INTO `database(.`table`id` ,`item` ,`cost` ,`location`) \nINSERT INTO `database(.`table`id` ,`item` ,`cost` ,`location`) \n","mode":"insert","cursor":{"row":0,"col":29},"anchor":{"row":0,"col":21},"sels":[{"cursor":{"row":0,"col":29},"anchor":{"row":0,"col":21}},{"cursor":{"row":1,"col":29},"anchor":{"row":1,"col":21}},{"cursor":{"row":2,"col":29},"anchor":{"row":2,"col":21}}],"primary":0} +{"name":"golf-csv-to-sql-16","text":"INSERT INTO `database(.`table`id` ,`item` ,`cost` ,`location`) \nINSERT INTO `database(.`table`id` ,`item` ,`cost` ,`location`) \nINSERT INTO `database(.`table`id` ,`item` ,`cost` ,`location`) \n","mode":"insert","cursor":{"row":0,"col":30},"anchor":{"row":0,"col":29},"sels":[{"cursor":{"row":0,"col":30},"anchor":{"row":0,"col":29}},{"cursor":{"row":1,"col":30},"anchor":{"row":1,"col":29}},{"cursor":{"row":2,"col":30},"anchor":{"row":2,"col":29}}],"primary":0} +{"name":"golf-csv-to-sql-17","text":"INSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) \nINSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) \nINSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) \n","mode":"normal","cursor":{"row":0,"col":30},"anchor":{"row":0,"col":29},"sels":[{"cursor":{"row":0,"col":30},"anchor":{"row":0,"col":29}},{"cursor":{"row":1,"col":30},"anchor":{"row":1,"col":29}},{"cursor":{"row":2,"col":30},"anchor":{"row":2,"col":29}}],"primary":0} +{"name":"golf-csv-to-sql-18","text":"INSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES (\nINSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES (\nINSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES (\n","mode":"insert","cursor":{"row":0,"col":72},"anchor":{"row":0,"col":72},"sels":[{"cursor":{"row":0,"col":72},"anchor":{"row":0,"col":72}},{"cursor":{"row":1,"col":72},"anchor":{"row":1,"col":72}},{"cursor":{"row":2,"col":72},"anchor":{"row":2,"col":72}}],"primary":0} +{"name":"golf-csv-to-sql-19","text":"INSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES (\nINSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES (\nINSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES (\n","mode":"normal","cursor":{"row":0,"col":72},"anchor":{"row":0,"col":72},"sels":[{"cursor":{"row":0,"col":72},"anchor":{"row":0,"col":72}},{"cursor":{"row":1,"col":72},"anchor":{"row":1,"col":72}},{"cursor":{"row":2,"col":72},"anchor":{"row":2,"col":72}}],"primary":0} +{"name":"golf-csv-to-sql-20","text":"INSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES (id 1,Item 1,cost 1,location 1\nINSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES (id 2,Item 2,cost 2,location 2\nINSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES (id 10,Item 10,cost 10,location 10\n","mode":"normal","cursor":{"row":0,"col":100},"anchor":{"row":0,"col":72},"sels":[{"cursor":{"row":0,"col":100},"anchor":{"row":0,"col":72}},{"cursor":{"row":1,"col":100},"anchor":{"row":1,"col":72}},{"cursor":{"row":2,"col":104},"anchor":{"row":2,"col":72}}],"primary":0} +{"name":"golf-csv-to-sql-21","text":"INSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES (id 1,Item 1,cost 1,location 1\nINSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES (id 2,Item 2,cost 2,location 2\nINSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES (id 10,Item 10,cost 10,location 10\n","mode":"normal","cursor":{"row":0,"col":75},"anchor":{"row":0,"col":72},"sels":[{"cursor":{"row":0,"col":75},"anchor":{"row":0,"col":72}},{"cursor":{"row":0,"col":82},"anchor":{"row":0,"col":77}},{"cursor":{"row":0,"col":89},"anchor":{"row":0,"col":84}},{"cursor":{"row":0,"col":100},"anchor":{"row":0,"col":91}},{"cursor":{"row":1,"col":75},"anchor":{"row":1,"col":72}},{"cursor":{"row":1,"col":82},"anchor":{"row":1,"col":77}},{"cursor":{"row":1,"col":89},"anchor":{"row":1,"col":84}},{"cursor":{"row":1,"col":100},"anchor":{"row":1,"col":91}},{"cursor":{"row":2,"col":76},"anchor":{"row":2,"col":72}},{"cursor":{"row":2,"col":84},"anchor":{"row":2,"col":78}},{"cursor":{"row":2,"col":92},"anchor":{"row":2,"col":86}},{"cursor":{"row":2,"col":104},"anchor":{"row":2,"col":94}}],"primary":0} +{"name":"golf-csv-to-sql-22","text":"INSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES ('id 1','Item 1','cost 1','location 1'\nINSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES ('id 2','Item 2','cost 2','location 2'\nINSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES ('id 10','Item 10','cost 10','location 10'\n","mode":"normal","cursor":{"row":0,"col":77},"anchor":{"row":0,"col":72},"sels":[{"cursor":{"row":0,"col":77},"anchor":{"row":0,"col":72}},{"cursor":{"row":0,"col":86},"anchor":{"row":0,"col":79}},{"cursor":{"row":0,"col":95},"anchor":{"row":0,"col":88}},{"cursor":{"row":0,"col":108},"anchor":{"row":0,"col":97}},{"cursor":{"row":1,"col":77},"anchor":{"row":1,"col":72}},{"cursor":{"row":1,"col":86},"anchor":{"row":1,"col":79}},{"cursor":{"row":1,"col":95},"anchor":{"row":1,"col":88}},{"cursor":{"row":1,"col":108},"anchor":{"row":1,"col":97}},{"cursor":{"row":2,"col":78},"anchor":{"row":2,"col":72}},{"cursor":{"row":2,"col":88},"anchor":{"row":2,"col":80}},{"cursor":{"row":2,"col":98},"anchor":{"row":2,"col":90}},{"cursor":{"row":2,"col":112},"anchor":{"row":2,"col":100}}],"primary":0} +{"name":"golf-csv-to-sql-23","text":"INSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES ('id 1','Item 1','cost 1','location 1';\nINSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES ('id 2','Item 2','cost 2','location 2';\nINSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES ('id 10','Item 10','cost 10','location 10';\n","mode":"insert","cursor":{"row":0,"col":110},"anchor":{"row":0,"col":110},"sels":[{"cursor":{"row":0,"col":110},"anchor":{"row":0,"col":110}},{"cursor":{"row":1,"col":110},"anchor":{"row":1,"col":110}},{"cursor":{"row":2,"col":114},"anchor":{"row":2,"col":114}}],"primary":0} +{"name":"golf-csv-to-sql-24","text":"INSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES ('id 1','Item 1','cost 1','location 1';\nINSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES ('id 2','Item 2','cost 2','location 2';\nINSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES ('id 10','Item 10','cost 10','location 10';\n","mode":"normal","cursor":{"row":0,"col":98},"anchor":{"row":0,"col":110},"sels":[{"cursor":{"row":0,"col":98},"anchor":{"row":0,"col":110}},{"cursor":{"row":1,"col":98},"anchor":{"row":1,"col":110}},{"cursor":{"row":2,"col":101},"anchor":{"row":2,"col":114}}],"primary":0} +{"name":"golf-csv-to-sql-25","text":"INSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES ('id 1','Item 1','cost 1','location 1';\nINSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES ('id 2','Item 2','cost 2','location 2';\nINSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES ('id 10','Item 10','cost 10','location 10';\n","mode":"normal","cursor":{"row":0,"col":98},"anchor":{"row":0,"col":98},"sels":[{"cursor":{"row":0,"col":98},"anchor":{"row":0,"col":98}},{"cursor":{"row":1,"col":98},"anchor":{"row":1,"col":98}},{"cursor":{"row":2,"col":101},"anchor":{"row":2,"col":101}}],"primary":0} +{"name":"golf-csv-to-sql","text":"INSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES ('id 1','Item 1','cost 1','Location 1';\nINSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES ('id 2','Item 2','cost 2','Location 2';\nINSERT INTO `database(.`table` id` ,`item` ,`cost` ,`location`) VALUES ('id 10','Item 10','cost 10','Location 10';\n","mode":"normal","cursor":{"row":0,"col":98},"anchor":{"row":0,"col":98},"sels":[{"cursor":{"row":0,"col":98},"anchor":{"row":0,"col":98}},{"cursor":{"row":1,"col":98},"anchor":{"row":1,"col":98}},{"cursor":{"row":2,"col":101},"anchor":{"row":2,"col":101}}],"primary":0} +{"name":"golf-enumerate-and-align-01","text":"[\n { word: \"a\", count: 2565 },\n { word: \"and\", count: 1777 },\n { word: \"of\", count: 1331 },\n { word: \"that\", count: 1263 },\n { word: \"to\", count: 1030 },\n { word: \"in\", count: 1027 },\n { word: \"it\", count: 754 },\n { word: \"as\", count: 730 },\n { word: \"was\", count: 687 },\n { word: \"you\", count: 652 },\n { word: \"for\", count: 630 },\n];\n","mode":"normal","cursor":{"row":12,"col":2},"anchor":{"row":0,"col":0}} +{"name":"golf-enumerate-and-align-02","text":"[\n { word: \"a\", count: 2565 },\n { word: \"and\", count: 1777 },\n { word: \"of\", count: 1331 },\n { word: \"that\", count: 1263 },\n { word: \"to\", count: 1030 },\n { word: \"in\", count: 1027 },\n { word: \"it\", count: 754 },\n { word: \"as\", count: 730 },\n { word: \"was\", count: 687 },\n { word: \"you\", count: 652 },\n { word: \"for\", count: 630 },\n];\n","mode":"normal","cursor":{"row":12,"col":2},"anchor":{"row":0,"col":0}} +{"name":"golf-enumerate-and-align-03","text":"[\n { word: \"a\", count: 2565 },\n { word: \"and\", count: 1777 },\n { word: \"of\", count: 1331 },\n { word: \"that\", count: 1263 },\n { word: \"to\", count: 1030 },\n { word: \"in\", count: 1027 },\n { word: \"it\", count: 754 },\n { word: \"as\", count: 730 },\n { word: \"was\", count: 687 },\n { word: \"you\", count: 652 },\n { word: \"for\", count: 630 },\n];\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2},"sels":[{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}},{"cursor":{"row":2,"col":2},"anchor":{"row":2,"col":2}},{"cursor":{"row":3,"col":2},"anchor":{"row":3,"col":2}},{"cursor":{"row":4,"col":2},"anchor":{"row":4,"col":2}},{"cursor":{"row":5,"col":2},"anchor":{"row":5,"col":2}},{"cursor":{"row":6,"col":2},"anchor":{"row":6,"col":2}},{"cursor":{"row":7,"col":2},"anchor":{"row":7,"col":2}},{"cursor":{"row":8,"col":2},"anchor":{"row":8,"col":2}},{"cursor":{"row":9,"col":2},"anchor":{"row":9,"col":2}},{"cursor":{"row":10,"col":2},"anchor":{"row":10,"col":2}},{"cursor":{"row":11,"col":2},"anchor":{"row":11,"col":2}}],"primary":0} +{"name":"golf-enumerate-and-align-04","text":"[\n { word: \"a\", count: 2565 },\n { word: \"and\", count: 1777 },\n { word: \"of\", count: 1331 },\n { word: \"that\", count: 1263 },\n { word: \"to\", count: 1030 },\n { word: \"in\", count: 1027 },\n { word: \"it\", count: 754 },\n { word: \"as\", count: 730 },\n { word: \"was\", count: 687 },\n { word: \"you\", count: 652 },\n { word: \"for\", count: 630 },\n];\n","mode":"insert","cursor":{"row":1,"col":4},"anchor":{"row":1,"col":2},"sels":[{"cursor":{"row":1,"col":4},"anchor":{"row":1,"col":2}},{"cursor":{"row":2,"col":4},"anchor":{"row":2,"col":2}},{"cursor":{"row":3,"col":4},"anchor":{"row":3,"col":2}},{"cursor":{"row":4,"col":4},"anchor":{"row":4,"col":2}},{"cursor":{"row":5,"col":4},"anchor":{"row":5,"col":2}},{"cursor":{"row":6,"col":4},"anchor":{"row":6,"col":2}},{"cursor":{"row":7,"col":4},"anchor":{"row":7,"col":2}},{"cursor":{"row":8,"col":4},"anchor":{"row":8,"col":2}},{"cursor":{"row":9,"col":4},"anchor":{"row":9,"col":2}},{"cursor":{"row":10,"col":4},"anchor":{"row":10,"col":2}},{"cursor":{"row":11,"col":4},"anchor":{"row":11,"col":2}}],"primary":0} +{"name":"golf-enumerate-and-align-05","text":"[\n { rank: word: \"a\", count: 2565 },\n { rank: word: \"and\", count: 1777 },\n { rank: word: \"of\", count: 1331 },\n { rank: word: \"that\", count: 1263 },\n { rank: word: \"to\", count: 1030 },\n { rank: word: \"in\", count: 1027 },\n { rank: word: \"it\", count: 754 },\n { rank: word: \"as\", count: 730 },\n { rank: word: \"was\", count: 687 },\n { rank: word: \"you\", count: 652 },\n { rank: word: \"for\", count: 630 },\n];\n","mode":"insert","cursor":{"row":1,"col":10},"anchor":{"row":1,"col":2},"sels":[{"cursor":{"row":1,"col":10},"anchor":{"row":1,"col":2}},{"cursor":{"row":2,"col":10},"anchor":{"row":2,"col":2}},{"cursor":{"row":3,"col":10},"anchor":{"row":3,"col":2}},{"cursor":{"row":4,"col":10},"anchor":{"row":4,"col":2}},{"cursor":{"row":5,"col":10},"anchor":{"row":5,"col":2}},{"cursor":{"row":6,"col":10},"anchor":{"row":6,"col":2}},{"cursor":{"row":7,"col":10},"anchor":{"row":7,"col":2}},{"cursor":{"row":8,"col":10},"anchor":{"row":8,"col":2}},{"cursor":{"row":9,"col":10},"anchor":{"row":9,"col":2}},{"cursor":{"row":10,"col":10},"anchor":{"row":10,"col":2}},{"cursor":{"row":11,"col":10},"anchor":{"row":11,"col":2}}],"primary":0} +{"name":"golf-enumerate-and-align-06","text":"[\n { rank: 1 word: \"a\", count: 2565 },\n { rank: 2 word: \"and\", count: 1777 },\n { rank: 3 word: \"of\", count: 1331 },\n { rank: 4 word: \"that\", count: 1263 },\n { rank: 5 word: \"to\", count: 1030 },\n { rank: 6 word: \"in\", count: 1027 },\n { rank: 7 word: \"it\", count: 754 },\n { rank: 8 word: \"as\", count: 730 },\n { rank: 9 word: \"was\", count: 687 },\n { rank: 10 word: \"you\", count: 652 },\n { rank: 11 word: \"for\", count: 630 },\n];\n","mode":"insert","cursor":{"row":1,"col":11},"anchor":{"row":1,"col":2},"sels":[{"cursor":{"row":1,"col":11},"anchor":{"row":1,"col":2}},{"cursor":{"row":2,"col":11},"anchor":{"row":2,"col":2}},{"cursor":{"row":3,"col":11},"anchor":{"row":3,"col":2}},{"cursor":{"row":4,"col":11},"anchor":{"row":4,"col":2}},{"cursor":{"row":5,"col":11},"anchor":{"row":5,"col":2}},{"cursor":{"row":6,"col":11},"anchor":{"row":6,"col":2}},{"cursor":{"row":7,"col":11},"anchor":{"row":7,"col":2}},{"cursor":{"row":8,"col":11},"anchor":{"row":8,"col":2}},{"cursor":{"row":9,"col":11},"anchor":{"row":9,"col":2}},{"cursor":{"row":10,"col":12},"anchor":{"row":10,"col":2}},{"cursor":{"row":11,"col":12},"anchor":{"row":11,"col":2}}],"primary":0} +{"name":"golf-enumerate-and-align-07","text":"[\n { rank: 1, word: \"a\", count: 2565 },\n { rank: 2, word: \"and\", count: 1777 },\n { rank: 3, word: \"of\", count: 1331 },\n { rank: 4, word: \"that\", count: 1263 },\n { rank: 5, word: \"to\", count: 1030 },\n { rank: 6, word: \"in\", count: 1027 },\n { rank: 7, word: \"it\", count: 754 },\n { rank: 8, word: \"as\", count: 730 },\n { rank: 9, word: \"was\", count: 687 },\n { rank: 10, word: \"you\", count: 652 },\n { rank: 11, word: \"for\", count: 630 },\n];\n","mode":"insert","cursor":{"row":1,"col":12},"anchor":{"row":1,"col":2},"sels":[{"cursor":{"row":1,"col":12},"anchor":{"row":1,"col":2}},{"cursor":{"row":2,"col":12},"anchor":{"row":2,"col":2}},{"cursor":{"row":3,"col":12},"anchor":{"row":3,"col":2}},{"cursor":{"row":4,"col":12},"anchor":{"row":4,"col":2}},{"cursor":{"row":5,"col":12},"anchor":{"row":5,"col":2}},{"cursor":{"row":6,"col":12},"anchor":{"row":6,"col":2}},{"cursor":{"row":7,"col":12},"anchor":{"row":7,"col":2}},{"cursor":{"row":8,"col":12},"anchor":{"row":8,"col":2}},{"cursor":{"row":9,"col":12},"anchor":{"row":9,"col":2}},{"cursor":{"row":10,"col":13},"anchor":{"row":10,"col":2}},{"cursor":{"row":11,"col":13},"anchor":{"row":11,"col":2}}],"primary":0} +{"name":"golf-enumerate-and-align-08","text":"[\n { rank: 1, word: \"a\", count: 2565 },\n { rank: 2, word: \"and\", count: 1777 },\n { rank: 3, word: \"of\", count: 1331 },\n { rank: 4, word: \"that\", count: 1263 },\n { rank: 5, word: \"to\", count: 1030 },\n { rank: 6, word: \"in\", count: 1027 },\n { rank: 7, word: \"it\", count: 754 },\n { rank: 8, word: \"as\", count: 730 },\n { rank: 9, word: \"was\", count: 687 },\n { rank: 10, word: \"you\", count: 652 },\n { rank: 11, word: \"for\", count: 630 },\n];\n","mode":"normal","cursor":{"row":1,"col":11},"anchor":{"row":1,"col":2},"sels":[{"cursor":{"row":1,"col":11},"anchor":{"row":1,"col":2}},{"cursor":{"row":2,"col":11},"anchor":{"row":2,"col":2}},{"cursor":{"row":3,"col":11},"anchor":{"row":3,"col":2}},{"cursor":{"row":4,"col":11},"anchor":{"row":4,"col":2}},{"cursor":{"row":5,"col":11},"anchor":{"row":5,"col":2}},{"cursor":{"row":6,"col":11},"anchor":{"row":6,"col":2}},{"cursor":{"row":7,"col":11},"anchor":{"row":7,"col":2}},{"cursor":{"row":8,"col":11},"anchor":{"row":8,"col":2}},{"cursor":{"row":9,"col":11},"anchor":{"row":9,"col":2}},{"cursor":{"row":10,"col":12},"anchor":{"row":10,"col":2}},{"cursor":{"row":11,"col":12},"anchor":{"row":11,"col":2}}],"primary":0} +{"name":"golf-enumerate-and-align-09","text":"[\n { rank: 1, word: \"a\", count: 2565 },\n { rank: 2, word: \"and\", count: 1777 },\n { rank: 3, word: \"of\", count: 1331 },\n { rank: 4, word: \"that\", count: 1263 },\n { rank: 5, word: \"to\", count: 1030 },\n { rank: 6, word: \"in\", count: 1027 },\n { rank: 7, word: \"it\", count: 754 },\n { rank: 8, word: \"as\", count: 730 },\n { rank: 9, word: \"was\", count: 687 },\n { rank: 10, word: \"you\", count: 652 },\n { rank: 11, word: \"for\", count: 630 },\n];\n","mode":"normal","cursor":{"row":12,"col":2},"anchor":{"row":0,"col":0}} +{"name":"golf-enumerate-and-align-10","text":"[\n { rank: 1, word: \"a\", count: 2565 },\n { rank: 2, word: \"and\", count: 1777 },\n { rank: 3, word: \"of\", count: 1331 },\n { rank: 4, word: \"that\", count: 1263 },\n { rank: 5, word: \"to\", count: 1030 },\n { rank: 6, word: \"in\", count: 1027 },\n { rank: 7, word: \"it\", count: 754 },\n { rank: 8, word: \"as\", count: 730 },\n { rank: 9, word: \"was\", count: 687 },\n { rank: 10, word: \"you\", count: 652 },\n { rank: 11, word: \"for\", count: 630 },\n];\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}},{"cursor":{"row":1,"col":10},"anchor":{"row":1,"col":10}},{"cursor":{"row":1,"col":12},"anchor":{"row":1,"col":12}},{"cursor":{"row":1,"col":18},"anchor":{"row":1,"col":18}},{"cursor":{"row":1,"col":23},"anchor":{"row":1,"col":23}},{"cursor":{"row":1,"col":30},"anchor":{"row":1,"col":30}},{"cursor":{"row":1,"col":34},"anchor":{"row":1,"col":31}},{"cursor":{"row":1,"col":35},"anchor":{"row":1,"col":35}},{"cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}},{"cursor":{"row":2,"col":1},"anchor":{"row":2,"col":1}},{"cursor":{"row":2,"col":3},"anchor":{"row":2,"col":3}},{"cursor":{"row":2,"col":9},"anchor":{"row":2,"col":9}},{"cursor":{"row":2,"col":10},"anchor":{"row":2,"col":10}},{"cursor":{"row":2,"col":12},"anchor":{"row":2,"col":12}},{"cursor":{"row":2,"col":18},"anchor":{"row":2,"col":18}},{"cursor":{"row":2,"col":25},"anchor":{"row":2,"col":25}},{"cursor":{"row":2,"col":32},"anchor":{"row":2,"col":32}},{"cursor":{"row":2,"col":36},"anchor":{"row":2,"col":33}},{"cursor":{"row":2,"col":37},"anchor":{"row":2,"col":37}},{"cursor":{"row":3,"col":0},"anchor":{"row":3,"col":0}},{"cursor":{"row":3,"col":1},"anchor":{"row":3,"col":1}},{"cursor":{"row":3,"col":3},"anchor":{"row":3,"col":3}},{"cursor":{"row":3,"col":9},"anchor":{"row":3,"col":9}},{"cursor":{"row":3,"col":10},"anchor":{"row":3,"col":10}},{"cursor":{"row":3,"col":12},"anchor":{"row":3,"col":12}},{"cursor":{"row":3,"col":18},"anchor":{"row":3,"col":18}},{"cursor":{"row":3,"col":24},"anchor":{"row":3,"col":24}},{"cursor":{"row":3,"col":31},"anchor":{"row":3,"col":31}},{"cursor":{"row":3,"col":35},"anchor":{"row":3,"col":32}},{"cursor":{"row":3,"col":36},"anchor":{"row":3,"col":36}},{"cursor":{"row":4,"col":0},"anchor":{"row":4,"col":0}},{"cursor":{"row":4,"col":1},"anchor":{"row":4,"col":1}},{"cursor":{"row":4,"col":3},"anchor":{"row":4,"col":3}},{"cursor":{"row":4,"col":9},"anchor":{"row":4,"col":9}},{"cursor":{"row":4,"col":10},"anchor":{"row":4,"col":10}},{"cursor":{"row":4,"col":12},"anchor":{"row":4,"col":12}},{"cursor":{"row":4,"col":18},"anchor":{"row":4,"col":18}},{"cursor":{"row":4,"col":26},"anchor":{"row":4,"col":26}},{"cursor":{"row":4,"col":33},"anchor":{"row":4,"col":33}},{"cursor":{"row":4,"col":37},"anchor":{"row":4,"col":34}},{"cursor":{"row":4,"col":38},"anchor":{"row":4,"col":38}},{"cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}},{"cursor":{"row":5,"col":1},"anchor":{"row":5,"col":1}},{"cursor":{"row":5,"col":3},"anchor":{"row":5,"col":3}},{"cursor":{"row":5,"col":9},"anchor":{"row":5,"col":9}},{"cursor":{"row":5,"col":10},"anchor":{"row":5,"col":10}},{"cursor":{"row":5,"col":12},"anchor":{"row":5,"col":12}},{"cursor":{"row":5,"col":18},"anchor":{"row":5,"col":18}},{"cursor":{"row":5,"col":24},"anchor":{"row":5,"col":24}},{"cursor":{"row":5,"col":31},"anchor":{"row":5,"col":31}},{"cursor":{"row":5,"col":35},"anchor":{"row":5,"col":32}},{"cursor":{"row":5,"col":36},"anchor":{"row":5,"col":36}},{"cursor":{"row":6,"col":0},"anchor":{"row":6,"col":0}},{"cursor":{"row":6,"col":1},"anchor":{"row":6,"col":1}},{"cursor":{"row":6,"col":3},"anchor":{"row":6,"col":3}},{"cursor":{"row":6,"col":9},"anchor":{"row":6,"col":9}},{"cursor":{"row":6,"col":10},"anchor":{"row":6,"col":10}},{"cursor":{"row":6,"col":12},"anchor":{"row":6,"col":12}},{"cursor":{"row":6,"col":18},"anchor":{"row":6,"col":18}},{"cursor":{"row":6,"col":24},"anchor":{"row":6,"col":24}},{"cursor":{"row":6,"col":31},"anchor":{"row":6,"col":31}},{"cursor":{"row":6,"col":35},"anchor":{"row":6,"col":32}},{"cursor":{"row":6,"col":36},"anchor":{"row":6,"col":36}},{"cursor":{"row":7,"col":0},"anchor":{"row":7,"col":0}},{"cursor":{"row":7,"col":1},"anchor":{"row":7,"col":1}},{"cursor":{"row":7,"col":3},"anchor":{"row":7,"col":3}},{"cursor":{"row":7,"col":9},"anchor":{"row":7,"col":9}},{"cursor":{"row":7,"col":10},"anchor":{"row":7,"col":10}},{"cursor":{"row":7,"col":12},"anchor":{"row":7,"col":12}},{"cursor":{"row":7,"col":18},"anchor":{"row":7,"col":18}},{"cursor":{"row":7,"col":24},"anchor":{"row":7,"col":24}},{"cursor":{"row":7,"col":31},"anchor":{"row":7,"col":31}},{"cursor":{"row":7,"col":34},"anchor":{"row":7,"col":32}},{"cursor":{"row":7,"col":35},"anchor":{"row":7,"col":35}},{"cursor":{"row":8,"col":0},"anchor":{"row":8,"col":0}},{"cursor":{"row":8,"col":1},"anchor":{"row":8,"col":1}},{"cursor":{"row":8,"col":3},"anchor":{"row":8,"col":3}},{"cursor":{"row":8,"col":9},"anchor":{"row":8,"col":9}},{"cursor":{"row":8,"col":10},"anchor":{"row":8,"col":10}},{"cursor":{"row":8,"col":12},"anchor":{"row":8,"col":12}},{"cursor":{"row":8,"col":18},"anchor":{"row":8,"col":18}},{"cursor":{"row":8,"col":24},"anchor":{"row":8,"col":24}},{"cursor":{"row":8,"col":31},"anchor":{"row":8,"col":31}},{"cursor":{"row":8,"col":34},"anchor":{"row":8,"col":32}},{"cursor":{"row":8,"col":35},"anchor":{"row":8,"col":35}},{"cursor":{"row":9,"col":0},"anchor":{"row":9,"col":0}},{"cursor":{"row":9,"col":1},"anchor":{"row":9,"col":1}},{"cursor":{"row":9,"col":3},"anchor":{"row":9,"col":3}},{"cursor":{"row":9,"col":9},"anchor":{"row":9,"col":9}},{"cursor":{"row":9,"col":10},"anchor":{"row":9,"col":10}},{"cursor":{"row":9,"col":12},"anchor":{"row":9,"col":12}},{"cursor":{"row":9,"col":18},"anchor":{"row":9,"col":18}},{"cursor":{"row":9,"col":25},"anchor":{"row":9,"col":25}},{"cursor":{"row":9,"col":32},"anchor":{"row":9,"col":32}},{"cursor":{"row":9,"col":35},"anchor":{"row":9,"col":33}},{"cursor":{"row":9,"col":36},"anchor":{"row":9,"col":36}},{"cursor":{"row":10,"col":0},"anchor":{"row":10,"col":0}},{"cursor":{"row":10,"col":1},"anchor":{"row":10,"col":1}},{"cursor":{"row":10,"col":3},"anchor":{"row":10,"col":3}},{"cursor":{"row":10,"col":9},"anchor":{"row":10,"col":9}},{"cursor":{"row":10,"col":11},"anchor":{"row":10,"col":10}},{"cursor":{"row":10,"col":13},"anchor":{"row":10,"col":13}},{"cursor":{"row":10,"col":19},"anchor":{"row":10,"col":19}},{"cursor":{"row":10,"col":26},"anchor":{"row":10,"col":26}},{"cursor":{"row":10,"col":33},"anchor":{"row":10,"col":33}},{"cursor":{"row":10,"col":36},"anchor":{"row":10,"col":34}},{"cursor":{"row":10,"col":37},"anchor":{"row":10,"col":37}},{"cursor":{"row":11,"col":0},"anchor":{"row":11,"col":0}},{"cursor":{"row":11,"col":1},"anchor":{"row":11,"col":1}},{"cursor":{"row":11,"col":3},"anchor":{"row":11,"col":3}},{"cursor":{"row":11,"col":9},"anchor":{"row":11,"col":9}},{"cursor":{"row":11,"col":11},"anchor":{"row":11,"col":10}},{"cursor":{"row":11,"col":13},"anchor":{"row":11,"col":13}},{"cursor":{"row":11,"col":19},"anchor":{"row":11,"col":19}},{"cursor":{"row":11,"col":26},"anchor":{"row":11,"col":26}},{"cursor":{"row":11,"col":33},"anchor":{"row":11,"col":33}},{"cursor":{"row":11,"col":36},"anchor":{"row":11,"col":34}},{"cursor":{"row":11,"col":37},"anchor":{"row":11,"col":37}}],"primary":0} +{"name":"golf-enumerate-and-align","text":"[\n { rank: 1, word: \"a\", count: 2565 },\n { rank: 2, word: \"and\", count: 1777 },\n { rank: 3, word: \"of\", count: 1331 },\n { rank: 4, word: \"that\", count: 1263 },\n { rank: 5, word: \"to\", count: 1030 },\n { rank: 6, word: \"in\", count: 1027 },\n { rank: 7, word: \"it\", count: 754 },\n { rank: 8, word: \"as\", count: 730 },\n { rank: 9, word: \"was\", count: 687 },\n { rank: 10, word: \"you\", count: 652 },\n { rank: 11, word: \"for\", count: 630 },\n];\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}},{"cursor":{"row":1,"col":11},"anchor":{"row":1,"col":11}},{"cursor":{"row":1,"col":13},"anchor":{"row":1,"col":13}},{"cursor":{"row":1,"col":19},"anchor":{"row":1,"col":19}},{"cursor":{"row":1,"col":27},"anchor":{"row":1,"col":27}},{"cursor":{"row":1,"col":34},"anchor":{"row":1,"col":34}},{"cursor":{"row":1,"col":38},"anchor":{"row":1,"col":35}},{"cursor":{"row":1,"col":39},"anchor":{"row":1,"col":39}},{"cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}},{"cursor":{"row":2,"col":1},"anchor":{"row":2,"col":1}},{"cursor":{"row":2,"col":3},"anchor":{"row":2,"col":3}},{"cursor":{"row":2,"col":9},"anchor":{"row":2,"col":9}},{"cursor":{"row":2,"col":11},"anchor":{"row":2,"col":11}},{"cursor":{"row":2,"col":13},"anchor":{"row":2,"col":13}},{"cursor":{"row":2,"col":19},"anchor":{"row":2,"col":19}},{"cursor":{"row":2,"col":27},"anchor":{"row":2,"col":27}},{"cursor":{"row":2,"col":34},"anchor":{"row":2,"col":34}},{"cursor":{"row":2,"col":38},"anchor":{"row":2,"col":35}},{"cursor":{"row":2,"col":39},"anchor":{"row":2,"col":39}},{"cursor":{"row":3,"col":0},"anchor":{"row":3,"col":0}},{"cursor":{"row":3,"col":1},"anchor":{"row":3,"col":1}},{"cursor":{"row":3,"col":3},"anchor":{"row":3,"col":3}},{"cursor":{"row":3,"col":9},"anchor":{"row":3,"col":9}},{"cursor":{"row":3,"col":11},"anchor":{"row":3,"col":11}},{"cursor":{"row":3,"col":13},"anchor":{"row":3,"col":13}},{"cursor":{"row":3,"col":19},"anchor":{"row":3,"col":19}},{"cursor":{"row":3,"col":27},"anchor":{"row":3,"col":27}},{"cursor":{"row":3,"col":34},"anchor":{"row":3,"col":34}},{"cursor":{"row":3,"col":38},"anchor":{"row":3,"col":35}},{"cursor":{"row":3,"col":39},"anchor":{"row":3,"col":39}},{"cursor":{"row":4,"col":0},"anchor":{"row":4,"col":0}},{"cursor":{"row":4,"col":1},"anchor":{"row":4,"col":1}},{"cursor":{"row":4,"col":3},"anchor":{"row":4,"col":3}},{"cursor":{"row":4,"col":9},"anchor":{"row":4,"col":9}},{"cursor":{"row":4,"col":11},"anchor":{"row":4,"col":11}},{"cursor":{"row":4,"col":13},"anchor":{"row":4,"col":13}},{"cursor":{"row":4,"col":19},"anchor":{"row":4,"col":19}},{"cursor":{"row":4,"col":27},"anchor":{"row":4,"col":27}},{"cursor":{"row":4,"col":34},"anchor":{"row":4,"col":34}},{"cursor":{"row":4,"col":38},"anchor":{"row":4,"col":35}},{"cursor":{"row":4,"col":39},"anchor":{"row":4,"col":39}},{"cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}},{"cursor":{"row":5,"col":1},"anchor":{"row":5,"col":1}},{"cursor":{"row":5,"col":3},"anchor":{"row":5,"col":3}},{"cursor":{"row":5,"col":9},"anchor":{"row":5,"col":9}},{"cursor":{"row":5,"col":11},"anchor":{"row":5,"col":11}},{"cursor":{"row":5,"col":13},"anchor":{"row":5,"col":13}},{"cursor":{"row":5,"col":19},"anchor":{"row":5,"col":19}},{"cursor":{"row":5,"col":27},"anchor":{"row":5,"col":27}},{"cursor":{"row":5,"col":34},"anchor":{"row":5,"col":34}},{"cursor":{"row":5,"col":38},"anchor":{"row":5,"col":35}},{"cursor":{"row":5,"col":39},"anchor":{"row":5,"col":39}},{"cursor":{"row":6,"col":0},"anchor":{"row":6,"col":0}},{"cursor":{"row":6,"col":1},"anchor":{"row":6,"col":1}},{"cursor":{"row":6,"col":3},"anchor":{"row":6,"col":3}},{"cursor":{"row":6,"col":9},"anchor":{"row":6,"col":9}},{"cursor":{"row":6,"col":11},"anchor":{"row":6,"col":11}},{"cursor":{"row":6,"col":13},"anchor":{"row":6,"col":13}},{"cursor":{"row":6,"col":19},"anchor":{"row":6,"col":19}},{"cursor":{"row":6,"col":27},"anchor":{"row":6,"col":27}},{"cursor":{"row":6,"col":34},"anchor":{"row":6,"col":34}},{"cursor":{"row":6,"col":38},"anchor":{"row":6,"col":35}},{"cursor":{"row":6,"col":39},"anchor":{"row":6,"col":39}},{"cursor":{"row":7,"col":0},"anchor":{"row":7,"col":0}},{"cursor":{"row":7,"col":1},"anchor":{"row":7,"col":1}},{"cursor":{"row":7,"col":3},"anchor":{"row":7,"col":3}},{"cursor":{"row":7,"col":9},"anchor":{"row":7,"col":9}},{"cursor":{"row":7,"col":11},"anchor":{"row":7,"col":11}},{"cursor":{"row":7,"col":13},"anchor":{"row":7,"col":13}},{"cursor":{"row":7,"col":19},"anchor":{"row":7,"col":19}},{"cursor":{"row":7,"col":27},"anchor":{"row":7,"col":27}},{"cursor":{"row":7,"col":34},"anchor":{"row":7,"col":34}},{"cursor":{"row":7,"col":38},"anchor":{"row":7,"col":36}},{"cursor":{"row":7,"col":39},"anchor":{"row":7,"col":39}},{"cursor":{"row":8,"col":0},"anchor":{"row":8,"col":0}},{"cursor":{"row":8,"col":1},"anchor":{"row":8,"col":1}},{"cursor":{"row":8,"col":3},"anchor":{"row":8,"col":3}},{"cursor":{"row":8,"col":9},"anchor":{"row":8,"col":9}},{"cursor":{"row":8,"col":11},"anchor":{"row":8,"col":11}},{"cursor":{"row":8,"col":13},"anchor":{"row":8,"col":13}},{"cursor":{"row":8,"col":19},"anchor":{"row":8,"col":19}},{"cursor":{"row":8,"col":27},"anchor":{"row":8,"col":27}},{"cursor":{"row":8,"col":34},"anchor":{"row":8,"col":34}},{"cursor":{"row":8,"col":38},"anchor":{"row":8,"col":36}},{"cursor":{"row":8,"col":39},"anchor":{"row":8,"col":39}},{"cursor":{"row":9,"col":0},"anchor":{"row":9,"col":0}},{"cursor":{"row":9,"col":1},"anchor":{"row":9,"col":1}},{"cursor":{"row":9,"col":3},"anchor":{"row":9,"col":3}},{"cursor":{"row":9,"col":9},"anchor":{"row":9,"col":9}},{"cursor":{"row":9,"col":11},"anchor":{"row":9,"col":11}},{"cursor":{"row":9,"col":13},"anchor":{"row":9,"col":13}},{"cursor":{"row":9,"col":19},"anchor":{"row":9,"col":19}},{"cursor":{"row":9,"col":27},"anchor":{"row":9,"col":27}},{"cursor":{"row":9,"col":34},"anchor":{"row":9,"col":34}},{"cursor":{"row":9,"col":38},"anchor":{"row":9,"col":36}},{"cursor":{"row":9,"col":39},"anchor":{"row":9,"col":39}},{"cursor":{"row":10,"col":0},"anchor":{"row":10,"col":0}},{"cursor":{"row":10,"col":1},"anchor":{"row":10,"col":1}},{"cursor":{"row":10,"col":3},"anchor":{"row":10,"col":3}},{"cursor":{"row":10,"col":9},"anchor":{"row":10,"col":9}},{"cursor":{"row":10,"col":11},"anchor":{"row":10,"col":10}},{"cursor":{"row":10,"col":13},"anchor":{"row":10,"col":13}},{"cursor":{"row":10,"col":19},"anchor":{"row":10,"col":19}},{"cursor":{"row":10,"col":27},"anchor":{"row":10,"col":27}},{"cursor":{"row":10,"col":34},"anchor":{"row":10,"col":34}},{"cursor":{"row":10,"col":38},"anchor":{"row":10,"col":36}},{"cursor":{"row":10,"col":39},"anchor":{"row":10,"col":39}},{"cursor":{"row":11,"col":0},"anchor":{"row":11,"col":0}},{"cursor":{"row":11,"col":1},"anchor":{"row":11,"col":1}},{"cursor":{"row":11,"col":3},"anchor":{"row":11,"col":3}},{"cursor":{"row":11,"col":9},"anchor":{"row":11,"col":9}},{"cursor":{"row":11,"col":11},"anchor":{"row":11,"col":10}},{"cursor":{"row":11,"col":13},"anchor":{"row":11,"col":13}},{"cursor":{"row":11,"col":19},"anchor":{"row":11,"col":19}},{"cursor":{"row":11,"col":27},"anchor":{"row":11,"col":27}},{"cursor":{"row":11,"col":34},"anchor":{"row":11,"col":34}},{"cursor":{"row":11,"col":38},"anchor":{"row":11,"col":36}},{"cursor":{"row":11,"col":39},"anchor":{"row":11,"col":39}}],"primary":0} +{"name":"golf-enumerate-and-align-from09-09","text":"[\n { rank: 1, word: \"a\", count: 2565 },\n { rank: 2, word: \"and\", count: 1777 },\n { rank: 3, word: \"of\", count: 1331 },\n { rank: 4, word: \"that\", count: 1263 },\n { rank: 5, word: \"to\", count: 1030 },\n { rank: 6, word: \"in\", count: 1027 },\n { rank: 7, word: \"it\", count: 754 },\n { rank: 8, word: \"as\", count: 730 },\n { rank: 9, word: \"was\", count: 687 },\n { rank: 10, word: \"you\", count: 652 },\n { rank: 11, word: \"for\", count: 630 },\n];\n","mode":"normal","cursor":{"row":12,"col":2},"anchor":{"row":0,"col":0}} +{"name":"golf-enumerate-and-align-from09-10","text":"[\n { rank: 1, word: \"a\", count: 2565 },\n { rank: 2, word: \"and\", count: 1777 },\n { rank: 3, word: \"of\", count: 1331 },\n { rank: 4, word: \"that\", count: 1263 },\n { rank: 5, word: \"to\", count: 1030 },\n { rank: 6, word: \"in\", count: 1027 },\n { rank: 7, word: \"it\", count: 754 },\n { rank: 8, word: \"as\", count: 730 },\n { rank: 9, word: \"was\", count: 687 },\n { rank: 10, word: \"you\", count: 652 },\n { rank: 11, word: \"for\", count: 630 },\n];\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}},{"cursor":{"row":1,"col":10},"anchor":{"row":1,"col":10}},{"cursor":{"row":1,"col":12},"anchor":{"row":1,"col":12}},{"cursor":{"row":1,"col":18},"anchor":{"row":1,"col":18}},{"cursor":{"row":1,"col":23},"anchor":{"row":1,"col":23}},{"cursor":{"row":1,"col":30},"anchor":{"row":1,"col":30}},{"cursor":{"row":1,"col":34},"anchor":{"row":1,"col":31}},{"cursor":{"row":1,"col":35},"anchor":{"row":1,"col":35}},{"cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}},{"cursor":{"row":2,"col":1},"anchor":{"row":2,"col":1}},{"cursor":{"row":2,"col":3},"anchor":{"row":2,"col":3}},{"cursor":{"row":2,"col":9},"anchor":{"row":2,"col":9}},{"cursor":{"row":2,"col":10},"anchor":{"row":2,"col":10}},{"cursor":{"row":2,"col":12},"anchor":{"row":2,"col":12}},{"cursor":{"row":2,"col":18},"anchor":{"row":2,"col":18}},{"cursor":{"row":2,"col":25},"anchor":{"row":2,"col":25}},{"cursor":{"row":2,"col":32},"anchor":{"row":2,"col":32}},{"cursor":{"row":2,"col":36},"anchor":{"row":2,"col":33}},{"cursor":{"row":2,"col":37},"anchor":{"row":2,"col":37}},{"cursor":{"row":3,"col":0},"anchor":{"row":3,"col":0}},{"cursor":{"row":3,"col":1},"anchor":{"row":3,"col":1}},{"cursor":{"row":3,"col":3},"anchor":{"row":3,"col":3}},{"cursor":{"row":3,"col":9},"anchor":{"row":3,"col":9}},{"cursor":{"row":3,"col":10},"anchor":{"row":3,"col":10}},{"cursor":{"row":3,"col":12},"anchor":{"row":3,"col":12}},{"cursor":{"row":3,"col":18},"anchor":{"row":3,"col":18}},{"cursor":{"row":3,"col":24},"anchor":{"row":3,"col":24}},{"cursor":{"row":3,"col":31},"anchor":{"row":3,"col":31}},{"cursor":{"row":3,"col":35},"anchor":{"row":3,"col":32}},{"cursor":{"row":3,"col":36},"anchor":{"row":3,"col":36}},{"cursor":{"row":4,"col":0},"anchor":{"row":4,"col":0}},{"cursor":{"row":4,"col":1},"anchor":{"row":4,"col":1}},{"cursor":{"row":4,"col":3},"anchor":{"row":4,"col":3}},{"cursor":{"row":4,"col":9},"anchor":{"row":4,"col":9}},{"cursor":{"row":4,"col":10},"anchor":{"row":4,"col":10}},{"cursor":{"row":4,"col":12},"anchor":{"row":4,"col":12}},{"cursor":{"row":4,"col":18},"anchor":{"row":4,"col":18}},{"cursor":{"row":4,"col":26},"anchor":{"row":4,"col":26}},{"cursor":{"row":4,"col":33},"anchor":{"row":4,"col":33}},{"cursor":{"row":4,"col":37},"anchor":{"row":4,"col":34}},{"cursor":{"row":4,"col":38},"anchor":{"row":4,"col":38}},{"cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}},{"cursor":{"row":5,"col":1},"anchor":{"row":5,"col":1}},{"cursor":{"row":5,"col":3},"anchor":{"row":5,"col":3}},{"cursor":{"row":5,"col":9},"anchor":{"row":5,"col":9}},{"cursor":{"row":5,"col":10},"anchor":{"row":5,"col":10}},{"cursor":{"row":5,"col":12},"anchor":{"row":5,"col":12}},{"cursor":{"row":5,"col":18},"anchor":{"row":5,"col":18}},{"cursor":{"row":5,"col":24},"anchor":{"row":5,"col":24}},{"cursor":{"row":5,"col":31},"anchor":{"row":5,"col":31}},{"cursor":{"row":5,"col":35},"anchor":{"row":5,"col":32}},{"cursor":{"row":5,"col":36},"anchor":{"row":5,"col":36}},{"cursor":{"row":6,"col":0},"anchor":{"row":6,"col":0}},{"cursor":{"row":6,"col":1},"anchor":{"row":6,"col":1}},{"cursor":{"row":6,"col":3},"anchor":{"row":6,"col":3}},{"cursor":{"row":6,"col":9},"anchor":{"row":6,"col":9}},{"cursor":{"row":6,"col":10},"anchor":{"row":6,"col":10}},{"cursor":{"row":6,"col":12},"anchor":{"row":6,"col":12}},{"cursor":{"row":6,"col":18},"anchor":{"row":6,"col":18}},{"cursor":{"row":6,"col":24},"anchor":{"row":6,"col":24}},{"cursor":{"row":6,"col":31},"anchor":{"row":6,"col":31}},{"cursor":{"row":6,"col":35},"anchor":{"row":6,"col":32}},{"cursor":{"row":6,"col":36},"anchor":{"row":6,"col":36}},{"cursor":{"row":7,"col":0},"anchor":{"row":7,"col":0}},{"cursor":{"row":7,"col":1},"anchor":{"row":7,"col":1}},{"cursor":{"row":7,"col":3},"anchor":{"row":7,"col":3}},{"cursor":{"row":7,"col":9},"anchor":{"row":7,"col":9}},{"cursor":{"row":7,"col":10},"anchor":{"row":7,"col":10}},{"cursor":{"row":7,"col":12},"anchor":{"row":7,"col":12}},{"cursor":{"row":7,"col":18},"anchor":{"row":7,"col":18}},{"cursor":{"row":7,"col":24},"anchor":{"row":7,"col":24}},{"cursor":{"row":7,"col":31},"anchor":{"row":7,"col":31}},{"cursor":{"row":7,"col":34},"anchor":{"row":7,"col":32}},{"cursor":{"row":7,"col":35},"anchor":{"row":7,"col":35}},{"cursor":{"row":8,"col":0},"anchor":{"row":8,"col":0}},{"cursor":{"row":8,"col":1},"anchor":{"row":8,"col":1}},{"cursor":{"row":8,"col":3},"anchor":{"row":8,"col":3}},{"cursor":{"row":8,"col":9},"anchor":{"row":8,"col":9}},{"cursor":{"row":8,"col":10},"anchor":{"row":8,"col":10}},{"cursor":{"row":8,"col":12},"anchor":{"row":8,"col":12}},{"cursor":{"row":8,"col":18},"anchor":{"row":8,"col":18}},{"cursor":{"row":8,"col":24},"anchor":{"row":8,"col":24}},{"cursor":{"row":8,"col":31},"anchor":{"row":8,"col":31}},{"cursor":{"row":8,"col":34},"anchor":{"row":8,"col":32}},{"cursor":{"row":8,"col":35},"anchor":{"row":8,"col":35}},{"cursor":{"row":9,"col":0},"anchor":{"row":9,"col":0}},{"cursor":{"row":9,"col":1},"anchor":{"row":9,"col":1}},{"cursor":{"row":9,"col":3},"anchor":{"row":9,"col":3}},{"cursor":{"row":9,"col":9},"anchor":{"row":9,"col":9}},{"cursor":{"row":9,"col":10},"anchor":{"row":9,"col":10}},{"cursor":{"row":9,"col":12},"anchor":{"row":9,"col":12}},{"cursor":{"row":9,"col":18},"anchor":{"row":9,"col":18}},{"cursor":{"row":9,"col":25},"anchor":{"row":9,"col":25}},{"cursor":{"row":9,"col":32},"anchor":{"row":9,"col":32}},{"cursor":{"row":9,"col":35},"anchor":{"row":9,"col":33}},{"cursor":{"row":9,"col":36},"anchor":{"row":9,"col":36}},{"cursor":{"row":10,"col":0},"anchor":{"row":10,"col":0}},{"cursor":{"row":10,"col":1},"anchor":{"row":10,"col":1}},{"cursor":{"row":10,"col":3},"anchor":{"row":10,"col":3}},{"cursor":{"row":10,"col":9},"anchor":{"row":10,"col":9}},{"cursor":{"row":10,"col":11},"anchor":{"row":10,"col":10}},{"cursor":{"row":10,"col":13},"anchor":{"row":10,"col":13}},{"cursor":{"row":10,"col":19},"anchor":{"row":10,"col":19}},{"cursor":{"row":10,"col":26},"anchor":{"row":10,"col":26}},{"cursor":{"row":10,"col":33},"anchor":{"row":10,"col":33}},{"cursor":{"row":10,"col":36},"anchor":{"row":10,"col":34}},{"cursor":{"row":10,"col":37},"anchor":{"row":10,"col":37}},{"cursor":{"row":11,"col":0},"anchor":{"row":11,"col":0}},{"cursor":{"row":11,"col":1},"anchor":{"row":11,"col":1}},{"cursor":{"row":11,"col":3},"anchor":{"row":11,"col":3}},{"cursor":{"row":11,"col":9},"anchor":{"row":11,"col":9}},{"cursor":{"row":11,"col":11},"anchor":{"row":11,"col":10}},{"cursor":{"row":11,"col":13},"anchor":{"row":11,"col":13}},{"cursor":{"row":11,"col":19},"anchor":{"row":11,"col":19}},{"cursor":{"row":11,"col":26},"anchor":{"row":11,"col":26}},{"cursor":{"row":11,"col":33},"anchor":{"row":11,"col":33}},{"cursor":{"row":11,"col":36},"anchor":{"row":11,"col":34}},{"cursor":{"row":11,"col":37},"anchor":{"row":11,"col":37}}],"primary":0} +{"name":"golf-enumerate-and-align-from09","text":"[\n { rank: 1, word: \"a\", count: 2565 },\n { rank: 2, word: \"and\", count: 1777 },\n { rank: 3, word: \"of\", count: 1331 },\n { rank: 4, word: \"that\", count: 1263 },\n { rank: 5, word: \"to\", count: 1030 },\n { rank: 6, word: \"in\", count: 1027 },\n { rank: 7, word: \"it\", count: 754 },\n { rank: 8, word: \"as\", count: 730 },\n { rank: 9, word: \"was\", count: 687 },\n { rank: 10, word: \"you\", count: 652 },\n { rank: 11, word: \"for\", count: 630 },\n];\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}},{"cursor":{"row":1,"col":11},"anchor":{"row":1,"col":11}},{"cursor":{"row":1,"col":13},"anchor":{"row":1,"col":13}},{"cursor":{"row":1,"col":19},"anchor":{"row":1,"col":19}},{"cursor":{"row":1,"col":27},"anchor":{"row":1,"col":27}},{"cursor":{"row":1,"col":34},"anchor":{"row":1,"col":34}},{"cursor":{"row":1,"col":38},"anchor":{"row":1,"col":35}},{"cursor":{"row":1,"col":39},"anchor":{"row":1,"col":39}},{"cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}},{"cursor":{"row":2,"col":1},"anchor":{"row":2,"col":1}},{"cursor":{"row":2,"col":3},"anchor":{"row":2,"col":3}},{"cursor":{"row":2,"col":9},"anchor":{"row":2,"col":9}},{"cursor":{"row":2,"col":11},"anchor":{"row":2,"col":11}},{"cursor":{"row":2,"col":13},"anchor":{"row":2,"col":13}},{"cursor":{"row":2,"col":19},"anchor":{"row":2,"col":19}},{"cursor":{"row":2,"col":27},"anchor":{"row":2,"col":27}},{"cursor":{"row":2,"col":34},"anchor":{"row":2,"col":34}},{"cursor":{"row":2,"col":38},"anchor":{"row":2,"col":35}},{"cursor":{"row":2,"col":39},"anchor":{"row":2,"col":39}},{"cursor":{"row":3,"col":0},"anchor":{"row":3,"col":0}},{"cursor":{"row":3,"col":1},"anchor":{"row":3,"col":1}},{"cursor":{"row":3,"col":3},"anchor":{"row":3,"col":3}},{"cursor":{"row":3,"col":9},"anchor":{"row":3,"col":9}},{"cursor":{"row":3,"col":11},"anchor":{"row":3,"col":11}},{"cursor":{"row":3,"col":13},"anchor":{"row":3,"col":13}},{"cursor":{"row":3,"col":19},"anchor":{"row":3,"col":19}},{"cursor":{"row":3,"col":27},"anchor":{"row":3,"col":27}},{"cursor":{"row":3,"col":34},"anchor":{"row":3,"col":34}},{"cursor":{"row":3,"col":38},"anchor":{"row":3,"col":35}},{"cursor":{"row":3,"col":39},"anchor":{"row":3,"col":39}},{"cursor":{"row":4,"col":0},"anchor":{"row":4,"col":0}},{"cursor":{"row":4,"col":1},"anchor":{"row":4,"col":1}},{"cursor":{"row":4,"col":3},"anchor":{"row":4,"col":3}},{"cursor":{"row":4,"col":9},"anchor":{"row":4,"col":9}},{"cursor":{"row":4,"col":11},"anchor":{"row":4,"col":11}},{"cursor":{"row":4,"col":13},"anchor":{"row":4,"col":13}},{"cursor":{"row":4,"col":19},"anchor":{"row":4,"col":19}},{"cursor":{"row":4,"col":27},"anchor":{"row":4,"col":27}},{"cursor":{"row":4,"col":34},"anchor":{"row":4,"col":34}},{"cursor":{"row":4,"col":38},"anchor":{"row":4,"col":35}},{"cursor":{"row":4,"col":39},"anchor":{"row":4,"col":39}},{"cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}},{"cursor":{"row":5,"col":1},"anchor":{"row":5,"col":1}},{"cursor":{"row":5,"col":3},"anchor":{"row":5,"col":3}},{"cursor":{"row":5,"col":9},"anchor":{"row":5,"col":9}},{"cursor":{"row":5,"col":11},"anchor":{"row":5,"col":11}},{"cursor":{"row":5,"col":13},"anchor":{"row":5,"col":13}},{"cursor":{"row":5,"col":19},"anchor":{"row":5,"col":19}},{"cursor":{"row":5,"col":27},"anchor":{"row":5,"col":27}},{"cursor":{"row":5,"col":34},"anchor":{"row":5,"col":34}},{"cursor":{"row":5,"col":38},"anchor":{"row":5,"col":35}},{"cursor":{"row":5,"col":39},"anchor":{"row":5,"col":39}},{"cursor":{"row":6,"col":0},"anchor":{"row":6,"col":0}},{"cursor":{"row":6,"col":1},"anchor":{"row":6,"col":1}},{"cursor":{"row":6,"col":3},"anchor":{"row":6,"col":3}},{"cursor":{"row":6,"col":9},"anchor":{"row":6,"col":9}},{"cursor":{"row":6,"col":11},"anchor":{"row":6,"col":11}},{"cursor":{"row":6,"col":13},"anchor":{"row":6,"col":13}},{"cursor":{"row":6,"col":19},"anchor":{"row":6,"col":19}},{"cursor":{"row":6,"col":27},"anchor":{"row":6,"col":27}},{"cursor":{"row":6,"col":34},"anchor":{"row":6,"col":34}},{"cursor":{"row":6,"col":38},"anchor":{"row":6,"col":35}},{"cursor":{"row":6,"col":39},"anchor":{"row":6,"col":39}},{"cursor":{"row":7,"col":0},"anchor":{"row":7,"col":0}},{"cursor":{"row":7,"col":1},"anchor":{"row":7,"col":1}},{"cursor":{"row":7,"col":3},"anchor":{"row":7,"col":3}},{"cursor":{"row":7,"col":9},"anchor":{"row":7,"col":9}},{"cursor":{"row":7,"col":11},"anchor":{"row":7,"col":11}},{"cursor":{"row":7,"col":13},"anchor":{"row":7,"col":13}},{"cursor":{"row":7,"col":19},"anchor":{"row":7,"col":19}},{"cursor":{"row":7,"col":27},"anchor":{"row":7,"col":27}},{"cursor":{"row":7,"col":34},"anchor":{"row":7,"col":34}},{"cursor":{"row":7,"col":38},"anchor":{"row":7,"col":36}},{"cursor":{"row":7,"col":39},"anchor":{"row":7,"col":39}},{"cursor":{"row":8,"col":0},"anchor":{"row":8,"col":0}},{"cursor":{"row":8,"col":1},"anchor":{"row":8,"col":1}},{"cursor":{"row":8,"col":3},"anchor":{"row":8,"col":3}},{"cursor":{"row":8,"col":9},"anchor":{"row":8,"col":9}},{"cursor":{"row":8,"col":11},"anchor":{"row":8,"col":11}},{"cursor":{"row":8,"col":13},"anchor":{"row":8,"col":13}},{"cursor":{"row":8,"col":19},"anchor":{"row":8,"col":19}},{"cursor":{"row":8,"col":27},"anchor":{"row":8,"col":27}},{"cursor":{"row":8,"col":34},"anchor":{"row":8,"col":34}},{"cursor":{"row":8,"col":38},"anchor":{"row":8,"col":36}},{"cursor":{"row":8,"col":39},"anchor":{"row":8,"col":39}},{"cursor":{"row":9,"col":0},"anchor":{"row":9,"col":0}},{"cursor":{"row":9,"col":1},"anchor":{"row":9,"col":1}},{"cursor":{"row":9,"col":3},"anchor":{"row":9,"col":3}},{"cursor":{"row":9,"col":9},"anchor":{"row":9,"col":9}},{"cursor":{"row":9,"col":11},"anchor":{"row":9,"col":11}},{"cursor":{"row":9,"col":13},"anchor":{"row":9,"col":13}},{"cursor":{"row":9,"col":19},"anchor":{"row":9,"col":19}},{"cursor":{"row":9,"col":27},"anchor":{"row":9,"col":27}},{"cursor":{"row":9,"col":34},"anchor":{"row":9,"col":34}},{"cursor":{"row":9,"col":38},"anchor":{"row":9,"col":36}},{"cursor":{"row":9,"col":39},"anchor":{"row":9,"col":39}},{"cursor":{"row":10,"col":0},"anchor":{"row":10,"col":0}},{"cursor":{"row":10,"col":1},"anchor":{"row":10,"col":1}},{"cursor":{"row":10,"col":3},"anchor":{"row":10,"col":3}},{"cursor":{"row":10,"col":9},"anchor":{"row":10,"col":9}},{"cursor":{"row":10,"col":11},"anchor":{"row":10,"col":10}},{"cursor":{"row":10,"col":13},"anchor":{"row":10,"col":13}},{"cursor":{"row":10,"col":19},"anchor":{"row":10,"col":19}},{"cursor":{"row":10,"col":27},"anchor":{"row":10,"col":27}},{"cursor":{"row":10,"col":34},"anchor":{"row":10,"col":34}},{"cursor":{"row":10,"col":38},"anchor":{"row":10,"col":36}},{"cursor":{"row":10,"col":39},"anchor":{"row":10,"col":39}},{"cursor":{"row":11,"col":0},"anchor":{"row":11,"col":0}},{"cursor":{"row":11,"col":1},"anchor":{"row":11,"col":1}},{"cursor":{"row":11,"col":3},"anchor":{"row":11,"col":3}},{"cursor":{"row":11,"col":9},"anchor":{"row":11,"col":9}},{"cursor":{"row":11,"col":11},"anchor":{"row":11,"col":10}},{"cursor":{"row":11,"col":13},"anchor":{"row":11,"col":13}},{"cursor":{"row":11,"col":19},"anchor":{"row":11,"col":19}},{"cursor":{"row":11,"col":27},"anchor":{"row":11,"col":27}},{"cursor":{"row":11,"col":34},"anchor":{"row":11,"col":34}},{"cursor":{"row":11,"col":38},"anchor":{"row":11,"col":36}},{"cursor":{"row":11,"col":39},"anchor":{"row":11,"col":39}}],"primary":0} +{"name":"golf-export-from-mod-01","text":"mod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\n","mode":"normal","cursor":{"row":2,"col":13},"anchor":{"row":0,"col":0}} +{"name":"golf-export-from-mod-02","text":"mod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\nmod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\n","mode":"normal","cursor":{"row":5,"col":13},"anchor":{"row":3,"col":0}} +{"name":"golf-export-from-mod-03","text":"mod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\n\nmod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\n","mode":"normal","cursor":{"row":6,"col":13},"anchor":{"row":4,"col":0}} +{"name":"golf-export-from-mod-04","text":"mod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\n\nmod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\n","mode":"normal","cursor":{"row":4,"col":2},"anchor":{"row":4,"col":0},"sels":[{"cursor":{"row":4,"col":2},"anchor":{"row":4,"col":0}},{"cursor":{"row":5,"col":2},"anchor":{"row":5,"col":0}},{"cursor":{"row":6,"col":2},"anchor":{"row":6,"col":0}}],"primary":0} +{"name":"golf-export-from-mod-05","text":"mod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\n\npub use generate_demos;\npub use mdbook_preprocessor;\npub use validate;\n","mode":"normal","cursor":{"row":4,"col":7},"anchor":{"row":4,"col":7},"sels":[{"cursor":{"row":4,"col":7},"anchor":{"row":4,"col":7}},{"cursor":{"row":5,"col":7},"anchor":{"row":5,"col":7}},{"cursor":{"row":6,"col":7},"anchor":{"row":6,"col":7}}],"primary":0} +{"name":"golf-export-from-mod-06","text":"mod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\n\npub use generate_demos;\npub use mdbook_preprocessor;\npub use validate;\n","mode":"normal","cursor":{"row":4,"col":21},"anchor":{"row":4,"col":8},"sels":[{"cursor":{"row":4,"col":21},"anchor":{"row":4,"col":8}},{"cursor":{"row":5,"col":26},"anchor":{"row":5,"col":8}},{"cursor":{"row":6,"col":15},"anchor":{"row":6,"col":8}}],"primary":0} +{"name":"golf-export-from-mod-07","text":"mod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\n\npub use generate_demosgenerate_demos;\npub use mdbook_preprocessormdbook_preprocessor;\npub use validatevalidate;\n","mode":"normal","cursor":{"row":4,"col":35},"anchor":{"row":4,"col":22},"sels":[{"cursor":{"row":4,"col":35},"anchor":{"row":4,"col":22}},{"cursor":{"row":5,"col":45},"anchor":{"row":5,"col":27}},{"cursor":{"row":6,"col":23},"anchor":{"row":6,"col":16}}],"primary":0} +{"name":"golf-export-from-mod","text":"mod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\n\npub use generate_demos::generate_demos;\npub use mdbook_preprocessor::mdbook_preprocessor;\npub use validate::validate;\n","mode":"insert","cursor":{"row":4,"col":24},"anchor":{"row":4,"col":37},"sels":[{"cursor":{"row":4,"col":24},"anchor":{"row":4,"col":37}},{"cursor":{"row":5,"col":29},"anchor":{"row":5,"col":47}},{"cursor":{"row":6,"col":18},"anchor":{"row":6,"col":25}}],"primary":0} +{"name":"golf-function-into-class-01","text":"def calculate_area(length, width):\n result = length * width\n return result\n\ndef calculate_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef calculate_volume(length, width, height):\n result = length * width * height\n return result\n","mode":"normal","cursor":{"row":10,"col":17},"anchor":{"row":0,"col":0}} +{"name":"golf-function-into-class-02","text":"def calculate_area(length, width):\n result = length * width\n return result\n\ndef calculate_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef calculate_volume(length, width, height):\n result = length * width * height\n return result\n","mode":"normal","cursor":{"row":0,"col":12},"anchor":{"row":0,"col":4},"sels":[{"cursor":{"row":0,"col":12},"anchor":{"row":0,"col":4}},{"cursor":{"row":4,"col":12},"anchor":{"row":4,"col":4}},{"cursor":{"row":8,"col":12},"anchor":{"row":8,"col":4}}],"primary":0} +{"name":"golf-function-into-class-03","text":"def get_area(length, width):\n result = length * width\n return result\n\ndef get_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef get_volume(length, width, height):\n result = length * width * height\n return result\n","mode":"insert","cursor":{"row":0,"col":7},"anchor":{"row":0,"col":7},"sels":[{"cursor":{"row":0,"col":7},"anchor":{"row":0,"col":7}},{"cursor":{"row":4,"col":7},"anchor":{"row":4,"col":7}},{"cursor":{"row":8,"col":7},"anchor":{"row":8,"col":7}}],"primary":0} +{"name":"golf-function-into-class-04","text":"def get_area(length, width):\n result = length * width\n return result\n\ndef get_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef get_volume(length, width, height):\n result = length * width * height\n return result\n","mode":"normal","cursor":{"row":0,"col":7},"anchor":{"row":0,"col":7},"sels":[{"cursor":{"row":0,"col":7},"anchor":{"row":0,"col":7}},{"cursor":{"row":4,"col":7},"anchor":{"row":4,"col":7}},{"cursor":{"row":8,"col":7},"anchor":{"row":8,"col":7}}],"primary":0} +{"name":"golf-function-into-class-05","text":"@staticmethod\ndef get_area(length, width):\n result = length * width\n return result\n\n@staticmethod\ndef get_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\n@staticmethod\ndef get_volume(length, width, height):\n result = length * width * height\n return result\n","mode":"insert","cursor":{"row":0,"col":13},"anchor":{"row":0,"col":13},"sels":[{"cursor":{"row":0,"col":13},"anchor":{"row":0,"col":13}},{"cursor":{"row":5,"col":13},"anchor":{"row":5,"col":13}},{"cursor":{"row":10,"col":13},"anchor":{"row":10,"col":13}}],"primary":0} +{"name":"golf-function-into-class-06","text":"@staticmethod\ndef get_area(length, width):\n result = length * width\n return result\n\n@staticmethod\ndef get_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\n@staticmethod\ndef get_volume(length, width, height):\n result = length * width * height\n return result\n","mode":"normal","cursor":{"row":0,"col":13},"anchor":{"row":0,"col":13},"sels":[{"cursor":{"row":0,"col":13},"anchor":{"row":0,"col":13}},{"cursor":{"row":5,"col":13},"anchor":{"row":5,"col":13}},{"cursor":{"row":10,"col":13},"anchor":{"row":10,"col":13}}],"primary":0} +{"name":"golf-function-into-class-07","text":"@staticmethod\ndef get_area(length, width):\n result = length * width\n return result\n\n@staticmethod\ndef get_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\n@staticmethod\ndef get_volume(length, width, height):\n result = length * width * height\n return result\n","mode":"normal","cursor":{"row":2,"col":13},"anchor":{"row":2,"col":13},"sels":[{"cursor":{"row":2,"col":13},"anchor":{"row":2,"col":13}},{"cursor":{"row":7,"col":13},"anchor":{"row":7,"col":13}},{"cursor":{"row":12,"col":13},"anchor":{"row":12,"col":13}}],"primary":0} +{"name":"golf-function-into-class-08","text":"@staticmethod\ndef get_area(length, width):\n result = length * width\n return result\n\n@staticmethod\ndef get_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\n@staticmethod\ndef get_volume(length, width, height):\n result = length * width * height\n return result\n","mode":"select","cursor":{"row":2,"col":26},"anchor":{"row":2,"col":13},"sels":[{"cursor":{"row":2,"col":26},"anchor":{"row":2,"col":13}},{"cursor":{"row":7,"col":32},"anchor":{"row":7,"col":13}},{"cursor":{"row":12,"col":35},"anchor":{"row":12,"col":13}}],"primary":0} +{"name":"golf-function-into-class-09","text":"@staticmethod\ndef get_area(length, width):\n result = length * width\n return result\n\n@staticmethod\ndef get_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\n@staticmethod\ndef get_volume(length, width, height):\n result = length * width * height\n return result\n","mode":"normal","cursor":{"row":2,"col":26},"anchor":{"row":2,"col":13},"sels":[{"cursor":{"row":2,"col":26},"anchor":{"row":2,"col":13}},{"cursor":{"row":7,"col":32},"anchor":{"row":7,"col":13}},{"cursor":{"row":12,"col":35},"anchor":{"row":12,"col":13}}],"primary":0} +{"name":"golf-function-into-class-10","text":"@staticmethod\ndef get_area(length, width):\n return result\n\n@staticmethod\ndef get_perimiter(length, width):\n return result\n\n@staticmethod\ndef get_volume(length, width, height):\n return result\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0},"sels":[{"cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}},{"cursor":{"row":6,"col":0},"anchor":{"row":6,"col":0}},{"cursor":{"row":10,"col":0},"anchor":{"row":10,"col":0}}],"primary":0} +{"name":"golf-function-into-class-11","text":"@staticmethod\ndef get_area(length, width):\n return result\n\n@staticmethod\ndef get_perimiter(length, width):\n return result\n\n@staticmethod\ndef get_volume(length, width, height):\n return result\n","mode":"normal","cursor":{"row":2,"col":11},"anchor":{"row":2,"col":16},"sels":[{"cursor":{"row":2,"col":11},"anchor":{"row":2,"col":16}},{"cursor":{"row":6,"col":11},"anchor":{"row":6,"col":16}},{"cursor":{"row":10,"col":11},"anchor":{"row":10,"col":16}}],"primary":0} +{"name":"golf-function-into-class-12","text":"@staticmethod\ndef get_area(length, width):\n return length * width\n\n@staticmethod\ndef get_perimiter(length, width):\n return 2 * (length + width)\n\n@staticmethod\ndef get_volume(length, width, height):\n return length * width * height\n","mode":"normal","cursor":{"row":2,"col":11},"anchor":{"row":2,"col":24},"sels":[{"cursor":{"row":2,"col":11},"anchor":{"row":2,"col":24}},{"cursor":{"row":6,"col":11},"anchor":{"row":6,"col":30}},{"cursor":{"row":10,"col":11},"anchor":{"row":10,"col":33}}],"primary":0} +{"name":"golf-function-into-class-13","text":"@staticmethod\ndef get_area(length, width):\n return length * width\n\n@staticmethod\ndef get_perimiter(length, width):\n return 2 * (length + width)\n\n@staticmethod\ndef get_volume(length, width, height):\n return length * width * height\n","mode":"normal","cursor":{"row":2,"col":25},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":2,"col":25},"anchor":{"row":1,"col":0}},{"cursor":{"row":6,"col":31},"anchor":{"row":5,"col":0}},{"cursor":{"row":10,"col":34},"anchor":{"row":9,"col":0}}],"primary":0} +{"name":"golf-function-into-class-14","text":"@staticmethod\ndef get_area(length, width):\n return length * width\n\n@staticmethod\ndef get_perimiter(length, width):\n return 2 * (length + width)\n\n@staticmethod\ndef get_volume(length, width, height):\n return length * width * height\n","mode":"normal","cursor":{"row":1,"col":18},"anchor":{"row":1,"col":13},"sels":[{"cursor":{"row":1,"col":18},"anchor":{"row":1,"col":13}},{"cursor":{"row":1,"col":25},"anchor":{"row":1,"col":21}},{"cursor":{"row":2,"col":16},"anchor":{"row":2,"col":11}},{"cursor":{"row":2,"col":24},"anchor":{"row":2,"col":20}},{"cursor":{"row":5,"col":23},"anchor":{"row":5,"col":18}},{"cursor":{"row":5,"col":30},"anchor":{"row":5,"col":26}},{"cursor":{"row":6,"col":21},"anchor":{"row":6,"col":16}},{"cursor":{"row":6,"col":29},"anchor":{"row":6,"col":25}},{"cursor":{"row":9,"col":20},"anchor":{"row":9,"col":15}},{"cursor":{"row":9,"col":27},"anchor":{"row":9,"col":23}},{"cursor":{"row":9,"col":35},"anchor":{"row":9,"col":30}},{"cursor":{"row":10,"col":16},"anchor":{"row":10,"col":11}},{"cursor":{"row":10,"col":24},"anchor":{"row":10,"col":20}},{"cursor":{"row":10,"col":33},"anchor":{"row":10,"col":28}}],"primary":0} +{"name":"golf-function-into-class-15","text":"@staticmethod\ndef get_area(length, width):\n return length * width\n\n@staticmethod\ndef get_perimiter(length, width):\n return 2 * (length + width)\n\n@staticmethod\ndef get_volume(length, width, height):\n return length * width * height\n","mode":"normal","cursor":{"row":1,"col":13},"anchor":{"row":1,"col":18},"sels":[{"cursor":{"row":1,"col":13},"anchor":{"row":1,"col":18}},{"cursor":{"row":1,"col":21},"anchor":{"row":1,"col":25}},{"cursor":{"row":2,"col":11},"anchor":{"row":2,"col":16}},{"cursor":{"row":2,"col":20},"anchor":{"row":2,"col":24}},{"cursor":{"row":5,"col":18},"anchor":{"row":5,"col":23}},{"cursor":{"row":5,"col":26},"anchor":{"row":5,"col":30}},{"cursor":{"row":6,"col":16},"anchor":{"row":6,"col":21}},{"cursor":{"row":6,"col":25},"anchor":{"row":6,"col":29}},{"cursor":{"row":9,"col":15},"anchor":{"row":9,"col":20}},{"cursor":{"row":9,"col":23},"anchor":{"row":9,"col":27}},{"cursor":{"row":9,"col":30},"anchor":{"row":9,"col":35}},{"cursor":{"row":10,"col":11},"anchor":{"row":10,"col":16}},{"cursor":{"row":10,"col":20},"anchor":{"row":10,"col":24}},{"cursor":{"row":10,"col":28},"anchor":{"row":10,"col":33}}],"primary":0} +{"name":"golf-function-into-class-16","text":"@staticmethod\ndef get_area(length, width):\n return length * width\n\n@staticmethod\ndef get_perimiter(length, width):\n return 2 * (length + width)\n\n@staticmethod\ndef get_volume(length, width, height):\n return length * width * height\n","mode":"normal","cursor":{"row":1,"col":16},"anchor":{"row":1,"col":16},"sels":[{"cursor":{"row":1,"col":16},"anchor":{"row":1,"col":16}},{"cursor":{"row":1,"col":24},"anchor":{"row":1,"col":24}},{"cursor":{"row":2,"col":14},"anchor":{"row":2,"col":14}},{"cursor":{"row":2,"col":23},"anchor":{"row":2,"col":23}},{"cursor":{"row":5,"col":21},"anchor":{"row":5,"col":21}},{"cursor":{"row":5,"col":29},"anchor":{"row":5,"col":29}},{"cursor":{"row":6,"col":19},"anchor":{"row":6,"col":19}},{"cursor":{"row":6,"col":28},"anchor":{"row":6,"col":28}},{"cursor":{"row":9,"col":18},"anchor":{"row":9,"col":18}},{"cursor":{"row":9,"col":26},"anchor":{"row":9,"col":26}},{"cursor":{"row":9,"col":33},"anchor":{"row":9,"col":33}},{"cursor":{"row":10,"col":14},"anchor":{"row":10,"col":14}},{"cursor":{"row":10,"col":23},"anchor":{"row":10,"col":23}},{"cursor":{"row":10,"col":31},"anchor":{"row":10,"col":31}}],"primary":0} +{"name":"golf-function-into-class-17","text":"@staticmethod\ndef get_area(len, wid):\n return len * wid\n\n@staticmethod\ndef get_perimiter(len, wid):\n return 2 * (len + wid)\n\n@staticmethod\ndef get_volume(len, wid, hei):\n return len * wid * hei\n","mode":"normal","cursor":{"row":1,"col":16},"anchor":{"row":1,"col":16},"sels":[{"cursor":{"row":1,"col":16},"anchor":{"row":1,"col":16}},{"cursor":{"row":1,"col":21},"anchor":{"row":1,"col":21}},{"cursor":{"row":2,"col":14},"anchor":{"row":2,"col":14}},{"cursor":{"row":2,"col":20},"anchor":{"row":2,"col":20}},{"cursor":{"row":5,"col":21},"anchor":{"row":5,"col":21}},{"cursor":{"row":5,"col":26},"anchor":{"row":5,"col":26}},{"cursor":{"row":6,"col":19},"anchor":{"row":6,"col":19}},{"cursor":{"row":6,"col":25},"anchor":{"row":6,"col":25}},{"cursor":{"row":9,"col":18},"anchor":{"row":9,"col":18}},{"cursor":{"row":9,"col":23},"anchor":{"row":9,"col":23}},{"cursor":{"row":9,"col":28},"anchor":{"row":9,"col":28}},{"cursor":{"row":10,"col":14},"anchor":{"row":10,"col":14}},{"cursor":{"row":10,"col":20},"anchor":{"row":10,"col":20}},{"cursor":{"row":10,"col":26},"anchor":{"row":10,"col":26}}],"primary":0} +{"name":"golf-function-into-class-18","text":" @staticmethod\n def get_area(len, wid):\n return len * wid\n\n @staticmethod\n def get_perimiter(len, wid):\n return 2 * (len + wid)\n\n @staticmethod\n def get_volume(len, wid, hei):\n return len * wid * hei\n","mode":"normal","cursor":{"row":10,"col":30},"anchor":{"row":0,"col":4}} +{"name":"golf-function-into-class-19","text":"\n @staticmethod\n def get_area(len, wid):\n return len * wid\n\n @staticmethod\n def get_perimiter(len, wid):\n return 2 * (len + wid)\n\n @staticmethod\n def get_volume(len, wid, hei):\n return len * wid * hei\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"golf-function-into-class","text":"class Calculator:\n @staticmethod\n def get_area(len, wid):\n return len * wid\n\n @staticmethod\n def get_perimiter(len, wid):\n return 2 * (len + wid)\n\n @staticmethod\n def get_volume(len, wid, hei):\n return len * wid * hei\n","mode":"insert","cursor":{"row":0,"col":17},"anchor":{"row":0,"col":17}} +{"name":"golf-function-into-class-from18-18","text":" @staticmethod\n def get_area(len, wid):\n return len * wid\n\n @staticmethod\n def get_perimiter(len, wid):\n return 2 * (len + wid)\n\n @staticmethod\n def get_volume(len, wid, hei):\n return len * wid * hei\n","mode":"normal","cursor":{"row":10,"col":30},"anchor":{"row":0,"col":4}} +{"name":"golf-function-into-class-from18-19","text":"\n @staticmethod\n def get_area(len, wid):\n return len * wid\n\n @staticmethod\n def get_perimiter(len, wid):\n return 2 * (len + wid)\n\n @staticmethod\n def get_volume(len, wid, hei):\n return len * wid * hei\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"golf-function-into-class-from18","text":"class Calculator:\n @staticmethod\n def get_area(len, wid):\n return len * wid\n\n @staticmethod\n def get_perimiter(len, wid):\n return 2 * (len + wid)\n\n @staticmethod\n def get_volume(len, wid, hei):\n return len * wid * hei\n","mode":"insert","cursor":{"row":0,"col":17},"anchor":{"row":0,"col":17}} +{"name":"golf-invert-dictionary-01","text":"var color_to_points = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":18}} +{"name":"golf-invert-dictionary-02","text":"var color_to_points = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n","mode":"normal","cursor":{"row":0,"col":8},"anchor":{"row":0,"col":4},"sels":[{"cursor":{"row":0,"col":8},"anchor":{"row":0,"col":4}},{"cursor":{"row":0,"col":18},"anchor":{"row":0,"col":13}}],"primary":0} +{"name":"golf-invert-dictionary-03","text":"var points_to_color = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n","mode":"normal","cursor":{"row":0,"col":18},"anchor":{"row":0,"col":14},"sels":[{"cursor":{"row":0,"col":9},"anchor":{"row":0,"col":4}},{"cursor":{"row":0,"col":18},"anchor":{"row":0,"col":14}}],"primary":1} +{"name":"golf-invert-dictionary-04","text":"var points_to_color = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n","mode":"normal","cursor":{"row":0,"col":18},"anchor":{"row":0,"col":14},"sels":[{"cursor":{"row":0,"col":9},"anchor":{"row":0,"col":4}},{"cursor":{"row":0,"col":18},"anchor":{"row":0,"col":14}}],"primary":1} +{"name":"golf-invert-dictionary-05","text":"var points_to_color = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n","mode":"normal","cursor":{"row":0,"col":23},"anchor":{"row":0,"col":0}} +{"name":"golf-invert-dictionary-06","text":"var points_to_color = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n","mode":"normal","cursor":{"row":1,"col":12},"anchor":{"row":0,"col":23}} +{"name":"golf-invert-dictionary-07","text":"var points_to_color = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n","mode":"normal","cursor":{"row":1,"col":9},"anchor":{"row":0,"col":23},"sels":[{"cursor":{"row":1,"col":9},"anchor":{"row":0,"col":23}},{"cursor":{"row":1,"col":12},"anchor":{"row":1,"col":11}}],"primary":0} +{"name":"golf-invert-dictionary-08","text":"var points_to_color = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n","mode":"normal","cursor":{"row":1,"col":8},"anchor":{"row":1,"col":4},"sels":[{"cursor":{"row":1,"col":8},"anchor":{"row":1,"col":4}},{"cursor":{"row":1,"col":12},"anchor":{"row":1,"col":12}}],"primary":0} +{"name":"golf-invert-dictionary-09","text":"var points_to_color = {\n 0 = \"red\",\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n","mode":"normal","cursor":{"row":1,"col":12},"anchor":{"row":1,"col":8},"sels":[{"cursor":{"row":1,"col":4},"anchor":{"row":1,"col":4}},{"cursor":{"row":1,"col":12},"anchor":{"row":1,"col":8}}],"primary":1} +{"name":"golf-invert-dictionary-10","text":"var points_to_color = {\n 0 = \"red\",\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n","mode":"normal","cursor":{"row":1,"col":12},"anchor":{"row":1,"col":8},"sels":[{"cursor":{"row":1,"col":4},"anchor":{"row":1,"col":4}},{"cursor":{"row":1,"col":12},"anchor":{"row":1,"col":8}}],"primary":1} +{"name":"golf-invert-dictionary","text":"var points_to_color = {\n 0 = \"red\",\n 5 = \"orange\",\n 10 = \"yellow\",\n 15 = \"green\",\n 20 = \"blue\",\n 30 = \"purple\",\n 50 = \"black\",\n}\n","mode":"normal","cursor":{"row":7,"col":15},"anchor":{"row":7,"col":9},"sels":[{"cursor":{"row":7,"col":5},"anchor":{"row":7,"col":4}},{"cursor":{"row":7,"col":15},"anchor":{"row":7,"col":9}}],"primary":1} +{"name":"golf-invert-dictionary-2-01","text":"var color_to_points = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":18}} +{"name":"golf-invert-dictionary-2-02","text":"var color_to_points = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n","mode":"normal","cursor":{"row":0,"col":8},"anchor":{"row":0,"col":4},"sels":[{"cursor":{"row":0,"col":8},"anchor":{"row":0,"col":4}},{"cursor":{"row":0,"col":18},"anchor":{"row":0,"col":13}}],"primary":0} +{"name":"golf-invert-dictionary-2-03","text":"var points_to_color = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n","mode":"normal","cursor":{"row":0,"col":18},"anchor":{"row":0,"col":14},"sels":[{"cursor":{"row":0,"col":9},"anchor":{"row":0,"col":4}},{"cursor":{"row":0,"col":18},"anchor":{"row":0,"col":14}}],"primary":1} +{"name":"golf-invert-dictionary-2-04","text":"var points_to_color = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n","mode":"normal","cursor":{"row":0,"col":23},"anchor":{"row":0,"col":0}} +{"name":"golf-invert-dictionary-2-05","text":"var points_to_color = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n","mode":"normal","cursor":{"row":7,"col":17},"anchor":{"row":0,"col":23}} +{"name":"golf-invert-dictionary-2-06","text":"var points_to_color = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n","mode":"normal","cursor":{"row":1,"col":9},"anchor":{"row":0,"col":23},"sels":[{"cursor":{"row":1,"col":9},"anchor":{"row":0,"col":23}},{"cursor":{"row":1,"col":12},"anchor":{"row":1,"col":11}},{"cursor":{"row":2,"col":12},"anchor":{"row":1,"col":14}},{"cursor":{"row":2,"col":15},"anchor":{"row":2,"col":14}},{"cursor":{"row":3,"col":12},"anchor":{"row":2,"col":17}},{"cursor":{"row":3,"col":16},"anchor":{"row":3,"col":14}},{"cursor":{"row":4,"col":11},"anchor":{"row":3,"col":18}},{"cursor":{"row":4,"col":15},"anchor":{"row":4,"col":13}},{"cursor":{"row":5,"col":10},"anchor":{"row":4,"col":17}},{"cursor":{"row":5,"col":14},"anchor":{"row":5,"col":12}},{"cursor":{"row":6,"col":12},"anchor":{"row":5,"col":16}},{"cursor":{"row":6,"col":16},"anchor":{"row":6,"col":14}},{"cursor":{"row":7,"col":11},"anchor":{"row":6,"col":18}},{"cursor":{"row":7,"col":15},"anchor":{"row":7,"col":13}},{"cursor":{"row":7,"col":17},"anchor":{"row":7,"col":17}}],"primary":0} +{"name":"golf-invert-dictionary-2-07","text":"var points_to_color = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n","mode":"normal","cursor":{"row":1,"col":8},"anchor":{"row":1,"col":4},"sels":[{"cursor":{"row":1,"col":8},"anchor":{"row":1,"col":4}},{"cursor":{"row":1,"col":12},"anchor":{"row":1,"col":12}},{"cursor":{"row":2,"col":11},"anchor":{"row":2,"col":4}},{"cursor":{"row":2,"col":15},"anchor":{"row":2,"col":15}},{"cursor":{"row":3,"col":11},"anchor":{"row":3,"col":4}},{"cursor":{"row":3,"col":16},"anchor":{"row":3,"col":15}},{"cursor":{"row":4,"col":10},"anchor":{"row":4,"col":4}},{"cursor":{"row":4,"col":15},"anchor":{"row":4,"col":14}},{"cursor":{"row":5,"col":9},"anchor":{"row":5,"col":4}},{"cursor":{"row":5,"col":14},"anchor":{"row":5,"col":13}},{"cursor":{"row":6,"col":11},"anchor":{"row":6,"col":4}},{"cursor":{"row":6,"col":16},"anchor":{"row":6,"col":15}},{"cursor":{"row":7,"col":10},"anchor":{"row":7,"col":4}},{"cursor":{"row":7,"col":15},"anchor":{"row":7,"col":14}}],"primary":0} +{"name":"golf-invert-dictionary-2","text":"var points_to_color = {\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n \"red\" = 0,\n}\n","mode":"normal","cursor":{"row":7,"col":8},"anchor":{"row":7,"col":4},"sels":[{"cursor":{"row":1,"col":11},"anchor":{"row":1,"col":4}},{"cursor":{"row":1,"col":15},"anchor":{"row":1,"col":15}},{"cursor":{"row":2,"col":11},"anchor":{"row":2,"col":4}},{"cursor":{"row":2,"col":16},"anchor":{"row":2,"col":15}},{"cursor":{"row":3,"col":10},"anchor":{"row":3,"col":4}},{"cursor":{"row":3,"col":15},"anchor":{"row":3,"col":14}},{"cursor":{"row":4,"col":9},"anchor":{"row":4,"col":4}},{"cursor":{"row":4,"col":14},"anchor":{"row":4,"col":13}},{"cursor":{"row":5,"col":11},"anchor":{"row":5,"col":4}},{"cursor":{"row":5,"col":16},"anchor":{"row":5,"col":15}},{"cursor":{"row":6,"col":10},"anchor":{"row":6,"col":4}},{"cursor":{"row":6,"col":15},"anchor":{"row":6,"col":14}},{"cursor":{"row":7,"col":8},"anchor":{"row":7,"col":4}},{"cursor":{"row":7,"col":12},"anchor":{"row":7,"col":12}}],"primary":12} +{"name":"golf-object-into-array-01","text":"const palette = {\n apricot: \"#f47868\",\n lightning: \"#ffcd1c\",\n delta: \"6f44f0\",\n};\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}} +{"name":"golf-object-into-array-02","text":"const palette = [\n apricot: \"#f47868\",\n lightning: \"#ffcd1c\",\n delta: \"6f44f0\",\n];\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}} +{"name":"golf-object-into-array-03","text":"const palette = [\n apricot: \"#f47868\",\n lightning: \"#ffcd1c\",\n delta: \"6f44f0\",\n];\n","mode":"normal","cursor":{"row":3,"col":18},"anchor":{"row":0,"col":17}} +{"name":"golf-object-into-array-04","text":"const palette = [\n apricot: \"#f47868\",\n lightning: \"#ffcd1c\",\n delta: \"6f44f0\",\n];\n","mode":"normal","cursor":{"row":3,"col":18},"anchor":{"row":0,"col":17}} +{"name":"golf-object-into-array-05","text":"const palette = [\n apricot: \"#f47868\",\n lightning: \"#ffcd1c\",\n delta: \"6f44f0\",\n];\n","mode":"normal","cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9},"sels":[{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}},{"cursor":{"row":2,"col":11},"anchor":{"row":2,"col":11}},{"cursor":{"row":3,"col":7},"anchor":{"row":3,"col":7}}],"primary":0} +{"name":"golf-object-into-array-06","text":"const palette = [\n apricot, \"#f47868\",\n lightning, \"#ffcd1c\",\n delta, \"6f44f0\",\n];\n","mode":"normal","cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9},"sels":[{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}},{"cursor":{"row":2,"col":11},"anchor":{"row":2,"col":11}},{"cursor":{"row":3,"col":7},"anchor":{"row":3,"col":7}}],"primary":0} +{"name":"golf-object-into-array-07","text":"const palette = [\n apricot, \"#f47868\",\n lightning, \"#ffcd1c\",\n delta, \"6f44f0\",\n];\n","mode":"normal","cursor":{"row":1,"col":8},"anchor":{"row":1,"col":2},"sels":[{"cursor":{"row":1,"col":8},"anchor":{"row":1,"col":2}},{"cursor":{"row":2,"col":10},"anchor":{"row":2,"col":2}},{"cursor":{"row":3,"col":6},"anchor":{"row":3,"col":2}}],"primary":0} +{"name":"golf-object-into-array-08","text":"const palette = [\n \"apricot\", \"#f47868\",\n \"lightning\", \"#ffcd1c\",\n \"delta\", \"6f44f0\",\n];\n","mode":"normal","cursor":{"row":1,"col":10},"anchor":{"row":1,"col":2},"sels":[{"cursor":{"row":1,"col":10},"anchor":{"row":1,"col":2}},{"cursor":{"row":2,"col":12},"anchor":{"row":2,"col":2}},{"cursor":{"row":3,"col":8},"anchor":{"row":3,"col":2}}],"primary":0} +{"name":"golf-object-into-array-09","text":"const palette = [\n \"apricot\", \"#f47868\",\n \"lightning\", \"#ffcd1c\",\n \"delta\", \"6f44f0\",\n];\n","mode":"select","cursor":{"row":1,"col":21},"anchor":{"row":1,"col":2},"sels":[{"cursor":{"row":1,"col":21},"anchor":{"row":1,"col":2}},{"cursor":{"row":2,"col":23},"anchor":{"row":2,"col":2}},{"cursor":{"row":3,"col":18},"anchor":{"row":3,"col":2}}],"primary":0} +{"name":"golf-object-into-array","text":"const palette = [\n [\"apricot\", \"#f47868\"],\n [\"lightning\", \"#ffcd1c\"],\n [\"delta\", \"6f44f0\"],\n];\n","mode":"normal","cursor":{"row":1,"col":23},"anchor":{"row":1,"col":2},"sels":[{"cursor":{"row":1,"col":23},"anchor":{"row":1,"col":2}},{"cursor":{"row":2,"col":25},"anchor":{"row":2,"col":2}},{"cursor":{"row":3,"col":20},"anchor":{"row":3,"col":2}}],"primary":0} +{"name":"golf-reverse-golf-example-01","text":"# snake_case to camelCase\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":11}} +{"name":"golf-reverse-golf-example-02","text":"# snake_case to camelCase\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":11}} +{"name":"golf-reverse-golf-example-03","text":"# snake_case to camelCase\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"normal","cursor":{"row":0,"col":16},"anchor":{"row":0,"col":24}} +{"name":"golf-reverse-golf-example-04","text":"# snake_case to camelCase\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"normal","cursor":{"row":0,"col":16},"anchor":{"row":0,"col":24}} +{"name":"golf-reverse-golf-example-05","text":"# snake_case to snake_case\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"normal","cursor":{"row":0,"col":16},"anchor":{"row":0,"col":25}} +{"name":"golf-reverse-golf-example-06","text":"# snake_case to snake_case\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":0,"col":16},"anchor":{"row":0,"col":25}} +{"name":"golf-reverse-golf-example-07","text":"# snake_case to snake_case\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":11},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":11}},{"cursor":{"row":0,"col":16},"anchor":{"row":0,"col":25}}],"primary":0} +{"name":"golf-reverse-golf-example-08","text":"# snake_case to snake_case\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":2,"col":24},"anchor":{"row":2,"col":32},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":11}},{"cursor":{"row":0,"col":16},"anchor":{"row":0,"col":25}},{"cursor":{"row":2,"col":24},"anchor":{"row":2,"col":32}}],"primary":2} +{"name":"golf-reverse-golf-example-09","text":"# camelCase to snake_case\n\nRename all fields to be snake_case.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":10},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":10}},{"cursor":{"row":0,"col":15},"anchor":{"row":0,"col":24}},{"cursor":{"row":2,"col":24},"anchor":{"row":2,"col":33}}],"primary":0} +{"name":"golf-reverse-golf-example-10","text":"# camelCase to snake_case\n\nRename all fields to be snake_case.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":14,"col":3},"anchor":{"row":0,"col":0}} +{"name":"golf-reverse-golf-example-11","text":"# camelCase to snake_case\n\nRename all fields to be snake_case.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":6,"col":3},"anchor":{"row":6,"col":0},"sels":[{"cursor":{"row":6,"col":3},"anchor":{"row":6,"col":0}},{"cursor":{"row":12,"col":3},"anchor":{"row":12,"col":0}}],"primary":0} +{"name":"golf-reverse-golf-example-12","text":"# camelCase to snake_case\n\nRename all fields to be snake_case.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":7,"col":41},"anchor":{"row":6,"col":0},"sels":[{"cursor":{"row":7,"col":41},"anchor":{"row":6,"col":0}},{"cursor":{"row":13,"col":39},"anchor":{"row":12,"col":0}}],"primary":0} +{"name":"golf-reverse-golf-example","text":"# camelCase to snake_case\n\nRename all fields to be snake_case.\n\n## Before\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n\n## After\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n","mode":"select","cursor":{"row":13,"col":41},"anchor":{"row":12,"col":0},"sels":[{"cursor":{"row":7,"col":39},"anchor":{"row":6,"col":0}},{"cursor":{"row":13,"col":41},"anchor":{"row":12,"col":0}}],"primary":1} +{"name":"golf-snake-case-to-camel-case-01","text":"const user_profile = {\n first_name: \"John\",\n last_name: \"Doe\",\n birth_date: \"1990-05-15\",\n email_address: \"john_doe@example.com\",\n phone_number: \"555-123-4567\",\n mailing_address: {\n street_name: \"Main Street\",\n house_number: 123,\n apartment_unit: \"4B\",\n zip_code: \"10001\",\n city_name: \"New York\",\n },\n};\n","mode":"normal","cursor":{"row":13,"col":2},"anchor":{"row":0,"col":0}} +{"name":"golf-snake-case-to-camel-case-02","text":"const user_profile = {\n first_name: \"John\",\n last_name: \"Doe\",\n birth_date: \"1990-05-15\",\n email_address: \"john_doe@example.com\",\n phone_number: \"555-123-4567\",\n mailing_address: {\n street_name: \"Main Street\",\n house_number: 123,\n apartment_unit: \"4B\",\n zip_code: \"10001\",\n city_name: \"New York\",\n },\n};\n","mode":"normal","cursor":{"row":0,"col":10},"anchor":{"row":0,"col":10},"sels":[{"cursor":{"row":0,"col":10},"anchor":{"row":0,"col":10}},{"cursor":{"row":1,"col":7},"anchor":{"row":1,"col":7}},{"cursor":{"row":2,"col":6},"anchor":{"row":2,"col":6}},{"cursor":{"row":3,"col":7},"anchor":{"row":3,"col":7}},{"cursor":{"row":4,"col":7},"anchor":{"row":4,"col":7}},{"cursor":{"row":4,"col":22},"anchor":{"row":4,"col":22}},{"cursor":{"row":5,"col":7},"anchor":{"row":5,"col":7}},{"cursor":{"row":6,"col":9},"anchor":{"row":6,"col":9}},{"cursor":{"row":7,"col":10},"anchor":{"row":7,"col":10}},{"cursor":{"row":8,"col":9},"anchor":{"row":8,"col":9}},{"cursor":{"row":9,"col":13},"anchor":{"row":9,"col":13}},{"cursor":{"row":10,"col":7},"anchor":{"row":10,"col":7}},{"cursor":{"row":11,"col":8},"anchor":{"row":11,"col":8}}],"primary":0} +{"name":"golf-snake-case-to-camel-case-03","text":"const user_profile = {\n first_name: \"John\",\n last_name: \"Doe\",\n birth_date: \"1990-05-15\",\n email_address: \"john_doe@example.com\",\n phone_number: \"555-123-4567\",\n mailing_address: {\n street_name: \"Main Street\",\n house_number: 123,\n apartment_unit: \"4B\",\n zip_code: \"10001\",\n city_name: \"New York\",\n },\n};\n","mode":"normal","cursor":{"row":4,"col":22},"anchor":{"row":4,"col":22},"sels":[{"cursor":{"row":0,"col":10},"anchor":{"row":0,"col":10}},{"cursor":{"row":1,"col":7},"anchor":{"row":1,"col":7}},{"cursor":{"row":2,"col":6},"anchor":{"row":2,"col":6}},{"cursor":{"row":3,"col":7},"anchor":{"row":3,"col":7}},{"cursor":{"row":4,"col":7},"anchor":{"row":4,"col":7}},{"cursor":{"row":4,"col":22},"anchor":{"row":4,"col":22}},{"cursor":{"row":5,"col":7},"anchor":{"row":5,"col":7}},{"cursor":{"row":6,"col":9},"anchor":{"row":6,"col":9}},{"cursor":{"row":7,"col":10},"anchor":{"row":7,"col":10}},{"cursor":{"row":8,"col":9},"anchor":{"row":8,"col":9}},{"cursor":{"row":9,"col":13},"anchor":{"row":9,"col":13}},{"cursor":{"row":10,"col":7},"anchor":{"row":10,"col":7}},{"cursor":{"row":11,"col":8},"anchor":{"row":11,"col":8}}],"primary":5} +{"name":"golf-snake-case-to-camel-case-04","text":"const user_profile = {\n first_name: \"John\",\n last_name: \"Doe\",\n birth_date: \"1990-05-15\",\n email_address: \"john_doe@example.com\",\n phone_number: \"555-123-4567\",\n mailing_address: {\n street_name: \"Main Street\",\n house_number: 123,\n apartment_unit: \"4B\",\n zip_code: \"10001\",\n city_name: \"New York\",\n },\n};\n","mode":"normal","cursor":{"row":5,"col":7},"anchor":{"row":5,"col":7},"sels":[{"cursor":{"row":0,"col":10},"anchor":{"row":0,"col":10}},{"cursor":{"row":1,"col":7},"anchor":{"row":1,"col":7}},{"cursor":{"row":2,"col":6},"anchor":{"row":2,"col":6}},{"cursor":{"row":3,"col":7},"anchor":{"row":3,"col":7}},{"cursor":{"row":4,"col":7},"anchor":{"row":4,"col":7}},{"cursor":{"row":5,"col":7},"anchor":{"row":5,"col":7}},{"cursor":{"row":6,"col":9},"anchor":{"row":6,"col":9}},{"cursor":{"row":7,"col":10},"anchor":{"row":7,"col":10}},{"cursor":{"row":8,"col":9},"anchor":{"row":8,"col":9}},{"cursor":{"row":9,"col":13},"anchor":{"row":9,"col":13}},{"cursor":{"row":10,"col":7},"anchor":{"row":10,"col":7}},{"cursor":{"row":11,"col":8},"anchor":{"row":11,"col":8}}],"primary":5} +{"name":"golf-snake-case-to-camel-case-05","text":"const userprofile = {\n firstname: \"John\",\n lastname: \"Doe\",\n birthdate: \"1990-05-15\",\n emailaddress: \"john_doe@example.com\",\n phonenumber: \"555-123-4567\",\n mailingaddress: {\n streetname: \"Main Street\",\n housenumber: 123,\n apartmentunit: \"4B\",\n zipcode: \"10001\",\n cityname: \"New York\",\n },\n};\n","mode":"normal","cursor":{"row":5,"col":7},"anchor":{"row":5,"col":7},"sels":[{"cursor":{"row":0,"col":10},"anchor":{"row":0,"col":10}},{"cursor":{"row":1,"col":7},"anchor":{"row":1,"col":7}},{"cursor":{"row":2,"col":6},"anchor":{"row":2,"col":6}},{"cursor":{"row":3,"col":7},"anchor":{"row":3,"col":7}},{"cursor":{"row":4,"col":7},"anchor":{"row":4,"col":7}},{"cursor":{"row":5,"col":7},"anchor":{"row":5,"col":7}},{"cursor":{"row":6,"col":9},"anchor":{"row":6,"col":9}},{"cursor":{"row":7,"col":10},"anchor":{"row":7,"col":10}},{"cursor":{"row":8,"col":9},"anchor":{"row":8,"col":9}},{"cursor":{"row":9,"col":13},"anchor":{"row":9,"col":13}},{"cursor":{"row":10,"col":7},"anchor":{"row":10,"col":7}},{"cursor":{"row":11,"col":8},"anchor":{"row":11,"col":8}}],"primary":5} +{"name":"golf-snake-case-to-camel-case","text":"const userProfile = {\n firstName: \"John\",\n lastName: \"Doe\",\n birthDate: \"1990-05-15\",\n emailAddress: \"john_doe@example.com\",\n phoneNumber: \"555-123-4567\",\n mailingAddress: {\n streetName: \"Main Street\",\n houseNumber: 123,\n apartmentUnit: \"4B\",\n zipCode: \"10001\",\n cityName: \"New York\",\n },\n};\n","mode":"normal","cursor":{"row":5,"col":7},"anchor":{"row":5,"col":7},"sels":[{"cursor":{"row":0,"col":10},"anchor":{"row":0,"col":10}},{"cursor":{"row":1,"col":7},"anchor":{"row":1,"col":7}},{"cursor":{"row":2,"col":6},"anchor":{"row":2,"col":6}},{"cursor":{"row":3,"col":7},"anchor":{"row":3,"col":7}},{"cursor":{"row":4,"col":7},"anchor":{"row":4,"col":7}},{"cursor":{"row":5,"col":7},"anchor":{"row":5,"col":7}},{"cursor":{"row":6,"col":9},"anchor":{"row":6,"col":9}},{"cursor":{"row":7,"col":10},"anchor":{"row":7,"col":10}},{"cursor":{"row":8,"col":9},"anchor":{"row":8,"col":9}},{"cursor":{"row":9,"col":13},"anchor":{"row":9,"col":13}},{"cursor":{"row":10,"col":7},"anchor":{"row":10,"col":7}},{"cursor":{"row":11,"col":8},"anchor":{"row":11,"col":8}}],"primary":5} +{"name":"golf-text-into-array-01","text":"Hello\nThis\nIs\nHelix\n","mode":"normal","cursor":{"row":3,"col":5},"anchor":{"row":0,"col":0}} +{"name":"golf-text-into-array-02","text":"Hello\nThis\nIs\nHelix\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":0}},{"cursor":{"row":2,"col":1},"anchor":{"row":2,"col":0}},{"cursor":{"row":3,"col":4},"anchor":{"row":3,"col":0}}],"primary":0} +{"name":"golf-text-into-array-03","text":"\"Hello\"\n\"This\"\n\"Is\"\n\"Helix\"\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":0}},{"cursor":{"row":2,"col":3},"anchor":{"row":2,"col":0}},{"cursor":{"row":3,"col":6},"anchor":{"row":3,"col":0}}],"primary":0} +{"name":"golf-text-into-array-04","text":"\"Hello\", \"This\", \"Is\", \"Helix\"","mode":"insert","cursor":{"row":0,"col":8},"anchor":{"row":0,"col":8},"sels":[{"cursor":{"row":0,"col":8},"anchor":{"row":0,"col":8}},{"cursor":{"row":0,"col":16},"anchor":{"row":0,"col":16}},{"cursor":{"row":0,"col":22},"anchor":{"row":0,"col":22}}],"primary":0} +{"name":"golf-text-into-array","text":"[\"Hello\", \"This\", \"Is\", \"Helix\"]","mode":"normal","cursor":{"row":0,"col":31},"anchor":{"row":0,"col":0}} diff --git a/test/hxcases/golf.jsonl b/test/hxcases/golf.jsonl new file mode 100644 index 00000000..f327eaf1 --- /dev/null +++ b/test/hxcases/golf.jsonl @@ -0,0 +1,124 @@ +{"name": "golf-csv-to-sql-01", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%"} +{"name": "golf-csv-to-sql-02", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%"} +{"name": "golf-csv-to-sql-03", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yy"} +{"name": "golf-csv-to-sql-04", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yys\\d"} +{"name": "golf-csv-to-sql-05", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yys\\dd"} +{"name": "golf-csv-to-sql-06", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yys\\ddhh"} +{"name": "golf-csv-to-sql-07", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yys\\ddhhb"} +{"name": "golf-csv-to-sql-08", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yys\\ddhhbms`"} +{"name": "golf-csv-to-sql-09", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yys\\ddhhbms``"} +{"name": "golf-csv-to-sql-10", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yys\\ddhhbms``x_"} +{"name": "golf-csv-to-sql-11", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yys\\ddhhbms``x_ms("} +{"name": "golf-csv-to-sql-12", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yys\\ddhhbms``x_ms(I"} +{"name": "golf-csv-to-sql-13", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yys\\ddhhbms``x_ms(IINSERT INTO `database"} +{"name": "golf-csv-to-sql-14", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yys\\ddhhbms``x_ms(IINSERT INTO `database"} +{"name": "golf-csv-to-sql-15", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yys\\ddhhbms``x_ms(IINSERT INTO `databasea.`table"} +{"name": "golf-csv-to-sql-16", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yys\\ddhhbms``x_ms(IINSERT INTO `databasea.`tablela"} +{"name": "golf-csv-to-sql-17", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yys\\ddhhbms``x_ms(IINSERT INTO `databasea.`tablela "} +{"name": "golf-csv-to-sql-18", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yys\\ddhhbms``x_ms(IINSERT INTO `databasea.`tablela AVALUES ("} +{"name": "golf-csv-to-sql-19", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yys\\ddhhbms``x_ms(IINSERT INTO `databasea.`tablela AVALUES ("} +{"name": "golf-csv-to-sql-20", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yys\\ddhhbms``x_ms(IINSERT INTO `databasea.`tablela AVALUES (\"yP"} +{"name": "golf-csv-to-sql-21", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yys\\ddhhbms``x_ms(IINSERT INTO `databasea.`tablela AVALUES (\"yPS,"} +{"name": "golf-csv-to-sql-22", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yys\\ddhhbms``x_ms(IINSERT INTO `databasea.`tablela AVALUES (\"yPS,ms'"} +{"name": "golf-csv-to-sql-23", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yys\\ddhhbms``x_ms(IINSERT INTO `databasea.`tablela AVALUES (\"yPS,ms'A;"} +{"name": "golf-csv-to-sql-24", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yys\\ddhhbms``x_ms(IINSERT INTO `databasea.`tablela AVALUES (\"yPS,ms'A;Fl"} +{"name": "golf-csv-to-sql-25", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yys\\ddhhbms``x_ms(IINSERT INTO `databasea.`tablela AVALUES (\"yPS,ms'A;Fl;"} +{"name": "golf-csv-to-sql", "pane": "file", "text": "id 1,Item 1,cost 1,location 1\nid 2,Item 2,cost 2,location 2\nid 10,Item 10,cost 10,location 10\n", "keys": "%\"yys\\ddhhbms``x_ms(IINSERT INTO `databasea.`tablela AVALUES (\"yPS,ms'A;Fl;~"} +{"name": "golf-enumerate-and-align-01", "pane": "file", "text": "[\n { word: \"a\", count: 2565 },\n { word: \"and\", count: 1777 },\n { word: \"of\", count: 1331 },\n { word: \"that\", count: 1263 },\n { word: \"to\", count: 1030 },\n { word: \"in\", count: 1027 },\n { word: \"it\", count: 754 },\n { word: \"as\", count: 730 },\n { word: \"was\", count: 687 },\n { word: \"you\", count: 652 },\n { word: \"for\", count: 630 },\n];\n", "keys": "%"} +{"name": "golf-enumerate-and-align-02", "pane": "file", "text": "[\n { word: \"a\", count: 2565 },\n { word: \"and\", count: 1777 },\n { word: \"of\", count: 1331 },\n { word: \"that\", count: 1263 },\n { word: \"to\", count: 1030 },\n { word: \"in\", count: 1027 },\n { word: \"it\", count: 754 },\n { word: \"as\", count: 730 },\n { word: \"was\", count: 687 },\n { word: \"you\", count: 652 },\n { word: \"for\", count: 630 },\n];\n", "keys": "%s"} +{"name": "golf-enumerate-and-align-03", "pane": "file", "text": "[\n { word: \"a\", count: 2565 },\n { word: \"and\", count: 1777 },\n { word: \"of\", count: 1331 },\n { word: \"that\", count: 1263 },\n { word: \"to\", count: 1030 },\n { word: \"in\", count: 1027 },\n { word: \"it\", count: 754 },\n { word: \"as\", count: 730 },\n { word: \"was\", count: 687 },\n { word: \"you\", count: 652 },\n { word: \"for\", count: 630 },\n];\n", "keys": "%s\\{"} +{"name": "golf-enumerate-and-align-04", "pane": "file", "text": "[\n { word: \"a\", count: 2565 },\n { word: \"and\", count: 1777 },\n { word: \"of\", count: 1331 },\n { word: \"that\", count: 1263 },\n { word: \"to\", count: 1030 },\n { word: \"in\", count: 1027 },\n { word: \"it\", count: 754 },\n { word: \"as\", count: 730 },\n { word: \"was\", count: 687 },\n { word: \"you\", count: 652 },\n { word: \"for\", count: 630 },\n];\n", "keys": "%s\\{a "} +{"name": "golf-enumerate-and-align-05", "pane": "file", "text": "[\n { word: \"a\", count: 2565 },\n { word: \"and\", count: 1777 },\n { word: \"of\", count: 1331 },\n { word: \"that\", count: 1263 },\n { word: \"to\", count: 1030 },\n { word: \"in\", count: 1027 },\n { word: \"it\", count: 754 },\n { word: \"as\", count: 730 },\n { word: \"was\", count: 687 },\n { word: \"you\", count: 652 },\n { word: \"for\", count: 630 },\n];\n", "keys": "%s\\{a rank: "} +{"name": "golf-enumerate-and-align-06", "pane": "file", "text": "[\n { word: \"a\", count: 2565 },\n { word: \"and\", count: 1777 },\n { word: \"of\", count: 1331 },\n { word: \"that\", count: 1263 },\n { word: \"to\", count: 1030 },\n { word: \"in\", count: 1027 },\n { word: \"it\", count: 754 },\n { word: \"as\", count: 730 },\n { word: \"was\", count: 687 },\n { word: \"you\", count: 652 },\n { word: \"for\", count: 630 },\n];\n", "keys": "%s\\{a rank: #"} +{"name": "golf-enumerate-and-align-07", "pane": "file", "text": "[\n { word: \"a\", count: 2565 },\n { word: \"and\", count: 1777 },\n { word: \"of\", count: 1331 },\n { word: \"that\", count: 1263 },\n { word: \"to\", count: 1030 },\n { word: \"in\", count: 1027 },\n { word: \"it\", count: 754 },\n { word: \"as\", count: 730 },\n { word: \"was\", count: 687 },\n { word: \"you\", count: 652 },\n { word: \"for\", count: 630 },\n];\n", "keys": "%s\\{a rank: #,"} +{"name": "golf-enumerate-and-align-08", "pane": "file", "text": "[\n { word: \"a\", count: 2565 },\n { word: \"and\", count: 1777 },\n { word: \"of\", count: 1331 },\n { word: \"that\", count: 1263 },\n { word: \"to\", count: 1030 },\n { word: \"in\", count: 1027 },\n { word: \"it\", count: 754 },\n { word: \"as\", count: 730 },\n { word: \"was\", count: 687 },\n { word: \"you\", count: 652 },\n { word: \"for\", count: 630 },\n];\n", "keys": "%s\\{a rank: #,"} +{"name": "golf-enumerate-and-align-09", "pane": "file", "text": "[\n { word: \"a\", count: 2565 },\n { word: \"and\", count: 1777 },\n { word: \"of\", count: 1331 },\n { word: \"that\", count: 1263 },\n { word: \"to\", count: 1030 },\n { word: \"in\", count: 1027 },\n { word: \"it\", count: 754 },\n { word: \"as\", count: 730 },\n { word: \"was\", count: 687 },\n { word: \"you\", count: 652 },\n { word: \"for\", count: 630 },\n];\n", "keys": "%s\\{a rank: #,%s"} +{"name": "golf-enumerate-and-align-10", "pane": "file", "text": "[\n { word: \"a\", count: 2565 },\n { word: \"and\", count: 1777 },\n { word: \"of\", count: 1331 },\n { word: \"that\", count: 1263 },\n { word: \"to\", count: 1030 },\n { word: \"in\", count: 1027 },\n { word: \"it\", count: 754 },\n { word: \"as\", count: 730 },\n { word: \"was\", count: 687 },\n { word: \"you\", count: 652 },\n { word: \"for\", count: 630 },\n];\n", "keys": "%s\\{a rank: #,%s |\\d+"} +{"name": "golf-enumerate-and-align", "pane": "file", "text": "[\n { word: \"a\", count: 2565 },\n { word: \"and\", count: 1777 },\n { word: \"of\", count: 1331 },\n { word: \"that\", count: 1263 },\n { word: \"to\", count: 1030 },\n { word: \"in\", count: 1027 },\n { word: \"it\", count: 754 },\n { word: \"as\", count: 730 },\n { word: \"was\", count: 687 },\n { word: \"you\", count: 652 },\n { word: \"for\", count: 630 },\n];\n", "keys": "%s\\{a rank: #,%s |\\d+&"} +{"name": "golf-enumerate-and-align-from09-09", "pane": "file", "text": "[\n { rank: 1, word: \"a\", count: 2565 },\n { rank: 2, word: \"and\", count: 1777 },\n { rank: 3, word: \"of\", count: 1331 },\n { rank: 4, word: \"that\", count: 1263 },\n { rank: 5, word: \"to\", count: 1030 },\n { rank: 6, word: \"in\", count: 1027 },\n { rank: 7, word: \"it\", count: 754 },\n { rank: 8, word: \"as\", count: 730 },\n { rank: 9, word: \"was\", count: 687 },\n { rank: 10, word: \"you\", count: 652 },\n { rank: 11, word: \"for\", count: 630 },\n];\n", "keys": "%s"} +{"name": "golf-enumerate-and-align-from09-10", "pane": "file", "text": "[\n { rank: 1, word: \"a\", count: 2565 },\n { rank: 2, word: \"and\", count: 1777 },\n { rank: 3, word: \"of\", count: 1331 },\n { rank: 4, word: \"that\", count: 1263 },\n { rank: 5, word: \"to\", count: 1030 },\n { rank: 6, word: \"in\", count: 1027 },\n { rank: 7, word: \"it\", count: 754 },\n { rank: 8, word: \"as\", count: 730 },\n { rank: 9, word: \"was\", count: 687 },\n { rank: 10, word: \"you\", count: 652 },\n { rank: 11, word: \"for\", count: 630 },\n];\n", "keys": "%s |\\d+"} +{"name": "golf-enumerate-and-align-from09", "pane": "file", "text": "[\n { rank: 1, word: \"a\", count: 2565 },\n { rank: 2, word: \"and\", count: 1777 },\n { rank: 3, word: \"of\", count: 1331 },\n { rank: 4, word: \"that\", count: 1263 },\n { rank: 5, word: \"to\", count: 1030 },\n { rank: 6, word: \"in\", count: 1027 },\n { rank: 7, word: \"it\", count: 754 },\n { rank: 8, word: \"as\", count: 730 },\n { rank: 9, word: \"was\", count: 687 },\n { rank: 10, word: \"you\", count: 652 },\n { rank: 11, word: \"for\", count: 630 },\n];\n", "keys": "%s |\\d+&"} +{"name": "golf-export-from-mod-01", "pane": "file", "text": "mod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\n", "keys": "%"} +{"name": "golf-export-from-mod-02", "pane": "file", "text": "mod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\n", "keys": "%yp"} +{"name": "golf-export-from-mod-03", "pane": "file", "text": "mod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\n", "keys": "%yp["} +{"name": "golf-export-from-mod-04", "pane": "file", "text": "mod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\n", "keys": "%yp[gse"} +{"name": "golf-export-from-mod-05", "pane": "file", "text": "mod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\n", "keys": "%yp[gsecpub use"} +{"name": "golf-export-from-mod-06", "pane": "file", "text": "mod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\n", "keys": "%yp[gsecpub useley"} +{"name": "golf-export-from-mod-07", "pane": "file", "text": "mod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\n", "keys": "%yp[gsecpub useleyp"} +{"name": "golf-export-from-mod", "pane": "file", "text": "mod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\n", "keys": "%yp[gsecpub useleypi::"} +{"name": "golf-function-into-class-01", "pane": "file", "text": "def calculate_area(length, width):\n result = length * width\n return result\n\ndef calculate_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef calculate_volume(length, width, height):\n result = length * width * height\n return result\n", "keys": "%"} +{"name": "golf-function-into-class-02", "pane": "file", "text": "def calculate_area(length, width):\n result = length * width\n return result\n\ndef calculate_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef calculate_volume(length, width, height):\n result = length * width * height\n return result\n", "keys": "%scalculate"} +{"name": "golf-function-into-class-03", "pane": "file", "text": "def calculate_area(length, width):\n result = length * width\n return result\n\ndef calculate_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef calculate_volume(length, width, height):\n result = length * width * height\n return result\n", "keys": "%scalculatecget"} +{"name": "golf-function-into-class-04", "pane": "file", "text": "def calculate_area(length, width):\n result = length * width\n return result\n\ndef calculate_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef calculate_volume(length, width, height):\n result = length * width * height\n return result\n", "keys": "%scalculatecget"} +{"name": "golf-function-into-class-05", "pane": "file", "text": "def calculate_area(length, width):\n result = length * width\n return result\n\ndef calculate_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef calculate_volume(length, width, height):\n result = length * width * height\n return result\n", "keys": "%scalculatecgetO@staticmethod"} +{"name": "golf-function-into-class-06", "pane": "file", "text": "def calculate_area(length, width):\n result = length * width\n return result\n\ndef calculate_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef calculate_volume(length, width, height):\n result = length * width * height\n return result\n", "keys": "%scalculatecgetO@staticmethod"} +{"name": "golf-function-into-class-07", "pane": "file", "text": "def calculate_area(length, width):\n result = length * width\n return result\n\ndef calculate_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef calculate_volume(length, width, height):\n result = length * width * height\n return result\n", "keys": "%scalculatecgetO@staticmethodjj"} +{"name": "golf-function-into-class-08", "pane": "file", "text": "def calculate_area(length, width):\n result = length * width\n return result\n\ndef calculate_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef calculate_volume(length, width, height):\n result = length * width * height\n return result\n", "keys": "%scalculatecgetO@staticmethodjjvgl"} +{"name": "golf-function-into-class-09", "pane": "file", "text": "def calculate_area(length, width):\n result = length * width\n return result\n\ndef calculate_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef calculate_volume(length, width, height):\n result = length * width * height\n return result\n", "keys": "%scalculatecgetO@staticmethodjjvgly"} +{"name": "golf-function-into-class-10", "pane": "file", "text": "def calculate_area(length, width):\n result = length * width\n return result\n\ndef calculate_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef calculate_volume(length, width, height):\n result = length * width * height\n return result\n", "keys": "%scalculatecgetO@staticmethodjjvglyx"} +{"name": "golf-function-into-class-11", "pane": "file", "text": "def calculate_area(length, width):\n result = length * width\n return result\n\ndef calculate_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef calculate_volume(length, width, height):\n result = length * width * height\n return result\n", "keys": "%scalculatecgetO@staticmethodjjvglyxxb"} +{"name": "golf-function-into-class-12", "pane": "file", "text": "def calculate_area(length, width):\n result = length * width\n return result\n\ndef calculate_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef calculate_volume(length, width, height):\n result = length * width * height\n return result\n", "keys": "%scalculatecgetO@staticmethodjjvglyxxbR"} +{"name": "golf-function-into-class-13", "pane": "file", "text": "def calculate_area(length, width):\n result = length * width\n return result\n\ndef calculate_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef calculate_volume(length, width, height):\n result = length * width * height\n return result\n", "keys": "%scalculatecgetO@staticmethodjjvglyxxbRkxx"} +{"name": "golf-function-into-class-14", "pane": "file", "text": "def calculate_area(length, width):\n result = length * width\n return result\n\ndef calculate_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef calculate_volume(length, width, height):\n result = length * width * height\n return result\n", "keys": "%scalculatecgetO@staticmethodjjvglyxxbRkxxslength|width|height"} +{"name": "golf-function-into-class-15", "pane": "file", "text": "def calculate_area(length, width):\n result = length * width\n return result\n\ndef calculate_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef calculate_volume(length, width, height):\n result = length * width * height\n return result\n", "keys": "%scalculatecgetO@staticmethodjjvglyxxbRkxxslength|width|heightb"} +{"name": "golf-function-into-class-16", "pane": "file", "text": "def calculate_area(length, width):\n result = length * width\n return result\n\ndef calculate_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef calculate_volume(length, width, height):\n result = length * width * height\n return result\n", "keys": "%scalculatecgetO@staticmethodjjvglyxxbRkxxslength|width|heightblll"} +{"name": "golf-function-into-class-17", "pane": "file", "text": "def calculate_area(length, width):\n result = length * width\n return result\n\ndef calculate_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef calculate_volume(length, width, height):\n result = length * width * height\n return result\n", "keys": "%scalculatecgetO@staticmethodjjvglyxxbRkxxslength|width|heightbllled"} +{"name": "golf-function-into-class-18", "pane": "file", "text": "def calculate_area(length, width):\n result = length * width\n return result\n\ndef calculate_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef calculate_volume(length, width, height):\n result = length * width * height\n return result\n", "keys": "%scalculatecgetO@staticmethodjjvglyxxbRkxxslength|width|heightbllled%"} +{"name": "golf-function-into-class-19", "pane": "file", "text": "def calculate_area(length, width):\n result = length * width\n return result\n\ndef calculate_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef calculate_volume(length, width, height):\n result = length * width * height\n return result\n", "keys": "%scalculatecgetO@staticmethodjjvglyxxbRkxxslength|width|heightbllled%O"} +{"name": "golf-function-into-class", "pane": "file", "text": "def calculate_area(length, width):\n result = length * width\n return result\n\ndef calculate_perimiter(length, width):\n result = 2 * (length + width)\n return result\n\ndef calculate_volume(length, width, height):\n result = length * width * height\n return result\n", "keys": "%scalculatecgetO@staticmethodjjvglyxxbRkxxslength|width|heightbllled%Oclass Calculator:"} +{"name": "golf-function-into-class-from18-18", "pane": "file", "text": "@staticmethod\ndef get_area(len, wid):\n return len * wid\n\n@staticmethod\ndef get_perimiter(len, wid):\n return 2 * (len + wid)\n\n@staticmethod\ndef get_volume(len, wid, hei):\n return len * wid * hei\n", "keys": "%"} +{"name": "golf-function-into-class-from18-19", "pane": "file", "text": "@staticmethod\ndef get_area(len, wid):\n return len * wid\n\n@staticmethod\ndef get_perimiter(len, wid):\n return 2 * (len + wid)\n\n@staticmethod\ndef get_volume(len, wid, hei):\n return len * wid * hei\n", "keys": "%O"} +{"name": "golf-function-into-class-from18", "pane": "file", "text": "@staticmethod\ndef get_area(len, wid):\n return len * wid\n\n@staticmethod\ndef get_perimiter(len, wid):\n return 2 * (len + wid)\n\n@staticmethod\ndef get_volume(len, wid, hei):\n return len * wid * hei\n", "keys": "%Oclass Calculator:"} +{"name": "golf-invert-dictionary-01", "pane": "file", "text": "var color_to_points = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n", "keys": "web"} +{"name": "golf-invert-dictionary-02", "pane": "file", "text": "var color_to_points = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n", "keys": "webS_to_"} +{"name": "golf-invert-dictionary-03", "pane": "file", "text": "var color_to_points = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n", "keys": "webS_to_"} +{"name": "golf-invert-dictionary-04", "pane": "file", "text": "var color_to_points = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n", "keys": "webS_to_Q"} +{"name": "golf-invert-dictionary-05", "pane": "file", "text": "var color_to_points = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n", "keys": "webS_to_Qx"} +{"name": "golf-invert-dictionary-06", "pane": "file", "text": "var color_to_points = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n", "keys": "webS_to_Qxt,"} +{"name": "golf-invert-dictionary-07", "pane": "file", "text": "var color_to_points = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n", "keys": "webS_to_Qxt,S="} +{"name": "golf-invert-dictionary-08", "pane": "file", "text": "var color_to_points = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n", "keys": "webS_to_Qxt,S=_"} +{"name": "golf-invert-dictionary-09", "pane": "file", "text": "var color_to_points = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n", "keys": "webS_to_Qxt,S=_"} +{"name": "golf-invert-dictionary-10", "pane": "file", "text": "var color_to_points = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n", "keys": "webS_to_Qxt,S=_Q"} +{"name": "golf-invert-dictionary", "pane": "file", "text": "var color_to_points = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n", "keys": "webS_to_Qxt,S=_Q6q"} +{"name": "golf-invert-dictionary-2-01", "pane": "file", "text": "var color_to_points = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n", "keys": "web"} +{"name": "golf-invert-dictionary-2-02", "pane": "file", "text": "var color_to_points = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n", "keys": "webS_to_"} +{"name": "golf-invert-dictionary-2-03", "pane": "file", "text": "var color_to_points = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n", "keys": "webS_to_"} +{"name": "golf-invert-dictionary-2-04", "pane": "file", "text": "var color_to_points = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n", "keys": "webS_to_x"} +{"name": "golf-invert-dictionary-2-05", "pane": "file", "text": "var color_to_points = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n", "keys": "webS_to_xt}"} +{"name": "golf-invert-dictionary-2-06", "pane": "file", "text": "var color_to_points = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n", "keys": "webS_to_xt}S,|="} +{"name": "golf-invert-dictionary-2-07", "pane": "file", "text": "var color_to_points = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n", "keys": "webS_to_xt}S,|=_"} +{"name": "golf-invert-dictionary-2", "pane": "file", "text": "var color_to_points = {\n \"red\" = 0,\n \"orange\" = 5,\n \"yellow\" = 10,\n \"green\" = 15,\n \"blue\" = 20,\n \"purple\" = 30,\n \"black\" = 50,\n}\n", "keys": "webS_to_xt}S,|=_2"} +{"name": "golf-object-into-array-01", "pane": "file", "text": "const palette = {\n apricot: \"#f47868\",\n lightning: \"#ffcd1c\",\n delta: \"6f44f0\",\n};\n", "keys": "j"} +{"name": "golf-object-into-array-02", "pane": "file", "text": "const palette = {\n apricot: \"#f47868\",\n lightning: \"#ffcd1c\",\n delta: \"6f44f0\",\n};\n", "keys": "jmr{["} +{"name": "golf-object-into-array-03", "pane": "file", "text": "const palette = {\n apricot: \"#f47868\",\n lightning: \"#ffcd1c\",\n delta: \"6f44f0\",\n};\n", "keys": "jmr{[mi["} +{"name": "golf-object-into-array-04", "pane": "file", "text": "const palette = {\n apricot: \"#f47868\",\n lightning: \"#ffcd1c\",\n delta: \"6f44f0\",\n};\n", "keys": "jmr{[mi[s"} +{"name": "golf-object-into-array-05", "pane": "file", "text": "const palette = {\n apricot: \"#f47868\",\n lightning: \"#ffcd1c\",\n delta: \"6f44f0\",\n};\n", "keys": "jmr{[mi[s:"} +{"name": "golf-object-into-array-06", "pane": "file", "text": "const palette = {\n apricot: \"#f47868\",\n lightning: \"#ffcd1c\",\n delta: \"6f44f0\",\n};\n", "keys": "jmr{[mi[s:r,"} +{"name": "golf-object-into-array-07", "pane": "file", "text": "const palette = {\n apricot: \"#f47868\",\n lightning: \"#ffcd1c\",\n delta: \"6f44f0\",\n};\n", "keys": "jmr{[mi[s:r,be"} +{"name": "golf-object-into-array-08", "pane": "file", "text": "const palette = {\n apricot: \"#f47868\",\n lightning: \"#ffcd1c\",\n delta: \"6f44f0\",\n};\n", "keys": "jmr{[mi[s:r,bems\""} +{"name": "golf-object-into-array-09", "pane": "file", "text": "const palette = {\n apricot: \"#f47868\",\n lightning: \"#ffcd1c\",\n delta: \"6f44f0\",\n};\n", "keys": "jmr{[mi[s:r,bems\"vt,"} +{"name": "golf-object-into-array", "pane": "file", "text": "const palette = {\n apricot: \"#f47868\",\n lightning: \"#ffcd1c\",\n delta: \"6f44f0\",\n};\n", "keys": "jmr{[mi[s:r,bems\"vt,ms["} +{"name": "golf-reverse-golf-example-01", "pane": "file", "text": "# snake_case to camelCase\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n", "keys": "eb"} +{"name": "golf-reverse-golf-example-02", "pane": "file", "text": "# snake_case to camelCase\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n", "keys": "eby"} +{"name": "golf-reverse-golf-example-03", "pane": "file", "text": "# snake_case to camelCase\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n", "keys": "ebyxb"} +{"name": "golf-reverse-golf-example-04", "pane": "file", "text": "# snake_case to camelCase\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n", "keys": "ebyxb*"} +{"name": "golf-reverse-golf-example-05", "pane": "file", "text": "# snake_case to camelCase\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n", "keys": "ebyxb*R"} +{"name": "golf-reverse-golf-example-06", "pane": "file", "text": "# snake_case to camelCase\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n", "keys": "ebyxb*Rv"} +{"name": "golf-reverse-golf-example-07", "pane": "file", "text": "# snake_case to camelCase\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n", "keys": "ebyxb*Rv\"\"N"} +{"name": "golf-reverse-golf-example-08", "pane": "file", "text": "# snake_case to camelCase\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n", "keys": "ebyxb*Rv\"\"Nn"} +{"name": "golf-reverse-golf-example-09", "pane": "file", "text": "# snake_case to camelCase\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n", "keys": "ebyxb*Rv\"\"Nn"} +{"name": "golf-reverse-golf-example-10", "pane": "file", "text": "# snake_case to camelCase\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n", "keys": "ebyxb*Rv\"\"Nn%"} +{"name": "golf-reverse-golf-example-11", "pane": "file", "text": "# snake_case to camelCase\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n", "keys": "ebyxb*Rv\"\"Nn%s`+j"} +{"name": "golf-reverse-golf-example-12", "pane": "file", "text": "# snake_case to camelCase\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n", "keys": "ebyxb*Rv\"\"Nn%s`+jf;"} +{"name": "golf-reverse-golf-example", "pane": "file", "text": "# snake_case to camelCase\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n", "keys": "ebyxb*Rv\"\"Nn%s`+jf;"} +{"name": "golf-snake-case-to-camel-case-01", "pane": "file", "text": "const user_profile = {\n first_name: \"John\",\n last_name: \"Doe\",\n birth_date: \"1990-05-15\",\n email_address: \"john_doe@example.com\",\n phone_number: \"555-123-4567\",\n mailing_address: {\n street_name: \"Main Street\",\n house_number: 123,\n apartment_unit: \"4B\",\n zip_code: \"10001\",\n city_name: \"New York\",\n },\n};\n", "keys": "%"} +{"name": "golf-snake-case-to-camel-case-02", "pane": "file", "text": "const user_profile = {\n first_name: \"John\",\n last_name: \"Doe\",\n birth_date: \"1990-05-15\",\n email_address: \"john_doe@example.com\",\n phone_number: \"555-123-4567\",\n mailing_address: {\n street_name: \"Main Street\",\n house_number: 123,\n apartment_unit: \"4B\",\n zip_code: \"10001\",\n city_name: \"New York\",\n },\n};\n", "keys": "%s_"} +{"name": "golf-snake-case-to-camel-case-03", "pane": "file", "text": "const user_profile = {\n first_name: \"John\",\n last_name: \"Doe\",\n birth_date: \"1990-05-15\",\n email_address: \"john_doe@example.com\",\n phone_number: \"555-123-4567\",\n mailing_address: {\n street_name: \"Main Street\",\n house_number: 123,\n apartment_unit: \"4B\",\n zip_code: \"10001\",\n city_name: \"New York\",\n },\n};\n", "keys": "%s_5)"} +{"name": "golf-snake-case-to-camel-case-04", "pane": "file", "text": "const user_profile = {\n first_name: \"John\",\n last_name: \"Doe\",\n birth_date: \"1990-05-15\",\n email_address: \"john_doe@example.com\",\n phone_number: \"555-123-4567\",\n mailing_address: {\n street_name: \"Main Street\",\n house_number: 123,\n apartment_unit: \"4B\",\n zip_code: \"10001\",\n city_name: \"New York\",\n },\n};\n", "keys": "%s_5)"} +{"name": "golf-snake-case-to-camel-case-05", "pane": "file", "text": "const user_profile = {\n first_name: \"John\",\n last_name: \"Doe\",\n birth_date: \"1990-05-15\",\n email_address: \"john_doe@example.com\",\n phone_number: \"555-123-4567\",\n mailing_address: {\n street_name: \"Main Street\",\n house_number: 123,\n apartment_unit: \"4B\",\n zip_code: \"10001\",\n city_name: \"New York\",\n },\n};\n", "keys": "%s_5)d"} +{"name": "golf-snake-case-to-camel-case", "pane": "file", "text": "const user_profile = {\n first_name: \"John\",\n last_name: \"Doe\",\n birth_date: \"1990-05-15\",\n email_address: \"john_doe@example.com\",\n phone_number: \"555-123-4567\",\n mailing_address: {\n street_name: \"Main Street\",\n house_number: 123,\n apartment_unit: \"4B\",\n zip_code: \"10001\",\n city_name: \"New York\",\n },\n};\n", "keys": "%s_5)d~"} +{"name": "golf-text-into-array-01", "pane": "file", "text": "Hello\nThis\nIs\nHelix\n", "keys": "%"} +{"name": "golf-text-into-array-02", "pane": "file", "text": "Hello\nThis\nIs\nHelix\n", "keys": "%"} +{"name": "golf-text-into-array-03", "pane": "file", "text": "Hello\nThis\nIs\nHelix\n", "keys": "%ms\""} +{"name": "golf-text-into-array-04", "pane": "file", "text": "Hello\nThis\nIs\nHelix\n", "keys": "%ms\"i,"} +{"name": "golf-text-into-array", "pane": "file", "text": "Hello\nThis\nIs\nHelix\n", "keys": "%ms\"i,xms["} -- cgit v1.3 From ff29fd633e979d24a191e7ecd0c26541e3b597ac Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:28:03 -0300 Subject: Surround keeps the direction of the selection it wraps ms always left the wrapped range pointing forward. helix gives each range back with_direction(range.direction()), so a selection made backward (b, or v moving up) keeps its head on the open, which is where the next motion starts from. helix-golf's csv_to_sql (bms`) is where it showed. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 2 +- src/edit.zig | 12 +++++++++--- test/hxcases/cases.jsonl | 3 +++ test/hxcases/goldens.jsonl | 3 +++ 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 84ee5124..0b0da2ae 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -283,7 +283,7 @@ keeps the matches it found so far. | --- | --- | --- | --- | | `mm` | goto matching bracket | dumb text scan with nesting for `()[]{}<>`; ON a bracket only (no TS "nearest pair"); works on terminals via the motion surface | helix-verified (phase 5) | | `mi` / `ma` | select inside / around textobject | pairs `( ) [ ] { } < >` nesting-aware multi-line; quotes `' " `` ` `` ` **line-scoped** (plain-text strings don't span lines); `w`/`W` word run (+trailing ws around, leading if none); `p` blank-line block (+trailing blanks around). Empty inside (`()`) = no-op. Selections work on terminals; `Pane.pending2` holds the i/a/s/r/d sub-key | helix-verified (phase 5) | -| `ms` | surround selection (or cursor char) with the `` pair; wrap incl. pair becomes the selection | either bracket names its pair; any other ASCII char wraps with itself; file panes only | helix-verified (phase 5) | +| `ms` | surround selection (or cursor char) with the `` pair; wrap incl. pair becomes the selection, keeping the direction it had | either bracket names its pair; any other ASCII char wraps with itself; file panes only | helix-verified (phase 5) | | `mr` | replace enclosing `` pair chars with ``'s | `Pane.pending_ch` holds `` while `` pends; file panes only | helix-verified (phase 5) | | `md` | delete the enclosing `` pair chars | file panes only | helix-verified (phase 5) | diff --git a/src/edit.zig b/src/edit.zig index 3cbbc28c..56d537bf 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -1512,16 +1512,22 @@ pub fn surroundAdd(p: *Pardes, t: *Text, ch: u21) void { t.select = false; const eb = editTextEol(p, t, selRows(t)) orelse return; const r = selRange(t, eb.text, eb.row0); + // the wrapped range keeps the direction it had (helix with_direction) + const back = t.vsel.active and (t.cur_row < t.vsel.row or (t.cur_row == t.vsel.row and t.cur_col < t.vsel.col)); pushUndo(p, t); const close_col = modal.nextGrapheme(modal.lineSlice(eb.text, r.b.row), r.b.col); var new = modal.insertAt(p.gpa, eb.text, .{ .row = r.b.row, .col = close_col }, &[1]u8{pr.c}) catch return; setEditText(p, t, new); new = modal.insertAt(p.gpa, new, .{ .row = r.a.row, .col = r.a.col }, &[1]u8{pr.o}) catch return; setEditText(p, t, new); + const open_row = @as(i32, @intCast(r.a.row)) + eb.row0; + const open_col: i32 = @intCast(r.a.col); + const close_row = @as(i32, @intCast(r.b.row)) + eb.row0; + const close_at: i32 = @intCast(close_col + @as(usize, if (r.a.row == r.b.row) 1 else 0)); t.msel.active = false; - t.vsel = .{ .active = true, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false }; - t.cur_row = @as(i32, @intCast(r.b.row)) + eb.row0; - t.cur_col = @intCast(close_col + @as(usize, if (r.a.row == r.b.row) 1 else 0)); + t.vsel = .{ .active = true, .row = if (back) close_row else open_row, .col = if (back) close_at else open_col, .explicit = false }; + t.cur_row = if (back) open_row else close_row; + t.cur_col = if (back) open_col else close_at; t.cur_pinned = true; t.sticky_col = -1; t.show(); diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index b0a3aad4..d7f76912 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -316,6 +316,9 @@ {"name": "ms-sel", "pane": "file", "text": "hello\n", "keys": "vllms("} {"name": "ms-bare", "pane": "file", "text": "ab\n", "keys": "ms("} {"name": "ms-quote-self", "pane": "file", "text": "ab cd\n", "keys": "wems'"} +{"name": "ms-backward", "pane": "file", "text": "ab cd\n", "keys": "wbms("} +{"name": "ms-backward-lines", "pane": "file", "text": "ab\ncd\nef\n", "keys": "jjlvkkms["} +{"name": "msel-ms-backward", "pane": "file", "text": "id 1,x 2\n", "keys": "%s\\ddhbms`"} {"name": "md-basic", "pane": "file", "text": "a(bcd)e\n", "keys": "3lmd("} {"name": "md-quote", "pane": "file", "text": "a 'bc' d\n", "keys": "3lmd'"} {"name": "mr-basic", "pane": "file", "text": "a(bcd)e\n", "keys": "3lmr(["} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index eee7294b..cab7393b 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -316,6 +316,9 @@ {"name":"ms-sel","text":"(hel)lo\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":0}} {"name":"ms-bare","text":"(a)b\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} {"name":"ms-quote-self","text":"ab' cd'\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":2}} +{"name":"ms-backward","text":"(ab )cd\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":4}} +{"name":"ms-backward-lines","text":"a[b\ncd\nef]\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":2,"col":2}} +{"name":"msel-ms-backward","text":"`id `,`x `\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":4},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":4}},{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":9}}],"primary":0} {"name":"md-basic","text":"abcde\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}} {"name":"md-quote","text":"a bc d\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}} {"name":"mr-basic","text":"a[bcd]e\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}} -- cgit v1.3 From f59785670a2ff12cec33894add6dc1590c7b77b1 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:28:36 -0300 Subject: Replacing a selection with the register keeps its direction R always left the head on the pasted text's last char. helix maps the replaced range through the change, so a backward selection comes back backward, its head on the first char: helix-golf's reverse_golf_example (xbR) is where it showed. SPC R shares the path. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 2 +- src/edit.zig | 29 +++++++++++++++++++++-------- test/hxcases/cases.jsonl | 2 ++ test/hxcases/goldens.jsonl | 2 ++ 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 0b0da2ae..816010a6 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -182,7 +182,7 @@ found. Pure text math in `modal.zig`: `findChar`, `matchBracket`, | Key | Behavior | Notes | Status | | --- | --- | --- | --- | | `r` | replace selection/char with ``, newlines kept | terminals: replaces the typed-run byte under the cursor if any, else no-op — one code path for both kinds, since `normalReplaceChar` goes through `Pardes.editText`/`setEditText`, which route to a file's content or to `term_pane`'s typed-run overlay | helix-verified (phase 5) | -| `R` | replace selection (or cursor char) with the yank register; pasted text becomes the selection, head on its last char | uses the INTERNAL yank (`p.yank`), no clipboard round trip — `SPC R` is the system-clipboard twin; empty register = no-op; terminals: no-op | helix-verified (phase 5) | +| `R` | replace selection (or cursor char) with the yank register; pasted text becomes the selection in the direction the replaced one had (head on its last char, or its first when backward) | uses the INTERNAL yank (`p.yank`), no clipboard round trip — `SPC R` is the system-clipboard twin; empty register = no-op; terminals: no-op | helix-verified (phase 5) | | `~` | switch case of selection/char, selection kept | terminals: run byte only | helix-verified (phase 5) | | `` ` `` | selection to lowercase | terminals: run byte only | helix-verified (phase 5) | | ``Alt-` `` | selection to uppercase | terminals: run byte only | helix-verified (phase 5) | diff --git a/src/edit.zig b/src/edit.zig index 56d537bf..fb8681d8 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -1125,27 +1125,40 @@ pub fn normalReplaceYank(p: *Pardes, t: *Text) void { replaceWithText(p, t, p.yank orelse return); } -/// The pasted text becomes the selection, head on its last char (which for -/// a trailing-newline `y` is the '\n' cell of the last full line). +/// The pasted text becomes the selection, in the direction the replaced one +/// had: a forward head lands on its last char (which for a trailing-newline +/// `y` is the '\n' cell of the last full line), a backward one on its first. fn replaceWithText(p: *Pardes, t: *Text, y: []const u8) void { if (y.len == 0) return; t.select = false; const eb = editTextEol(p, t, selRows(t)) orelse return; const r = selRange(t, eb.text, eb.row0); + const back = t.vsel.active and (t.cur_row < t.vsel.row or (t.cur_row == t.vsel.row and t.cur_col < t.vsel.col)); pushUndo(p, t); const new = modal.replaceRange(p.gpa, eb.text, r.a, r.b, y) catch return; setEditText(p, t, new); t.msel.active = false; - t.vsel = .{ .active = modal.nextGrapheme(y, 0) < y.len, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false }; const end = modal.advanceBy(r.a, y); + var last_row: i32 = undefined; + var last_col: i32 = undefined; if (end.col > 0) { - t.cur_row = @as(i32, @intCast(end.row)) + eb.row0; - t.cur_col = @intCast(modal.prevGrapheme(modal.lineSlice(new, end.row), end.col)); + last_row = @as(i32, @intCast(end.row)) + eb.row0; + last_col = @intCast(modal.prevGrapheme(modal.lineSlice(new, end.row), end.col)); } else { - // the yank ended in '\n': the cursor lands ON that newline - t.cur_row = @as(i32, @intCast(end.row -| 1)) + eb.row0; - t.cur_col = @intCast(modal.lineSlice(new, end.row -| 1).len); + // the yank ended in '\n': its last cell is that newline + last_row = @as(i32, @intCast(end.row -| 1)) + eb.row0; + last_col = @intCast(modal.lineSlice(new, end.row -| 1).len); } + const first_row = @as(i32, @intCast(r.a.row)) + eb.row0; + const first_col: i32 = @intCast(r.a.col); + t.vsel = .{ + .active = modal.nextGrapheme(y, 0) < y.len, + .row = if (back) last_row else first_row, + .col = if (back) last_col else first_col, + .explicit = false, + }; + t.cur_row = if (back) first_row else last_row; + t.cur_col = if (back) first_col else last_col; t.cur_pinned = true; t.sticky_col = -1; t.show(); diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index d7f76912..6e1b8188 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -287,6 +287,8 @@ {"name": "r-cancel-esc", "pane": "file", "text": "abc\n", "keys": "rl"} {"name": "R-inline", "pane": "file", "text": "alpha beta\n", "keys": "wyggR"} {"name": "R-linewise", "pane": "file", "text": "one\ntwo\n", "keys": "xyjR"} +{"name": "R-backward", "pane": "file", "text": "alpha beta\n", "keys": "wyebR"} +{"name": "R-backward-linewise", "pane": "file", "text": "one\ntwo three\n", "keys": "xyjxbR"} {"name": "tilde-sel-keeps", "pane": "file", "text": "abc\n", "keys": "vll~"} {"name": "tilde-bare", "pane": "file", "text": "aBc\n", "keys": "~"} {"name": "backtick-lower", "pane": "file", "text": "ABC\n", "keys": "vll`"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index cab7393b..8ac110e6 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -287,6 +287,8 @@ {"name":"r-cancel-esc","text":"abc\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} {"name":"R-inline","text":"alpha lpha beta\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}} {"name":"R-linewise","text":"one\ntwoone\n","mode":"normal","cursor":{"row":1,"col":6},"anchor":{"row":1,"col":3}} +{"name":"R-backward","text":"alpha alpha \n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":11}} +{"name":"R-backward-linewise","text":"one\ntwo one\n\n","mode":"normal","cursor":{"row":1,"col":4},"anchor":{"row":1,"col":7}} {"name":"tilde-sel-keeps","text":"ABC\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} {"name":"tilde-bare","text":"ABc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} {"name":"backtick-lower","text":"abc\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} -- cgit v1.3 From e35e4654c34cf303cf3bc6077a3cbffee70f7909 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:29:13 -0300 Subject: Blank lines open above a selection's first line and below its last [ and ] measured from the cursor's line, so over a selection of several lines whose head was at the bottom, [ opened a line INSIDE the selection. helix takes the range's line_range: above its start, below its end. helix-golf's export_from_mod (yp[) is where it showed. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 2 +- src/edit.zig | 19 +++++++++++++------ test/hxcases/cases.jsonl | 3 +++ test/hxcases/goldens.jsonl | 3 +++ 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 816010a6..f6880f33 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -301,7 +301,7 @@ keeps the matches it found so far. | Key | Behavior | Notes | Status | | --- | --- | --- | --- | | `]p` / `[p` | next / prev paragraph (+ count) | helix `goto_next/prev_paragraph`: selects to the boundary (anchor at the origin), blank-line-delimited blocks, count iterates | helix-verified (phase 5) | -| `]Space` / `[Space` | add `` blank lines below / above, cursor staying on its text line | file panes only (shell output immutable) | helix-verified (phase 5) | +| `]Space` / `[Space` | add `` blank lines below the selection's last line / above its first, the selection staying on its text | file panes only (shell output immutable) | helix-verified (phase 5) | ### Insert mode diff --git a/src/edit.zig b/src/edit.zig index fb8681d8..b548c7c6 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -1452,9 +1452,12 @@ pub fn normalAdjustNumber(p: *Pardes, t: *Text, delta: i64) void { /// `]Space` / `[Space`: add blank lines below/above, the cursor staying /// on its text line (the selection rides along) pub fn addNewline(p: *Pardes, t: *Text, below: bool, cnt: usize) void { - const eb = editText(p, t, t.cur_row, t.cur_row, -1) orelse return; + // above the selection's first line, below its last (helix line_range) + const sr = selRows(t); + const at = if (below) sr.hi_row else sr.lo_row; + const eb = editText(p, t, at, at, -1) orelse return; pushUndo(p, t); - const row: usize = @intCast(@max(0, t.cur_row - eb.row0)); + const row: usize = @intCast(@max(0, at - eb.row0)); const arena = p.scratch.allocator(); const nl = arena.alloc(u8, cnt) catch return; @memset(nl, '\n'); @@ -1462,10 +1465,14 @@ pub fn addNewline(p: *Pardes, t: *Text, below: bool, cnt: usize) void { const new = modal.insertAt(p.gpa, eb.text, .{ .row = row, .col = if (below) llen else 0 }, nl) catch return; setEditText(p, t, new); if (!below) { - t.cur_row += @intCast(cnt); // the line moved down; stay on it - if (t.vsel.active and t.vsel.row >= @as(i32, @intCast(row))) t.vsel.row += @intCast(cnt); - } else if (t.vsel.active and t.vsel.row > @as(i32, @intCast(row))) { - t.vsel.row += @intCast(cnt); + // the whole selection moved down; stay on it + const n: i32 = @intCast(cnt); + t.cur_row += n; + if (t.vsel.active) t.vsel.row += n; + if (t.msel.active) { + t.msel.r0 += n; + t.msel.r1 += n; + } } t.cur_pinned = true; t.show(); diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 6e1b8188..ada7363b 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -252,6 +252,9 @@ {"name": "add-nl-below", "pane": "file", "text": "aa\nbb\n", "keys": "]"} {"name": "add-nl-above", "pane": "file", "text": "aa\nbb\n", "keys": "j["} {"name": "add-nl-count", "pane": "file", "text": "aa\nbb\n", "keys": "2]"} +{"name": "add-nl-above-sel", "pane": "file", "text": "aa\nbb\ncc\n", "keys": "vj["} +{"name": "add-nl-below-sel", "pane": "file", "text": "aa\nbb\ncc\n", "keys": "jvk]"} +{"name": "add-nl-above-lines", "pane": "file", "text": "aa\nbb\ncc\n", "keys": "jxx["} {"name": "d-word", "pane": "file", "text": "alpha beta\n", "keys": "wd"} {"name": "d-bare-char", "pane": "file", "text": "abc\n", "keys": "d"} {"name": "d-on-nl-joins", "pane": "file", "text": "ab\ncd\n", "keys": "lld"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index 8ac110e6..51ab8939 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -252,6 +252,9 @@ {"name":"add-nl-below","text":"aa\n\nbb\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} {"name":"add-nl-above","text":"aa\n\nbb\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}} {"name":"add-nl-count","text":"aa\n\n\nbb\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"add-nl-above-sel","text":"\naa\nbb\ncc\n","mode":"select","cursor":{"row":2,"col":0},"anchor":{"row":1,"col":0}} +{"name":"add-nl-below-sel","text":"aa\nbb\n\ncc\n","mode":"select","cursor":{"row":0,"col":0},"anchor":{"row":1,"col":0}} +{"name":"add-nl-above-lines","text":"aa\n\nbb\ncc\n","mode":"normal","cursor":{"row":3,"col":2},"anchor":{"row":2,"col":0}} {"name":"d-word","text":"beta\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} {"name":"d-bare-char","text":"bc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} {"name":"d-on-nl-joins","text":"abcd\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}} -- cgit v1.3 From 4315121885bebd48f8c03a2b1c7b0bd943c67afd Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:31:55 -0300 Subject: Alt-J joins lines and selects the spaces it put in helix-golf's text_into_array turns lines into an array with %ms"i,: join_selections_space joins like J and leaves one cursor on each space it inserted. J and Alt-J now make one edit over every range, as helix does, rather than J being replayed per range; that is what lets Alt-J hand back all the spaces as the new selection. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 2 +- src/config.zig | 1 + src/edit.zig | 103 +++++++++++++++++++++++++++++---------------- src/modal.zig | 5 ++- src/normal.zig | 3 +- test/hxcases/cases.jsonl | 6 +++ test/hxcases/goldens.jsonl | 6 +++ 7 files changed, 86 insertions(+), 40 deletions(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index f6880f33..7fe2c476 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -187,6 +187,7 @@ found. Pure text math in `modal.zig`: `findChar`, `matchBracket`, | `` ` `` | selection to lowercase | terminals: run byte only | helix-verified (phase 5) | | ``Alt-` `` | selection to uppercase | terminals: run byte only | helix-verified (phase 5) | | `J` | join lines in selection (or cur+next; helix ignores the count) | helix semantics: the newline + next line's leading whitespace collapse to ONE space (first line untrimmed); selection and cursor STAY where they were (vim's cursor-on-the-space removed in phase 5); terminals: no-op | helix-verified (phase 5) | +| `Alt-J` | join like `J`, then select the spaces the join put in (one bare cursor each); a join that put none in keeps the selection | helix `join_selections_space`. `J` and `Alt-J` are one edit over every range, so a line two ranges share is joined once | helix-verified | | `>` / `<` | indent / unindent selected lines (count times) | width = `modal.INDENT_W` = **4 spaces** (helix harness pins its no-language indent style to Spaces(4) to match); `<` also takes one leading tab as a level; empty lines never indented (helix); selection kept, positions mapped through the edit (phase 5) | helix-verified (phase 5) | | `Ctrl-a` / `Ctrl-x` | increment / decrement the decimal int under the cursor by count | helix-style: under the cursor only (no vim forward scan), `-` handled, i64 saturating, zero-padding width preserved (phase 5); cursor to the last digit, selection kept; terminals: no-op | helix-verified (phase 5) | | `Alt-d` | delete without yanking | `normalDelete(yank=false)` | helix-verified (phase 5) | @@ -347,7 +348,6 @@ text. | `Alt-u` / `Alt-U` | undo-history earlier/later | history timeline — linear snapshot u/U covers pardes | | `K Alt-K`, `Alt-:` | regex keep/remove, ensure-forward | `K`/`Alt-K` are the same prompt `s`/`S` now have, filtering instead of splitting (`s S` moved to "Regex selection" in A, the rest of the family to "Multiple cursors") | | `&`, `Alt-(` / `Alt-)` | align selections, rotate selection CONTENTS | see the multiple-cursors table for why | -| `Alt-J` | join + select the inserted space | marginal over `J` | | `Alt-\|` `!` `Alt-!` `$` | shell pipe-to (output discarded), insert output, append output, keep-by-exit-status | shell — `\|` (`shell_pipe`, output replaces the selection) is implemented in section A; these four are the other members of helix's shell family. `$` is additionally taken for line-end here (section A) | | `:` | command mode | side-effects/file-ops — pardes builtins live in the tag, and `:` is bound to focusing it (section A) | | `gf` | goto file under selection | covered by pardes Enter-look | diff --git a/src/config.zig b/src/config.zig index 9ad9cc7e..372c4884 100644 --- a/src/config.zig +++ b/src/config.zig @@ -538,6 +538,7 @@ pub const switch_case: []const Chord = &.{.{ .cp = '~' }}; pub const to_lowercase: []const Chord = &.{.{ .cp = '`' }}; pub const to_uppercase: []const Chord = &.{.{ .cp = '`', .alt = true }}; pub const join_lines: []const Chord = &.{.{ .cp = 'J' }}; +pub const join_select_space: []const Chord = &.{.{ .cp = 'J', .alt = true }}; pub const indent: []const Chord = &.{.{ .cp = '>' }}; pub const unindent: []const Chord = &.{.{ .cp = '<' }}; pub const format: []const Chord = &.{.{ .cp = '=' }}; diff --git a/src/edit.zig b/src/edit.zig index b548c7c6..09c3ffe7 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -1214,53 +1214,82 @@ fn applyChanges(p: *Pardes, text: []const u8, chs: []const TextChange) ![]u8 { return out; } -pub fn normalJoin(p: *Pardes, t: *Text) void { +/// `J` / `Alt-J`: join every range's lines with the next, in ONE edit +/// (helix join_selections): the newline and the next line's leading blanks +/// become one space, or nothing when that line is blank, and a line two +/// ranges share is joined once. `Alt-J` then selects the spaces it put in. +pub fn normalJoin(p: *Pardes, t: *Text, select_space: bool) void { + const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; // a join always eats the newline of its last line, so the buffer has - // to reach one row PAST the selection - const sr = selRows(t); - const eb = editText(p, t, sr.lo_row, sr.hi_row + 1, -1) orelse return; + // to reach one row PAST every range + var b = selRows(t); + for (t.sels[0..t.nsel]) |s| { + b.lo_row = @min(b.lo_row, @min(s.row, s.arow)); + b.hi_row = @max(b.hi_row, @max(s.row, s.arow)); + } + const eb = editText(p, t, b.lo_row, b.hi_row + 1, -1) orelse return; const text = eb.text; - const range = t.primaryRange(text, eb.row0); - const span = Text.rangeLineSpan(text, range); + var rs: [Text.max_selections]modal.Selection = undefined; + const got = t.ranges(text, eb.row0, &rs); const nlines = modal.cursorLineCount(text); - var end = span.end; - if (span.start == end) end = @min(end + 1, nlines - 1); - if (end <= span.start) return; const arena = p.scratch.allocator(); - const chs = arena.alloc(TextChange, end - span.start) catch return; + const chs = arena.alloc(TextChange, nlines) catch return; var chs_len: usize = 0; - var l = span.start; - while (l < end) : (l += 1) { - const from = modal.lineEndOffset(text, l); - var to = if (l + 1 >= nlines) text.len else modal.lineStartOffset(text, l + 1); - while (to < text.len and (text[to] == ' ' or text[to] == '\t')) to += 1; - const sep: []const u8 = if (to == modal.lineEndOffset(text, @min(l + 1, nlines - 1))) "" else " "; - chs[chs_len] = .{ .from = from, .to = to, .ins = sep }; - chs_len += 1; + var next: usize = 0; // lines below this one are joined already + for (rs[0..got.n]) |r| { + const span = Text.rangeLineSpan(text, r); + var end = span.end; + if (span.start == end) end = @min(end + 1, nlines - 1); + var l = @max(span.start, next); + while (l < end) : (l += 1) { + const from = modal.lineEndOffset(text, l); + var to = if (l + 1 >= nlines) text.len else modal.lineStartOffset(text, l + 1); + while (to < text.len and (text[to] == ' ' or text[to] == '\t')) to += 1; + const sep: []const u8 = if (to == modal.lineEndOffset(text, @min(l + 1, nlines - 1))) "" else " "; + chs[chs_len] = .{ .from = from, .to = to, .ins = sep }; + chs_len += 1; + } + next = @max(next, end); } if (chs_len == 0) return; pushUndo(p, t); - const cur_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row - eb.row0)), .col = @intCast(@max(0, t.cur_col)) }); - const anc_off = if (t.vsel.active) - modal.offsetAt(text, .{ .row = @intCast(@max(0, t.vsel.row - eb.row0)), .col = @intCast(@max(0, t.vsel.col)) }) - else - cur_off; const new = applyChanges(p, text, chs[0..chs_len]) catch return; - const nc = mapThroughChanges(chs[0..chs_len], cur_off); - const na = mapThroughChanges(chs[0..chs_len], anc_off); - setEditText(p, t, new); - const cc = modal.positionAt(new, nc); - const ac = modal.positionAt(new, na); - t.cur_row = @as(i32, @intCast(cc.row)) + eb.row0; - t.cur_col = @intCast(cc.col); - if (t.vsel.active) { - t.vsel.row = @as(i32, @intCast(ac.row)) + eb.row0; - t.vsel.col = @intCast(ac.col); + // every range's cells ride through the edit; Alt-J replaces them with + // the spaces, when it put any in + var cells: [Text.max_selections]Text.SelRange = undefined; + var n: usize = 0; + var pri = got.pri; + if (select_space) { + var delta: i64 = 0; + for (chs[0..chs_len]) |ch| { + const at: usize = @intCast(@as(i64, @intCast(ch.from)) + delta); + delta += @as(i64, @intCast(ch.ins.len)) - @as(i64, @intCast(ch.to - ch.from)); + if (ch.ins.len == 0 or n == cells.len) continue; + const pos = modal.positionAt(new, at); + cells[n] = .{ .row = @as(i32, @intCast(pos.row)) + eb.row0, .col = @intCast(pos.col), .arow = @as(i32, @intCast(pos.row)) + eb.row0, .acol = @intCast(pos.col) }; + n += 1; + } + pri = 0; } - t.msel.active = false; - t.cur_pinned = true; - t.sticky_col = -1; - t.show(); + if (n == 0) { + for (rs[0..got.n]) |r| { + const c = Text.rangeCells(text, r); + const cc = modal.positionAt(new, mapThroughChanges(chs[0..chs_len], c.cur)); + const ac = modal.positionAt(new, mapThroughChanges(chs[0..chs_len], c.anc)); + cells[n] = .{ + .row = @as(i32, @intCast(cc.row)) + eb.row0, + .col = @intCast(cc.col), + .arow = @as(i32, @intCast(ac.row)) + eb.row0, + .acol = @intCast(ac.col), + }; + n += 1; + } + pri = got.pri; + } + setEditText(p, t, new); + const t2 = flatSurface(p, t) catch return; + for (cells[0..n], 0..) |s, i| rs[i] = Text.cellRange(t2, s.arow, s.acol, s.row, s.col); + t.setRanges(t2, rs[0..n], &.{}, pri, expl); } pub fn normalIndent(p: *Pardes, t: *Text, cnt: usize, add: bool) void { diff --git a/src/modal.zig b/src/modal.zig index 3827d11f..0e6aba61 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -108,6 +108,7 @@ pub const Normal = struct { to_lowercase, to_uppercase, join_lines, + join_select_space, indent, unindent, format, @@ -260,6 +261,7 @@ pub const Normal = struct { lowercase, uppercase, join_lines, + join_select_space, indent, unindent, comment_toggle, @@ -317,7 +319,7 @@ pub const Normal = struct { .search_step, => .once, .edit => |edit| switch (edit.kind) { - .comment_toggle, .undo, .redo => .once, + .join_lines, .join_select_space, .comment_toggle, .undo, .redo => .once, else => .per_selection, }, else => .per_selection, @@ -549,6 +551,7 @@ pub const Normal = struct { if (key.has(.to_lowercase)) return resultAction(.{ .edit = .{ .kind = .lowercase, .count = count } }); if (key.has(.to_uppercase)) return resultAction(.{ .edit = .{ .kind = .uppercase, .count = count } }); if (key.has(.join_lines)) return resultAction(.{ .edit = .{ .kind = .join_lines, .count = count } }); + if (key.has(.join_select_space)) return resultAction(.{ .edit = .{ .kind = .join_select_space, .count = count } }); if (key.has(.indent)) return resultAction(.{ .edit = .{ .kind = .indent, .count = count } }); if (key.has(.unindent)) return resultAction(.{ .edit = .{ .kind = .unindent, .count = count } }); if (key.has(.format)) return resultAction(.{ .lsp = .format }); diff --git a/src/normal.zig b/src/normal.zig index da707e26..c976823c 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -540,7 +540,8 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void .switch_case => return edit.normalCase(p, t, .toggle), .lowercase => return edit.normalCase(p, t, .lower), .uppercase => return edit.normalCase(p, t, .upper), - .join_lines => return edit.normalJoin(p, t), + .join_lines => return edit.normalJoin(p, t, false), + .join_select_space => return edit.normalJoin(p, t, true), .indent => return edit.normalIndent(p, t, op.count, true), .unindent => return edit.normalIndent(p, t, op.count, false), .comment_toggle => return edit.normalToggleComment(p, t), diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index ada7363b..0b8858e6 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -302,6 +302,12 @@ {"name": "J-empty-next-nospace", "pane": "file", "text": "aa\n\nbb\n", "keys": "J"} {"name": "J-last-line-eats-nl", "pane": "file", "text": "aa\nbb\n", "keys": "jJ"} {"name": "J-count-ignored", "pane": "file", "text": "a\nb\nc\nd\n", "keys": "3J"} +{"name": "msel-J-same-line", "pane": "file", "text": "aa bb\ncc\ndd\n", "keys": "wCJ"} +{"name": "msel-J-shared-line", "pane": "file", "text": "ab\ncd\nef\n", "keys": "%s\\wJ"} +{"name": "alt-J-selects-space", "pane": "file", "text": "aa\n bb\ncc\n", "keys": "xx"} +{"name": "alt-J-each-range", "pane": "file", "text": "a\nb\nc\nd\n", "keys": "%"} +{"name": "alt-J-blank-no-space", "pane": "file", "text": "aa\n\nbb\n", "keys": ""} +{"name": "msel-J-two-lines", "pane": "file", "text": "aa bb\ncc\ndd\nee\n", "keys": "Cs\\wJ"} {"name": "indent-basic", "pane": "file", "text": "foo\nbar\n", "keys": ""} {"name": "indent-realign", "pane": "file", "text": " x\n", "keys": "gs"} {"name": "indent-cursor-maps", "pane": "file", "text": "foo\n", "keys": "ll"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index 51ab8939..79745d43 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -302,6 +302,12 @@ {"name":"J-empty-next-nospace","text":"aa\nbb\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} {"name":"J-last-line-eats-nl","text":"aa\nbb","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}} {"name":"J-count-ignored","text":"a b\nc\nd\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"msel-J-same-line","text":"aa bb cc dd\n","mode":"normal","cursor":{"row":0,"col":8},"anchor":{"row":0,"col":6},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":8},"anchor":{"row":0,"col":6}}],"primary":1} +{"name":"msel-J-shared-line","text":"ab cd ef","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}},{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}},{"cursor":{"row":0,"col":7},"anchor":{"row":0,"col":7}}],"primary":0} +{"name":"alt-J-selects-space","text":"aa bb\ncc\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}} +{"name":"alt-J-each-range","text":"a b c d","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}}],"primary":0} +{"name":"alt-J-blank-no-space","text":"aa\nbb\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"msel-J-two-lines","text":"aa bb cc dd\nee\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}}],"primary":0} {"name":"indent-basic","text":" foo\nbar\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}} {"name":"indent-realign","text":" x\n","mode":"normal","cursor":{"row":0,"col":8},"anchor":{"row":0,"col":8}} {"name":"indent-cursor-maps","text":" foo\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}} -- cgit v1.3 From 122281b0ea3473f8950910765c487ef3fc82e7d4 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:33:48 -0300 Subject: Alt-( and Alt-) rotate the text of the selections helix-golf swaps keys and values with rotate_selection_contents (webS_to_ , then per line). Each range's text moves to the next range (the previous one for Alt-(), in one edit; a count rotates by that many ranges, each range comes back over the text it now holds in its own direction, and the primary follows its text. That is the harness's helix; 25.07.1 read the count as a group size and left the primary alone. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 4 ++-- src/config.zig | 2 ++ src/edit.zig | 51 ++++++++++++++++++++++++++++++++++++++++++++++ src/modal.zig | 15 +++++++++++++- src/normal.zig | 2 ++ test/hxcases/cases.jsonl | 7 +++++++ test/hxcases/goldens.jsonl | 7 +++++++ 7 files changed, 85 insertions(+), 3 deletions(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 7fe2c476..23d63454 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -227,7 +227,7 @@ grew `sels` + `primary`, emitted only when there is more than one range. | `y` with several ranges | joins the ranges' text with newlines into the ONE register | helix keeps a register VALUE per range and pastes value[i] at range[i]. Waived (`msel-yank-paste`) | waived | | `a` … `Esc` with several ranges | the primary's appended-over span is restored; the others collapse to bare cursors | `Pane.append_at` is a single field. Waived (`msel-append`) | waived | | `&` | align selections into a column | **skipped**: needs visual (tab-expanded) columns, which nothing else in pardes measures | -| `Alt-(` / `Alt-)` | rotate the CONTENTS of the selections | skipped: a separate feature from rotating which range is primary | +| `Alt-)` / `Alt-(` / `Alt-)` | rotate the CONTENTS of the selections forward / back by one range (n ranges), in one edit; each range comes back over the text it now holds and the primary moves with its text | helix `rotate_selection_contents_*` as of the harness's helix: 25.07.1 read the count as a GROUP size instead (rotate by one within each run of n ranges) and left the primary where it was, which is what helix-golf's `invert_dictionary_2` relies on | helix-verified | Anything that reaches outside the buffer — a builtin, a language query — runs once from the primary and drops back to a single cursor rather than firing per @@ -347,7 +347,7 @@ text. | `Ctrl-s` (normal) | save jumplist position | jumplist itself is implemented (`Ctrl-o`/`Ctrl-i`, section A); only the explicit save point is skipped | | `Alt-u` / `Alt-U` | undo-history earlier/later | history timeline — linear snapshot u/U covers pardes | | `K Alt-K`, `Alt-:` | regex keep/remove, ensure-forward | `K`/`Alt-K` are the same prompt `s`/`S` now have, filtering instead of splitting (`s S` moved to "Regex selection" in A, the rest of the family to "Multiple cursors") | -| `&`, `Alt-(` / `Alt-)` | align selections, rotate selection CONTENTS | see the multiple-cursors table for why | +| `&` | align selections | see the multiple-cursors table for why | | `Alt-\|` `!` `Alt-!` `$` | shell pipe-to (output discarded), insert output, append output, keep-by-exit-status | shell — `\|` (`shell_pipe`, output replaces the selection) is implemented in section A; these four are the other members of helix's shell family. `$` is additionally taken for line-end here (section A) | | `:` | command mode | side-effects/file-ops — pardes builtins live in the tag, and `:` is bound to focusing it (section A) | | `gf` | goto file under selection | covered by pardes Enter-look | diff --git a/src/config.zig b/src/config.zig index 372c4884..edc73837 100644 --- a/src/config.zig +++ b/src/config.zig @@ -539,6 +539,8 @@ pub const to_lowercase: []const Chord = &.{.{ .cp = '`' }}; pub const to_uppercase: []const Chord = &.{.{ .cp = '`', .alt = true }}; pub const join_lines: []const Chord = &.{.{ .cp = 'J' }}; pub const join_select_space: []const Chord = &.{.{ .cp = 'J', .alt = true }}; +pub const rotate_contents_fwd: []const Chord = &.{.{ .cp = ')', .alt = true }}; +pub const rotate_contents_back: []const Chord = &.{.{ .cp = '(', .alt = true }}; pub const indent: []const Chord = &.{.{ .cp = '>' }}; pub const unindent: []const Chord = &.{.{ .cp = '<' }}; pub const format: []const Chord = &.{.{ .cp = '=' }}; diff --git a/src/edit.zig b/src/edit.zig index 09c3ffe7..2dc79ab5 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -1292,6 +1292,57 @@ pub fn normalJoin(p: *Pardes, t: *Text, select_space: bool) void { t.setRanges(t2, rs[0..n], &.{}, pri, expl); } +/// `Alt-)` / `Alt-(`: rotate the TEXT of the selections forward / back by +/// one range (count ranges), in one edit (helix reorder_selection_contents). +/// Each range comes back over the text it now holds, in its own direction, +/// and the primary moves with the text it had. +pub fn normalRotateContents(p: *Pardes, t: *Text, forward: bool, cnt: usize) void { + const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; + var b = selRows(t); + for (t.sels[0..t.nsel]) |s| { + b.lo_row = @min(b.lo_row, @min(s.row, s.arow)); + b.hi_row = @max(b.hi_row, @max(s.row, s.arow)); + } + const eb = editTextEol(p, t, b) orelse return; + const text = eb.text; + var rs: [Text.max_selections]modal.Selection = undefined; + const got = t.ranges(text, eb.row0, &rs); + const n = got.n; + if (n < 2) return; + const by = @min(cnt, n); + var chs: [Text.max_selections]TextChange = undefined; + for (rs[0..n], 0..) |r, i| { + const src = rs[if (forward) (i + n - by) % n else (i + by) % n]; + chs[i] = .{ + .from = @min(r.anchor, r.head), + .to = @max(r.anchor, r.head), + .ins = text[@min(src.anchor, src.head)..@max(src.anchor, src.head)], + }; + } + pushUndo(p, t); + const new = applyChanges(p, text, chs[0..n]) catch return; + var cells: [Text.max_selections]Text.SelRange = undefined; + var delta: i64 = 0; + for (chs[0..n], rs[0..n], 0..) |ch, r, i| { + const from: usize = @intCast(@as(i64, @intCast(ch.from)) + delta); + delta += @as(i64, @intCast(ch.ins.len)) - @as(i64, @intCast(ch.to - ch.from)); + const to = from + ch.ins.len; + const c = Text.rangeCells(new, if (r.anchor > r.head) .{ .anchor = to, .head = from } else .{ .anchor = from, .head = to }); + const cc = modal.positionAt(new, c.cur); + const ac = modal.positionAt(new, c.anc); + cells[i] = .{ + .row = @as(i32, @intCast(cc.row)) + eb.row0, + .col = @intCast(cc.col), + .arow = @as(i32, @intCast(ac.row)) + eb.row0, + .acol = @intCast(ac.col), + }; + } + setEditText(p, t, new); + const t2 = flatSurface(p, t) catch return; + for (cells[0..n], 0..) |s, i| rs[i] = Text.cellRange(t2, s.arow, s.acol, s.row, s.col); + t.setRanges(t2, rs[0..n], &.{}, if (forward) (got.pri + by) % n else (got.pri + n - by) % n, expl); +} + pub fn normalIndent(p: *Pardes, t: *Text, cnt: usize, add: bool) void { t.select = false; const sr = selRows(t); diff --git a/src/modal.zig b/src/modal.zig index 0e6aba61..6bf7918f 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -109,6 +109,8 @@ pub const Normal = struct { to_uppercase, join_lines, join_select_space, + rotate_contents_fwd, + rotate_contents_back, indent, unindent, format, @@ -262,6 +264,8 @@ pub const Normal = struct { uppercase, join_lines, join_select_space, + rotate_contents_forward, + rotate_contents_backward, indent, unindent, comment_toggle, @@ -319,7 +323,14 @@ pub const Normal = struct { .search_step, => .once, .edit => |edit| switch (edit.kind) { - .join_lines, .join_select_space, .comment_toggle, .undo, .redo => .once, + .join_lines, + .join_select_space, + .rotate_contents_forward, + .rotate_contents_backward, + .comment_toggle, + .undo, + .redo, + => .once, else => .per_selection, }, else => .per_selection, @@ -552,6 +563,8 @@ pub const Normal = struct { if (key.has(.to_uppercase)) return resultAction(.{ .edit = .{ .kind = .uppercase, .count = count } }); if (key.has(.join_lines)) return resultAction(.{ .edit = .{ .kind = .join_lines, .count = count } }); if (key.has(.join_select_space)) return resultAction(.{ .edit = .{ .kind = .join_select_space, .count = count } }); + if (key.has(.rotate_contents_fwd)) return resultAction(.{ .edit = .{ .kind = .rotate_contents_forward, .count = count } }); + if (key.has(.rotate_contents_back)) return resultAction(.{ .edit = .{ .kind = .rotate_contents_backward, .count = count } }); if (key.has(.indent)) return resultAction(.{ .edit = .{ .kind = .indent, .count = count } }); if (key.has(.unindent)) return resultAction(.{ .edit = .{ .kind = .unindent, .count = count } }); if (key.has(.format)) return resultAction(.{ .lsp = .format }); diff --git a/src/normal.zig b/src/normal.zig index c976823c..9c6f2acd 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -542,6 +542,8 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void .uppercase => return edit.normalCase(p, t, .upper), .join_lines => return edit.normalJoin(p, t, false), .join_select_space => return edit.normalJoin(p, t, true), + .rotate_contents_forward => return edit.normalRotateContents(p, t, true, op.count), + .rotate_contents_backward => return edit.normalRotateContents(p, t, false, op.count), .indent => return edit.normalIndent(p, t, op.count, true), .unindent => return edit.normalIndent(p, t, op.count, false), .comment_toggle => return edit.normalToggleComment(p, t), diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 0b8858e6..471d3a6c 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -404,6 +404,13 @@ {"name": "msel-trim", "pane": "file", "text": " ab \n", "keys": "%_"} {"name": "msel-trim-all-ws", "pane": "file", "text": " \n", "keys": "%_"} {"name": "msel-trim-multi", "pane": "file", "text": " ab \n cd \n", "keys": "xC_"} +{"name": "msel-rotate-contents-fwd", "pane": "file", "text": "a b c\n", "keys": "%s\\w"} +{"name": "msel-rotate-contents-back", "pane": "file", "text": "a b c\n", "keys": "%s\\w"} +{"name": "msel-rotate-contents-count", "pane": "file", "text": "a b c d\n", "keys": "%s\\w2"} +{"name": "msel-rotate-contents-lengths", "pane": "file", "text": "one to three\n", "keys": "%s\\w+)"} +{"name": "msel-rotate-contents-backward", "pane": "file", "text": "ab cde\n", "keys": "%s\\w+"} +{"name": "msel-rotate-contents-lines", "pane": "file", "text": "aa\nbbb\n", "keys": "%"} +{"name": "rotate-contents-one", "pane": "file", "text": "ab\n", "keys": "vl"} {"name": "msel-insert", "pane": "file", "text": "aa\nbb\n", "keys": "Cix"} {"name": "msel-insert-tty", "pane": "tty", "text": "aa\nbb\n", "keys": "Cix"} {"name": "msel-append", "pane": "file", "text": "aa\nbb\n", "keys": "Caz"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index 79745d43..516b2417 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -404,6 +404,13 @@ {"name":"msel-trim","text":" ab \n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":2}} {"name":"msel-trim-all-ws","text":" \n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}} {"name":"msel-trim-multi","text":" ab \n cd \n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":1}}],"primary":1} +{"name":"msel-rotate-contents-fwd","text":"c a b\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}}],"primary":1} +{"name":"msel-rotate-contents-back","text":"b c a\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}}],"primary":2} +{"name":"msel-rotate-contents-count","text":"c d a b\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}},{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}}],"primary":2} +{"name":"msel-rotate-contents-lengths","text":"to three one\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":7},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":11},"anchor":{"row":0,"col":9}}],"primary":0} +{"name":"msel-rotate-contents-backward","text":"cde ab\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":5},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":5}}],"primary":1} +{"name":"msel-rotate-contents-lines","text":"bbb\naa\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1} +{"name":"rotate-contents-one","text":"ab\n","mode":"select","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}} {"name":"msel-insert","text":"xaa\nxbb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1} {"name":"msel-insert-tty","text":"xaa\nxbb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1} {"name":"msel-append","text":"aza\nbzb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1} -- cgit v1.3 From ccd3075cac77044498a8c35d2482c8b4cb8f56e7 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:35:52 -0300 Subject: Backspace in a line's leading blanks goes back to the previous indent stop helix's delete_char_backward dedents: when everything before the cursor on its line is blank, it deletes back to the previous multiple of the indent width, a whole unit when already on one (a tab before the cursor still goes alone). pardes deleted one space, so O under an indented line left three of four, which is where helix-golf's function_into_class ended up. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 6 +++--- src/edit.zig | 15 +++++++++++++-- test/hxcases/cases.jsonl | 5 +++++ test/hxcases/goldens.jsonl | 5 +++++ test/hxcases/parity-waivers.jsonl | 1 + 5 files changed, 27 insertions(+), 5 deletions(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 23d63454..8023ba6e 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -129,7 +129,7 @@ language-backend queries, and the shell pipe. | `n` / `N` (any pane) | MOTION, not a jump: move the SELECTION to the next / previous look-able text and open NOTHING. Enter — the look chord — on what it leaves selected is what opens it | KEEP, do not touch; not in the corpus (helix's `n`/`N` walk regex search hits). What a step selects is the pane's GRAIN (`output_pane.Grain`, read in `Pardes.lookSpanIn`): in FREE TEXT — a terminal, a file, a PDF, a prose answer buffer — the largest whitespace-delimited run `look.resolve` can act on (`look.lookableSpan`, wrapper punctuation peeled off both ends), several to a line; in a RESULTS BUFFER one stop per ROW, the largest run its head resolves as (`look.lookableLineSpan`), because a row there IS one location and the words after it are the match rather than a second place to go; in a COMMAND list the whole line. The walk is a RING across PANES: every pane that has performed a Look, most recent first (`Pardes.look_src`), then the output buffers that have not, newest first, and only when both are empty the active pane. Exhausting a pane enters the next at its first (forward) / last (backward) span and the end wraps to the start, so `N` is the exact inverse of `n`. What it lands on becomes an EXPLICIT `vsel` with the cursor on its FIRST column, in the pane the walk focuses. ONE motion in every pane kind and every buffer kind — a PDF steps the `+Search` buffer its own search filled, `n` to select the row and Enter to jump. The single thing a buffer may change is that grain, and it changes it by BEING a kind of buffer rather than by a branch: `output_pane.Traits.steps` (a list of locations) makes a step take one row at a time, and `Traits.commands` makes it take the WHOLE LINE, because a command list (`ThemeSel`/`FontSel`) holds words to run and there is no path inside `Theme gruvbox` to pick out. Tab on what `n` selected wears the theme, which is the same middle click on the row is. `]d`/`[d` are helix's diagnostic motions, a different binding, and they do still jump to each diagnostic (`docs/lsp.md`) | pardes-specific | | insert: printable text | file: real edit; terminal: typed run splice | | helix-verified (file) | | insert: `Enter` | newline; keeps the current full indent levels and adds one 4-space logical tab when the text before the cursor ends in `(`, `[`, `{`, or `)` (including `})`) | plain lines match `insert_newline`; delimiter heuristic is pardes-specific | helix-verified (plain) / pardes-specific (delimiter) | -| insert: `Backspace` (+ `Shift-Backspace`) | delete prev char, joins lines at col 0 | matches `delete_char_backward`; `Ctrl-h` alias in B | helix-verified | +| insert: `Backspace` (+ `Shift-Backspace`) | delete prev char, joins lines at col 0; in a line's leading blanks, back to the previous 4-column indent stop (a whole unit when on one; a tab still goes alone) | matches `delete_char_backward` and its dedent; `Ctrl-h` alias in B | helix-verified | | insert: `Up` `Down` `Left` `Right` | move cursor | matches helix's "not recommended" insert arrows | helix-verified | | `gd` `gD` `gy` `gi` `gr` | LSP definition / declaration / type-definition / implementation / references. ONE answer jumps straight there; several fill `+Search`, where n/N walk and Enter opens | in-process ZLS (`src/lsp/lsp_zls.zig`), `.zig` only — on a file the backend does not speak these do nothing at all, with no error row. Ctrl+left-click is the mouse spelling of `gd` | out of corpus | | `]d` `[d` / `]D` `[D` | step the diagnostics list / go to its last or first; if no list is up, asking the backend for one is part of the press | | out of corpus | @@ -397,11 +397,11 @@ Files (all in `test/hxcases/`): `sel-regex-dot-newline` (mvzr is not the Rust regex crate: no multi-line `^`/`$`, and `.` matches a newline). - `parity.jsonl` — 80 further cases, used only by the parity gate below. -- `parity-waivers.jsonl` — 13 named exemptions for the parity gate, in three +- `parity-waivers.jsonl` — 14 named exemptions for the parity gate, in three classes: one deliberate pardes binding (`ctrl-b-page`, since `Ctrl-b` IS the tty toggle), eight pty VIEWPORT divergences (a terminal's view cannot scroll below the vt's live grid bottom, so the cursor snaps into a different - scrolloff band — no text differs), and four case texts a pty cannot hold + scrolloff band — no text differs), and five case texts a pty cannot hold verbatim (a literal TAB the emulator expands, a file with no trailing newline, an all-whitespace last row the dump trims). - `golf.jsonl` + `golf-goldens.jsonl` — every example on helix-golf diff --git a/src/edit.zig b/src/edit.zig index 2dc79ab5..5ba46ca7 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -694,8 +694,19 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { Key.backspace => { if (t.cur_col > 0) { const line = modal.lineSlice(text, c.row); - const prev = modal.prevGrapheme(line, c.col); - const new = modal.deleteChar(p.gpa, text, .{ .row = c.row, .col = prev }) catch return; + var prev = modal.prevGrapheme(line, c.col); + // in leading blanks, back to the previous indent stop (helix + // dedent): a whole unit when on one, else the part past it + const lead = line[0..@min(c.col, line.len)]; + if (std.mem.indexOfNone(u8, lead, " \t") == null and prev < line.len and line[prev] != '\t') { + var width: usize = 0; + for (lead) |ch| width += if (ch == '\t') modal.INDENT_W else 1; + var drop = width % modal.INDENT_W; + if (drop == 0) drop = modal.INDENT_W; + prev = c.col; + while (drop > 0 and prev > 0 and line[prev - 1] == ' ') : (drop -= 1) prev -= 1; + } + const new = modal.deleteSpan(p.gpa, text, .{ .row = c.row, .col = prev }, c) catch return; setEditText(p, t, new); t.cur_col = @intCast(prev); t.cur_pinned = true; diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 471d3a6c..cfd82cce 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -355,6 +355,11 @@ {"name": "ins-enter-indent", "pane": "file", "text": " foo\n", "keys": "Ax"} {"name": "ins-backspace", "pane": "file", "text": "abc\n", "keys": "lli"} {"name": "ins-backspace-join", "pane": "file", "text": "ab\ncd\n", "keys": "ji"} +{"name": "ins-backspace-indent-unit", "pane": "file", "text": " x\n", "keys": "8li"} +{"name": "ins-backspace-indent-partial", "pane": "file", "text": " x\n", "keys": "6li"} +{"name": "ins-backspace-indent-tab", "pane": "file", "text": "\t\tx\n", "keys": "2li"} +{"name": "ins-backspace-open-indent", "pane": "file", "text": " ab\n", "keys": "O"} +{"name": "ins-backspace-after-text", "pane": "file", "text": " a x\n", "keys": "7li"} {"name": "ins-ctrl-w", "pane": "file", "text": "one two\n", "keys": "A"} {"name": "ins-ctrl-w-col0-join", "pane": "file", "text": "ab\ncd\n", "keys": "ji"} {"name": "ins-alt-bs", "pane": "file", "text": "one two\n", "keys": "A"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index 516b2417..e258b485 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -355,6 +355,11 @@ {"name":"ins-enter-indent","text":" foo\n x\n","mode":"normal","cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}} {"name":"ins-backspace","text":"ac\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} {"name":"ins-backspace-join","text":"abcd\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}} +{"name":"ins-backspace-indent-unit","text":" x\n","mode":"insert","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}} +{"name":"ins-backspace-indent-partial","text":" x\n","mode":"insert","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}} +{"name":"ins-backspace-indent-tab","text":"\tx\n","mode":"insert","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} +{"name":"ins-backspace-open-indent","text":"\n ab\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"ins-backspace-after-text","text":" a x\n","mode":"insert","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}} {"name":"ins-ctrl-w","text":"one \n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}} {"name":"ins-ctrl-w-col0-join","text":"cd\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} {"name":"ins-alt-bs","text":"one \n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}} diff --git a/test/hxcases/parity-waivers.jsonl b/test/hxcases/parity-waivers.jsonl index 39a6a7c9..ad24eaa9 100644 --- a/test/hxcases/parity-waivers.jsonl +++ b/test/hxcases/parity-waivers.jsonl @@ -11,3 +11,4 @@ {"name":"comment-tab-indent","reason":"The terminal expands literal tabs to spaces before editing.","reference":{"name":"comment-tab-indent","text":"\t# alpha\n\t# beta\n","mode":"normal","cursor":{"row":1,"col":7},"anchor":{"row":0,"col":0}},"expected":{"name":"comment-tab-indent","text":" # alpha\n # beta\n","mode":"normal","cursor":{"row":1,"col":14},"anchor":{"row":0,"col":0}}} {"name":"comment-noeol","reason":"Terminal capture restores a trailing newline that the source omits.","reference":{"name":"comment-noeol","text":"# alpha","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},"expected":{"name":"comment-noeol","text":"# alpha\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}} {"name":"comment-only-blank","reason":"Terminal capture expands tabs and trims trailing blank rows.","reference":{"name":"comment-only-blank","text":" \n\t\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":0,"col":0}},"expected":{"name":"comment-only-blank","text":" \n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}}} +{"name":"ins-backspace-indent-tab","reason":"The terminal expands literal tabs to spaces before editing.","reference":{"name":"ins-backspace-indent-tab","text":"\tx\n","mode":"insert","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},"expected":{"name":"ins-backspace-indent-tab","text":" x\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}} -- cgit v1.3 From 9bade7130bc0aa8cbcea82c446fc5747453b1360 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:38:50 -0300 Subject: & aligns the selections into columns helix-golf's enumerate_and_align ends on %s |\d+&. align_selections treats the k-th range of each line as column k and puts spaces before each range until its head reaches the widest head of its column, measured in display cells, which File.rawDisplayCol already gives. A range over several lines refuses, as in helix. The example itself still needs more than 64 ranges, which the selection model does not hold. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 3 +- src/config.zig | 1 + src/edit.zig | 80 ++++++++++++++++++++++++++++++++++++++++++++++ src/modal.zig | 4 +++ src/normal.zig | 1 + test/hxcases/cases.jsonl | 7 ++++ test/hxcases/goldens.jsonl | 7 ++++ 7 files changed, 101 insertions(+), 2 deletions(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 8023ba6e..dc0f3647 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -226,7 +226,7 @@ grew `sels` + `primary`, emitted only when there is more than one range. | every motion/operator | acts at every cursor | replayed last-range-first, so an edit never disturbs a range still waiting; each pass's result is remembered as a distance from the END of the text, which an earlier edit cannot move | helix-verified (56 msel-* cases) | | `y` with several ranges | joins the ranges' text with newlines into the ONE register | helix keeps a register VALUE per range and pastes value[i] at range[i]. Waived (`msel-yank-paste`) | waived | | `a` … `Esc` with several ranges | the primary's appended-over span is restored; the others collapse to bare cursors | `Pane.append_at` is a single field. Waived (`msel-append`) | waived | -| `&` | align selections into a column | **skipped**: needs visual (tab-expanded) columns, which nothing else in pardes measures | +| `&` | align selections into columns: the k-th range of each line is column k, and spaces go in before each range until its head reaches the column's widest head, in display cells (`File.rawDisplayCol`) | helix `align_selections` as of the harness's helix (25.07.1 grouped columns differently). A range over several lines refuses. A TAB counts as `tab_width` cells, not up to the next stop | helix-verified | | `Alt-)` / `Alt-(` / `Alt-)` | rotate the CONTENTS of the selections forward / back by one range (n ranges), in one edit; each range comes back over the text it now holds and the primary moves with its text | helix `rotate_selection_contents_*` as of the harness's helix: 25.07.1 read the count as a GROUP size instead (rotate by one within each run of n ranges) and left the primary where it was, which is what helix-golf's `invert_dictionary_2` relies on | helix-verified | Anything that reaches outside the buffer — a builtin, a language query — runs @@ -347,7 +347,6 @@ text. | `Ctrl-s` (normal) | save jumplist position | jumplist itself is implemented (`Ctrl-o`/`Ctrl-i`, section A); only the explicit save point is skipped | | `Alt-u` / `Alt-U` | undo-history earlier/later | history timeline — linear snapshot u/U covers pardes | | `K Alt-K`, `Alt-:` | regex keep/remove, ensure-forward | `K`/`Alt-K` are the same prompt `s`/`S` now have, filtering instead of splitting (`s S` moved to "Regex selection" in A, the rest of the family to "Multiple cursors") | -| `&` | align selections | see the multiple-cursors table for why | | `Alt-\|` `!` `Alt-!` `$` | shell pipe-to (output discarded), insert output, append output, keep-by-exit-status | shell — `\|` (`shell_pipe`, output replaces the selection) is implemented in section A; these four are the other members of helix's shell family. `$` is additionally taken for line-end here (section A) | | `:` | command mode | side-effects/file-ops — pardes builtins live in the tag, and `:` is bound to focusing it (section A) | | `gf` | goto file under selection | covered by pardes Enter-look | diff --git a/src/config.zig b/src/config.zig index edc73837..85a6bc59 100644 --- a/src/config.zig +++ b/src/config.zig @@ -541,6 +541,7 @@ pub const join_lines: []const Chord = &.{.{ .cp = 'J' }}; pub const join_select_space: []const Chord = &.{.{ .cp = 'J', .alt = true }}; pub const rotate_contents_fwd: []const Chord = &.{.{ .cp = ')', .alt = true }}; pub const rotate_contents_back: []const Chord = &.{.{ .cp = '(', .alt = true }}; +pub const align_selections: []const Chord = &.{.{ .cp = '&' }}; pub const indent: []const Chord = &.{.{ .cp = '>' }}; pub const unindent: []const Chord = &.{.{ .cp = '<' }}; pub const format: []const Chord = &.{.{ .cp = '=' }}; diff --git a/src/edit.zig b/src/edit.zig index 5ba46ca7..24354df8 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -1354,6 +1354,86 @@ pub fn normalRotateContents(p: *Pardes, t: *Text, forward: bool, cnt: usize) voi t.setRanges(t2, rs[0..n], &.{}, if (forward) (got.pri + by) % n else (got.pri + n - by) % n, expl); } +/// `&`: pad the selections into columns, in one edit (helix +/// align_selections). The k-th range of every line is a column; spaces go in +/// before each range so its head lands on the column's widest head, measured +/// in display cells. A range over several lines aligns nothing. +pub fn normalAlign(p: *Pardes, t: *Text) void { + const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; + var b = selRows(t); + for (t.sels[0..t.nsel]) |s| { + b.lo_row = @min(b.lo_row, @min(s.row, s.arow)); + b.hi_row = @max(b.hi_row, @max(s.row, s.arow)); + } + const eb = editTextEol(p, t, b) orelse return; + const text = eb.text; + var rs: [Text.max_selections]modal.Selection = undefined; + const got = t.ranges(text, eb.row0, &rs); + const n = got.n; + var row: [Text.max_selections]usize = undefined; + var col: [Text.max_selections]usize = undefined; + var widths: [Text.max_selections]usize = @splat(0); + var line: usize = std.math.maxInt(usize); + var k: usize = 0; + var run: usize = 0; + for (rs[0..n], 0..) |r, i| { + row[i] = modal.lineAtOffset(text, r.head); + if (modal.lineAtOffset(text, r.anchor) != row[i]) return; + const ls = modal.lineStartOffset(text, row[i]); + col[i] = panes.File.rawDisplayCol(text[ls..modal.lineEndOffset(text, row[i])], r.head - ls); + if (row[i] != line) { + line = row[i]; + k = 0; + run = 0; + } + widths[k] = @max(widths[k], col[i] - run); + run = col[i]; + k += 1; + } + t.select = false; // helix exit_select_mode + // each column's head position, left to right + for (1..n) |i| widths[i] += widths[i - 1]; + const arena = p.scratch.allocator(); + var chs: [Text.max_selections]TextChange = undefined; + var m: usize = 0; + line = std.math.maxInt(usize); + for (rs[0..n], 0..) |r, i| { + if (row[i] != line) { + line = row[i]; + k = 0; + run = 0; + } + const pad = widths[k] - col[i] - run; + k += 1; + run += pad; + if (pad == 0) continue; + const spaces = arena.alloc(u8, pad) catch return; + @memset(spaces, ' '); + const at = @min(r.anchor, r.head); + chs[m] = .{ .from = at, .to = at, .ins = spaces }; + m += 1; + } + if (m == 0) return; + pushUndo(p, t); + const new = applyChanges(p, text, chs[0..m]) catch return; + var cells: [Text.max_selections]Text.SelRange = undefined; + for (rs[0..n], 0..) |r, i| { + const c = Text.rangeCells(text, r); + const cc = modal.positionAt(new, mapThroughChanges(chs[0..m], c.cur)); + const ac = modal.positionAt(new, mapThroughChanges(chs[0..m], c.anc)); + cells[i] = .{ + .row = @as(i32, @intCast(cc.row)) + eb.row0, + .col = @intCast(cc.col), + .arow = @as(i32, @intCast(ac.row)) + eb.row0, + .acol = @intCast(ac.col), + }; + } + setEditText(p, t, new); + const t2 = flatSurface(p, t) catch return; + for (cells[0..n], 0..) |s, i| rs[i] = Text.cellRange(t2, s.arow, s.acol, s.row, s.col); + t.setRanges(t2, rs[0..n], &.{}, got.pri, expl); +} + pub fn normalIndent(p: *Pardes, t: *Text, cnt: usize, add: bool) void { t.select = false; const sr = selRows(t); diff --git a/src/modal.zig b/src/modal.zig index 6bf7918f..aadaab00 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -111,6 +111,7 @@ pub const Normal = struct { join_select_space, rotate_contents_fwd, rotate_contents_back, + align_selections, indent, unindent, format, @@ -266,6 +267,7 @@ pub const Normal = struct { join_select_space, rotate_contents_forward, rotate_contents_backward, + align_selections, indent, unindent, comment_toggle, @@ -327,6 +329,7 @@ pub const Normal = struct { .join_select_space, .rotate_contents_forward, .rotate_contents_backward, + .align_selections, .comment_toggle, .undo, .redo, @@ -565,6 +568,7 @@ pub const Normal = struct { if (key.has(.join_select_space)) return resultAction(.{ .edit = .{ .kind = .join_select_space, .count = count } }); if (key.has(.rotate_contents_fwd)) return resultAction(.{ .edit = .{ .kind = .rotate_contents_forward, .count = count } }); if (key.has(.rotate_contents_back)) return resultAction(.{ .edit = .{ .kind = .rotate_contents_backward, .count = count } }); + if (key.has(.align_selections)) return resultAction(.{ .edit = .{ .kind = .align_selections, .count = count } }); if (key.has(.indent)) return resultAction(.{ .edit = .{ .kind = .indent, .count = count } }); if (key.has(.unindent)) return resultAction(.{ .edit = .{ .kind = .unindent, .count = count } }); if (key.has(.format)) return resultAction(.{ .lsp = .format }); diff --git a/src/normal.zig b/src/normal.zig index 9c6f2acd..5a6c1cd5 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -544,6 +544,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void .join_select_space => return edit.normalJoin(p, t, true), .rotate_contents_forward => return edit.normalRotateContents(p, t, true, op.count), .rotate_contents_backward => return edit.normalRotateContents(p, t, false, op.count), + .align_selections => return edit.normalAlign(p, t), .indent => return edit.normalIndent(p, t, op.count, true), .unindent => return edit.normalIndent(p, t, op.count, false), .comment_toggle => return edit.normalToggleComment(p, t), diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index cfd82cce..487cd445 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -415,6 +415,13 @@ {"name": "msel-rotate-contents-lengths", "pane": "file", "text": "one to three\n", "keys": "%s\\w+)"} {"name": "msel-rotate-contents-backward", "pane": "file", "text": "ab cde\n", "keys": "%s\\w+"} {"name": "msel-rotate-contents-lines", "pane": "file", "text": "aa\nbbb\n", "keys": "%"} +{"name": "msel-align-basic", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=&"} +{"name": "msel-align-columns", "pane": "file", "text": "a,b,c\nccc,d,e\n", "keys": "%s,&"} +{"name": "msel-align-uneven", "pane": "file", "text": "a,b\nccc,d,eee,f\n", "keys": "%s,&"} +{"name": "msel-align-words", "pane": "file", "text": "x = 1\nlonger = 22\n", "keys": "%s\\w+&"} +{"name": "msel-align-backward", "pane": "file", "text": "x = 11\nlonger = 222\n", "keys": "%s\\d+&"} +{"name": "align-multiline-refused", "pane": "file", "text": "ab\ncd\n", "keys": "vj&"} +{"name": "msel-align-select-mode", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=v&"} {"name": "rotate-contents-one", "pane": "file", "text": "ab\n", "keys": "vl"} {"name": "msel-insert", "pane": "file", "text": "aa\nbb\n", "keys": "Cix"} {"name": "msel-insert-tty", "pane": "tty", "text": "aa\nbb\n", "keys": "Cix"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index e258b485..006524e8 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -415,6 +415,13 @@ {"name":"msel-rotate-contents-lengths","text":"to three one\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":7},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":11},"anchor":{"row":0,"col":9}}],"primary":0} {"name":"msel-rotate-contents-backward","text":"cde ab\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":5},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":5}}],"primary":1} {"name":"msel-rotate-contents-lines","text":"bbb\naa\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1} +{"name":"msel-align-basic","text":"a =1\nbbb=2\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":0} +{"name":"msel-align-columns","text":"a ,b,c\nccc,d,e\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}}],"primary":0} +{"name":"msel-align-uneven","text":"a ,b\nccc,d,eee,f\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}}],"primary":0} +{"name":"msel-align-words","text":" x = 1\nlonger = 22\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5},"sels":[{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":0,"col":10},"anchor":{"row":0,"col":10}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":0}},{"cursor":{"row":1,"col":10},"anchor":{"row":1,"col":9}}],"primary":0} +{"name":"msel-align-backward","text":"x = 11\nlonger = 222\n","mode":"normal","cursor":{"row":0,"col":9},"anchor":{"row":0,"col":10},"sels":[{"cursor":{"row":0,"col":9},"anchor":{"row":0,"col":10}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":11}}],"primary":0} +{"name":"align-multiline-refused","text":"ab\ncd\n","mode":"select","cursor":{"row":1,"col":0},"anchor":{"row":0,"col":0}} +{"name":"msel-align-select-mode","text":"a =1\nbbb=2\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":0} {"name":"rotate-contents-one","text":"ab\n","mode":"select","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}} {"name":"msel-insert","text":"xaa\nxbb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1} {"name":"msel-insert-tty","text":"xaa\nxbb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1} -- cgit v1.3 From 68b0fe7600987587ed0c27cf38a3fca8216c45b4 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:40:03 -0300 Subject: docs/selections.md says how normal mode holds its selections The primary and the others, cells against helix's gaps, how setRanges normalizes, which commands replay per range and which make one edit, and what undo, the register and repeat do with several ranges, with the places helix differs listed at the end. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + docs/selections.md | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 docs/selections.md diff --git a/README.md b/README.md index c82bd310..3e7a96f2 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,7 @@ sketch; `docs/design.pdf` is a retained rendering/search fixture and may lag it. | `docs/detached.md` | one core, many frontends, over a unix socket | | `docs/config.md` | build options and runtime configuration | | `docs/tags.md` | editable workspace, column and pane tags; filename drafts | +| `docs/selections.md` | the normal-mode selection model and how it differs from helix's | | `docs/fs.md` | default 9P service, Look resolution, and named mounts | | `docs/lsp.md` | in-process ZLS and external language servers | | `docs/lsp-evaluation.md` | why that backend, measured against the alternatives | diff --git a/docs/selections.md b/docs/selections.md new file mode 100644 index 00000000..5b992cc4 --- /dev/null +++ b/docs/selections.md @@ -0,0 +1,103 @@ +# Selections + +How normal mode holds and changes its selections, and where that differs from +helix's `Selection` (a list of ranges over gap offsets, with a primary index). +Key-by-key behaviour is in docs/helix-keys.md; this is the model underneath. + +## What a selection is + +Every `Text` (src/Text.zig) has its own: a pane's body, its tag, a prompt's +answer, a column's or the workspace's tag. A selection is one primary range +plus up to 63 others. + +- The PRIMARY is where the cursor always was: `cur_row`/`cur_col` is the + head, `vsel.row`/`vsel.col` the anchor while `vsel.active`, and a bare + cursor (inactive `vsel`) is the one-character range under it. `msel` is + the older whole-line form (`r0`..`r1`), kept for search-result highlights. +- The others are `sels[0..nsel]`, each a `SelRange` of head and anchor plus + its own `j`/`k` goal column, in document order. The primary is not in the + list; `Text.ranges` slots it in and returns its index. +- Ends are CELLS, the characters a block cursor sits on, where helix's are + gaps between characters. `Text.cellRange`, `cellOffRange` and `rangeCells` + convert. A range covers at least one character, as in helix (min width 1), + so there are no empty ranges. +- `vsel.explicit` says the selection was made on purpose (`v`, `x`, `%`, + `s`, `n`/`N`, ...) rather than left behind by a motion. Only pardes reads + it: the look (Enter) and execute (Tab) chords act on explicit selections + and on the word under the cursor otherwise. +- `select` is `v` extend mode. `append_at` remembers where an `a` began, so + Esc can give back the range it appended over. + +`Text.setRanges` is the one writer of a whole selection: it sorts by start, +merges ranges that overlap or share a start (helix `normalize`), follows the +primary through the merges, and keeps the first 64. `setRange` writes one +range and drops the others. + +## Making, splitting, merging, rotating, removing + +- Made several by `C`/`Alt-C` (copy to the next/previous line), `s` (every + match inside each range), `S` (the pieces between matches), `Alt-s` (one + per line), `o`/`O`, and `Alt-J` (the spaces a join put in). `s`/`S` + re-run on every keystroke from the selection the prompt opened on + (`Pane.sel_snap`); Esc is that snapshot restored. +- `Alt-minus` merges all into one, `Alt-_` the consecutive ones, `_` trims + whitespace (dropping all-blank ranges). +- `)`/`(` rotate which range is primary; `Alt-)`/`Alt-(` rotate the TEXT + between ranges, the primary moving with its text. +- `,` keeps only the primary, `Alt-,` removes it (the next range takes over). +- After `s`, `S` and `Alt-s` the primary is the first range, which is + helix's behaviour (its own TODO), not a choice. + +## How a command reaches every range + +A parsed action has a scope (`modal.Normal.Action.scope`). + +- `per_selection` (motions, `d`, `c`, `y`, `p`, `r`, `~`, `>`, `ms`, ...): + `normal.replaySels` runs the ONE-range handler once per range, last range + first, each pass seeing a single selection. A pass's result is stored as + distances from the END of the text, which an edit at an earlier range + cannot move; `setRanges` then puts them back together. The mode, prefix + and select state that survive are the primary's pass. Insert-mode keys are + replayed the same way. +- `once` (the `multi` family, `s`/`S`, `J`/`Alt-J`, `Alt-(`/`Alt-)`, `&`, + `Ctrl-c`, undo/redo, builtins, search): one function takes `Text.ranges`, + makes one edit and hands the mapped ranges to `setRanges`, the way a helix + command builds one transaction. +- Anything that reaches outside the text (a builtin, a language query, a + look) runs once from the primary and drops back to one cursor + (`normal.multiOnce`). + +## Undo, registers, repeat + +- One keystroke is one undo step however many ranges it edited: only the + first replay pass records (`pushUndo`). +- An undo snapshot (`File.Snapshot`) holds the content and the PRIMARY + range. Undo puts the primary back and leaves the other ranges where they + were; helix restores the whole selection of that revision. +- There is ONE register, `Pardes.yank`, written by `y`, `d` and `c` and read + by `p`, `P` and `R`. A yank at several ranges joins their texts with + newlines into it, and a paste puts that whole value at every range. +- `Alt-.` repeats the last `f`/`t`/`F`/`T`. There is no `.` and there are no + macros. + +## Where it differs from helix + +- Registers: helix keeps one value PER RANGE and pastes value `i` at range + `i`; it has named registers (`"a`), special ones (`#` range indices, + `.` the selection, `/` the search, `_` the black hole) and inserts a + register in insert mode with `Ctrl-r`. None exist here (waiver + `msel-yank-paste`). +- Insert mode: helix maps every range through each keystroke's edit, so a + range an `i`/`a` started from stretches over what is typed and keeps its + anchor. Here only the cursor cells move, and after Esc only the primary's + `append_at` span comes back (waivers `wiX-edit-drops-sel`, `msel-append`). +- Direction of a one-character range: both of its cells are the same cell, + so it has none. helix can flip one (`Alt-;`) and the commands that read + the head (`&`, the next extend) see the difference. +- Count: at most 64 ranges; matches past that are dropped silently. +- `n`/`N` walk the look ring (acme), not regex search hits, and there is no + `*`; helix-golf's `*`, `""N` and `n` do not apply. +- `s`/`S` match as sam does (docs/helix-keys.md, "Regex selection"): each + line is its own haystack, so `^`/`$` hold at every line and a class like + `\s` does not reach the newline unless the pattern names `\n`. +- Undo restores only the primary (above). -- cgit v1.3 From ab1c353f65efe8741b9a4d1aaef065af0aee885a Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:54:41 -0300 Subject: The two regex waivers are gone: s and S already match helix there s and S search each line as its own haystack, so ^ and $ hold at every line and . never takes a newline, which is helix's multi_line regex. The waivers for the old difference, sel-regex-caret and sel-regex-dot-newline, had gone stale, and --strict stopped on the first before printing its summary. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 13 ++++++------- test/hxcases/waivers.jsonl | 2 -- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index dc0f3647..972ef0d2 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -261,8 +261,8 @@ keeps the matches it found so far. | `S` Enter | split each range on its matches | helix `split_on_matches`; the pieces BETWEEN the matches, including the empty one a leading match produces | helix-verified | | `s`/`S` then Esc | back to the selection the prompt opened on | the empty pattern applies nothing, so cancelling IS the restore — one path, not a second one | helix-verified | | an all-lowercase pattern | matches case-blind | helix's smart-case. mvzr has no such flag, so the surface is lowercased instead (ASCII folding is byte-for-byte, so the offsets are identical) | helix-verified | -| `^` and `$` | assert at the SCAN position, not at a line | helix compiles with `multi_line(true)`, so its `^` is every line start. Waived (`sel-regex-caret`) | waived | -| `.` | matches a newline like any other byte | the Rust regex crate excludes `\n` by default; mvzr does not. `[^\n]` is the workaround and agrees in both. Waived (`sel-regex-dot-newline`) | waived | +| `^` and `$` | hold at every line's start and end | helix compiles with `multi_line(true)`; pardes gets the same by searching each line as its own haystack (`src/regexp.zig`) | helix-verified | +| `.` | never matches a newline | as the Rust regex crate: a pattern without `\n` searches line by line, and one that names `\n` has its `.`s made `[^\n]` (`src/regexp.zig`) | helix-verified | | more than 64 matches | the ones past `MAX_SELS` are dropped | the ceiling the whole selection model has, not this key's | | `K` / `Alt-K` | keep / remove ranges matching a regex | **skipped**: the same prompt, filtering instead of splitting — worth adding next | @@ -388,13 +388,12 @@ Files (all in `test/hxcases/`): (runs the `hx-harness` binary from the helix checkout; override with `$HX_HARNESS`). Only needed when cases change — the diff itself runs offline. -- `waivers.jsonl` — named exemptions, each with a reason. Six live ones: +- `waivers.jsonl` — named exemptions, each with a reason. Four live ones: `wiX-edit-drops-sel` and `msel-append` (helix maps selections through insert-mode edits, pardes does not), `alt-c-window-op` (the pardes - window op deliberately shadows helix change-noyank), `msel-yank-paste` - (one yank register, not one value per range), and `sel-regex-caret` / - `sel-regex-dot-newline` (mvzr is not the Rust regex crate: no - multi-line `^`/`$`, and `.` matches a newline). + window op deliberately shadows helix change-noyank) and `msel-yank-paste` + (one yank register, not one value per range). `sel-regex-caret` and + `sel-regex-dot-newline` went when `s`/`S` began searching line by line. - `parity.jsonl` — 80 further cases, used only by the parity gate below. - `parity-waivers.jsonl` — 14 named exemptions for the parity gate, in three classes: one deliberate pardes binding (`ctrl-b-page`, since `Ctrl-b` IS the diff --git a/test/hxcases/waivers.jsonl b/test/hxcases/waivers.jsonl index 82da2a08..01e6cfa7 100644 --- a/test/hxcases/waivers.jsonl +++ b/test/hxcases/waivers.jsonl @@ -2,5 +2,3 @@ {"name":"alt-c-window-op","reason":"Alt-c moves a pane to a new column instead of Helix's change-noyank.","reference":{"name":"alt-c-window-op","text":"bc def\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},"expected":{"name":"alt-c-window-op","text":"abc def\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}} {"name":"msel-append","reason":"Append-mode selection origins are tracked only for the primary selection.","reference":{"name":"msel-append","text":"aza\nbzb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1},"expected":{"name":"msel-append","text":"aza\nbzb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1}} {"name":"msel-yank-paste","reason":"Pardes has one combined yank register, not one value per selection.","reference":{"name":"msel-yank-paste","text":"aab\nccd\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1},"expected":{"name":"msel-yank-paste","text":"aa\ncb\nca\ncd\n","mode":"normal","cursor":{"row":3,"col":0},"anchor":{"row":2,"col":1},"sels":[{"cursor":{"row":1,"col":0},"anchor":{"row":0,"col":1}},{"cursor":{"row":3,"col":0},"anchor":{"row":2,"col":1}}],"primary":1}} -{"name":"sel-regex-dot-newline","reason":"The regex engine includes newlines in dot matches.","reference":{"name":"sel-regex-dot-newline","text":"ab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}],"primary":0},"expected":{"name":"sel-regex-dot-newline","text":"ab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}],"primary":0}} -{"name":"sel-regex-caret","reason":"The regex engine lacks Helix's multiline anchors.","reference":{"name":"sel-regex-caret","text":"ab\nab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":0},"expected":{"name":"sel-regex-caret","text":"ab\nab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}} -- cgit v1.3 From 767aefd1e1cfd79e2a240a59d2d16bfb326f062d Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:59:00 -0300 Subject: Registers hold a value per range, and " names one Registers.zig replaces the one yank buffer: every register keeps a value per range, y fills them in document order, and p, P, R and insert Ctrl-r put value i at range i, repeating the last (helix paste_impl). " names the register for the next command; _ swallows, # numbers the ranges, . is each range's text, % the file's name, / the last s/S pattern, and + and * are the system clipboard through the ClipYank and ClipPaste paths. SPC y now writes + alone, as helix's does. The acme chords and a paste into a terminal take the default register joined by newlines. The msel-yank-paste waiver is gone. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 21 +++++---- docs/selections.md | 15 +++--- src/Registers.zig | 106 ++++++++++++++++++++++++++++++++++++++++++ src/Text.zig | 2 + src/config.zig | 2 + src/edit.zig | 112 +++++++++++++++++++++++++++++++++++++-------- src/gui/gui.zig | 2 +- src/look.zig | 6 ++- src/modal.zig | 13 ++++++ src/mouse.zig | 12 ++--- src/normal.zig | 17 +++++-- src/pardes.zig | 22 ++++++--- test/hxcases/cases.jsonl | 13 ++++++ test/hxcases/goldens.jsonl | 13 ++++++ test/hxcases/waivers.jsonl | 1 - test/panes.zig | 20 ++++---- 16 files changed, 312 insertions(+), 65 deletions(-) create mode 100644 src/Registers.zig diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 972ef0d2..90efc463 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -110,9 +110,9 @@ language-backend queries, and the shell pipe. | `x` / `x` | select current line / extend one (n) line(s) down, char-range through the newline, cursor ON the `\n` | col-0 vim-ism removed (phase 5); matches `extend_line_below`, mode stays normal | helix-verified | | `d` | delete selection (implicit or explicit); bare cursor = the 1-wide selection | always yanks (`Alt-d` noyank in B); terminals: drops typed runs only | helix-verified | | `c` | change (delete + insert mode); linewise selections open a fresh indented line | same shape as `d` | helix-verified | -| `y` | yank selection; bare cursor yanks the 1-wide selection (char under cursor) | line-yank vim-ism removed (phase 5); yank keeps selection AND cursor (helix). Writes the DEFAULT REGISTER and nothing else — the system clipboard is `SPC y`, which is helix's own split and so moves this row TOWARDS helix, not away: a `d` of one character can no longer clobber what the desktop was holding | helix-verified | +| `y` | yank each range's text into the register (`"` names one, else `"`), one value per range | line-yank vim-ism removed (phase 5); yank keeps selection AND cursor (helix). The system clipboard is `"+y` / `SPC y`, helix's own split: a `d` of one character never clobbers what the desktop holds | helix-verified | | `u` / `U` | undo / redo | restores the pre-edit selection (helix); snapshot granularity, no `Alt-u`/`Alt-U` history walking (skipped) | helix-verified | -| `p` (normal) | paste the core's yank register after the selection | helix default-register semantics, and only the register — nothing on this path reads or writes the system clipboard. `SPC p` is the word that does, and on a tty its read is OSC 52, which most terminals refuse: an honest no-op there rather than a paste of the wrong text | helix-verified | +| `p` (normal) | paste the register after each range: value i at range i, the last value repeated when there are fewer | helix `paste_impl`. `"+p` asks the system clipboard (`SPC p`); on a tty that read is OSC 52, which most terminals refuse: an honest no-op rather than a paste of the wrong text | helix-verified | | `Esc` (body normal) | clear a pending modal prefix / exit select mode, keeping the selection, then run `Last`: hop to the pane you were in before this one, whichever kind it was, exactly like `SPC j j` — so held down it alternates between two panes, two files as readily as a file and its shell. A PDF pane is the ONE exception: there Esc is the document's own cancel (drop the mouse selection and the search overlay, stay where you are reading) and `Shift-Esc` is the hop out, while raw tty only intercepts unmodified Esc at a detected shell prompt | Pardes-specific focus binding layered on helix's cleanup. A leader path, tag, topbar or search owns Esc while it is active; raw tty forwards it | pardes-specific (cleanup helix-verified) | | `Esc` (insert) | back to normal mode, cursor right after the insertion (no vim left-step) | | helix-verified | | `Enter` (normal) | acme **look** chord: EXPLICIT selection, else file-ish word under cursor | pardes-specific, keep (helix normal-mode Enter unbound). Covers helix `gf`. Implicit motion residue falls back to the cursor word | pardes-specific | @@ -123,7 +123,7 @@ language-backend queries, and the shell pipe. | `Alt-n` | new terminal below (outside raw tty) | shadows helix `Alt-n` TS sibling-select — skipped anyway (tree-sitter) | pardes-specific | | `Alt-c` | move active terminal to a fresh column (outside raw tty) | helix `Alt-c` is change-noyank; the pardes window op wins (do-not-touch contract). `Alt-d` + `i` covers the behavior | waived (`alt-c-window-op`) | | `Space` (normal, body) | starts the pardes LEADER: a key path runs the same builtin words used by tags and the topbar. The main groups are `f` files, `h` docs, `c` columns, `t` toggles/effects, `a` panel animations, `s` session, `j` jumps, `l` language, and `w` directional focus; `SPC ?` lists every path and ` ?` lists one group in `+Help`. Neither `Exit` (quits the editor, acme's Exit) nor `Kill` (stops the commands pardes typed into terminals, acme's Kill) has a leader path: both are the topbar's and the root `ctl`'s. The pending path appears on the active pane's transient body/message row. Esc or an unmapped key abandons it. Paths, Help rows, and dispatch are all generated from the builtin registry at comptime; the leader applies only to a BODY in normal mode because tags and tty programs own their input | pardes-specific; helix spends Space on pickers/LSP (section C), while pardes uses acme-style executable words | -| `SPC y` `SPC Y` `SPC p` `SPC P` `SPC R` | helix's clipboard menu on helix's own letters: yank the selection to the system clipboard (`ClipYank`) or the PRIMARY selection alone (`ClipYankMain`), paste the system clipboard after (`ClipPaste`) / before (`ClipPasteBefore`) the selection, replace the selection with it (`ClipReplace`) | the ONLY five words in pardes that touch the desktop's clipboard — `y`/`d`/`c`/`p`/`P`/`R` and the acme cut/paste chords are the internal register alone. Builtins rather than bare chords because a leader path names a builtin: they land in Help's index and are executable words like every other verb. One divergence: pardes keeps a single register VALUE where helix keeps one per range, so `SPC y` at N cursors joins them with newlines (`Pardes.setYank`, the divergence `msel-yank-paste` already waives). On a tty the write is OSC 52 out and the READ is OSC 52 back, which many terminals refuse or gate — so `SPC y` works there and `SPC p` can be a no-op | out of corpus | +| `SPC y` `SPC Y` `SPC p` `SPC P` `SPC R` | helix's clipboard menu on helix's own letters: yank the selection to the system clipboard (`ClipYank`) or the PRIMARY selection alone (`ClipYankMain`), paste the system clipboard after (`ClipPaste`) / before (`ClipPasteBefore`) the selection, replace the selection with it (`ClipReplace`) | the same as `"+y`, `"+p`, `"+P`, `"+R`: register `+` (and `*`) IS the desktop's clipboard, the only register that reaches it. `SPC y` writes `+` alone, as helix's does, not the default register too. Builtins rather than bare chords because a leader path names a builtin: they land in Help's index and are executable words like every other verb. The clipboard holds one text, so N values go out joined by newlines, as helix sends them. On a tty the write is OSC 52 out and the READ is OSC 52 back, which many terminals refuse or gate — so `SPC y` works there and `SPC p` can be a no-op | out of corpus | | a paste from the OUTER terminal | one `Event.paste`, spliced in at the cursor | the tty shell enables bracketed paste and coalesces `paste_start`..`paste_end` into a single event; before that the bytes arrived as individual key presses and normal mode RAN them, which is how a pasted `d` deleted a line. The bytes deliberately never enter the yank register — clipboard and default register are separate stores in both directions | pardes-specific | | `/` (any pane) | pardes' own plain-substring search into a `+Search` output buffer: the pattern is typed on a line of its own on the pane's notice band, Enter fills the buffer, and its rows are ordinary look targets. Enter also GOES to the first row — the buffer is focused and then the step `n` is and the look Enter is run in it (`Pardes.lookFirstHit`), so `/foo` lands on the first hit with the matched span selected. A pattern that matched nothing opens its empty buffer and moves nothing | KEEP, do not touch; not in the corpus (helix `/` is regex search). Find and Grep answer with OTHER files and deliberately do NOT jump. The stepping half is the next row | pardes-specific | | `n` / `N` (any pane) | MOTION, not a jump: move the SELECTION to the next / previous look-able text and open NOTHING. Enter — the look chord — on what it leaves selected is what opens it | KEEP, do not touch; not in the corpus (helix's `n`/`N` walk regex search hits). What a step selects is the pane's GRAIN (`output_pane.Grain`, read in `Pardes.lookSpanIn`): in FREE TEXT — a terminal, a file, a PDF, a prose answer buffer — the largest whitespace-delimited run `look.resolve` can act on (`look.lookableSpan`, wrapper punctuation peeled off both ends), several to a line; in a RESULTS BUFFER one stop per ROW, the largest run its head resolves as (`look.lookableLineSpan`), because a row there IS one location and the words after it are the match rather than a second place to go; in a COMMAND list the whole line. The walk is a RING across PANES: every pane that has performed a Look, most recent first (`Pardes.look_src`), then the output buffers that have not, newest first, and only when both are empty the active pane. Exhausting a pane enters the next at its first (forward) / last (backward) span and the end wraps to the start, so `N` is the exact inverse of `n`. What it lands on becomes an EXPLICIT `vsel` with the cursor on its FIRST column, in the pane the walk focuses. ONE motion in every pane kind and every buffer kind — a PDF steps the `+Search` buffer its own search filled, `n` to select the row and Enter to jump. The single thing a buffer may change is that grain, and it changes it by BEING a kind of buffer rather than by a branch: `output_pane.Traits.steps` (a list of locations) makes a step take one row at a time, and `Traits.commands` makes it take the WHOLE LINE, because a command list (`ThemeSel`/`FontSel`) holds words to run and there is no path inside `Theme gruvbox` to pick out. Tab on what `n` selected wears the theme, which is the same middle click on the row is. `]d`/`[d` are helix's diagnostic motions, a different binding, and they do still jump to each diagnostic (`docs/lsp.md`) | pardes-specific | @@ -224,7 +224,9 @@ grew `sels` + `primary`, emitted only when there is more than one range. | `_` | trim whitespace off both ends of every range | empty and all-whitespace ranges drop; nothing left = collapse + keep primary (helix) | helix-verified | | `o` / `O` | open n lines, one cursor per line | helix `open` with a count; this is why the `o-count` golden gained a `sels` field | helix-verified | | every motion/operator | acts at every cursor | replayed last-range-first, so an edit never disturbs a range still waiting; each pass's result is remembered as a distance from the END of the text, which an earlier edit cannot move | helix-verified (56 msel-* cases) | -| `y` with several ranges | joins the ranges' text with newlines into the ONE register | helix keeps a register VALUE per range and pastes value[i] at range[i]. Waived (`msel-yank-paste`) | waived | +| `y` with several ranges | one value per range, pasted back value i at range i by `p`/`P`/`R` and insert `Ctrl-r`; the acme cut/paste chords and a paste into a terminal take the values joined by newlines | `Registers.zig` | helix-verified | +| `"` | the next command's register: any character names one of its own. Computed ones: `_` swallows writes and reads nothing (`"_d`), `#` is each range's number from 1, `.` each range's text, `%` the file's name, `/` the last `s`/`S` pattern; `+`/`*` the system clipboard. A count typed before `"` stays the command's | helix `select_register` | helix-verified | +| insert `Ctrl-r ` | type this range's value of the register (`Ctrl-r #` numbers the cursors). Esc after `Ctrl-r` only cancels it. `Ctrl-r +` reads what pardes last put on the clipboard, not the desktop's | helix `insert_register` | helix-verified | | `a` … `Esc` with several ranges | the primary's appended-over span is restored; the others collapse to bare cursors | `Pane.append_at` is a single field. Waived (`msel-append`) | waived | | `&` | align selections into columns: the k-th range of each line is column k, and spaces go in before each range until its head reaches the column's widest head, in display cells (`File.rawDisplayCol`) | helix `align_selections` as of the harness's helix (25.07.1 grouped columns differently). A range over several lines refuses. A TAB counts as `tab_width` cells, not up to the next stop | helix-verified | | `Alt-)` / `Alt-(` / `Alt-)` | rotate the CONTENTS of the selections forward / back by one range (n ranges), in one edit; each range comes back over the text it now holds and the primary moves with its text | helix `rotate_selection_contents_*` as of the harness's helix: 25.07.1 read the count as a GROUP size instead (rotate by one within each run of n ranges) and left the primary where it was, which is what helix-golf's `invert_dictionary_2` relies on | helix-verified | @@ -342,7 +344,6 @@ text. | `]f [f ]t [t ]a [a ]c [c ]e [e ]T [T ]x [x` | TS unimpaired jumps | tree-sitter | | `]g [g ]G [G` | git change jumps | needs VCS diff state | | insert `Ctrl-x` | completion menu | completion exists, but not as a popup: insert-mode Tab straight after a `.` opens a buffer of candidate DECLARATIONS (section A). helix's menu itself is skipped | -| `"` ``, insert `Ctrl-r` | register select / insert | registers — one yank register and no way to name a second; the system clipboard is not spelled as a register here either, it is the five `SPC` commands in A | | `Q` / `q` | record / replay macro | macros — needs replayable input log | | `Ctrl-s` (normal) | save jumplist position | jumplist itself is implemented (`Ctrl-o`/`Ctrl-i`, section A); only the explicit save point is skipped | | `Alt-u` / `Alt-U` | undo-history earlier/later | history timeline — linear snapshot u/U covers pardes | @@ -388,12 +389,12 @@ Files (all in `test/hxcases/`): (runs the `hx-harness` binary from the helix checkout; override with `$HX_HARNESS`). Only needed when cases change — the diff itself runs offline. -- `waivers.jsonl` — named exemptions, each with a reason. Four live ones: +- `waivers.jsonl` — named exemptions, each with a reason. Three live ones: `wiX-edit-drops-sel` and `msel-append` (helix maps selections through - insert-mode edits, pardes does not), `alt-c-window-op` (the pardes - window op deliberately shadows helix change-noyank) and `msel-yank-paste` - (one yank register, not one value per range). `sel-regex-caret` and - `sel-regex-dot-newline` went when `s`/`S` began searching line by line. + insert-mode edits, pardes does not) and `alt-c-window-op` (the pardes + window op deliberately shadows helix change-noyank). `msel-yank-paste` + went with registers of one value per range; `sel-regex-caret` and + `sel-regex-dot-newline` when `s`/`S` began searching line by line. - `parity.jsonl` — 80 further cases, used only by the parity gate below. - `parity-waivers.jsonl` — 14 named exemptions for the parity gate, in three classes: one deliberate pardes binding (`ctrl-b-page`, since `Ctrl-b` IS the diff --git a/docs/selections.md b/docs/selections.md index 5b992cc4..0f000e2c 100644 --- a/docs/selections.md +++ b/docs/selections.md @@ -74,19 +74,18 @@ A parsed action has a scope (`modal.Normal.Action.scope`). - An undo snapshot (`File.Snapshot`) holds the content and the PRIMARY range. Undo puts the primary back and leaves the other ranges where they were; helix restores the whole selection of that revision. -- There is ONE register, `Pardes.yank`, written by `y`, `d` and `c` and read - by `p`, `P` and `R`. A yank at several ranges joins their texts with - newlines into it, and a paste puts that whole value at every range. +- Registers (`Registers.zig`) hold one value per range. A yank under the + replay fills slot `Pardes.multi_index` of `multi_count`, and `p`, `P`, `R` + and insert `Ctrl-r` read slot i at range i, the last value standing in + for missing ones. `"` names the register (`Pardes.register` for the + length of one command); `_ # . % /` are computed or special and `+`/`*` + is the system clipboard. The acme chords and a paste into a terminal read + the default register joined by newlines. - `Alt-.` repeats the last `f`/`t`/`F`/`T`. There is no `.` and there are no macros. ## Where it differs from helix -- Registers: helix keeps one value PER RANGE and pastes value `i` at range - `i`; it has named registers (`"a`), special ones (`#` range indices, - `.` the selection, `/` the search, `_` the black hole) and inserts a - register in insert mode with `Ctrl-r`. None exist here (waiver - `msel-yank-paste`). - Insert mode: helix maps every range through each keystroke's edit, so a range an `i`/`a` started from stretches over what is typed and keeps its anchor. Here only the cursor cells move, and after Esc only the primary's diff --git a/src/Registers.zig b/src/Registers.zig new file mode 100644 index 00000000..d2a42434 --- /dev/null +++ b/src/Registers.zig @@ -0,0 +1,106 @@ +//! helix's registers: named stores of text, each holding one VALUE PER RANGE +//! of the selection that wrote it. `"` is the default, `+` the one the +//! system clipboard is copied from, `/` the last s/S pattern, `@` the last +//! macro; any other character names a register of its own. The registers +//! computed from the editor rather than stored (`_`, `#`, `.`, `%`) and the +//! clipboard's paste side are read in edit.zig, which has the pane at hand. +const std = @import("std"); + +const Registers = @This(); + +list: std.ArrayList(Reg) = .empty, + +pub const Reg = struct { + name: u21, + /// gpa-owned, one per range, in document order + values: [][]u8, + /// the values joined by newlines, for the readers that take one text + /// (the acme chords, a paste into a terminal, the clipboard); gpa-owned, + /// made on first read + joined: ?[]u8 = null, +}; + +pub const default = '"'; + +pub fn deinit(r: *Registers, gpa: std.mem.Allocator) void { + for (r.list.items) |*reg| freeReg(gpa, reg); + r.list.deinit(gpa); + r.* = .{}; +} + +fn freeReg(gpa: std.mem.Allocator, reg: *Reg) void { + for (reg.values) |v| gpa.free(v); + gpa.free(reg.values); + if (reg.joined) |j| gpa.free(j); +} + +pub fn get(r: *Registers, name: u21) ?*Reg { + for (r.list.items) |*reg| if (reg.name == name) return reg; + return null; +} + +/// Value `i` of register `name`: a register holding fewer values than there +/// are ranges repeats its last (helix paste_impl). +pub fn value(r: *Registers, name: u21, i: usize) ?[]const u8 { + const reg = r.get(name) orelse return null; + if (reg.values.len == 0) return null; + return reg.values[@min(i, reg.values.len - 1)]; +} + +/// Store value `i` of `n`. `fresh` starts the register over with `n` empty +/// values; the replay across ranges passes it on its first pass only, so +/// every range fills its own slot. +pub fn put(r: *Registers, gpa: std.mem.Allocator, name: u21, bytes: []const u8, i: usize, n: usize, fresh: bool) void { + const copy = gpa.dupe(u8, bytes) catch return; + const reg = r.get(name) orelse blk: { + r.list.append(gpa, .{ .name = name, .values = &.{} }) catch { + gpa.free(copy); + return; + }; + break :blk &r.list.items[r.list.items.len - 1]; + }; + if (fresh or reg.values.len != n) { + const values = gpa.alloc([]u8, n) catch { + gpa.free(copy); + return; + }; + for (values) |*v| v.* = &.{}; + freeReg(gpa, reg); + reg.* = .{ .name = name, .values = values }; + } + gpa.free(reg.values[i]); + reg.values[i] = copy; + if (reg.joined) |j| gpa.free(j); + reg.joined = null; +} + +/// The whole register as one text: its values joined by newlines, no +/// newline added after a value that already ends in one. +pub fn text(r: *Registers, gpa: std.mem.Allocator, name: u21) ?[]const u8 { + const reg = r.get(name) orelse return null; + if (reg.joined) |j| return j; + var out: std.ArrayList(u8) = .empty; + for (reg.values, 0..) |v, i| { + if (i > 0 and !std.mem.endsWith(u8, reg.values[i - 1], "\n")) out.append(gpa, '\n') catch return null; + out.appendSlice(gpa, v) catch return null; + } + reg.joined = out.toOwnedSlice(gpa) catch return null; + return reg.joined; +} + +test "a register holds a value per range and joins them for one-text readers" { + const gpa = std.testing.allocator; + var r: Registers = .{}; + defer r.deinit(gpa); + try std.testing.expect(r.value(default, 0) == null); + // a replay fills the last range first + r.put(gpa, default, "two", 1, 2, true); + r.put(gpa, default, "one\n", 0, 2, false); + try std.testing.expectEqualStrings("one\n", r.value(default, 0).?); + try std.testing.expectEqualStrings("two", r.value(default, 1).?); + try std.testing.expectEqualStrings("two", r.value(default, 5).?); + try std.testing.expectEqualStrings("one\ntwo", r.text(gpa, default).?); + r.put(gpa, 'a', "x", 0, 1, true); + try std.testing.expectEqualStrings("x", r.text(gpa, 'a').?); + try std.testing.expectEqualStrings("one\ntwo", r.text(gpa, default).?); +} diff --git a/src/Text.zig b/src/Text.zig index 43dd8a95..7ad243a1 100644 --- a/src/Text.zig +++ b/src/Text.zig @@ -66,6 +66,8 @@ select: bool = false, /// non-vertical range write resets it to -1. sticky_col: i32 = -1, append_at: ?struct { row: i32, col: i32 } = null, +/// insert `Ctrl-r` was pressed: the next key names the register to insert +reg_wait: bool = false, normal: modal.Normal.State = .{}, /// last f/F/t/T motion, for Alt-. repeat find_op: u8 = 0, diff --git a/src/config.zig b/src/config.zig index 85a6bc59..4412ef0d 100644 --- a/src/config.zig +++ b/src/config.zig @@ -492,6 +492,7 @@ pub const match_around = 'a'; pub const surround_add = 's'; pub const surround_replace = 'r'; pub const surround_delete = 'd'; +pub const register_prefix = '"'; pub const goto_paragraph: []const Chord = &.{.{ .cp = 'p' }}; pub const add_newline: []const Chord = &.{.{ .cp = ' ' }}; @@ -557,6 +558,7 @@ pub const escape: []const Chord = &.{.{ .cp = Key.escape }}; pub const insert_backspace_alias: []const Chord = &.{.{ .cp = 'h', .ctrl = true }}; pub const insert_enter_alias: []const Chord = &.{.{ .cp = 'j', .ctrl = true }}; pub const insert_delete_alias: []const Chord = &.{.{ .cp = 'd', .ctrl = true }}; +pub const insert_register: []const Chord = &.{.{ .cp = 'r', .ctrl = true }}; pub const delete_word_backward: []const Chord = &.{ .{ .cp = 'w', .ctrl = true }, .{ .cp = Key.backspace, .alt = true } }; pub const delete_word_forward: []const Chord = &.{ .{ .cp = 'd', .alt = true }, .{ .cp = Key.delete, .alt = true } }; pub const kill_to_line_start: []const Chord = &.{.{ .cp = 'u', .ctrl = true }}; diff --git a/src/edit.zig b/src/edit.zig index 24354df8..14d8b7ed 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -26,23 +26,75 @@ const hit = pardes.hit; const sel_slot = pardes.sel_slot; const Effect = pardes.Effect; const Pardes = pardes.Pardes; +const Registers = pardes.Registers; +/// Write `text` as this range's value of the register the running command +/// names (`"`), the default one otherwise. `_` swallows it, and the +/// registers computed from the editor (`#`, `.`, `%`) cannot be written. +/// `+` and `*` are the system clipboard: the value is sent there too. pub fn setYank(p: *Pardes, text: []const u8) void { - if (p.multi_on and !p.multi_first) { - const old = p.yank orelse ""; - const sep: []const u8 = if (text.len > 0 and text[text.len - 1] == '\n') "" else "\n"; - const joined = std.fmt.allocPrint(p.gpa, "{s}{s}{s}", .{ text, sep, old }) catch return; - if (p.yank) |y| p.gpa.free(y); - p.yank = joined; - return; + var name: u21 = if (p.register != 0) p.register else Registers.default; + switch (name) { + '_', '#', '.', '%' => return, + '*' => name = '+', + else => {}, } - if (p.yank) |y| p.gpa.free(y); - p.yank = p.gpa.dupe(u8, text) catch null; + const one = !p.multi_on; + p.registers.put(p.gpa, name, text, if (one) 0 else p.multi_index, if (one) 1 else p.multi_count, one or p.multi_first); + if (name == '+' and (one or p.multi_first)) p.emit(.{ .set_clipboard = {} }); } pub fn setClipboard(p: *Pardes, text: []const u8) void { + const named = p.register; + p.register = '+'; + defer p.register = named; setYank(p, text); - if (!p.multi_on or p.multi_first) p.emit(.{ .set_clipboard = {} }); +} + +/// Value `i` of register `name` as the command at range `i` reads it, the +/// computed registers included: `#` is the range's number from 1, `.` its +/// text, `%` the file's name, `_` nothing. Scratch- or register-owned. +pub fn registerValue(p: *Pardes, t: *Text, name: u21) ?[]const u8 { + const i = if (p.multi_on) p.multi_index else 0; + return switch (name) { + '_' => null, + '#' => std.fmt.allocPrint(p.scratch.allocator(), "{d}", .{i + 1}) catch null, + '.' => selText(p, t), + '%' => if (t.what == .body) if (t.pane()) |pane| (if (pane.file) |f| f.path else null) else null else null, + else => p.registers.value(name, i), + }; +} + +/// the text of the range under the cursor: the selection, or the cursor's +/// own character +fn selText(p: *Pardes, t: *Text) []const u8 { + if (t.msel.active) return yankRows(p, t, @min(t.msel.r0, t.msel.r1), @max(t.msel.r0, t.msel.r1)); + const save = t.vsel; + defer t.vsel = save; + if (!t.vsel.active) t.vsel = .{ .active = true, .row = t.cur_row, .col = t.cur_col, .explicit = false }; + return vselText(p, t); +} + +/// `p` / `P` / `R` from the register the command names: this range's value +/// of it. The clipboard's is asked of the shell and pasted when it answers, +/// once, at the primary. +pub fn pasteRegister(p: *Pardes, t: *Text, how: enum { after, before, replace }, count: usize) void { + const name: u21 = if (p.register != 0) p.register else Registers.default; + if (name == '+' or name == '*') { + if (!normal.multiOnce(p)) return; + const pane = t.pane() orelse return; + return clipRequest(p, p.paneIdOf(pane) orelse return, switch (how) { + .after => .after, + .before => .before, + .replace => .replace, + }); + } + const y = registerValue(p, t, name) orelse return; + switch (how) { + .after => pasteText(p, t, y, false, count), + .before => pasteText(p, t, y, true, count), + .replace => replaceWithText(p, t, y), + } } /// Where a `SPC p` / `SPC P` / `SPC R` goes once the shell answers. @@ -125,6 +177,10 @@ pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void { if (pv.selection_text.len > 0) setClipboard(p, pv.selection_text); return; }; + // helix's `"+y`: the clipboard register, and only it + const named = p.register; + p.register = '+'; + defer p.register = named; if (pane.body.nsel > 0 and !main_only) { normal.replaySels(p, &pane.body, .{ .normal = .{ .edit = .{ .kind = .yank, .count = 1 } } }); } else { @@ -133,7 +189,6 @@ pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void { normalYank(p, &pane.body); pane.body.nsel = others; } - p.emit(.{ .set_clipboard = {} }); } /// tag + '\n' + body: the full selectable pane text, a row to a screen row @@ -286,6 +341,7 @@ pub fn currentSelText(p: *Pardes, pane: *Pane) ?[]const u8 { pub fn exitInsert(p: *Pardes, t: *Text) void { t.mode = .normal; + t.reg_wait = false; t.msel.active = false; t.normal.clear(); if (t.append_at) |aa| { @@ -565,6 +621,19 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { if (hit(key, config.insert_backspace_alias)) return insertKey(p, t, .{ .cp = Key.backspace }); if (hit(key, config.insert_enter_alias)) return insertKey(p, t, .{ .cp = Key.enter }); if (hit(key, config.insert_delete_alias)) return insertKey(p, t, .{ .cp = Key.delete }); + // Ctrl-r : type this range's value of the register (helix + // insert_register) + if (t.reg_wait) { + t.reg_wait = false; + if (key.ctrl or key.alt or key.cp >= 0xF0000) return; + const y = registerValue(p, t, key.cp) orelse return; + if (y.len > 0) insertKey(p, t, .{ .cp = key.cp, .text = y }); + return; + } + if (hit(key, config.insert_register)) { + t.reg_wait = true; + return; + } t.vsel.active = false; if (!t.cur_pinned) pinPaneCursor(p, t); _ = clampFileCursor(t); @@ -1132,8 +1201,9 @@ pub fn normalReplaceChar(p: *Pardes, t: *Text, ch: u21) void { /// `R`: replace the selection (or the cursor char) with the DEFAULT /// register. `SPC R` is the same verb over the system clipboard. -pub fn normalReplaceYank(p: *Pardes, t: *Text) void { - replaceWithText(p, t, p.yank orelse return); +/// The acme chords' replace: the default register as one text. +pub fn chordReplace(p: *Pardes, t: *Text) void { + replaceWithText(p, t, p.registers.text(p.gpa, Registers.default) orelse return); } /// The pasted text becomes the selection, in the direction the replaced one @@ -1957,16 +2027,20 @@ test "only the SPC clipboard commands cross to the system clipboard" { _ = drainedEffect(p, .set_clipboard); p.update(.{ .key = .{ .cp = 'y' } }); - try std.testing.expect(p.yank != null and p.yank.?.len > 0); + const plain = p.registers.text(gpa, Registers.default) orelse return error.MissingYank; + try std.testing.expect(plain.len > 0); try std.testing.expect(!drainedEffect(p, .set_clipboard)); p.update(.{ .key = .{ .cp = 'd' } }); try std.testing.expect(!drainedEffect(p, .set_clipboard)); - // `SPC y` is the one that does, and it puts the SAME text there that `y` - // put in the register — it is the ordinary yank path plus the mirror. + // `SPC y` is the one that does: helix's `"+y`, the ordinary yank into + // the clipboard register instead of the default one. p.update(.{ .key = .{ .cp = ' ' } }); p.update(.{ .key = .{ .cp = 'y' } }); - const yanked = p.yank orelse return error.MissingYank; + const yanked = try gpa.dupe(u8, p.registers.text(gpa, Registers.default) orelse ""); + defer gpa.free(yanked); + const clip: []const u8 = p.registers.text(gpa, '+') orelse ""; + try std.testing.expect(clip.len > 0); try std.testing.expect(drainedEffect(p, .set_clipboard)); const before = pane.file.?.content.len; @@ -1980,14 +2054,14 @@ test "only the SPC clipboard commands cross to the system clipboard" { try std.testing.expect(std.mem.indexOf(u8, pane.file.?.content, "PASTED") != null); // ...and it did NOT land in the register on its way past, nor echo back // out to the clipboard it came from. - try std.testing.expectEqualStrings(yanked, p.yank orelse ""); + try std.testing.expectEqualStrings(yanked, p.registers.text(gpa, Registers.default) orelse ""); try std.testing.expect(!drainedEffect(p, .set_clipboard)); // An UNSOLICITED paste — the outer terminal's bracketed paste, a Cmd-V — // is the same event with no request behind it, and means paste after. p.update(.{ .paste = "UNASKED" }); try std.testing.expect(std.mem.indexOf(u8, pane.file.?.content, "UNASKED") != null); - try std.testing.expectEqualStrings(yanked, p.yank orelse ""); + try std.testing.expectEqualStrings(yanked, p.registers.text(gpa, Registers.default) orelse ""); // A request the shell never answers dies at the next keystroke rather // than firing late into whatever pane is focused by then. diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 46bd5ffc..8f6248ce 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -4385,7 +4385,7 @@ test "the headless grid host round-trips a yank back as a paste" { core.update(.{ .key = .{ .cp = ' ' } }); core.update(.{ .key = .{ .cp = 'y' } }); while (core.nextEffect()) |e| core.perform(e); - const yanked = core.yank orelse return error.MissingYank; + const yanked = core.registers.text(core.gpa, '+') orelse return error.MissingYank; try std.testing.expect(yanked.len > 0); const before = pane.file.?.content.len; diff --git a/src/look.zig b/src/look.zig index d7e763d3..5e2d6068 100644 --- a/src/look.zig +++ b/src/look.zig @@ -502,7 +502,11 @@ const SearchStart = enum { top, cursor }; pub fn submitSearch(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; - if (normal.selRegexArmed(pane)) |a| return if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.split); + if (normal.selRegexArmed(pane)) |a| { + // helix keeps the pattern in `/` + if (a.pat.len > 0) p.registers.put(p.gpa, '/', a.pat, 0, 1, true); + return if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.split); + } const armed = pane.promptText() orelse return; const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return; if (std.mem.startsWith(u8, armed, config.rename_marker)) return p.lspRequest(id, .rename, armed[slash + 1 ..]); diff --git a/src/modal.zig b/src/modal.zig index aadaab00..aec1c579 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -15,6 +15,7 @@ pub const Normal = struct { prefix_replace, prefix_next, prefix_prev, + prefix_register, goto_file_start, goto_last_line, @@ -160,6 +161,7 @@ pub const Normal = struct { replace, next, prev, + register, }; pub const MatchSub = enum(u8) { @@ -176,6 +178,8 @@ pub const Normal = struct { prefix: Prefix = .none, match_sub: MatchSub = .none, held_char: u21 = 0, + /// `"`: the register the next command names, 0 for none + register: u21 = 0, pub fn clear(state: *State) void { state.* = .{}; @@ -491,6 +495,14 @@ pub const Normal = struct { if (key.has(.goto_diagnostic_end)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = true } }); return .ignored; }, + .register => { + state.prefix = .none; + const char = key.literal() orelse return .ignored; + state.register = char; + // the count typed before `"` is still the command's + state.count = if (counted.explicit) count else 0; + return .pending; + }, .none => {}, } @@ -505,6 +517,7 @@ pub const Normal = struct { if (key.has(.prefix_next)) return armPrefix(state, .next, if (counted.explicit) count else 0); if (key.has(.prefix_prev)) return armPrefix(state, .prev, if (counted.explicit) count else 0); if (key.has(.prefix_match)) return armPrefix(state, .match, 0); + if (key.has(.prefix_register)) return armPrefix(state, .register, if (counted.explicit) count else 0); if (key.has(.move_left)) return resultAction(.{ .move = .{ .motion = .left, .count = count } }); if (key.has(.move_right)) return resultAction(.{ .move = .{ .motion = .right, .count = count } }); diff --git a/src/mouse.zig b/src/mouse.zig index f222b5da..91ec41a6 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -963,8 +963,8 @@ fn chordCutPaste(p: *Pardes, cut: bool) void { if (cut) { if (t.vsel.active) edit.normalDelete(p, t, true); } else if (t.vsel.active) { - edit.normalReplaceYank(p, t); - } else edit.pasteText(p, t, p.yank orelse return, true, 1); + edit.chordReplace(p, t); + } else edit.pasteText(p, t, p.registers.text(p.gpa, pardes.Registers.default) orelse return, true, 1); return; } if (pane.body.mode == .tty) { @@ -973,7 +973,7 @@ fn chordCutPaste(p: *Pardes, cut: bool) void { pane.sel[sel_slot].state = .none; // drop the sweep highlight } if (cut) return; - const y = p.yank orelse return; + const y = p.registers.text(p.gpa, pardes.Registers.default) orelse return; if (y.len == 0) return; if (panes.Terminal.reportsMouse(pane)) { const col: u16 = @intCast(std.math.clamp(pane.sel[sel_slot].c1 + 1, 1, 9999)); @@ -1016,11 +1016,11 @@ fn chordCutPaste(p: *Pardes, cut: bool) void { if (cut) { if (pane.body.vsel.active) edit.normalDelete(p, &pane.body, true); } else if (pane.body.vsel.active) { - edit.normalReplaceYank(p, &pane.body); + edit.chordReplace(p, &pane.body); } else if (pane.file) |*f| { // bare cursor: splice the register in literally (no linewise // magic) so a paste right after a cut restores the text exactly - const y = p.yank orelse return; + const y = p.registers.text(p.gpa, pardes.Registers.default) orelse return; if (y.len == 0) return; const at = modal.Cursor{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) }; edit.pushUndo(p, &pane.body); @@ -1039,7 +1039,7 @@ fn chordCutPaste(p: *Pardes, cut: bool) void { pane.body.cur_pinned = true; pane.body.sticky_col = -1; pane.ensureCursorVisible(); - } else edit.pasteText(p, &pane.body, p.yank orelse return, true, 1); + } else edit.pasteText(p, &pane.body, p.registers.text(p.gpa, pardes.Registers.default) orelse return, true, 1); } test "plain left click clears explicit modal selection" { diff --git a/src/normal.zig b/src/normal.zig index 5a6c1cd5..f5e868a4 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -35,6 +35,7 @@ fn roleBindingName(comptime role: modal.Normal.Role) []const u8 { .prefix_replace => "replace_prefix", .prefix_next => "next_prefix", .prefix_prev => "prev_prefix", + .prefix_register => "register_prefix", else => @tagName(role), }; } @@ -149,6 +150,7 @@ const KeyState = struct { find_op: u8, find_ch: u21, append_at: @FieldType(Text, "append_at"), + reg_wait: bool, fn of(t: *Text) KeyState { return .{ @@ -158,6 +160,7 @@ const KeyState = struct { .find_op = t.find_op, .find_ch = t.find_ch, .append_at = t.append_at, + .reg_wait = t.reg_wait, }; } @@ -168,6 +171,7 @@ const KeyState = struct { t.find_op = s.find_op; t.find_ch = s.find_ch; t.append_at = s.append_at; + t.reg_wait = s.reg_wait; } }; @@ -227,6 +231,8 @@ pub fn replaySels(p: *Pardes, t: *Text, what: Replay) void { while (i > 0) { i -= 1; p.multi_first = passes == 0; + p.multi_index = i; + p.multi_count = n; passes += 1; saved.into(t); t.nsel = 0; // the handler underneath sees ONE selection @@ -284,11 +290,16 @@ pub fn replaySels(p: *Pardes, t: *Text, what: Replay) void { } pub fn handleNormal(p: *Pardes, t: *Text, key: Key) void { + // `"` is held by the parser until the command it names arrives + const named = t.normal.register; const parsed = modal.Normal.parse(&t.normal, normalInput(key)); const semantic = switch (parsed) { .pending, .ignored, .unbound => return, .action => |value| value, }; + t.normal.register = 0; + p.register = named; + defer p.register = 0; if (t.nsel == 0 or semantic.scope() == .once) return executeNormalAction(p, t, semantic); replaySels(p, t, .{ .normal = semantic }); @@ -534,9 +545,9 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void .delete_noyank => return edit.normalDelete(p, t, false), .change => return edit.normalChange(p, t), .yank => return edit.normalYank(p, t), - .replace_with_yank => return edit.normalReplaceYank(p, t), - .paste_after => return edit.pasteText(p, t, p.yank orelse return, false, op.count), - .paste_before => return edit.pasteText(p, t, p.yank orelse return, true, op.count), + .replace_with_yank => return edit.pasteRegister(p, t, .replace, op.count), + .paste_after => return edit.pasteRegister(p, t, .after, op.count), + .paste_before => return edit.pasteRegister(p, t, .before, op.count), .switch_case => return edit.normalCase(p, t, .toggle), .lowercase => return edit.normalCase(p, t, .lower), .uppercase => return edit.normalCase(p, t, .upper), diff --git a/src/pardes.zig b/src/pardes.zig index 9821839f..e4645d70 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -17,6 +17,7 @@ pub const normal = @import("normal.zig"); pub const edit = @import("edit.zig"); pub const body_layer = @import("body_layer.zig"); pub const Messages = @import("Messages.zig"); +pub const Registers = @import("Registers.zig"); pub const Pipe = @import("Pipe.zig"); pub const colors = @import("colors.zig"); pub const Theme = colors.Theme; @@ -453,6 +454,7 @@ test { _ = @import("regexp.zig"); _ = @import("edit.zig"); _ = @import("Messages.zig"); + _ = @import("Registers.zig"); _ = @import("selection_pipe.zig"); _ = @import("Pipe.zig"); _ = @import("colors.zig"); @@ -2326,7 +2328,7 @@ test "a host with no methods at all is a complete in-process pardes" { try std.testing.expectEqual(before, p.panes[0].?.terminal.?.vt.screens.active.cursor.y); // the clipboard round-trips through the in-process one - p.yank = try gpa.dupe(u8, "copied"); + p.registers.put(gpa, '+', "copied", 0, 1, true); p.perform(.set_clipboard); try std.testing.expectEqualStrings("copied", p.fallback.clipboard.items); @@ -4003,6 +4005,14 @@ pub const Pardes = struct { multi_on: bool = false, multi_first: bool = false, multi_stop: bool = false, + /// While a key is replayed across the ranges: which range this pass is, + /// in document order, and how many there are. A register value belongs + /// to its range by this index. + multi_index: usize = 0, + multi_count: usize = 1, + /// The register the running normal command names (`"`); 0 for the + /// default one. + register: u21 = 0, leader_on: bool = false, leader_keys: [4]u8 = undefined, leader_n: u8 = 0, @@ -4085,7 +4095,7 @@ pub const Pardes = struct { in_head: usize = 0, in_len: usize = 0, - yank: ?[]u8 = null, + registers: Registers = .{}, clip_pending: ?edit.ClipRequest = null, /// the last serialized dump (gpa-owned), read by the write_dump effect dump_out: ?[]u8 = null, @@ -4256,7 +4266,7 @@ pub const Pardes = struct { const gpa = p.gpa; for (&p.col_tags) |*t| t.deinit(gpa); p.global_tag.deinit(gpa); - if (p.yank) |y| gpa.free(y); + p.registers.deinit(gpa); if (p.dump_out) |d| gpa.free(d); p.fallback.deinit(); if (p.last_dump) |d| gpa.free(d); @@ -4826,7 +4836,7 @@ pub const Pardes = struct { } }, .set_clipboard => { - const text = p.yank orelse ""; + const text = p.registers.text(p.gpa, '+') orelse ""; if (v.set_clipboard) |f| f(p.host.ctx, text) else p.fallback.setClipboard(text); }, // No desktop to ask: answer from the in-process clipboard at once, @@ -5162,7 +5172,7 @@ pub const Pardes = struct { // in it the chord is the program's -- vim's visual block, // readline's quoted-insert -- and swallowing it would make // Ctrl-V a black hole in every full-screen application. - if (p.yank) |text| return edit.typeToTty(p, p.active, pane, text); + if (p.registers.text(p.gpa, Registers.default)) |text| return edit.typeToTty(p, p.active, pane, text); } return panes.Terminal.forwardKey(p, p.active, key); } @@ -5310,7 +5320,7 @@ pub const Pardes = struct { switch (t.mode) { .normal => normal.handleNormal(p, t, key), .insert => { - if (hit(key, config.escape)) return edit.exitInsert(p, t); + if (hit(key, config.escape) and !t.reg_wait) return edit.exitInsert(p, t); edit.handleInsert(p, t, key); }, .tty => panes.Terminal.forwardKey(p, p.active, key), diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 487cd445..7ba44f09 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -442,6 +442,19 @@ {"name": "msel-collapse", "pane": "file", "text": "abcd\nabcd\n", "keys": "vllC;"} {"name": "msel-flip", "pane": "file", "text": "abcd\nabcd\n", "keys": "vllC"} {"name": "msel-yank-paste", "pane": "file", "text": "ab\ncd\n", "keys": "Cyp"} +{"name": "reg-named-yank-paste", "pane": "file", "text": "ab cd\n", "keys": "\"ayw\"ap"} +{"name": "reg-named-keeps-default", "pane": "file", "text": "ab cd\n", "keys": "yw\"ay\"_dp"} +{"name": "reg-blackhole-delete", "pane": "file", "text": "ab cd\n", "keys": "yw\"_dP"} +{"name": "msel-reg-paste-per-range", "pane": "file", "text": "ab\ncd\n", "keys": "Cyl2Pp"} +{"name": "msel-reg-fewer-values", "pane": "file", "text": "ab\ncd\nef\n", "keys": "yCCp"} +{"name": "msel-reg-replace-per-range", "pane": "file", "text": "ab\ncd\n", "keys": "CyRy"} +{"name": "msel-reg-more-values-one-range", "pane": "file", "text": "ab\ncd\n", "keys": "Cy,p"} +{"name": "reg-index-insert", "pane": "file", "text": "x\nx\nx\n", "keys": "%i#"} +{"name": "reg-search-paste", "pane": "file", "text": "ab\n", "keys": "%sb\"/p"} +{"name": "reg-count-before-quote", "pane": "file", "text": "ab\n", "keys": "y2\"ap"} +{"name": "reg-count-after-quote", "pane": "file", "text": "ab\n", "keys": "\"ay\"a3p"} +{"name": "reg-ctrl-r-named", "pane": "file", "text": "ab\n", "keys": "\"aylia"} +{"name": "reg-ctrl-r-esc", "pane": "file", "text": "ab\n", "keys": "ix"} {"name": "msel-esc-keeps", "pane": "file", "text": "aa\nbb\n", "keys": "C"} {"name": "msel-select-mode", "pane": "file", "text": "abcd\nabcd\n", "keys": "Cvl"} {"name": "msel-open-count", "pane": "file", "text": "ab\n", "keys": "2O"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index 006524e8..f6a6aada 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -442,6 +442,19 @@ {"name":"msel-collapse","text":"abcd\nabcd\n","mode":"select","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}}],"primary":1} {"name":"msel-flip","text":"abcd\nabcd\n","mode":"select","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":2},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":2}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":2}}],"primary":1} {"name":"msel-yank-paste","text":"aab\nccd\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1} +{"name":"reg-named-yank-paste","text":"ab acd\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}} +{"name":"reg-named-keeps-default","text":"cad\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} +{"name":"reg-blackhole-delete","text":"acd\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"msel-reg-paste-per-range","text":"aaaab\nccccd\n","mode":"normal","cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":1} +{"name":"msel-reg-fewer-values","text":"aab\ncad\neaf\n","mode":"normal","cursor":{"row":2,"col":1},"anchor":{"row":2,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}},{"cursor":{"row":2,"col":1},"anchor":{"row":2,"col":1}}],"primary":2} +{"name":"msel-reg-replace-per-range","text":"ab\ncd\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":0} +{"name":"msel-reg-more-values-one-range","text":"ab\ncad\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}} +{"name":"reg-index-insert","text":"1x\n2x\n3x\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}},{"cursor":{"row":2,"col":1},"anchor":{"row":2,"col":1}}],"primary":0} +{"name":"reg-search-paste","text":"abb\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}} +{"name":"reg-count-before-quote","text":"ab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"reg-count-after-quote","text":"aaaab\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":1}} +{"name":"reg-ctrl-r-named","text":"aab\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}} +{"name":"reg-ctrl-r-esc","text":"xab\n","mode":"insert","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} {"name":"msel-esc-keeps","text":"aa\nbb\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":1} {"name":"msel-select-mode","text":"abcd\nabcd\n","mode":"select","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1} {"name":"msel-open-count","text":"\n\nab\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":0} diff --git a/test/hxcases/waivers.jsonl b/test/hxcases/waivers.jsonl index 01e6cfa7..8ac3bd9c 100644 --- a/test/hxcases/waivers.jsonl +++ b/test/hxcases/waivers.jsonl @@ -1,4 +1,3 @@ {"name":"wiX-edit-drops-sel","reason":"Insert edits clear the selection instead of tracking its anchor.","reference":{"name":"wiX-edit-drops-sel","text":"Xalpha beta\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":6}},"expected":{"name":"wiX-edit-drops-sel","text":"Xalpha beta\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}} {"name":"alt-c-window-op","reason":"Alt-c moves a pane to a new column instead of Helix's change-noyank.","reference":{"name":"alt-c-window-op","text":"bc def\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},"expected":{"name":"alt-c-window-op","text":"abc def\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}} {"name":"msel-append","reason":"Append-mode selection origins are tracked only for the primary selection.","reference":{"name":"msel-append","text":"aza\nbzb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1},"expected":{"name":"msel-append","text":"aza\nbzb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1}} -{"name":"msel-yank-paste","reason":"Pardes has one combined yank register, not one value per selection.","reference":{"name":"msel-yank-paste","text":"aab\nccd\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1},"expected":{"name":"msel-yank-paste","text":"aa\ncb\nca\ncd\n","mode":"normal","cursor":{"row":3,"col":0},"anchor":{"row":2,"col":1},"sels":[{"cursor":{"row":1,"col":0},"anchor":{"row":0,"col":1}},{"cursor":{"row":3,"col":0},"anchor":{"row":2,"col":1}}],"primary":1}} diff --git a/test/panes.zig b/test/panes.zig index d0551cfc..cbf9d097 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -325,7 +325,7 @@ const TagNavigationTests = struct { try std.testing.expectEqual(@as(i32, 0), pane.tag.cur_col); key(p, 'E'); key(p, 'y'); - try std.testing.expectEqualStrings("/test.txt", p.yank.?); + try std.testing.expectEqualStrings("/test.txt", p.registers.text(p.gpa, '"').?); // an edit that reaches into it is refused, the cursor with it const before = try std.testing.allocator.dupe(u8, try pardes.edit.flatSurface(p, &pane.tag)); defer std.testing.allocator.free(before); @@ -848,12 +848,12 @@ const TtySelectionTests = struct { const x = rect.x + config.GUTTER; const y = p.bodyTop(pane, rect); JumpSelectionTests.drag(p, x + 1, y, x + 3, y); - try std.testing.expectEqualStrings("lph", p.yank orelse return error.MissingAutomaticTtyYank); + try std.testing.expectEqualStrings("lph", p.registers.text(p.gpa, '"') orelse return error.MissingAutomaticTtyYank); p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); p.update(.{ .key = .{ .cp = 'l' } }); try std.testing.expectEqual(@as(i32, 4), pane.body.cur_col); p.update(.{ .key = .{ .cp = 'y' } }); - try std.testing.expectEqualStrings("a", p.yank.?); + try std.testing.expectEqualStrings("a", p.registers.text(p.gpa, '"').?); try noChildInput(p); } @@ -881,7 +881,7 @@ const TtySelectionTests = struct { const expected = if (prompt) "$ ab" else "lph\nrav"; try std.testing.expectEqual(panes.Text.Mode.tty, pane.body.mode); try std.testing.expectEqualStrings(expected, pardes.test_api.heldSelection(p, 0).?); - try std.testing.expectEqualStrings(expected, p.yank orelse return error.MissingAutomaticTtyYank); + try std.testing.expectEqualStrings(expected, p.registers.text(p.gpa, '"') orelse return error.MissingAutomaticTtyYank); try noChildInput(p); try std.testing.expect(p.executeBuiltinLine(0, "ClipYank")); @@ -897,7 +897,7 @@ const TtySelectionTests = struct { } try std.testing.expectEqualStrings(expected, pardes.test_api.heldSelection(p, 0).?); p.update(.{ .key = .{ .cp = 'y' } }); - try std.testing.expectEqualStrings(expected, p.yank.?); + try std.testing.expectEqualStrings(expected, p.registers.text(p.gpa, '"').?); try noChildInput(p); p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); try std.testing.expectEqual(panes.Text.Mode.tty, pane.body.mode); @@ -908,7 +908,7 @@ const TtySelectionTests = struct { _ = try p.render(frame.allocator()); try std.testing.expectEqual(panes.Text.Mode.tty, pane.body.mode); try std.testing.expectEqualStrings(expected, pardes.test_api.heldSelection(p, 0).?); - try std.testing.expectEqualStrings(expected, p.yank.?); + try std.testing.expectEqualStrings(expected, p.registers.text(p.gpa, '"').?); try noChildInput(p); } } @@ -940,7 +940,7 @@ const TtySelectionTests = struct { const sx = source.x + config.GUTTER; const sy = if (p.settings.tag_bottom) source.y else source.y + pardes.BOX_H; JumpSelectionTests.drag(p, sx + 1, sy, sx + 3, sy + 1); - try std.testing.expectEqualStrings("lph\nrav", p.yank orelse return error.MissingAutomaticTtyYank); + try std.testing.expectEqualStrings("lph\nrav", p.registers.text(p.gpa, '"') orelse return error.MissingAutomaticTtyYank); try noChildInput(p); const target = p.rects[1]; @@ -1001,7 +1001,7 @@ const TtySelectionTests = struct { try std.testing.expectEqual(panes.Text.Mode.tty, pane.body.mode); if (bracketed) p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h" } }); try std.testing.expectEqual(bracketed, panes.Terminal.bracketedPaste(pane)); - p.yank = try p.gpa.dupe(u8, "one\ntwo"); // what a `y` anywhere left behind + p.registers.put(p.gpa, '"', "one\ntwo", 0, 1, true); // what a `y` anywhere left behind var buf: [256]u8 = undefined; _ = childInput(p, &buf); @@ -1033,7 +1033,7 @@ const TtySelectionTests = struct { ); } // ...and neither chord put the clipboard in the register on its way. - try std.testing.expectEqualStrings("one\ntwo", p.yank orelse ""); + try std.testing.expectEqualStrings("one\ntwo", p.registers.text(p.gpa, '"') orelse ""); } } }; @@ -4242,7 +4242,7 @@ test "an empty counted paste leaves no count for the next motion" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 12 }); defer p.deinit(); const pane = try p.setTestFile("abcdefgh\n"); - if (empty_register) p.yank = try p.gpa.dupe(u8, ""); + if (empty_register) p.registers.put(p.gpa, '"', "", 0, 1, true); for ("3p") |cp| p.update(.{ .key = .{ .cp = cp } }); try std.testing.expectEqual(modal.Normal.State{}, pane.body.normal); p.update(.{ .key = .{ .cp = 'l' } }); -- cgit v1.3 From c811c34ed3bcd23954414125b45c7cac67307d5d Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:04:12 -0300 Subject: Every range lives through insert mode, carried through each edit Insert mode dropped the selection on the first key and restored only the primary's appended-over span on Esc. Now each insert-mode edit is made at the bare cursor as before, and the range is then carried through it with helix's Range::map rules: typing slides an i range and stretches an a range, Enter (which now also drops the blanks before the cursor, as helix does) slides or stretches by what the cursor moved, and an arrow collapses. The replay gives every other range the same. Esc after a pulls each range's end back by one (helix restore_cursor), which replaces Text.append_at. The wiX-edit-drops-sel and msel-append waivers are gone. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 36 ++++++++----- docs/selections.md | 22 +++++--- src/Output.zig | 2 +- src/Terminal.zig | 8 +-- src/Text.zig | 4 +- src/edit.zig | 127 ++++++++++++++++++++++++++++++++++++--------- src/look.zig | 2 +- src/mouse.zig | 4 +- src/normal.zig | 6 +-- src/tagline.zig | 4 +- test/hxcases/cases.jsonl | 15 ++++++ test/hxcases/goldens.jsonl | 15 ++++++ test/hxcases/waivers.jsonl | 2 - test/mode.zig | 6 +-- test/panes.zig | 4 +- transactions.txt | 15 ++---- 16 files changed, 194 insertions(+), 78 deletions(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 90efc463..35f62765 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -7,10 +7,17 @@ checkout (`~/05-genizah/helix`); that checkout now sits on the local `pardes-harness` branch, whose three commits add only the harness, so `book/src/keymap.md` is byte-identical at its tip (694e7dfd). As of phase 5 every helix-equivalent row in A and B is differentially verified against real -helix (see "Differential testing" at the bottom). Five of the six waivers are -named in the row they belong to; the sixth, `wiX-edit-drops-sel`, belongs to no -single key — it is the anchor-only divergence every insert-mode edit shares — -and is described in the Files list at the bottom instead. +helix (see "Differential testing" at the bottom). Every waiver is named in the +row it belongs to. + +**The reference helix is fixed at one commit:** the `hx-harness` built from +`694e7dfdd` on the genizah checkout's `pardes-harness` branch, which is upstream +`278b24389` (2026-06-29, `25.07-905`) plus the three harness commits. "helix" +in this document means that build, not the 25.07.1 release, which differs on +counted `Alt-(`/`Alt-)` and on `&`. Upstream master was 82 commits further on +2026-09-28 (`079a789e`); of those only `416a0e09` (continuing a comment in an +injected `comment` layer) touches the editing code, and it needs tree-sitter, +which the harness runs without. Code map, by SYMBOL — line numbers rot, names do not. Body-normal key RECOGNITION is `modal.Normal` in `src/modal.zig`: a state machine over `Role` (one @@ -227,7 +234,7 @@ grew `sels` + `primary`, emitted only when there is more than one range. | `y` with several ranges | one value per range, pasted back value i at range i by `p`/`P`/`R` and insert `Ctrl-r`; the acme cut/paste chords and a paste into a terminal take the values joined by newlines | `Registers.zig` | helix-verified | | `"` | the next command's register: any character names one of its own. Computed ones: `_` swallows writes and reads nothing (`"_d`), `#` is each range's number from 1, `.` each range's text, `%` the file's name, `/` the last `s`/`S` pattern; `+`/`*` the system clipboard. A count typed before `"` stays the command's | helix `select_register` | helix-verified | | insert `Ctrl-r ` | type this range's value of the register (`Ctrl-r #` numbers the cursors). Esc after `Ctrl-r` only cancels it. `Ctrl-r +` reads what pardes last put on the clipboard, not the desktop's | helix `insert_register` | helix-verified | -| `a` … `Esc` with several ranges | the primary's appended-over span is restored; the others collapse to bare cursors | `Pane.append_at` is a single field. Waived (`msel-append`) | waived | +| insert mode with a selection | every range is carried through each edit as helix maps it (`Range::map`): typing at the head of an `i` range slides it, typing at the end of an `a` range stretches it, Enter slides or stretches it by what the cursor moved, and an arrow key collapses it. Esc after `a` pulls each range's end back one character (helix `restore_cursor`) | `edit.insertKey`, `Text.restore_cursor` | helix-verified | | `&` | align selections into columns: the k-th range of each line is column k, and spaces go in before each range until its head reaches the column's widest head, in display cells (`File.rawDisplayCol`) | helix `align_selections` as of the harness's helix (25.07.1 grouped columns differently). A range over several lines refuses. A TAB counts as `tab_width` cells, not up to the next stop | helix-verified | | `Alt-)` / `Alt-(` / `Alt-)` | rotate the CONTENTS of the selections forward / back by one range (n ranges), in one edit; each range comes back over the text it now holds and the primary moves with its text | helix `rotate_selection_contents_*` as of the harness's helix: 25.07.1 read the count as a GROUP size instead (rotate by one within each run of n ranges) and left the primary where it was, which is what helix-golf's `invert_dictionary_2` relies on | helix-verified | @@ -389,11 +396,11 @@ Files (all in `test/hxcases/`): (runs the `hx-harness` binary from the helix checkout; override with `$HX_HARNESS`). Only needed when cases change — the diff itself runs offline. -- `waivers.jsonl` — named exemptions, each with a reason. Three live ones: - `wiX-edit-drops-sel` and `msel-append` (helix maps selections through - insert-mode edits, pardes does not) and `alt-c-window-op` (the pardes - window op deliberately shadows helix change-noyank). `msel-yank-paste` - went with registers of one value per range; `sel-regex-caret` and +- `waivers.jsonl` — named exemptions, each with a reason. One live one: + `alt-c-window-op` (the pardes window op deliberately shadows helix + change-noyank). `wiX-edit-drops-sel` and `msel-append` went when insert + mode began carrying every range through its edits, `msel-yank-paste` with + registers of one value per range, `sel-regex-caret` and `sel-regex-dot-newline` when `s`/`S` began searching line by line. - `parity.jsonl` — 80 further cases, used only by the parity gate below. - `parity-waivers.jsonl` — 14 named exemptions for the parity gate, in three @@ -412,10 +419,11 @@ Files (all in `test/hxcases/`): tested when an earlier one misses. The goldens are `hx-harness test/hxcases/golf.jsonl`; `zig build hxgolf` runs them. All ten examples reproduce their published result in hx 25.07.1 under the site's own conditions (a file of the example's language, auto-pairs - on). Under the harness pins two do not: `csv_to_sql` needs auto-pairs to - close its `VALUES (`, and `invert_dictionary_2` needs 25.07's - `Alt-(` (rotate within groups of count) where the harness's newer - helix rotates by count. + on). Under the harness two do not, and their goldens are the harness's + anyway, which pardes follows: `csv_to_sql` needs auto-pairs (off in the + harness and absent in pardes) to close its `VALUES (`, and + `invert_dictionary_2` needs 25.07.1's `Alt-(` (rotate within groups + of count) where the reference helix rotates by count. - `smoke.jsonl` — 20 cases referenced by nothing in the tree: no build step, no script. Either wire it up or delete it. diff --git a/docs/selections.md b/docs/selections.md index 0f000e2c..c08ccb14 100644 --- a/docs/selections.md +++ b/docs/selections.md @@ -3,6 +3,8 @@ How normal mode holds and changes its selections, and where that differs from helix's `Selection` (a list of ranges over gap offsets, with a primary index). Key-by-key behaviour is in docs/helix-keys.md; this is the model underneath. +"helix" here is the reference build docs/helix-keys.md names: `hx-harness` +at `694e7dfdd`, upstream `278b24389`. ## What a selection is @@ -25,8 +27,9 @@ plus up to 63 others. `s`, `n`/`N`, ...) rather than left behind by a motion. Only pardes reads it: the look (Enter) and execute (Tab) chords act on explicit selections and on the word under the cursor otherwise. -- `select` is `v` extend mode. `append_at` remembers where an `a` began, so - Esc can give back the range it appended over. +- `select` is `v` extend mode. `restore_cursor` says the insert session + began with `a`, so Esc gives each range back the character it was + stretched by. `Text.setRanges` is the one writer of a whole selection: it sorts by start, merges ranges that overlap or share a start (helix `normalize`), follows the @@ -67,6 +70,17 @@ A parsed action has a scope (`modal.Normal.Action.scope`). look) runs once from the primary and drops back to one cursor (`normal.multiOnce`). +## Insert mode + +Each range lives on through insert mode. Every insert-mode edit happens at +the cursor, so `edit.insertKey` runs it on the bare cursor and then carries +the range through what it did to the text, with helix's `Range::map` rules: +at an insertion, a backward range's head and an empty range move past it +and a forward range's head stays before it, so typing slides an `i` range +and stretches an `a` range. Enter slides or stretches the range by what the +cursor moved; an arrow key collapses it. The other ranges get the same by +the replay. + ## Undo, registers, repeat - One keystroke is one undo step however many ranges it edited: only the @@ -86,10 +100,6 @@ A parsed action has a scope (`modal.Normal.Action.scope`). ## Where it differs from helix -- Insert mode: helix maps every range through each keystroke's edit, so a - range an `i`/`a` started from stretches over what is typed and keeps its - anchor. Here only the cursor cells move, and after Esc only the primary's - `append_at` span comes back (waivers `wiX-edit-drops-sel`, `msel-append`). - Direction of a one-character range: both of its cells are the same cell, so it has none. helix can flip one (`Alt-;`) and the commands that read the head (`&`, the next extend) see the difference. diff --git a/src/Output.zig b/src/Output.zig index 6e1765a7..201b5491 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -749,7 +749,7 @@ pub fn resetBody(p: *Pardes, pane: *Pane) void { pane.body.select = false; pane.sel = @splat(.{}); pane.body.sticky_col = -1; - pane.body.append_at = null; + pane.body.restore_cursor = false; pane.body.normal.clear(); pane.look_at = null; if (p.look_hover_wait) |wait| if (wait.serial == pane.serial) { diff --git a/src/Terminal.zig b/src/Terminal.zig index e204b44c..e3378007 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -717,7 +717,7 @@ pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void { if (comptime !enabled) return; if (pane.terminal == null) return; const has_positions = pane.ovl != null or pane.body.cur_pinned or pane.body.vsel.active or - pane.body.msel.active or pane.body.nsel > 0 or pane.body.append_at != null or pane.look_at != null or + pane.body.msel.active or pane.body.nsel > 0 or pane.look_at != null or pane.body.ed_undo_len > 0 or pane.body.ed_redo_len > 0 or pointerRowCount(pane) > 0; // Sticky bottom, as every other terminal does it: output drags the // viewport down only when the viewport was already on the last row, so @@ -843,10 +843,6 @@ fn ingestWithPositions(pane: *Pane, bytes: []const u8) void { extra[nextra + 1] = &selection.arow; nextra += 2; } - if (pane.body.append_at) |*at| { - extra[nextra] = &at.row; - nextra += 1; - } if (pane.look_at) |*at| { extra[nextra] = &at.row; nextra += 1; @@ -1124,7 +1120,7 @@ pub fn enterTty(p: *Pardes, id: usize) void { // keeps its modal endpoint for the next return to editor mode. pane.body.cur_pinned = selected; pane.body.select = false; - pane.body.append_at = null; + pane.body.restore_cursor = false; pane.body.sticky_col = -1; pane.body.normal.clear(); } diff --git a/src/Text.zig b/src/Text.zig index 7ad243a1..764b8a6a 100644 --- a/src/Text.zig +++ b/src/Text.zig @@ -65,7 +65,9 @@ select: bool = false, /// sticky goal column for j/k runs (helix old_visual_position): any /// non-vertical range write resets it to -1. sticky_col: i32 = -1, -append_at: ?struct { row: i32, col: i32 } = null, +/// The insert session began with `a`: on Esc every range gives back the +/// one character it was stretched by (helix restore_cursor). +restore_cursor: bool = false, /// insert `Ctrl-r` was pressed: the next key names the register to insert reg_wait: bool = false, normal: modal.Normal.State = .{}, diff --git a/src/edit.zig b/src/edit.zig index 14d8b7ed..8f6f09c6 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -344,28 +344,20 @@ pub fn exitInsert(p: *Pardes, t: *Text) void { t.reg_wait = false; t.msel.active = false; t.normal.clear(); - if (t.append_at) |aa| { - t.append_at = null; - const text = flatSurface(p, t) catch return; - const gap = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) }); - const a_off = modal.offsetAt(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) }); - const back = @max(a_off, modal.prevGrapheme(text, gap)); - const bc = modal.positionAt(text, back); - t.cur_row = @intCast(bc.row); - t.cur_col = @intCast(bc.col); - t.vsel = .{ .active = a_off != back, .row = aa.row, .col = aa.col, .explicit = false }; - t.cur_pinned = true; - t.show(); - for (t.sels[0..t.nsel]) |*s| { - const sgap = modal.offsetAt(text, .{ .row = @intCast(@max(0, s.row)), .col = @intCast(@max(0, s.col)) }); - const b2 = if (back == gap) sgap else modal.prevGrapheme(text, sgap); - const bp = modal.positionAt(text, b2); - s.row = @intCast(bp.row); - s.col = @intCast(bp.col); - s.arow = s.row; - s.acol = s.col; - } + if (!t.restore_cursor) return; + t.restore_cursor = false; + // helix enter_normal_mode: every range, from its start to the char + // before its end when it points forward, else to its end + const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; + const text = flatSurface(p, t) catch return; + var rs: [Text.max_selections]modal.Selection = undefined; + const got = t.ranges(text, 0, &rs); + for (rs[0..got.n]) |*r| { + const from = @min(r.anchor, r.head); + const to = @max(r.anchor, r.head); + r.* = .{ .anchor = from, .head = if (r.head > r.anchor) @max(from, modal.prevGrapheme(text, to)) else to }; } + t.setRanges(text, rs[0..got.n], &.{}, got.pri, expl); } pub fn paneCursorLines(p: *Pardes, t: *Text) ![]const []const u8 { @@ -518,7 +510,7 @@ pub fn enterInsert(p: *Pardes, t: *Text, where: InsertAt, cnt: usize) void { // `u` undoes the whole session and restores what was selected pushUndo(p, t); t.select = false; - t.append_at = null; + t.restore_cursor = false; t.sticky_col = -1; const text = flatSurface(p, t) catch { t.mode = .insert; @@ -544,12 +536,14 @@ pub fn enterInsert(p: *Pardes, t: *Text, where: InsertAt, cnt: usize) void { const lo_col = if (b) |bb| bb.lo_col else t.cur_col; const hi_row = if (b) |bb| bb.hi_row else t.cur_row; const hi_col = if (b) |bb| bb.hi_col else t.cur_col; - t.append_at = .{ .row = lo_row, .col = lo_col }; + t.restore_cursor = true; const gap = modal.nextGrapheme(text, modal.offsetAt(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) })); const gc = modal.positionAt(text, gap); t.cur_row = @intCast(gc.row); t.cur_col = @intCast(gc.col); - t.vsel = .{ .active = b != null, .row = lo_row, .col = lo_col, .explicit = false }; + // helix append_mode: the range reaches one past its end, which + // is where typing goes; a bare cursor becomes a range too + t.vsel = .{ .active = true, .row = lo_row, .col = lo_col, .explicit = false }; t.cur_pinned = true; }, .line_start => { @@ -634,6 +628,72 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { t.reg_wait = true; return; } + // helix maps every range through each insert-mode edit (Range::map): + // the edits below work on the bare cursor, and the range the cursor + // heads is carried through what they did to the text afterwards + if (!t.vsel.active) return insertEdit(p, t, key); + const old = flatSurface(p, t) catch return insertEdit(p, t, key); + const r = t.primaryRange(old, 0); + const c = modal.selectionCursor(old, r); + const old_len = old.len; + const explicit = t.vsel.explicit; + t.vsel.active = false; + insertEdit(p, t, key); + if (t.mode != .insert) return; + const new = flatSurface(p, t) catch return; + const c2 = modal.offsetAt(new, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) }); + // every insert-mode edit happens at the cursor: an insertion there, or + // a deletion that ends there (backward) or starts there (forward) + if (key.cp == Key.enter) { + // helix insert_newline: an appending range stretches by what the + // cursor moved, any other slides by it + const d = @as(isize, @intCast(c2)) - @as(isize, @intCast(c)); + const head: usize = @intCast(@as(isize, @intCast(r.head)) + d); + const anchor: usize = if (c > r.anchor) r.anchor else @intCast(@as(isize, @intCast(r.anchor)) + d); + return setInsertRange(t, new, .{ .anchor = anchor, .head = head }, explicit); + } + var at: usize = c; + var del: usize = 0; + var ins: usize = 0; + if (new.len > old_len and c2 == c + (new.len - old_len)) { + ins = new.len - old_len; + } else if (new.len < old_len and c2 + (old_len - new.len) == c) { + at = c2; + del = old_len - new.len; + } else if (new.len < old_len and c2 == c) { + del = old_len - new.len; + } else if (new.len != old_len or c2 != c) { + return; // a motion: the range collapses onto the cursor (helix) + } + // helix Assoc: the head sticks after an insertion at it when the range + // points backward or is empty, the anchor when it points forward + const fwd = r.anchor < r.head; + setInsertRange(t, new, .{ + .anchor = mapThroughEdit(r.anchor, at, del, ins, !fwd and r.anchor != r.head), + .head = mapThroughEdit(r.head, at, del, ins, fwd), + }, explicit); +} + +fn setInsertRange(t: *Text, new: []const u8, mapped: modal.Selection, explicit: bool) void { + const cells = Text.rangeCells(new, mapped); + const cc = modal.positionAt(new, cells.cur); + const ac = modal.positionAt(new, cells.anc); + t.cur_row = @intCast(cc.row); + t.cur_col = @intCast(cc.col); + t.vsel = .{ .active = cells.anc != cells.cur, .row = @intCast(ac.row), .col = @intCast(ac.col), .explicit = explicit }; +} + +/// A position through one edit at `at` that deleted `del` bytes and put +/// `ins` in (one of the two is 0). A position exactly at an insertion stays +/// before it when `before` is set, else moves past it. +fn mapThroughEdit(pos: usize, at: usize, del: usize, ins: usize, before: bool) usize { + if (pos < at) return pos; + if (del > 0) return if (pos < at + del) at else pos - del; + if (pos == at and before) return pos; + return pos + ins; +} + +fn insertEdit(p: *Pardes, t: *Text, key: Key) void { t.vsel.active = false; if (!t.cur_pinned) pinPaneCursor(p, t); _ = clampFileCursor(t); @@ -748,12 +808,29 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { switch (key.cp) { Key.enter => { const line = modal.lineSlice(text, c.row); + const before = line[0..@min(c.col, line.len)]; + // helix insert_newline: after nothing but blanks, the line + // itself moves down, blanks and all; else the blanks before the + // cursor go and the new line takes the indent + if (std.mem.indexOfNone(u8, before, " \t") == null) { + const new = modal.insertAt(p.gpa, text, .{ .row = c.row, .col = 0 }, "\n") catch return; + setEditText(p, t, new); + t.cur_row += 1; + t.cur_pinned = true; + t.show(); + return; + } const indent = modal.newlineIndentWidth(line, c.col); const arena = p.scratch.allocator(); const block = arena.alloc(u8, 1 + indent) catch return; block[0] = '\n'; @memset(block[1..], ' '); - const new = modal.insertAt(p.gpa, text, c, block) catch return; + // a tag keeps its blanks: they part its words, and the last + // may be its read-only prefix's + const kept = if (t.what == .body) std.mem.trimEnd(u8, before, " \t").len else c.col; + const cut = modal.deleteSpan(p.gpa, text, .{ .row = c.row, .col = kept }, c) catch return; + defer p.gpa.free(cut); + const new = modal.insertAt(p.gpa, cut, .{ .row = c.row, .col = kept }, block) catch return; setEditText(p, t, new); t.cur_row += 1; t.cur_col = @intCast(indent); diff --git a/src/look.zig b/src/look.zig index 5e2d6068..30fb0dab 100644 --- a/src/look.zig +++ b/src/look.zig @@ -1016,7 +1016,7 @@ pub fn clearNavigationSelection(p: *Pardes, pane: *Pane) void { pane.body.nsel = 0; pane.body.select = false; pane.body.normal.clear(); - pane.body.append_at = null; + pane.body.restore_cursor = false; pane.body.sticky_col = -1; for (0..pane.sel.len) |slot| pane.clearPointerSelection(slot); pane.sel = @splat(.{}); diff --git a/src/mouse.zig b/src/mouse.zig index 91ec41a6..d1c32b40 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -747,7 +747,7 @@ fn mirrorTtySelection(p: *Pardes, pane: *Pane) void { pane.body.vsel.active = false; pane.body.nsel = 0; pane.body.select = false; - pane.body.append_at = null; + pane.body.restore_cursor = false; pane.body.sticky_col = -1; pane.body.normal.clear(); edit.setYank(p, text); @@ -859,7 +859,7 @@ fn dragRelease(p: *Pardes, button: Mouse.Button) void { pane.body.vsel.active = false; pane.body.nsel = 0; // a click says WHERE the one cursor is pane.body.select = false; - pane.body.append_at = null; + pane.body.restore_cursor = false; pane.body.sticky_col = -1; if (s.ctrl) p.lspRequest(s.id, .definition, ""); } diff --git a/src/normal.zig b/src/normal.zig index f5e868a4..53e416fd 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -149,7 +149,7 @@ const KeyState = struct { normal: modal.Normal.State, find_op: u8, find_ch: u21, - append_at: @FieldType(Text, "append_at"), + restore_cursor: bool, reg_wait: bool, fn of(t: *Text) KeyState { @@ -159,7 +159,7 @@ const KeyState = struct { .normal = t.normal, .find_op = t.find_op, .find_ch = t.find_ch, - .append_at = t.append_at, + .restore_cursor = t.restore_cursor, .reg_wait = t.reg_wait, }; } @@ -170,7 +170,7 @@ const KeyState = struct { t.normal = s.normal; t.find_op = s.find_op; t.find_ch = s.find_ch; - t.append_at = s.append_at; + t.restore_cursor = s.restore_cursor; t.reg_wait = s.reg_wait; } }; diff --git a/src/tagline.zig b/src/tagline.zig index 14bf5677..1c060a66 100644 --- a/src/tagline.zig +++ b/src/tagline.zig @@ -219,7 +219,7 @@ pub fn enterTag(p: *Pardes, pane: *Pane) void { t.msel.active = false; t.nsel = 0; t.select = false; - t.append_at = null; + t.restore_cursor = false; t.sticky_col = -1; t.mode = .normal; t.normal.clear(); @@ -325,7 +325,7 @@ pub fn enterHeader(p: *Pardes, column: ?usize) void { t.msel.active = false; t.nsel = 0; t.select = false; - t.append_at = null; + t.restore_cursor = false; t.mode = .normal; t.normal.clear(); } diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 7ba44f09..ba75c611 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -344,6 +344,21 @@ {"name": "i-keeps-sel-start", "pane": "file", "text": "alpha beta\n", "keys": "wi"} {"name": "wa-sel-end", "pane": "file", "text": "alpha beta\n", "keys": "wa"} {"name": "wiX-edit-drops-sel", "pane": "file", "text": "alpha beta\n", "keys": "wiX"} +{"name": "reg-selection-insert", "pane": "file", "text": "ab\n", "keys": "vli."} +{"name": "ins-sel-backspace", "pane": "file", "text": "alpha beta\n", "keys": "wi"} +{"name": "ins-append-enter", "pane": "file", "text": "ab cd\n", "keys": "wax"} +{"name": "ins-append-backspace", "pane": "file", "text": "ab cd\n", "keys": "ea"} +{"name": "ins-sel-delete", "pane": "file", "text": "ab cd\n", "keys": "wi"} +{"name": "ins-sel-arrow-collapses", "pane": "file", "text": "ab cd\n", "keys": "wi"} +{"name": "msel-insert-keeps-ranges", "pane": "file", "text": "ab cd\nab cd\n", "keys": "wCiX"} +{"name": "msel-append-lines", "pane": "file", "text": "aa\nbb\n", "keys": "%a!"} +{"name": "ins-append-bare", "pane": "file", "text": "ab\n", "keys": "axy"} +{"name": "ins-change-then-type", "pane": "file", "text": "ab cd\n", "keys": "wcX"} +{"name": "ins-open-below-sel", "pane": "file", "text": "ab\ncd\n", "keys": "xoX"} +{"name": "ins-enter-trims-blanks", "pane": "file", "text": "ab cd\n", "keys": "5li"} +{"name": "ins-enter-in-indent", "pane": "file", "text": " ab\n", "keys": "2li"} +{"name": "ins-enter-after-open", "pane": "file", "text": " ab\n", "keys": "ox"} +{"name": "msel-ins-enter", "pane": "file", "text": "ab cd\nab cd\n", "keys": "wCa"} {"name": "a-esc-restore", "pane": "file", "text": "abc\n", "keys": "a"} {"name": "ax-restore", "pane": "file", "text": "abc\n", "keys": "ax"} {"name": "lax-restore", "pane": "file", "text": "abc\n", "keys": "lax"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index f6a6aada..188b7ce4 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -344,6 +344,21 @@ {"name":"i-keeps-sel-start","text":"alpha beta\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":5}} {"name":"wa-sel-end","text":"alpha beta\n","mode":"insert","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":0}} {"name":"wiX-edit-drops-sel","text":"Xalpha beta\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":6}} +{"name":"reg-selection-insert","text":"abab\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":3}} +{"name":"ins-sel-backspace","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":5}} +{"name":"ins-append-enter","text":"ab\nxcd\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":0,"col":0}} +{"name":"ins-append-backspace","text":" cd\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"ins-sel-delete","text":"b cd\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":1}} +{"name":"ins-sel-arrow-collapses","text":"ab cd\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} +{"name":"msel-insert-keeps-ranges","text":"Xab cd\nXab cd\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":3},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":3}}],"primary":1} +{"name":"msel-append-lines","text":"aa!\nbb!\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":0}}],"primary":0} +{"name":"ins-append-bare","text":"axyb\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} +{"name":"ins-change-then-type","text":"Xcd\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} +{"name":"ins-open-below-sel","text":"ab\nX\ncd\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}} +{"name":"ins-enter-trims-blanks","text":"ab\ncd\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}} +{"name":"ins-enter-in-indent","text":"\n ab\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}} +{"name":"ins-enter-after-open","text":" ab\n\n x\n","mode":"normal","cursor":{"row":2,"col":5},"anchor":{"row":2,"col":5}} +{"name":"msel-ins-enter","text":"ab\ncd\nab\ncd\n","mode":"normal","cursor":{"row":2,"col":2},"anchor":{"row":2,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":2,"col":2},"anchor":{"row":2,"col":0}}],"primary":1} {"name":"a-esc-restore","text":"abc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} {"name":"ax-restore","text":"axbc\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}} {"name":"lax-restore","text":"abxc\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":1}} diff --git a/test/hxcases/waivers.jsonl b/test/hxcases/waivers.jsonl index 8ac3bd9c..2d74e346 100644 --- a/test/hxcases/waivers.jsonl +++ b/test/hxcases/waivers.jsonl @@ -1,3 +1 @@ -{"name":"wiX-edit-drops-sel","reason":"Insert edits clear the selection instead of tracking its anchor.","reference":{"name":"wiX-edit-drops-sel","text":"Xalpha beta\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":6}},"expected":{"name":"wiX-edit-drops-sel","text":"Xalpha beta\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}} {"name":"alt-c-window-op","reason":"Alt-c moves a pane to a new column instead of Helix's change-noyank.","reference":{"name":"alt-c-window-op","text":"bc def\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},"expected":{"name":"alt-c-window-op","text":"abc def\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}} -{"name":"msel-append","reason":"Append-mode selection origins are tracked only for the primary selection.","reference":{"name":"msel-append","text":"aza\nbzb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1},"expected":{"name":"msel-append","text":"aza\nbzb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1}} diff --git a/test/mode.zig b/test/mode.zig index c7d7d548..cba93c30 100644 --- a/test/mode.zig +++ b/test/mode.zig @@ -79,14 +79,14 @@ test "Mode from actual tag Exec preserves parked file and output insert transact p.update(.{ .key = .{ .cp = pardes.Key.tab } }); try std.testing.expectEqual(panes.Text.Mode.normal, pane.body.mode); try std.testing.expect(pane.focus == .body); - try std.testing.expect(pane.body.append_at == null); + try std.testing.expect(!pane.body.restore_cursor); p.update(.{ .key = .{ .cp = 'u' } }); try std.testing.expectEqualStrings("ab\n", pane.file.?.content); p.update(.{ .key = .{ .cp = 'a' } }); p.update(.{ .key = .{ .cp = 'z', .text = "z" } }); - try std.testing.expect(pane.body.append_at != null); + try std.testing.expect(pane.body.restore_cursor); try mode(p, 0, .normal); - try std.testing.expect(pane.body.append_at == null); + try std.testing.expect(!pane.body.restore_cursor); p.update(.{ .key = .{ .cp = 'u' } }); try std.testing.expectEqualStrings("ab\n", pane.file.?.content); } diff --git a/test/panes.zig b/test/panes.zig index cbf9d097..4e98f449 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -4547,7 +4547,7 @@ const LookResetTests = struct { pane.body.nsel = 1; pane.body.select = true; pane.body.normal.count = 5; - pane.body.append_at = .{ .row = 0, .col = 3 }; + pane.body.restore_cursor = true; pane.body.sticky_col = 7; for (0..pane.sel.len) |slot| { pane.clearPointerSelection(slot); @@ -4567,7 +4567,7 @@ const LookResetTests = struct { try std.testing.expectEqual(@as(u8, 0), pane.body.nsel); try std.testing.expect(!pane.body.select); try std.testing.expectEqual(modal.Normal.State{}, pane.body.normal); - try std.testing.expect(pane.body.append_at == null); + try std.testing.expect(!pane.body.restore_cursor); try std.testing.expectEqual(@as(i32, -1), pane.body.sticky_col); for (pane.sel) |selection| try std.testing.expectEqual(.none, selection.state); for (pane.pointer_selections) |selection| try std.testing.expect(selection == null); diff --git a/transactions.txt b/transactions.txt index 6c2e7646..8d2fda3f 100644 --- a/transactions.txt +++ b/transactions.txt @@ -1,13 +1,8 @@ -Selection anchors through insert-mode edits, and the transaction model -======================================================================= - -STATUS: still open. `wiX-edit-drops-sel` is line 1 of test/hxcases/waivers.jsonl, -so the gap below describes the code as it stands rather than something already -fixed. One correction to the sentence that follows: it is not quite "the one -place". Line 3 of the same file, `msel-append`, says in its own words "Same -family as wiX-edit-drops-sel: pardes does not map selections through insert-mode -edits" — so the gap has two waivers, and what is singular is the CAUSE below, -not the number of divergences it produces. +STATUS: closed for the waivers. Insert mode now carries every range through +each edit (edit.insertKey maps the range with helix's Range::map rules after +the edit is made), so `wiX-edit-drops-sel` and `msel-append` are gone. What +follows is the original analysis; the delta layer it proposes was not needed +for that and stays unbuilt. The one place the helix differential suite shows a real structural gap (waiver "wiX-edit-drops-sel" in test/hxcases/waivers.jsonl): helix keeps a -- cgit v1.3 From cc66593d1432813c9af029ce3779ccba9ded8883 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:11:08 -0300 Subject: The helix-golf steps that walk search hits are waived, so hxgolf can pass reverse_golf_example from ""N on needs *, N and n to walk the search register; in pardes those keys are the acme look ring, by decision. The seven steps are pinned in golf-waivers.jsonl, so a new mismatch stands out. Co-Authored-By: Claude Opus 5.5 --- build.zig | 2 +- docs/helix-keys.md | 5 ++++- docs/selections.md | 5 ++++- test/hxcases/golf-waivers.jsonl | 7 +++++++ 4 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 test/hxcases/golf-waivers.jsonl diff --git a/build.zig b/build.zig index 334d3705..f4a3b542 100644 --- a/build.zig +++ b/build.zig @@ -1100,7 +1100,7 @@ pub fn build(b: *std.Build) void { // every helix-golf example, one case per step of its walkthrough const run_hxgolf = b.addRunArtifact(hxdiff); run_hxgolf.has_side_effects = true; - run_hxgolf.addArgs(&.{ "--strict", "test/hxcases/golf.jsonl", "test/hxcases/golf-goldens.jsonl" }); + run_hxgolf.addArgs(&.{ "--strict", "test/hxcases/golf.jsonl", "test/hxcases/golf-goldens.jsonl", "test/hxcases/golf-waivers.jsonl" }); run_hxgolf.setCwd(b.path(".")); b.step("hxgolf", "compare the helix-golf examples against their Helix results").dependOn(&run_hxgolf.step); diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 35f62765..14a39239 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -417,7 +417,10 @@ Files (all in `test/hxcases/`): step that starts over with `%` also gets `-fromNN` cases replaying the rest of the command from helix's own text there, so a later step is still tested when an earlier one misses. The goldens are - `hx-harness test/hxcases/golf.jsonl`; `zig build hxgolf` runs them. All ten examples reproduce their published result in hx 25.07.1 under + `hx-harness test/hxcases/golf.jsonl`; `zig build hxgolf` runs them. + `golf-waivers.jsonl` pins the seven `reverse_golf_example` steps from `""N` + on: they walk the search register with `*`, `N` and `n`, which in pardes + are the acme look ring, by decision. All ten examples reproduce their published result in hx 25.07.1 under the site's own conditions (a file of the example's language, auto-pairs on). Under the harness two do not, and their goldens are the harness's anyway, which pardes follows: `csv_to_sql` needs auto-pairs (off in the diff --git a/docs/selections.md b/docs/selections.md index c08ccb14..35fa806f 100644 --- a/docs/selections.md +++ b/docs/selections.md @@ -94,7 +94,10 @@ the replay. for missing ones. `"` names the register (`Pardes.register` for the length of one command); `_ # . % /` are computed or special and `+`/`*` is the system clipboard. The acme chords and a paste into a terminal read - the default register joined by newlines. + the default register joined by newlines. The clipboard's read is an + answer from the shell that comes later, so `"+p` pastes once, at the + primary, and insert `Ctrl-r +` types what pardes last put on the + clipboard rather than asking the desktop. - `Alt-.` repeats the last `f`/`t`/`F`/`T`. There is no `.` and there are no macros. diff --git a/test/hxcases/golf-waivers.jsonl b/test/hxcases/golf-waivers.jsonl new file mode 100644 index 00000000..eb1f824b --- /dev/null +++ b/test/hxcases/golf-waivers.jsonl @@ -0,0 +1,7 @@ +{"name":"golf-reverse-golf-example-07","reason":"pardes n/N/* are the acme look ring, by decision: \"\"N, n and * do not walk the search register","reference":{"name":"golf-reverse-golf-example-07","text":"# snake_case to snake_case\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":11},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":11}},{"cursor":{"row":0,"col":16},"anchor":{"row":0,"col":25}}],"primary":0},"expected":{"name":"golf-reverse-golf-example-07","text":"# snake_case to snake_case\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":0,"col":16},"anchor":{"row":0,"col":25}}} +{"name":"golf-reverse-golf-example-08","reason":"pardes n/N/* are the acme look ring, by decision: \"\"N, n and * do not walk the search register","reference":{"name":"golf-reverse-golf-example-08","text":"# snake_case to snake_case\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":2,"col":24},"anchor":{"row":2,"col":32},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":11}},{"cursor":{"row":0,"col":16},"anchor":{"row":0,"col":25}},{"cursor":{"row":2,"col":24},"anchor":{"row":2,"col":32}}],"primary":2},"expected":{"name":"golf-reverse-golf-example-08","text":"# snake_case to snake_case\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":0,"col":16},"anchor":{"row":0,"col":25}}} +{"name":"golf-reverse-golf-example-09","reason":"pardes n/N/* are the acme look ring, by decision: \"\"N, n and * do not walk the search register","reference":{"name":"golf-reverse-golf-example-09","text":"# camelCase to snake_case\n\nRename all fields to be snake_case.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":10},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":10}},{"cursor":{"row":0,"col":15},"anchor":{"row":0,"col":24}},{"cursor":{"row":2,"col":24},"anchor":{"row":2,"col":33}}],"primary":0},"expected":{"name":"golf-reverse-golf-example-09","text":"# snake_case to snake_case\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":0,"col":16},"anchor":{"row":0,"col":25}}} +{"name":"golf-reverse-golf-example-10","reason":"pardes n/N/* are the acme look ring, by decision: \"\"N, n and * do not walk the search register","reference":{"name":"golf-reverse-golf-example-10","text":"# camelCase to snake_case\n\nRename all fields to be snake_case.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":14,"col":3},"anchor":{"row":0,"col":0}},"expected":{"name":"golf-reverse-golf-example-10","text":"# snake_case to snake_case\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":14,"col":3},"anchor":{"row":0,"col":0}}} +{"name":"golf-reverse-golf-example-11","reason":"pardes n/N/* are the acme look ring, by decision: \"\"N, n and * do not walk the search register","reference":{"name":"golf-reverse-golf-example-11","text":"# camelCase to snake_case\n\nRename all fields to be snake_case.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":6,"col":3},"anchor":{"row":6,"col":0},"sels":[{"cursor":{"row":6,"col":3},"anchor":{"row":6,"col":0}},{"cursor":{"row":12,"col":3},"anchor":{"row":12,"col":0}}],"primary":0},"expected":{"name":"golf-reverse-golf-example-11","text":"# snake_case to snake_case\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":6,"col":3},"anchor":{"row":6,"col":0},"sels":[{"cursor":{"row":6,"col":3},"anchor":{"row":6,"col":0}},{"cursor":{"row":12,"col":3},"anchor":{"row":12,"col":0}}],"primary":0}} +{"name":"golf-reverse-golf-example-12","reason":"pardes n/N/* are the acme look ring, by decision: \"\"N, n and * do not walk the search register","reference":{"name":"golf-reverse-golf-example-12","text":"# camelCase to snake_case\n\nRename all fields to be snake_case.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":7,"col":41},"anchor":{"row":6,"col":0},"sels":[{"cursor":{"row":7,"col":41},"anchor":{"row":6,"col":0}},{"cursor":{"row":13,"col":39},"anchor":{"row":12,"col":0}}],"primary":0},"expected":{"name":"golf-reverse-golf-example-12","text":"# snake_case to snake_case\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n\n## After\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n","mode":"select","cursor":{"row":7,"col":41},"anchor":{"row":6,"col":0},"sels":[{"cursor":{"row":7,"col":41},"anchor":{"row":6,"col":0}},{"cursor":{"row":13,"col":39},"anchor":{"row":12,"col":0}}],"primary":0}} +{"name":"golf-reverse-golf-example","reason":"pardes n/N/* are the acme look ring, by decision: \"\"N, n and * do not walk the search register","reference":{"name":"golf-reverse-golf-example","text":"# camelCase to snake_case\n\nRename all fields to be snake_case.\n\n## Before\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n\n## After\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n","mode":"select","cursor":{"row":13,"col":41},"anchor":{"row":12,"col":0},"sels":[{"cursor":{"row":7,"col":39},"anchor":{"row":6,"col":0}},{"cursor":{"row":13,"col":41},"anchor":{"row":12,"col":0}}],"primary":1},"expected":{"name":"golf-reverse-golf-example","text":"# snake_case to snake_case\n\nRename all fields to be camelCase.\n\n## Before\n\n```js\nconst userProfile = {firstName: \"John\"};\n```\n\n## After\n\n```js\nconst user_profile = {first_name: \"John\"};\n```\n","mode":"select","cursor":{"row":13,"col":41},"anchor":{"row":12,"col":0},"sels":[{"cursor":{"row":7,"col":39},"anchor":{"row":6,"col":0}},{"cursor":{"row":13,"col":41},"anchor":{"row":12,"col":0}}],"primary":1}} -- cgit v1.3 From 07a3f53993a27c886ccb3ed665acb9c8b34a2873 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:12:29 -0300 Subject: A tag's edited text stays alive until the next edit, so ms and md in a tag read it whole setEditText freed the text a tag edit was handed once it had copied the tag's own part out of it, but every caller reads that text back to place the cursor, and surround goes on to edit it again: ms and md in a tag read freed memory. The same held for a terminal without an edit buffer and a text with no pane. The text is now kept until the next such edit (edit.retireEditText), which covers every caller at once; the callers the audit found reading it back are surround's two, the paste and replace paths, the number adjust, and the insert-mode Enter. Co-Authored-By: Claude Opus 5.5 --- src/Terminal.zig | 2 +- src/edit.zig | 13 +++++++++++-- src/pardes.zig | 3 +++ test/panes.zig | 13 +++++++++++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/Terminal.zig b/src/Terminal.zig index e3378007..4f94533d 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -1382,7 +1382,7 @@ pub fn editText(p: *Pardes, pane: *Pane, lo: i32, hi: i32, col: i32) ?EditText { /// Consume a rewritten overlay, freeing the terminal edit text it replaces. pub fn setEditText(p: *Pardes, pane: *Pane, new: []u8) void { - const overlay = if (pane.ovl) |*value| value else return p.gpa.free(new); + const overlay = if (pane.ovl) |*value| value else return pardes.edit.retireEditText(p, new); for (0..pane.sel.len) |button| { pane.clearPointerSelection(button); pane.sel[button].state = .none; diff --git a/src/edit.zig b/src/edit.zig index 8f6f09c6..3e12f169 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -463,7 +463,9 @@ pub fn setEditText(p: *Pardes, t: *Text, new: []u8) void { if (t.what == .tag) { // A tag is edited as it is shown, but only what follows its // prefix is its own: an edit that changed the prefix is refused. - defer p.gpa.free(new); + // The shown text stays alive until the next such edit, since every + // caller reads the new text back, as a body's is kept. + defer retireEditText(p, new); const pane = t.pane() orelse return; const prefix = tagline.pathPrefix(p.scratch.allocator(), pane) catch return; if (!std.mem.startsWith(u8, new, prefix)) { @@ -480,11 +482,18 @@ pub fn setEditText(p: *Pardes, t: *Text, new: []u8) void { t.own = new; return; } - const pane = t.pane() orelse return p.gpa.free(new); + const pane = t.pane() orelse return retireEditText(p, new); if (pane.file) |*f| return panes.File.setContent(p, f, new); panes.Terminal.setEditText(p, pane, new); } +/// Take a text an edit made that nothing keeps, holding it until the next +/// one: the edit's caller still reads it to place the cursor. +pub fn retireEditText(p: *Pardes, new: []u8) void { + if (p.retired_edit) |old| p.gpa.free(old); + p.retired_edit = new; +} + pub const InsertAt = enum { at, append, line_start, line_end, open_below, open_above }; /// Files insert only inside existing rows and at grapheme boundaries. diff --git a/src/pardes.zig b/src/pardes.zig index e4645d70..c9d93979 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4096,6 +4096,8 @@ pub const Pardes = struct { in_len: usize = 0, registers: Registers = .{}, + /// an edited text nothing kept (a tag's shown text): see edit.retireEditText + retired_edit: ?[]u8 = null, clip_pending: ?edit.ClipRequest = null, /// the last serialized dump (gpa-owned), read by the write_dump effect dump_out: ?[]u8 = null, @@ -4267,6 +4269,7 @@ pub const Pardes = struct { for (&p.col_tags) |*t| t.deinit(gpa); p.global_tag.deinit(gpa); p.registers.deinit(gpa); + if (p.retired_edit) |r| gpa.free(r); if (p.dump_out) |d| gpa.free(d); p.fallback.deinit(); if (p.last_dump) |d| gpa.free(d); diff --git a/test/panes.zig b/test/panes.zig index 4e98f449..0b9909ad 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -314,6 +314,19 @@ const TagNavigationTests = struct { try std.testing.expect(pane.prompt == .none and pane.focus == .body); } + test "ms and md edit a tag's own text, and the text the edit made is read back whole" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 20 }); + defer p.deinit(); + const pane = try p.setTestFile("body\n"); + p.update(.tick); + key(p, ':'); // the tag, on `Save` the first time + for ("miwms(") |cp| key(p, cp); + try std.testing.expect(std.mem.indexOf(u8, pardes.tagline.curTail(pane), "(Save)") != null); + for ("md(") |cp| key(p, cp); + try std.testing.expect(std.mem.indexOf(u8, pardes.tagline.curTail(pane), " Save ") != null); + try std.testing.expect(std.mem.indexOf(u8, pardes.tagline.curTail(pane), "(") == null); + } + test "the keyboard reaches a tag's path, which it can select and yank but not edit" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 20 }); defer p.deinit(); -- cgit v1.3 From 06fe2290db7d0eb654b9a908358942cfde298e29 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:15:15 -0300 Subject: $ keeps the selections a shell command exits 0 on, and line end is gl $ was a second key for line end, which helix never meant: in helix it is shell_keep_pipe. It now arms the pipe prompt with a $ marker, runs the command once per range with that range's text on stdin, and keeps the ranges it exits 0 on; the primary stays if kept, else the last kept range takes over, and keeping none changes nothing. The runner's answer is all or nothing, so the command runs in a subshell whose status is echoed as its output. Line end stays on helix's own gl and End; the snapshots, tutor and docs that pressed $ for it press gl. Co-Authored-By: Claude Opus 5.5 --- docs/design.typ | 2 +- docs/helix-keys.md | 4 +-- src/Pipe.zig | 63 +++++++++++++++++++++++++++++++++++++++++++-- src/config.zig | 4 ++- src/modal.zig | 7 ++++- src/tutor.txt | 2 +- test/panes.zig | 6 ++++- test/pdf.zig | 3 ++- test/snapshots/badutf.snap | 2 +- test/snapshots/hscroll.snap | 4 +-- test/snapshots/tagnav.snap | 4 +-- 11 files changed, 86 insertions(+), 15 deletions(-) diff --git a/docs/design.typ b/docs/design.typ index 234c3692..4765fabd 100644 --- a/docs/design.typ +++ b/docs/design.typ @@ -1916,7 +1916,7 @@ delayed, theme-derived highlight of the exact side-effect-free selection that Look would expand; it neither focuses nor installs that selection, and pointer leave/input/content invalidation cancels it. Wheel: scroll hovered pane, batched. -*Modes.* normal: helix motions (`h j k l w b e W B E 0 $ ^`, `f F t T` and +*Modes.* normal: helix motions (`h j k l w b e W B E 0 gl ^`, `f F t T` and `Alt-.`, counts, `gg ge gh gs gl g| G`, `Ctrl-d/u/f/b`, `zt zz zb zj zk`), insert entries (`i a I A o O`), selections (`v` extend — displayed as a fourth mode name, "select" — `x`/`X`/`Alt-x`, `%`, `;`/`Alt-;`, `_`), MULTIPLE CURSORS diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 14a39239..cd33ccbe 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -100,7 +100,7 @@ language-backend queries, and the shell pipe. | `w` `b` `e` | select to next word start / prev word start / next word end | full helix model incl. landing conventions (block cursor one before the next word after `w`) and newline/punct/EOF edges | helix-verified | | `W` `B` `E` | long-word (WORD) variants | same | helix-verified | | `Home` / `End` | line start / line end | matches `goto_line_start` / `goto_line_end` | helix-verified | -| `0` / `$` / `^` | line start / line end / first non-ws | `0` and `^` are pardes extras — helix leaves those two unbound (it spells them `gh`/`gs`). `$` is NOT unbound in helix: it is `shell_keep_pipe` (`keymap/default.rs`), and taking it for line-end is a deliberate divergence — see the `$` row in C. It is the one shell command still unimplemented: it needs a per-selection verdict, where the runner's answer is atomic. `0` is a count digit while a count is pending | pardes-specific (`$`: deliberate) | +| `0` / `^` | line start / first non-ws | pardes extras: helix leaves both unbound (it spells them `gh`/`gs`), so they collide with nothing. Line end is helix's own `gl` and End. `0` is a count digit while a count is pending | pardes-specific | | `G` | bare `G` is a **no-op**; `G` = goto line n | vim-ism removed (phase 5): helix `goto_line` only acts with a count; `ge` is goto-last-line | helix-verified | | `gg` / `gg` | goto first line / line n | | helix-verified | | `ge` | goto last content line | matches `goto_last_line` (ignores the trailing empty line) | helix-verified | @@ -146,6 +146,7 @@ language-backend queries, and the shell pipe. | `A-\|` | the same, and the output is DISCARDED — the text is not touched at all | helix `shell_pipe_to`. For a command run for its effect. Marker `\|-` | out of corpus | | `!` | run with NO stdin, insert the output BEFORE each selection | helix `shell_insert_output`. Runs ONCE and every cursor gets that one answer, as helix does — ten cursors and `date` give ten identical stamps. Marker `!` | out of corpus | | `A-!` | the same, appended AFTER each selection | helix `shell_append_output`. Marker `!+` | out of corpus | +| `$` | keep only the selections a shell command exits 0 on: each range's text on its stdin, one run per range, its output discarded; the primary stays if kept, else the last kept range takes over, and keeping none changes nothing | helix `shell_keep_pipe`. The runner's answer is all or nothing, so the command runs as `(cmd) >/dev/null; echo $?` and the status comes back as the output. Marker `$` | out of corpus | ## B. To implement @@ -355,7 +356,6 @@ text. | `Ctrl-s` (normal) | save jumplist position | jumplist itself is implemented (`Ctrl-o`/`Ctrl-i`, section A); only the explicit save point is skipped | | `Alt-u` / `Alt-U` | undo-history earlier/later | history timeline — linear snapshot u/U covers pardes | | `K Alt-K`, `Alt-:` | regex keep/remove, ensure-forward | `K`/`Alt-K` are the same prompt `s`/`S` now have, filtering instead of splitting (`s S` moved to "Regex selection" in A, the rest of the family to "Multiple cursors") | -| `Alt-\|` `!` `Alt-!` `$` | shell pipe-to (output discarded), insert output, append output, keep-by-exit-status | shell — `\|` (`shell_pipe`, output replaces the selection) is implemented in section A; these four are the other members of helix's shell family. `$` is additionally taken for line-end here (section A) | | `:` | command mode | side-effects/file-ops — pardes builtins live in the tag, and `:` is bound to focusing it (section A) | | `gf` | goto file under selection | covered by pardes Enter-look | | `Ctrl-z` | suspend | pardes IS the terminal multiplexer | diff --git a/src/Pipe.zig b/src/Pipe.zig index 1ad7b826..37145edf 100644 --- a/src/Pipe.zig +++ b/src/Pipe.zig @@ -35,6 +35,7 @@ pub fn pipeMarker(how: modal.Normal.PipeBehavior) []const u8 { .ignore => config.pipe_marker_to, .insert => config.pipe_marker_insert, .append => config.pipe_marker_append, + .keep => config.pipe_marker_keep, }; } @@ -117,7 +118,13 @@ pub fn submitPipe(p: *Pardes, id: usize) void { p.gpa.free(inputs); return; } - const command_copy = p.gpa.dupe(u8, command) catch { + // `$` wants a verdict per selection where the runner's is all or + // nothing: the command runs in a subshell whose exit status becomes the + // output, so the runner always succeeds and the answer says which kept + const command_copy = (if (how == .keep) + std.fmt.allocPrint(p.gpa, "(\n{s}\n) >/dev/null; echo $?", .{command}) + else + p.gpa.dupe(u8, command)) catch { for (inputs) |input| p.gpa.free(@constCast(input.bytes)); p.gpa.free(inputs); return; @@ -202,7 +209,7 @@ fn pipeCut(wait: *const PendingPipe, content: []const u8, i: usize) ?struct { lo const hi = @max(range.anchor, range.head); if (hi > content.len) return null; return switch (wait.how) { - .replace, .ignore => .{ .lo = lo, .hi = hi }, + .replace, .ignore, .keep => .{ .lo = lo, .hi = hi }, .insert => .{ .lo = lo, .hi = lo }, .append => .{ .lo = hi, .hi = hi }, }; @@ -260,6 +267,21 @@ pub fn pipeResponse( if (changed) return p.reportError(wait.pane, "pipe", error.FileChangedWhileFiltering); if (wait.how == .ignore) return; + if (wait.how == .keep) { + // helix shell_keep_pipe: the primary stays if kept, else the last + // kept range takes over; keeping none changes nothing + var kept: [panes.Text.max_selections]modal.Selection = undefined; + var m: usize = 0; + var pri: ?usize = null; + for (0..wait.nranges) |i| { + if (!std.mem.eql(u8, std.mem.trim(u8, outputs[i], " \n"), "0")) continue; + if (i == wait.primary) pri = m; + kept[m] = wait.ranges[i]; + m += 1; + } + if (m == 0) return; + return t.setRanges(content, kept[0..m], &.{}, pri orelse m - 1, wait.explicit); + } const n = wait.nranges; var total_output: usize = 0; @@ -366,6 +388,43 @@ test "| in a tag pipes the tag's own text, and undo there brings it back" { try std.testing.expect(std.mem.startsWith(u8, pardes.tagline.curTail(pane), " Save ")); } +test "$ keeps the selections the command exits 0 on, and a real shell gives that verdict" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + const pane = try p.setTestFile("aa bb cc\n"); + const ranges = [_]modal.Selection{ + .{ .anchor = 0, .head = 2 }, + .{ .anchor = 3, .head = 5 }, + .{ .anchor = 6, .head = 8 }, + }; + pane.body.setRanges(pane.file.?.content, &ranges, &.{}, 1, true); + p.update(.{ .key = .{ .cp = '$' } }); + try std.testing.expectEqualStrings(config.pipe_marker_keep, pane.promptText().?); + p.update(.{ .key = .{ .cp = 'g', .text = "grep -v b" } }); + p.update(.{ .key = .{ .cp = Key.enter } }); + const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; + const request = p.pipe.pipeRequest(id) orelse return error.MissingPipeRequest; + try std.testing.expectEqual(@as(usize, 3), request.inputs.len); + // the wrapper turns each run's status into its output, as the runner sees it + const job = try selection_pipe.Job.copy(gpa, request); + defer job.deinit(gpa); + var response = selection_pipe.runJob(gpa, std.testing.io, job); + defer response.deinit(gpa); + try std.testing.expect(response.success); + try std.testing.expectEqualStrings("0\n", response.outputs[0]); + try std.testing.expectEqualStrings("1\n", response.outputs[1]); + p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = response.outputs } }); + try std.testing.expectEqualStrings("aa bb cc\n", pane.file.?.content); + var out: [panes.Text.max_selections]modal.Selection = undefined; + const got = pane.body.ranges(pane.file.?.content, 0, &out); + try std.testing.expectEqual(@as(usize, 2), got.n); + try std.testing.expectEqual(@as(usize, 6), out[1].anchor); + // the primary was dropped: the last kept range takes over + try std.testing.expectEqual(@as(usize, 1), got.pri); +} + test "selection pipe replaces all ranges atomically and undo restores them" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); diff --git a/src/config.zig b/src/config.zig index 4412ef0d..91665b14 100644 --- a/src/config.zig +++ b/src/config.zig @@ -266,10 +266,12 @@ pub const pipe_selection: []const Chord = &.{.{ .cp = '|' }}; pub const pipe_selection_to: []const Chord = &.{.{ .cp = '|', .alt = true }}; pub const insert_output: []const Chord = &.{.{ .cp = '!' }}; pub const append_output: []const Chord = &.{.{ .cp = '!', .alt = true }}; +pub const keep_pipe: []const Chord = &.{.{ .cp = '$' }}; // Armed inputs are parsed from their distinct, nonempty tag markers. pub const pipe_marker_to = " |-"; pub const pipe_marker_insert = " !"; pub const pipe_marker_append = " !+"; +pub const pipe_marker_keep = " $"; pub const search_submit: []const Chord = &.{.{ .cp = Key.enter }}; // Del from the keyboard on a pane with open panes both above and below it @@ -454,7 +456,7 @@ pub const next_long_word_start: []const Chord = &.{.{ .cp = 'W' }}; pub const prev_long_word_start: []const Chord = &.{.{ .cp = 'B' }}; pub const next_long_word_end: []const Chord = &.{.{ .cp = 'E' }}; pub const line_start: []const Chord = &.{ .{ .cp = '0' }, .{ .cp = Key.home } }; -pub const line_end: []const Chord = &.{ .{ .cp = '$' }, .{ .cp = Key.end } }; +pub const line_end: []const Chord = &.{.{ .cp = Key.end }}; pub const line_first_nonws: []const Chord = &.{.{ .cp = '^' }}; // Bare G does nothing; ge reaches the last line. pub const goto_line: []const Chord = &.{.{ .cp = 'G' }}; diff --git a/src/modal.zig b/src/modal.zig index aec1c579..bce941d5 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -128,6 +128,7 @@ pub const Normal = struct { pipe_selection_to, insert_output, append_output, + keep_pipe, search, search_next, search_prev, @@ -196,10 +197,13 @@ pub const Normal = struct { insert, /// `A-!` — no stdin, and the output is appended AFTER each selection. append, + /// `$` — stdin is the selection; the output is discarded, and only the + /// selections the command exits 0 on are kept. + keep, /// Do the selections become stdin? helix's `pipe` flag. pub fn pipes(b: PipeBehavior) bool { - return b == .replace or b == .ignore; + return b == .replace or b == .ignore or b == .keep; } }; @@ -597,6 +601,7 @@ pub const Normal = struct { if (key.has(.pipe_selection_to)) return resultAction(.{ .pipe_selection = .ignore }); if (key.has(.insert_output)) return resultAction(.{ .pipe_selection = .insert }); if (key.has(.append_output)) return resultAction(.{ .pipe_selection = .append }); + if (key.has(.keep_pipe)) return resultAction(.{ .pipe_selection = .keep }); if (key.has(.search)) return resultAction(.search); if (key.has(.search_next)) return resultAction(.{ .search_step = .forward }); if (key.has(.search_prev)) return resultAction(.{ .search_step = .backward }); diff --git a/src/tutor.txt b/src/tutor.txt index 507ce40e..0893415e 100644 --- a/src/tutor.txt +++ b/src/tutor.txt @@ -461,7 +461,7 @@ typed N frontends share ONE screen at the smallest common grid the default 9P socket works in a daemon too - KEYS h j k l w b e 0 $ ^ gg ge f F t T v x d c y p + KEYS h j k l w b e 0 gl ^ gg ge f F t T v x d c y p i a I A o O u undo U redo motions SELECT, so `i` types at the selection's start and `;` collapses first diff --git a/test/panes.zig b/test/panes.zig index 0b9909ad..f0e1ba3d 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -1954,7 +1954,11 @@ test "a prompt owns the pipe behavior through cancellation and submission" { p.update(.{ .key = .{ .cp = Key.enter } }); const pending = p.pipe.wait orelse return error.MissingPipe; try std.testing.expectEqual(how, pending.how); - try std.testing.expectEqualStrings("cat", pending.command); + // `$` runs the command in a subshell that reports its status + if (how == .keep) + try std.testing.expect(std.mem.indexOf(u8, pending.command, "\ncat\n") != null) + else + try std.testing.expectEqualStrings("cat", pending.command); try std.testing.expectEqual(@as(usize, 1), pending.inputs.len); try std.testing.expectEqualStrings(if (how.pipes()) "one" else "", pending.inputs[0].bytes); try std.testing.expect(pane.prompt == .none); diff --git a/test/pdf.zig b/test/pdf.zig index 3a592451..8b94c1ae 100644 --- a/test/pdf.zig +++ b/test/pdf.zig @@ -859,7 +859,8 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" { p.update(.{ .key = .{ .cp = '2' } }); p.update(.{ .key = .{ .cp = 'l' } }); try std.testing.expect(pane.pdf.?.pan_x > 0); - p.update(.{ .key = .{ .cp = '$' } }); + p.update(.{ .key = .{ .cp = 'g' } }); + p.update(.{ .key = .{ .cp = 'l' } }); try std.testing.expectEqual(std.math.maxInt(u16), pane.pdf.?.pan_x); p.update(.{ .key = .{ .cp = '2' } }); p.update(.{ .key = .{ .cp = 'h' } }); diff --git a/test/snapshots/badutf.snap b/test/snapshots/badutf.snap index ddd1588b..7171df00 100644 --- a/test/snapshots/badutf.snap +++ b/test/snapshots/badutf.snap @@ -19,6 +19,6 @@ stable 700 20000 # first visible CJK glyph intact instead of starting inside its UTF-8 bytes. key space t w stable 400 5000 -key $ +key g l stable 700 10000 snap hscroll-midglyph diff --git a/test/snapshots/hscroll.snap b/test/snapshots/hscroll.snap index 6f73154f..05366323 100644 --- a/test/snapshots/hscroll.snap +++ b/test/snapshots/hscroll.snap @@ -36,10 +36,10 @@ send \x1b[<67;10;8M send \x1b[<67;10;8M stable 400 5000 snap clamped -# $ walks the cursor to the line end: the view follows with the margin +# gl walks the cursor to the line end: the view follows with the margin key g g stable 400 5000 -key $ +key g l stable 400 5000 snap dollar # 0 back to column zero: the view snaps home diff --git a/test/snapshots/tagnav.snap b/test/snapshots/tagnav.snap index 95c4b751..f145480e 100644 --- a/test/snapshots/tagnav.snap +++ b/test/snapshots/tagnav.snap @@ -98,7 +98,7 @@ snap workspace-tag # lives up here too, so a builtin that frees the session must never return # into a focused header. Dump grows a `Restore ` word, which is also the # case where the line gets longer than the column you were parked on. -key $ +key g l stable 400 5000 snap topbar-dollar key B B B B @@ -114,7 +114,7 @@ snap topbar-exec key c-w k key c-w k stable 400 5000 -key $ B B +key g l B B stable 400 5000 snap topbar-restore-word key tab -- cgit v1.3 From 3992e9def8692710590deacbc20bd925e0f8d64a Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:20:24 -0300 Subject: Q records keys into a register and q types them again; . repeats the last insert Macro.zig keeps keys to be typed again. Q records into @ (or ") in helix's key notation until the next Q; q replays a register's keys count times through the modal handling, where Esc does not hop panes and Enter, Tab and Space neither look, execute nor open the leader. . replays the normal command that entered the last insert session once and what was typed in it count times (helix repeat_last_insert). a at the end of the text now adds the newline helix's append_mode does, which a terminal's text does not. helix-golf's invert_dictionary passes. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 15 ++-- docs/selections.md | 5 +- src/Macro.zig | 176 ++++++++++++++++++++++++++++++++++++++ src/config.zig | 3 + src/edit.zig | 15 +++- src/modal.zig | 12 +++ src/normal.zig | 42 ++++++++- src/pardes.zig | 50 +++++++++-- test/hxcases/cases.jsonl | 16 ++++ test/hxcases/goldens.jsonl | 16 ++++ test/hxcases/parity-waivers.jsonl | 1 + 11 files changed, 335 insertions(+), 16 deletions(-) create mode 100644 src/Macro.zig diff --git a/docs/helix-keys.md b/docs/helix-keys.md index cd33ccbe..3d02893b 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -80,8 +80,8 @@ at the bottom. `gj` the textual one. With the toggle off a line is one visual row and the two pairs are the same motion. - **Undo is snapshot-per-edit-op / per-insert-session**, not a - transaction log. Anything requiring replayable edits (`.`, - macros) needs new machinery. + transaction log. `.` and macros replay KEYS (`Macro.zig`), not + edits, so they need none. ## A. Implemented today @@ -147,6 +147,9 @@ language-backend queries, and the shell pipe. | `!` | run with NO stdin, insert the output BEFORE each selection | helix `shell_insert_output`. Runs ONCE and every cursor gets that one answer, as helix does — ten cursors and `date` give ten identical stamps. Marker `!` | out of corpus | | `A-!` | the same, appended AFTER each selection | helix `shell_append_output`. Marker `!+` | out of corpus | | `$` | keep only the selections a shell command exits 0 on: each range's text on its stdin, one run per range, its output discarded; the primary stays if kept, else the last kept range takes over, and keeping none changes nothing | helix `shell_keep_pipe`. The runner's answer is all or nothing, so the command runs as `(cmd) >/dev/null; echo $?` and the status comes back as the output. Marker `$` | out of corpus | +| `Q` / `"Q` | record the keys that follow into `@` (or the named register) until the next `Q`; kept as helix writes a macro, in key notation (`xt,S=_`), so `"@p` pastes it and a yanked text can be replayed | helix `record_macro` | helix-verified | +| `q` / `q` / `"q` | type a register's keys again, n times. Replayed keys go to the modal handling only: Esc does not hop panes, Enter and Tab neither look nor execute, and Space does not open the leader | helix `replay_macro`; `Macro.zig`, `Pardes.replayKeys` | helix-verified | +| `.` / `.` | repeat the last insert session: the normal command that entered it once, the keys typed n times, then leave insert mode the way it was left | helix `repeat_last_insert`; the same key log as macros | helix-verified | ## B. To implement @@ -352,7 +355,6 @@ text. | `]f [f ]t [t ]a [a ]c [c ]e [e ]T [T ]x [x` | TS unimpaired jumps | tree-sitter | | `]g [g ]G [G` | git change jumps | needs VCS diff state | | insert `Ctrl-x` | completion menu | completion exists, but not as a popup: insert-mode Tab straight after a `.` opens a buffer of candidate DECLARATIONS (section A). helix's menu itself is skipped | -| `Q` / `q` | record / replay macro | macros — needs replayable input log | | `Ctrl-s` (normal) | save jumplist position | jumplist itself is implemented (`Ctrl-o`/`Ctrl-i`, section A); only the explicit save point is skipped | | `Alt-u` / `Alt-U` | undo-history earlier/later | history timeline — linear snapshot u/U covers pardes | | `K Alt-K`, `Alt-:` | regex keep/remove, ensure-forward | `K`/`Alt-K` are the same prompt `s`/`S` now have, filtering instead of splitting (`s S` moved to "Regex selection" in A, the rest of the family to "Multiple cursors") | @@ -363,7 +365,6 @@ text. | `Shift-Tab` (insert), smart-tab semantics | insert tab / smart tab | smart-tab machinery; plain Tab-inserts-tab lands in B | | `Z` (sticky view mode) | persistent view mode | marginal; `z` one-shots suffice | | `zm` (view) | align middle horizontally | marginal even with hscroll | -| `.` | repeat last insert | **deferred by decision**: needs recording the insert session's keystrokes and a replay path — a new subsystem; undo is whole-buffer snapshots with no edit log to piggyback on. Revisit after phases 2–5 if the log exists by then for another reason | | Select/extend mode section (prose) | `v` turns all motions into extenders, `n`/`N` keep selections | implemented for motions (phase 5: `pane.select` + the fixed anchor, differential-verified — see the `v` row in A); helix's search-`n`/`N` extension doesn't apply (pardes' `n`/`N` is the look-ring motion, which REPLACES the selection with the span it lands on) | ## Differential testing (phase 5) @@ -403,13 +404,15 @@ Files (all in `test/hxcases/`): registers of one value per range, `sel-regex-caret` and `sel-regex-dot-newline` when `s`/`S` began searching line by line. - `parity.jsonl` — 80 further cases, used only by the parity gate below. -- `parity-waivers.jsonl` — 14 named exemptions for the parity gate, in three +- `parity-waivers.jsonl` — 15 named exemptions for the parity gate, in three classes: one deliberate pardes binding (`ctrl-b-page`, since `Ctrl-b` IS the tty toggle), eight pty VIEWPORT divergences (a terminal's view cannot scroll below the vt's live grid bottom, so the cursor snaps into a different scrolloff band — no text differs), and five case texts a pty cannot hold verbatim (a literal TAB the emulator expands, a file with no trailing - newline, an all-whitespace last row the dump trims). + newline, an all-whitespace last row the dump trims). One more, + `dot-append-count`: `a` at a file's end grows it by a newline (helix + append_mode), which a terminal's text, not the editor's to grow, does not. - `golf.jsonl` + `golf-goldens.jsonl` — every example on helix-golf (github.com/nik-rev/helix-golf, imported at d78c18c): one case per numbered step of each walkthrough, its keys being the command up to and including diff --git a/docs/selections.md b/docs/selections.md index 35fa806f..202ec3d9 100644 --- a/docs/selections.md +++ b/docs/selections.md @@ -98,8 +98,9 @@ the replay. answer from the shell that comes later, so `"+p` pastes once, at the primary, and insert `Ctrl-r +` types what pardes last put on the clipboard rather than asking the desktop. -- `Alt-.` repeats the last `f`/`t`/`F`/`T`. There is no `.` and there are no - macros. +- `Alt-.` repeats the last `f`/`t`/`F`/`T`. `.` and macros (`Q`/`q`) are + keys typed again (`Macro.zig`), so whatever those keys do to the ranges + happens again: there is no edit log underneath. ## Where it differs from helix diff --git a/src/Macro.zig b/src/Macro.zig new file mode 100644 index 00000000..7475992e --- /dev/null +++ b/src/Macro.zig @@ -0,0 +1,176 @@ +//! Keys kept to be typed again. `Q` records the keys that follow into a +//! register until the next `Q`, and `q` types them again (helix macros); +//! `.` types again the last insert session together with the normal command +//! that began it (helix repeat_last_insert). A macro is kept in its register +//! as helix writes one, in key notation (`xt,S=_`), so it can be +//! pasted, edited and yanked back like any text. +const std = @import("std"); +const pardes = @import("pardes.zig"); +const Key = pardes.Key; + +const Macro = @This(); + +/// The register `Q` is recording into, 0 when it is not. +recording: u21 = 0, +recorded: std.ArrayList(Stored) = .empty, +/// The keys of the normal command under way (a count, `"`, a prefix +/// and the key that completes it), dropped when it completes without +/// entering insert mode. +command: std.ArrayList(Stored) = .empty, +/// `.`'s: the command that began the last insert session and every key +/// typed in it, the one that left it included. +last_insert: std.ArrayList(Stored) = .empty, +/// Where in `last_insert` the typed keys begin, after the command's. +typed_from: usize = 0, +/// Keys are going into `last_insert`. +inserting: bool = false, +/// How deep macros are being typed again; `.`'s replay is `repeating`. +replaying: u8 = 0, +repeating: bool = false, + +/// A key as it can be typed again: what it was, not the text it came with. +pub const Stored = struct { + cp: u21, + ctrl: bool = false, + alt: bool = false, + shift: bool = false, + + pub fn of(key: Key) Stored { + return .{ .cp = key.cp, .ctrl = key.ctrl, .alt = key.alt, .shift = key.shift }; + } + + /// The key again, its text made from its codepoint into `buf`. + pub fn typed(s: Stored, buf: *[4]u8) Key { + var k: Key = .{ .cp = s.cp, .ctrl = s.ctrl, .alt = s.alt, .shift = s.shift }; + if (!s.ctrl and !s.alt and s.cp >= 0x20 and s.cp != Key.backspace and s.cp < 0xF0000) { + const n = std.unicode.utf8Encode(s.cp, buf) catch return k; + k.text = buf[0..n]; + } + return k; + } +}; + +pub fn deinit(m: *Macro, gpa: std.mem.Allocator) void { + m.recorded.deinit(gpa); + m.command.deinit(gpa); + m.last_insert.deinit(gpa); + m.* = .{}; +} + +/// A typed key, each of its characters one key: what a host delivers as +/// one key event with a run of text is that run typed. +pub fn append(list: *std.ArrayList(Stored), gpa: std.mem.Allocator, key: Key) void { + const view = std.unicode.Utf8View.init(key.text) catch null; + if (key.text.len == 0 or key.ctrl or key.alt or view == null) { + list.append(gpa, .of(key)) catch {}; + return; + } + var it = view.?.iterator(); + while (it.nextCodepoint()) |cp| list.append(gpa, .{ .cp = cp, .shift = key.shift }) catch {}; +} + +const names = [_]struct { cp: u21, name: []const u8 }{ + .{ .cp = Key.escape, .name = "esc" }, + .{ .cp = Key.enter, .name = "ret" }, + .{ .cp = Key.tab, .name = "tab" }, + .{ .cp = Key.backspace, .name = "backspace" }, + .{ .cp = Key.delete, .name = "del" }, + .{ .cp = ' ', .name = "space" }, + .{ .cp = '<', .name = "lt" }, + .{ .cp = '>', .name = "gt" }, + .{ .cp = '-', .name = "minus" }, + .{ .cp = Key.up, .name = "up" }, + .{ .cp = Key.down, .name = "down" }, + .{ .cp = Key.left, .name = "left" }, + .{ .cp = Key.right, .name = "right" }, + .{ .cp = Key.home, .name = "home" }, + .{ .cp = Key.end, .name = "end" }, + .{ .cp = Key.page_up, .name = "pageup" }, + .{ .cp = Key.page_down, .name = "pagedown" }, +}; + +/// Keys in helix's notation: a plain character as itself, anything else +/// in angle brackets with its modifiers (``, ``, ``). +pub fn encode(gpa: std.mem.Allocator, keys: []const Stored) ![]u8 { + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(gpa); + for (keys) |k| { + var name: ?[]const u8 = null; + for (names) |n| if (n.cp == k.cp) { + name = n.name; + }; + // `-` is only spelled out after a modifier, where helix needs it + if (k.cp == '-' and !k.ctrl and !k.alt) name = null; + var buf: [4]u8 = undefined; + const bare = if (name) |n| n else buf[0..(std.unicode.utf8Encode(k.cp, &buf) catch continue)]; + if (!k.ctrl and !k.alt and name == null) { + try out.appendSlice(gpa, bare); + continue; + } + try out.append(gpa, '<'); + if (k.ctrl) try out.appendSlice(gpa, "C-"); + if (k.alt) try out.appendSlice(gpa, "A-"); + try out.appendSlice(gpa, bare); + try out.append(gpa, '>'); + } + return out.toOwnedSlice(gpa); +} + +/// The keys helix notation names; what it cannot read ends the list. +pub fn decode(gpa: std.mem.Allocator, text: []const u8) ![]Stored { + var out: std.ArrayList(Stored) = .empty; + errdefer out.deinit(gpa); + var i: usize = 0; + while (i < text.len) { + const len = std.unicode.utf8ByteSequenceLength(text[i]) catch break; + if (i + len > text.len) break; + if (text[i] != '<') { + try out.append(gpa, .{ .cp = std.unicode.utf8Decode(text[i..][0..len]) catch break }); + i += len; + continue; + } + const close = std.mem.indexOfScalarPos(u8, text, i + 1, '>') orelse break; + var name = text[i + 1 .. close]; + i = close + 1; + var k: Stored = .{ .cp = 0 }; + while (name.len > 2 and name[1] == '-') : (name = name[2..]) switch (name[0]) { + 'C' => k.ctrl = true, + 'A' => k.alt = true, + 'S' => k.shift = true, + else => break, + }; + for (names) |n| if (std.mem.eql(u8, n.name, name)) { + k.cp = n.cp; + }; + if (k.cp == 0) k.cp = std.unicode.utf8Decode(name) catch break; + try out.append(gpa, k); + } + return out.toOwnedSlice(gpa); +} + +test "a macro's keys go into helix notation and come back the same" { + const gpa = std.testing.allocator; + const keys = [_]Stored{ + .{ .cp = 'x' }, + .{ .cp = 't' }, + .{ .cp = ',' }, + .{ .cp = 'S' }, + .{ .cp = '=' }, + .{ .cp = Key.enter }, + .{ .cp = '_' }, + .{ .cp = '(', .alt = true }, + .{ .cp = 'r', .ctrl = true }, + .{ .cp = '<' }, + .{ .cp = ' ' }, + .{ .cp = '-' }, + .{ .cp = '-', .alt = true }, + .{ .cp = 'λ' }, + .{ .cp = Key.escape }, + }; + const text = try encode(gpa, &keys); + defer gpa.free(text); + try std.testing.expectEqualStrings("xt,S=_-λ", text); + const back = try decode(gpa, text); + defer gpa.free(back); + try std.testing.expectEqualSlices(Stored, &keys, back); +} diff --git a/src/config.zig b/src/config.zig index 91665b14..bc053176 100644 --- a/src/config.zig +++ b/src/config.zig @@ -267,6 +267,9 @@ pub const pipe_selection_to: []const Chord = &.{.{ .cp = '|', .alt = true }}; pub const insert_output: []const Chord = &.{.{ .cp = '!' }}; pub const append_output: []const Chord = &.{.{ .cp = '!', .alt = true }}; pub const keep_pipe: []const Chord = &.{.{ .cp = '$' }}; +pub const macro_record: []const Chord = &.{.{ .cp = 'Q' }}; +pub const macro_replay: []const Chord = &.{.{ .cp = 'q' }}; +pub const repeat_insert: []const Chord = &.{.{ .cp = '.' }}; // Armed inputs are parsed from their distinct, nonempty tag markers. pub const pipe_marker_to = " |-"; pub const pipe_marker_insert = " !"; diff --git a/src/edit.zig b/src/edit.zig index 3e12f169..a373741c 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -546,8 +546,19 @@ pub fn enterInsert(p: *Pardes, t: *Text, where: InsertAt, cnt: usize) void { const hi_row = if (b) |bb| bb.hi_row else t.cur_row; const hi_col = if (b) |bb| bb.hi_col else t.cur_col; t.restore_cursor = true; - const gap = modal.nextGrapheme(text, modal.offsetAt(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) })); - const gc = modal.positionAt(text, gap); + const hi = modal.offsetAt(text, .{ .row = @intCast(@max(0, hi_row)), .col = @intCast(@max(0, hi_col)) }); + const gap = modal.nextGrapheme(text, hi); + // helix append_mode: a range that reaches the end of the text + // gets a newline after it to append before (a terminal's text + // is not the editor's to grow) + const terminal = t.what == .body and if (t.pane()) |pane| pane.isTerminal() else false; + var now = text; + if (hi < text.len and gap >= text.len and !terminal) { + const grown = std.mem.concat(p.gpa, u8, &.{ text, "\n" }) catch return; + setEditText(p, t, grown); + now = grown; // what setEditText keeps; `text` may be gone + } + const gc = modal.positionAt(now, gap); t.cur_row = @intCast(gc.row); t.cur_col = @intCast(gc.col); // helix append_mode: the range reaches one past its end, which diff --git a/src/modal.zig b/src/modal.zig index bce941d5..34c4dac7 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -129,6 +129,9 @@ pub const Normal = struct { insert_output, append_output, keep_pipe, + macro_record, + macro_replay, + repeat_insert, search, search_next, search_prev, @@ -320,6 +323,9 @@ pub const Normal = struct { pipe_selection: PipeBehavior, search, search_step: Direction, + macro_record, + macro_replay: u32, + repeat_insert: u32, pub fn scope(value: Action) Scope { return switch (value) { @@ -331,6 +337,9 @@ pub const Normal = struct { .pipe_selection, .search, .search_step, + .macro_record, + .macro_replay, + .repeat_insert, => .once, .edit => |edit| switch (edit.kind) { .join_lines, @@ -602,6 +611,9 @@ pub const Normal = struct { if (key.has(.insert_output)) return resultAction(.{ .pipe_selection = .insert }); if (key.has(.append_output)) return resultAction(.{ .pipe_selection = .append }); if (key.has(.keep_pipe)) return resultAction(.{ .pipe_selection = .keep }); + if (key.has(.macro_record)) return resultAction(.macro_record); + if (key.has(.macro_replay)) return resultAction(.{ .macro_replay = count }); + if (key.has(.repeat_insert)) return resultAction(.{ .repeat_insert = count }); if (key.has(.search)) return resultAction(.search); if (key.has(.search_next)) return resultAction(.{ .search_step = .forward }); if (key.has(.search_prev)) return resultAction(.{ .search_step = .backward }); diff --git a/src/normal.zig b/src/normal.zig index 53e416fd..542c9fb3 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -18,6 +18,7 @@ const Key = pardes.Key; const hit = pardes.hit; const sel_slot = pardes.sel_slot; const Pardes = pardes.Pardes; +const Macro = pardes.Macro; fn isPrefix(key: Key, cp: u21) bool { return key.cp == cp and !key.ctrl and !key.alt; @@ -324,6 +325,8 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void switch (semantic) { .escape => { t.select = false; + // typed again by a macro, Esc is only helix's + if (p.macro.replaying > 0) return; return exec.runBuiltin(p, .Last, p.active, "", null); }, .goto => |go| switch (go.target) { @@ -571,7 +574,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void .format => .format, }, ""), .adjust_number => |delta| return edit.normalAdjustNumber(p, t, delta), - .leader => { + .leader => if (p.macro.replaying == 0) { p.leader_on = true; p.leader_n = 0; }, @@ -588,6 +591,43 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void p, if (direction == .forward) @as(i32, 1) else -1, ), + .macro_record => { + const m = &p.macro; + if (m.recording == 0) { + m.recording = if (p.register != 0) p.register else '@'; + m.recorded.clearRetainingCapacity(); + return; + } + // the keys of this command (a count, `"`, the `Q`) are not + // the macro's + m.recorded.shrinkRetainingCapacity(m.recorded.items.len -| (m.command.items.len + 1)); + const keys = Macro.encode(p.gpa, m.recorded.items) catch return; + defer p.gpa.free(keys); + p.registers.put(p.gpa, m.recording, keys, 0, 1, true); + m.recording = 0; + }, + .macro_replay => |count| { + const name: u21 = if (p.register != 0) p.register else '@'; + const notation = p.registers.value(name, 0) orelse return; + const keys = Macro.decode(p.scratch.allocator(), notation) catch return; + p.register = 0; + for (0..count) |_| p.replayKeys(keys); + }, + .repeat_insert => |count| { + const m = &p.macro; + if (m.last_insert.items.len == 0 or m.repeating) return; + if (m.inserting) return; // the session is still open + const keys = p.scratch.allocator().dupe(Macro.Stored, m.last_insert.items) catch return; + m.repeating = true; + defer m.repeating = false; + // helix repeat_last_insert: the command once, what was typed + // count times, and out of insert mode the way it was left + const typed = @min(m.typed_from, keys.len); + p.replayKeys(keys[0..typed]); + if (keys.len == typed) return; + for (0..count) |_| p.replayKeys(keys[typed .. keys.len - 1]); + p.replayKeys(keys[keys.len - 1 ..]); + }, } } diff --git a/src/pardes.zig b/src/pardes.zig index c9d93979..87041f39 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -18,6 +18,7 @@ pub const edit = @import("edit.zig"); pub const body_layer = @import("body_layer.zig"); pub const Messages = @import("Messages.zig"); pub const Registers = @import("Registers.zig"); +pub const Macro = @import("Macro.zig"); pub const Pipe = @import("Pipe.zig"); pub const colors = @import("colors.zig"); pub const Theme = colors.Theme; @@ -455,6 +456,7 @@ test { _ = @import("edit.zig"); _ = @import("Messages.zig"); _ = @import("Registers.zig"); + _ = @import("Macro.zig"); _ = @import("selection_pipe.zig"); _ = @import("Pipe.zig"); _ = @import("colors.zig"); @@ -4096,6 +4098,7 @@ pub const Pardes = struct { in_len: usize = 0, registers: Registers = .{}, + macro: Macro = .{}, /// an edited text nothing kept (a tag's shown text): see edit.retireEditText retired_edit: ?[]u8 = null, clip_pending: ?edit.ClipRequest = null, @@ -4269,6 +4272,7 @@ pub const Pardes = struct { for (&p.col_tags) |*t| t.deinit(gpa); p.global_tag.deinit(gpa); p.registers.deinit(gpa); + p.macro.deinit(gpa); if (p.retired_edit) |r| gpa.free(r); if (p.dump_out) |d| gpa.free(d); p.fallback.deinit(); @@ -5125,7 +5129,21 @@ pub const Pardes = struct { } else panes.Terminal.enterTty(p, id); } + /// Type `keys` again (a macro, `.`): through the modal handling as + /// typed, except that no key leaves the text for pardes's own acts -- + /// Esc does not hop panes, and Enter and Tab neither look nor execute. + pub fn replayKeys(p: *Pardes, keys: []const Macro.Stored) void { + if (p.macro.replaying >= 8) return; // a macro typing itself + p.macro.replaying += 1; + defer p.macro.replaying -= 1; + for (keys) |stored| { + var buf: [4]u8 = undefined; + p.handleKey(stored.typed(&buf)); + } + } + fn handleKey(p: *Pardes, key: Key) void { + if (p.macro.recording != 0 and p.macro.replaying == 0) Macro.append(&p.macro.recorded, p.gpa, key); // A question waiting on a pane takes the next key, whatever mode the // pane or a header edit is in: that key is its answer. for (p.panes, 0..) |slot, id| { @@ -5266,7 +5284,7 @@ pub const Pardes = struct { if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |answered| normal.applySelRegex(p, pane, answered, a.pat, a.split); return; } - if (t.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) { + if (t.mode == .normal and p.macro.replaying == 0 and (hit(key, config.look_key) or hit(key, config.exec_key))) { // A header's words are the workspace's and column's commands, so // Enter runs them there, as the execute key does. const header_word = t.what == .column or t.what == .workspace; @@ -5320,14 +5338,36 @@ pub const Pardes = struct { defer if (t.refused) { t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel = kept; }; + const was = t.mode; switch (t.mode) { .normal => normal.handleNormal(p, t, key), - .insert => { - if (hit(key, config.escape) and !t.reg_wait) return edit.exitInsert(p, t); - edit.handleInsert(p, t, key); - }, + .insert => if (hit(key, config.escape) and !t.reg_wait) + edit.exitInsert(p, t) + else + edit.handleInsert(p, t, key), .tty => panes.Terminal.forwardKey(p, p.active, key), } + // `.`'s log: the keys of the normal command that entered insert + // mode, then everything typed until it is left + if (p.macro.repeating) return; + const m = &p.macro; + switch (was) { + .normal => { + Macro.append(&m.command, p.gpa, key); + if (t.mode == .insert) { + m.last_insert.clearRetainingCapacity(); + m.last_insert.appendSlice(p.gpa, m.command.items) catch {}; + m.typed_from = m.last_insert.items.len; + m.inserting = true; + m.command.clearRetainingCapacity(); + } else if (std.meta.eql(t.normal, modal.Normal.State{})) m.command.clearRetainingCapacity(); + }, + .insert => if (m.inserting) { + Macro.append(&m.last_insert, p.gpa, key); + if (t.mode != .insert) m.inserting = false; + }, + .tty => {}, + } } fn leaderKey(p: *Pardes, key: Key) void { diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index ba75c611..8d6b0e6e 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -470,6 +470,22 @@ {"name": "reg-count-after-quote", "pane": "file", "text": "ab\n", "keys": "\"ay\"a3p"} {"name": "reg-ctrl-r-named", "pane": "file", "text": "ab\n", "keys": "\"aylia"} {"name": "reg-ctrl-r-esc", "pane": "file", "text": "ab\n", "keys": "ix"} +{"name": "macro-record-replay", "pane": "file", "text": "a\nb\nc\n", "keys": "QA!jQq"} +{"name": "macro-count", "pane": "file", "text": "a\nb\nc\nd\n", "keys": "QA!jQ2q"} +{"name": "macro-named", "pane": "file", "text": "a\nb\nc\n", "keys": "\"aQA!jQ\"aq"} +{"name": "macro-register-text", "pane": "file", "text": "a\nb\n", "keys": "QA!jQ\"@P"} +{"name": "macro-with-prompt", "pane": "file", "text": "a,b\nc,d\n", "keys": "QxS,djQq"} +{"name": "macro-esc-in-normal", "pane": "file", "text": "ab\ncd\n", "keys": "QvljQq"} +{"name": "macro-empty", "pane": "file", "text": "ab\n", "keys": "QQq"} +{"name": "dot-insert", "pane": "file", "text": "ab\ncd\n", "keys": "iXj.."} +{"name": "dot-append-count", "pane": "file", "text": "ab\ncd\n", "keys": "aYZj3."} +{"name": "dot-change", "pane": "file", "text": "ab cd\nef gh\n", "keys": "wcXjb."} +{"name": "dot-open-below", "pane": "file", "text": "a\nb\n", "keys": "oXj."} +{"name": "dot-counted-command", "pane": "file", "text": "abcd\n", "keys": "2lixy."} +{"name": "dot-multi", "pane": "file", "text": "ab\nab\n", "keys": "CiX."} +{"name": "dot-nothing", "pane": "file", "text": "ab\n", "keys": ".x"} +{"name": "append-at-end-adds-newline", "pane": "file", "text": "ab\n", "keys": "jaX"} +{"name": "append-at-end-no-newline", "pane": "file", "text": "ab", "keys": "laX"} {"name": "msel-esc-keeps", "pane": "file", "text": "aa\nbb\n", "keys": "C"} {"name": "msel-select-mode", "pane": "file", "text": "abcd\nabcd\n", "keys": "Cvl"} {"name": "msel-open-count", "pane": "file", "text": "ab\n", "keys": "2O"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index 188b7ce4..d62cb633 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -470,6 +470,22 @@ {"name":"reg-count-after-quote","text":"aaaab\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":1}} {"name":"reg-ctrl-r-named","text":"aab\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}} {"name":"reg-ctrl-r-esc","text":"xab\n","mode":"insert","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} +{"name":"macro-record-replay","text":"a!\nb!\nc\n","mode":"normal","cursor":{"row":2,"col":1},"anchor":{"row":2,"col":1}} +{"name":"macro-count","text":"a!\nb!\nc!\nd\n","mode":"normal","cursor":{"row":3,"col":1},"anchor":{"row":3,"col":1}} +{"name":"macro-named","text":"a!\nb!\nc\n","mode":"normal","cursor":{"row":2,"col":1},"anchor":{"row":2,"col":1}} +{"name":"macro-register-text","text":"a!\nbA!j\n","mode":"normal","cursor":{"row":1,"col":8},"anchor":{"row":1,"col":1}} +{"name":"macro-with-prompt","text":",c,d\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}} +{"name":"macro-esc-in-normal","text":"ab\ncd\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}} +{"name":"macro-empty","text":"ab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"dot-insert","text":"Xab\ncXXd\n","mode":"normal","cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}} +{"name":"dot-append-count","text":"aYZb\ncd\nYZYZYZ\n","mode":"normal","cursor":{"row":2,"col":5},"anchor":{"row":1,"col":2}} +{"name":"dot-change","text":"Xcd\nX gh\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}} +{"name":"dot-open-below","text":"a\nX\nb\nX\n","mode":"normal","cursor":{"row":3,"col":1},"anchor":{"row":3,"col":1}} +{"name":"dot-counted-command","text":"abxyxycd\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}} +{"name":"dot-multi","text":"XXab\nXXab\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}}],"primary":1} +{"name":"dot-nothing","text":"ab\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} +{"name":"append-at-end-adds-newline","text":"ab\nX","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}} +{"name":"append-at-end-no-newline","text":"abX\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":1}} {"name":"msel-esc-keeps","text":"aa\nbb\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":1} {"name":"msel-select-mode","text":"abcd\nabcd\n","mode":"select","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1} {"name":"msel-open-count","text":"\n\nab\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":0} diff --git a/test/hxcases/parity-waivers.jsonl b/test/hxcases/parity-waivers.jsonl index ad24eaa9..2393681e 100644 --- a/test/hxcases/parity-waivers.jsonl +++ b/test/hxcases/parity-waivers.jsonl @@ -12,3 +12,4 @@ {"name":"comment-noeol","reason":"Terminal capture restores a trailing newline that the source omits.","reference":{"name":"comment-noeol","text":"# alpha","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},"expected":{"name":"comment-noeol","text":"# alpha\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}} {"name":"comment-only-blank","reason":"Terminal capture expands tabs and trims trailing blank rows.","reference":{"name":"comment-only-blank","text":" \n\t\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":0,"col":0}},"expected":{"name":"comment-only-blank","text":" \n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}}} {"name":"ins-backspace-indent-tab","reason":"The terminal expands literal tabs to spaces before editing.","reference":{"name":"ins-backspace-indent-tab","text":"\tx\n","mode":"insert","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},"expected":{"name":"ins-backspace-indent-tab","text":" x\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}} +{"name":"dot-append-count","reason":"A terminal's text is not the editor's to grow: a at its end adds no newline to append before, where a file gets one (helix append_mode).","reference":{"name":"dot-append-count","text":"aYZb\ncd\nYZYZYZ\n","mode":"normal","cursor":{"row":2,"col":5},"anchor":{"row":1,"col":2}},"expected":{"name":"dot-append-count","text":"aYZb\ncd\nYZYZYZ","mode":"normal","cursor":{"row":2,"col":6},"anchor":{"row":2,"col":6}}} -- cgit v1.3 From 7ce5d94b5d48b68c86d015060ebcabe63e299e1a Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:24:59 -0300 Subject: A selection holds up to 1024 ranges on the desktop, 64 on the board The limit was 64 everywhere, which helix-golf's enumerate_and_align outgrows (%s |\d+ makes 121 ranges). It is now memory.limits.selections, per platform, and the counts that hold it are u16. The ranges stay inline in each Text, so a pane grows by about 80 KiB on the desktop (three texts and the regex prompt's snapshot); the Pane size budget in Terminal.zig moves from 128 to 160 KiB and says why. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 2 +- docs/selections.md | 8 +++++--- src/Pipe.zig | 6 +++--- src/Terminal.zig | 5 ++++- src/Text.zig | 5 +++-- src/exec.zig | 4 ++-- src/memory.zig | 4 ++++ src/normal.zig | 2 +- src/panes.zig | 4 ++-- src/pardes.zig | 4 ++-- test/hxcases/cases.jsonl | 2 ++ test/hxcases/goldens.jsonl | 2 ++ test/panes.zig | 10 +++++----- 13 files changed, 36 insertions(+), 22 deletions(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 3d02893b..1c9e0763 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -276,7 +276,7 @@ keeps the matches it found so far. | an all-lowercase pattern | matches case-blind | helix's smart-case. mvzr has no such flag, so the surface is lowercased instead (ASCII folding is byte-for-byte, so the offsets are identical) | helix-verified | | `^` and `$` | hold at every line's start and end | helix compiles with `multi_line(true)`; pardes gets the same by searching each line as its own haystack (`src/regexp.zig`) | helix-verified | | `.` | never matches a newline | as the Rust regex crate: a pattern without `\n` searches line by line, and one that names `\n` has its `.`s made `[^\n]` (`src/regexp.zig`) | helix-verified | -| more than 64 matches | the ones past `MAX_SELS` are dropped | the ceiling the whole selection model has, not this key's | +| many matches | up to `memory.limits.selections` ranges: 1024 on the desktop, 64 on the board; matches past it are dropped | helix has no limit. The ranges are held inline in every `Text` (about 20 KiB each on the desktop) | helix-verified up to the limit | | `K` / `Alt-K` | keep / remove ranges matching a regex | **skipped**: the same prompt, filtering instead of splitting — worth adding next | ### `Ctrl-c` — toggle comments diff --git a/docs/selections.md b/docs/selections.md index 202ec3d9..59c4415b 100644 --- a/docs/selections.md +++ b/docs/selections.md @@ -10,7 +10,8 @@ at `694e7dfdd`, upstream `278b24389`. Every `Text` (src/Text.zig) has its own: a pane's body, its tag, a prompt's answer, a column's or the workspace's tag. A selection is one primary range -plus up to 63 others. +plus others, up to `memory.limits.selections` in all (1024 on the +desktop, 64 on the board). - The PRIMARY is where the cursor always was: `cur_row`/`cur_col` is the head, `vsel.row`/`vsel.col` the anchor while `vsel.active`, and a bare @@ -33,7 +34,7 @@ plus up to 63 others. `Text.setRanges` is the one writer of a whole selection: it sorts by start, merges ranges that overlap or share a start (helix `normalize`), follows the -primary through the merges, and keeps the first 64. `setRange` writes one +primary through the merges, and keeps the first `max_selections`. `setRange` writes one range and drops the others. ## Making, splitting, merging, rotating, removing @@ -107,7 +108,8 @@ the replay. - Direction of a one-character range: both of its cells are the same cell, so it has none. helix can flip one (`Alt-;`) and the commands that read the head (`&`, the next extend) see the difference. -- Count: at most 64 ranges; matches past that are dropped silently. +- Count: at most `memory.limits.selections` ranges (1024 on the desktop, 64 + on the board); matches past that are dropped silently. - `n`/`N` walk the look ring (acme), not regex search hits, and there is no `*`; helix-golf's `*`, `""N` and `n` do not apply. - `s`/`S` match as sam does (docs/helix-keys.md, "Regex selection"): each diff --git a/src/Pipe.zig b/src/Pipe.zig index 37145edf..48e98b7b 100644 --- a/src/Pipe.zig +++ b/src/Pipe.zig @@ -55,10 +55,10 @@ pub const PendingPipe = struct { cwd: []u8, inputs: []selection_pipe.Input, ranges: [panes.Text.max_selections]modal.Selection, - primary: u8, + primary: u16, explicit: bool, how: modal.Normal.PipeBehavior, - nranges: u8, + nranges: u16, pub fn deinit(wait: *PendingPipe, gpa: std.mem.Allocator) void { gpa.free(wait.command); @@ -453,7 +453,7 @@ test "selection pipe replaces all ranges atomically and undo restores them" { p.update(.{ .pipe_resp = .{ .id = id, .success = true, .outputs = outputs } }); try std.testing.expectEqualSlices(u8, "AA bb \n", pane.file.?.content); try std.testing.expectEqual(@as(usize, 1), pane.file.?.history.undo_len); - try std.testing.expectEqual(@as(u8, 1), pane.body.nsel); + try std.testing.expectEqual(@as(u16, 1), pane.body.nsel); try std.testing.expectEqual(@as(i32, 1), pane.body.cur_col); try std.testing.expectEqual(@as(i32, 6), pane.body.sels[0].col); try std.testing.expect(!denied.has_induced_failure); diff --git a/src/Terminal.zig b/src/Terminal.zig index 4f94533d..1ff561b1 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -545,7 +545,10 @@ test "replay ownership is terminal-only and construction rolls back on allocatio try std.testing.expectEqual(terminal and enabled, pane.terminal != null); } }; - try std.testing.expect(@sizeOf(Pane) < 128 * 1024); + // Most of it is the three texts' selections, limits.selections ranges + // each held inline (about 20 KiB a text on the desktop), and the regex + // prompt's snapshot of them. + try std.testing.expect(@sizeOf(Pane) < 160 * 1024); try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{false}); try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{true}); } diff --git a/src/Text.zig b/src/Text.zig index 764b8a6a..56ee199b 100644 --- a/src/Text.zig +++ b/src/Text.zig @@ -11,6 +11,7 @@ const std = @import("std"); const panes = @import("panes.zig"); const modal = @import("modal.zig"); const config = @import("config.zig"); +const memory = @import("memory.zig"); const Pane = panes.Pane; const File = panes.File; const Terminal = panes.Terminal; @@ -36,7 +37,7 @@ pub const CharSel = struct { explicit: bool = false, }; -pub const max_selections = 64; +pub const max_selections = memory.limits.selections; pub const SelRange = struct { row: i32, @@ -60,7 +61,7 @@ cur_col: i32 = 0, msel: LineSel = .{}, vsel: CharSel = .{}, sels: [max_selections - 1]SelRange = undefined, -nsel: u8 = 0, +nsel: u16 = 0, select: bool = false, /// sticky goal column for j/k runs (helix old_visual_position): any /// non-vertical range write resets it to -1. diff --git a/src/exec.zig b/src/exec.zig index 1b82d2a8..455a325e 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -752,7 +752,7 @@ test "the acme chords act once per selection, not once on the primary" { .{ .anchor = 7, .head = 13 }, }; pane.body.setRanges(pane.file.?.content, &ranges, &.{}, 0, true); - try std.testing.expectEqual(@as(u8, 1), pane.body.nsel); + try std.testing.expectEqual(@as(u16, 1), pane.body.nsel); const before = p.ncol; p.update(.{ .key = .{ .cp = Key.tab } }); // config.exec_key: Exec @@ -760,7 +760,7 @@ test "the acme chords act once per selection, not once on the primary" { // other cursor, so one keystroke over two cursors made one column. try std.testing.expectEqual(before + 2, p.ncol); // ...and the chord consumed the selection exactly as it does with one. - try std.testing.expectEqual(@as(u8, 0), p.panes[0].?.body.nsel); + try std.testing.expectEqual(@as(u16, 0), p.panes[0].?.body.nsel); } test "a switch clicked with a selection held flips unless the selection says on or off" { diff --git a/src/memory.zig b/src/memory.zig index 7b6ee2c0..abfd40f6 100644 --- a/src/memory.zig +++ b/src/memory.zig @@ -17,6 +17,9 @@ pub const limits = struct { pub const wrap_rows = if (board) 128 else 256; pub const undo_max = if (board) 16 else 256; pub const message_log = if (board) 16 else 128; + /// ranges one selection holds; helix has no limit, and a `%s` over a + /// file's words wants hundreds + pub const selections = if (board) 64 else 1024; /// /log's ring, which records whether or not anyone reads it. pub const log_bytes: usize = if (board) 4 * KiB else 64 * KiB; pub const max_tag_tail: usize = if (board) 512 else 4096; @@ -187,6 +190,7 @@ test "memory limits preserve desktop capacities" { try std.testing.expectEqual(256, limits.wrap_rows); try std.testing.expectEqual(256, limits.undo_max); try std.testing.expectEqual(128, limits.message_log); + try std.testing.expectEqual(1024, limits.selections); try std.testing.expectEqual(@as(usize, 4096), limits.max_tag_tail); try std.testing.expectEqual(@as(usize, 4095), limits.host_path_cap); try std.testing.expectEqual(config.embed_sources, limits.embedded_sources); diff --git a/src/normal.zig b/src/normal.zig index 542c9fb3..98ba80d6 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -771,7 +771,7 @@ test "flat text movement and selection replay need no scratch rows" { pane.body.sels[0] = .{ .row = 3072, .col = 0, .arow = 3072, .acol = 0 }; handleNormal(p, &pane.body, .{ .cp = 'l' }); handleNormal(p, &pane.body, .{ .cp = 'j' }); - try std.testing.expectEqual(@as(u8, 1), pane.body.nsel); + try std.testing.expectEqual(@as(u16, 1), pane.body.nsel); try std.testing.expectEqual(@as(i32, 1025), pane.body.cur_row); try std.testing.expectEqual(@as(i32, 1), pane.body.cur_col); try std.testing.expectEqual(@as(i32, 3073), pane.body.sels[0].row); diff --git a/src/panes.zig b/src/panes.zig index 9234912f..67954abd 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -155,8 +155,8 @@ pub const Pane = struct { search_row: ?usize = null, look_at: ?LookSpot = null, sel_snap: [Text.max_selections]modal.Selection = undefined, - nsel_snap: u8 = 0, - sel_snap_pri: u8 = 0, + nsel_snap: u16 = 0, + sel_snap_pri: u16 = 0, sel_snap_expl: bool = false, /// The tag: what the user keeps after the computed prefix (the path, the /// dirty marker, a PDF's page), its cursor, selections and undo. The diff --git a/src/pardes.zig b/src/pardes.zig index 87041f39..a7bcb195 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -3123,7 +3123,7 @@ test "s in a header selects in the header's own text" { const text = try edit.flatSurface(p, &p.global_tag); const got = p.global_tag.ranges(text, 0, &ranges); try std.testing.expectEqual(@as(usize, 3), got.n); // Newcol, Joincol, NextColor - try std.testing.expectEqual(@as(u8, 0), pane.body.nsel); + try std.testing.expectEqual(@as(u16, 0), pane.body.nsel); } test "Enter in a header runs the word under its cursor, as the execute key does" { @@ -7547,7 +7547,7 @@ test "jump history clears selections and lands at the recorded cursor" { try std.testing.expectEqual(@as(usize, 2), p.njumps); try std.testing.expectEqual(@as(u32, 3), p.jumps[0].line); try std.testing.expect(!pane.body.vsel.active and !pane.body.msel.active and !pane.body.select); - try std.testing.expectEqual(@as(u8, 0), pane.body.nsel); + try std.testing.expectEqual(@as(u16, 0), pane.body.nsel); try std.testing.expectEqual(.none, pane.sel[0].state); try std.testing.expect(pane.pointer_selections[0] == null); try std.testing.expectEqual(@as(i32, if (index == 0) 2 else 0), pane.body.cur_row); diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 8d6b0e6e..7aeb12e8 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -430,6 +430,8 @@ {"name": "msel-rotate-contents-lengths", "pane": "file", "text": "one to three\n", "keys": "%s\\w+)"} {"name": "msel-rotate-contents-backward", "pane": "file", "text": "ab cde\n", "keys": "%s\\w+"} {"name": "msel-rotate-contents-lines", "pane": "file", "text": "aa\nbbb\n", "keys": "%"} +{"name": "sel-many-ranges", "pane": "file", "text": "w w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\n", "keys": "%sw"} +{"name": "sel-many-ranges-edit", "pane": "file", "text": "w w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\n", "keys": "%swcxy"} {"name": "msel-align-basic", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=&"} {"name": "msel-align-columns", "pane": "file", "text": "a,b,c\nccc,d,e\n", "keys": "%s,&"} {"name": "msel-align-uneven", "pane": "file", "text": "a,b\nccc,d,eee,f\n", "keys": "%s,&"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index d62cb633..b8221c4c 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -430,6 +430,8 @@ {"name":"msel-rotate-contents-lengths","text":"to three one\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":7},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":11},"anchor":{"row":0,"col":9}}],"primary":0} {"name":"msel-rotate-contents-backward","text":"cde ab\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":5},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":5}}],"primary":1} {"name":"msel-rotate-contents-lines","text":"bbb\naa\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1} +{"name":"sel-many-ranges","text":"w w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}},{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}},{"cursor":{"row":0,"col":8},"anchor":{"row":0,"col":8}},{"cursor":{"row":0,"col":10},"anchor":{"row":0,"col":10}},{"cursor":{"row":0,"col":12},"anchor":{"row":0,"col":12}},{"cursor":{"row":0,"col":14},"anchor":{"row":0,"col":14}},{"cursor":{"row":0,"col":16},"anchor":{"row":0,"col":16}},{"cursor":{"row":0,"col":18},"anchor":{"row":0,"col":18}},{"cursor":{"row":0,"col":20},"anchor":{"row":0,"col":20}},{"cursor":{"row":0,"col":22},"anchor":{"row":0,"col":22}},{"cursor":{"row":0,"col":24},"anchor":{"row":0,"col":24}},{"cursor":{"row":0,"col":26},"anchor":{"row":0,"col":26}},{"cursor":{"row":0,"col":28},"anchor":{"row":0,"col":28}},{"cursor":{"row":0,"col":30},"anchor":{"row":0,"col":30}},{"cursor":{"row":0,"col":32},"anchor":{"row":0,"col":32}},{"cursor":{"row":0,"col":34},"anchor":{"row":0,"col":34}},{"cursor":{"row":0,"col":36},"anchor":{"row":0,"col":36}},{"cursor":{"row":0,"col":38},"anchor":{"row":0,"col":38}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}},{"cursor":{"row":1,"col":4},"anchor":{"row":1,"col":4}},{"cursor":{"row":1,"col":6},"anchor":{"row":1,"col":6}},{"cursor":{"row":1,"col":8},"anchor":{"row":1,"col":8}},{"cursor":{"row":1,"col":10},"anchor":{"row":1,"col":10}},{"cursor":{"row":1,"col":12},"anchor":{"row":1,"col":12}},{"cursor":{"row":1,"col":14},"anchor":{"row":1,"col":14}},{"cursor":{"row":1,"col":16},"anchor":{"row":1,"col":16}},{"cursor":{"row":1,"col":18},"anchor":{"row":1,"col":18}},{"cursor":{"row":1,"col":20},"anchor":{"row":1,"col":20}},{"cursor":{"row":1,"col":22},"anchor":{"row":1,"col":22}},{"cursor":{"row":1,"col":24},"anchor":{"row":1,"col":24}},{"cursor":{"row":1,"col":26},"anchor":{"row":1,"col":26}},{"cursor":{"row":1,"col":28},"anchor":{"row":1,"col":28}},{"cursor":{"row":1,"col":30},"anchor":{"row":1,"col":30}},{"cursor":{"row":1,"col":32},"anchor":{"row":1,"col":32}},{"cursor":{"row":1,"col":34},"anchor":{"row":1,"col":34}},{"cursor":{"row":1,"col":36},"anchor":{"row":1,"col":36}},{"cursor":{"row":1,"col":38},"anchor":{"row":1,"col":38}},{"cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}},{"cursor":{"row":2,"col":2},"anchor":{"row":2,"col":2}},{"cursor":{"row":2,"col":4},"anchor":{"row":2,"col":4}},{"cursor":{"row":2,"col":6},"anchor":{"row":2,"col":6}},{"cursor":{"row":2,"col":8},"anchor":{"row":2,"col":8}},{"cursor":{"row":2,"col":10},"anchor":{"row":2,"col":10}},{"cursor":{"row":2,"col":12},"anchor":{"row":2,"col":12}},{"cursor":{"row":2,"col":14},"anchor":{"row":2,"col":14}},{"cursor":{"row":2,"col":16},"anchor":{"row":2,"col":16}},{"cursor":{"row":2,"col":18},"anchor":{"row":2,"col":18}},{"cursor":{"row":2,"col":20},"anchor":{"row":2,"col":20}},{"cursor":{"row":2,"col":22},"anchor":{"row":2,"col":22}},{"cursor":{"row":2,"col":24},"anchor":{"row":2,"col":24}},{"cursor":{"row":2,"col":26},"anchor":{"row":2,"col":26}},{"cursor":{"row":2,"col":28},"anchor":{"row":2,"col":28}},{"cursor":{"row":2,"col":30},"anchor":{"row":2,"col":30}},{"cursor":{"row":2,"col":32},"anchor":{"row":2,"col":32}},{"cursor":{"row":2,"col":34},"anchor":{"row":2,"col":34}},{"cursor":{"row":2,"col":36},"anchor":{"row":2,"col":36}},{"cursor":{"row":2,"col":38},"anchor":{"row":2,"col":38}},{"cursor":{"row":3,"col":0},"anchor":{"row":3,"col":0}},{"cursor":{"row":3,"col":2},"anchor":{"row":3,"col":2}},{"cursor":{"row":3,"col":4},"anchor":{"row":3,"col":4}},{"cursor":{"row":3,"col":6},"anchor":{"row":3,"col":6}},{"cursor":{"row":3,"col":8},"anchor":{"row":3,"col":8}},{"cursor":{"row":3,"col":10},"anchor":{"row":3,"col":10}},{"cursor":{"row":3,"col":12},"anchor":{"row":3,"col":12}},{"cursor":{"row":3,"col":14},"anchor":{"row":3,"col":14}},{"cursor":{"row":3,"col":16},"anchor":{"row":3,"col":16}},{"cursor":{"row":3,"col":18},"anchor":{"row":3,"col":18}},{"cursor":{"row":3,"col":20},"anchor":{"row":3,"col":20}},{"cursor":{"row":3,"col":22},"anchor":{"row":3,"col":22}},{"cursor":{"row":3,"col":24},"anchor":{"row":3,"col":24}},{"cursor":{"row":3,"col":26},"anchor":{"row":3,"col":26}},{"cursor":{"row":3,"col":28},"anchor":{"row":3,"col":28}},{"cursor":{"row":3,"col":30},"anchor":{"row":3,"col":30}},{"cursor":{"row":3,"col":32},"anchor":{"row":3,"col":32}},{"cursor":{"row":3,"col":34},"anchor":{"row":3,"col":34}},{"cursor":{"row":3,"col":36},"anchor":{"row":3,"col":36}},{"cursor":{"row":3,"col":38},"anchor":{"row":3,"col":38}},{"cursor":{"row":4,"col":0},"anchor":{"row":4,"col":0}},{"cursor":{"row":4,"col":2},"anchor":{"row":4,"col":2}},{"cursor":{"row":4,"col":4},"anchor":{"row":4,"col":4}},{"cursor":{"row":4,"col":6},"anchor":{"row":4,"col":6}},{"cursor":{"row":4,"col":8},"anchor":{"row":4,"col":8}},{"cursor":{"row":4,"col":10},"anchor":{"row":4,"col":10}},{"cursor":{"row":4,"col":12},"anchor":{"row":4,"col":12}},{"cursor":{"row":4,"col":14},"anchor":{"row":4,"col":14}},{"cursor":{"row":4,"col":16},"anchor":{"row":4,"col":16}},{"cursor":{"row":4,"col":18},"anchor":{"row":4,"col":18}},{"cursor":{"row":4,"col":20},"anchor":{"row":4,"col":20}},{"cursor":{"row":4,"col":22},"anchor":{"row":4,"col":22}},{"cursor":{"row":4,"col":24},"anchor":{"row":4,"col":24}},{"cursor":{"row":4,"col":26},"anchor":{"row":4,"col":26}},{"cursor":{"row":4,"col":28},"anchor":{"row":4,"col":28}},{"cursor":{"row":4,"col":30},"anchor":{"row":4,"col":30}},{"cursor":{"row":4,"col":32},"anchor":{"row":4,"col":32}},{"cursor":{"row":4,"col":34},"anchor":{"row":4,"col":34}},{"cursor":{"row":4,"col":36},"anchor":{"row":4,"col":36}},{"cursor":{"row":4,"col":38},"anchor":{"row":4,"col":38}},{"cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}},{"cursor":{"row":5,"col":2},"anchor":{"row":5,"col":2}},{"cursor":{"row":5,"col":4},"anchor":{"row":5,"col":4}},{"cursor":{"row":5,"col":6},"anchor":{"row":5,"col":6}},{"cursor":{"row":5,"col":8},"anchor":{"row":5,"col":8}},{"cursor":{"row":5,"col":10},"anchor":{"row":5,"col":10}},{"cursor":{"row":5,"col":12},"anchor":{"row":5,"col":12}},{"cursor":{"row":5,"col":14},"anchor":{"row":5,"col":14}},{"cursor":{"row":5,"col":16},"anchor":{"row":5,"col":16}},{"cursor":{"row":5,"col":18},"anchor":{"row":5,"col":18}},{"cursor":{"row":5,"col":20},"anchor":{"row":5,"col":20}},{"cursor":{"row":5,"col":22},"anchor":{"row":5,"col":22}},{"cursor":{"row":5,"col":24},"anchor":{"row":5,"col":24}},{"cursor":{"row":5,"col":26},"anchor":{"row":5,"col":26}},{"cursor":{"row":5,"col":28},"anchor":{"row":5,"col":28}},{"cursor":{"row":5,"col":30},"anchor":{"row":5,"col":30}},{"cursor":{"row":5,"col":32},"anchor":{"row":5,"col":32}},{"cursor":{"row":5,"col":34},"anchor":{"row":5,"col":34}},{"cursor":{"row":5,"col":36},"anchor":{"row":5,"col":36}},{"cursor":{"row":5,"col":38},"anchor":{"row":5,"col":38}},{"cursor":{"row":6,"col":0},"anchor":{"row":6,"col":0}},{"cursor":{"row":6,"col":2},"anchor":{"row":6,"col":2}},{"cursor":{"row":6,"col":4},"anchor":{"row":6,"col":4}},{"cursor":{"row":6,"col":6},"anchor":{"row":6,"col":6}},{"cursor":{"row":6,"col":8},"anchor":{"row":6,"col":8}},{"cursor":{"row":6,"col":10},"anchor":{"row":6,"col":10}},{"cursor":{"row":6,"col":12},"anchor":{"row":6,"col":12}},{"cursor":{"row":6,"col":14},"anchor":{"row":6,"col":14}},{"cursor":{"row":6,"col":16},"anchor":{"row":6,"col":16}},{"cursor":{"row":6,"col":18},"anchor":{"row":6,"col":18}},{"cursor":{"row":6,"col":20},"anchor":{"row":6,"col":20}},{"cursor":{"row":6,"col":22},"anchor":{"row":6,"col":22}},{"cursor":{"row":6,"col":24},"anchor":{"row":6,"col":24}},{"cursor":{"row":6,"col":26},"anchor":{"row":6,"col":26}},{"cursor":{"row":6,"col":28},"anchor":{"row":6,"col":28}},{"cursor":{"row":6,"col":30},"anchor":{"row":6,"col":30}},{"cursor":{"row":6,"col":32},"anchor":{"row":6,"col":32}},{"cursor":{"row":6,"col":34},"anchor":{"row":6,"col":34}},{"cursor":{"row":6,"col":36},"anchor":{"row":6,"col":36}},{"cursor":{"row":6,"col":38},"anchor":{"row":6,"col":38}},{"cursor":{"row":7,"col":0},"anchor":{"row":7,"col":0}},{"cursor":{"row":7,"col":2},"anchor":{"row":7,"col":2}},{"cursor":{"row":7,"col":4},"anchor":{"row":7,"col":4}},{"cursor":{"row":7,"col":6},"anchor":{"row":7,"col":6}},{"cursor":{"row":7,"col":8},"anchor":{"row":7,"col":8}},{"cursor":{"row":7,"col":10},"anchor":{"row":7,"col":10}},{"cursor":{"row":7,"col":12},"anchor":{"row":7,"col":12}},{"cursor":{"row":7,"col":14},"anchor":{"row":7,"col":14}},{"cursor":{"row":7,"col":16},"anchor":{"row":7,"col":16}},{"cursor":{"row":7,"col":18},"anchor":{"row":7,"col":18}},{"cursor":{"row":7,"col":20},"anchor":{"row":7,"col":20}},{"cursor":{"row":7,"col":22},"anchor":{"row":7,"col":22}},{"cursor":{"row":7,"col":24},"anchor":{"row":7,"col":24}},{"cursor":{"row":7,"col":26},"anchor":{"row":7,"col":26}},{"cursor":{"row":7,"col":28},"anchor":{"row":7,"col":28}},{"cursor":{"row":7,"col":30},"anchor":{"row":7,"col":30}},{"cursor":{"row":7,"col":32},"anchor":{"row":7,"col":32}},{"cursor":{"row":7,"col":34},"anchor":{"row":7,"col":34}},{"cursor":{"row":7,"col":36},"anchor":{"row":7,"col":36}},{"cursor":{"row":7,"col":38},"anchor":{"row":7,"col":38}},{"cursor":{"row":8,"col":0},"anchor":{"row":8,"col":0}},{"cursor":{"row":8,"col":2},"anchor":{"row":8,"col":2}},{"cursor":{"row":8,"col":4},"anchor":{"row":8,"col":4}},{"cursor":{"row":8,"col":6},"anchor":{"row":8,"col":6}},{"cursor":{"row":8,"col":8},"anchor":{"row":8,"col":8}},{"cursor":{"row":8,"col":10},"anchor":{"row":8,"col":10}},{"cursor":{"row":8,"col":12},"anchor":{"row":8,"col":12}},{"cursor":{"row":8,"col":14},"anchor":{"row":8,"col":14}},{"cursor":{"row":8,"col":16},"anchor":{"row":8,"col":16}},{"cursor":{"row":8,"col":18},"anchor":{"row":8,"col":18}},{"cursor":{"row":8,"col":20},"anchor":{"row":8,"col":20}},{"cursor":{"row":8,"col":22},"anchor":{"row":8,"col":22}},{"cursor":{"row":8,"col":24},"anchor":{"row":8,"col":24}},{"cursor":{"row":8,"col":26},"anchor":{"row":8,"col":26}},{"cursor":{"row":8,"col":28},"anchor":{"row":8,"col":28}},{"cursor":{"row":8,"col":30},"anchor":{"row":8,"col":30}},{"cursor":{"row":8,"col":32},"anchor":{"row":8,"col":32}},{"cursor":{"row":8,"col":34},"anchor":{"row":8,"col":34}},{"cursor":{"row":8,"col":36},"anchor":{"row":8,"col":36}},{"cursor":{"row":8,"col":38},"anchor":{"row":8,"col":38}},{"cursor":{"row":9,"col":0},"anchor":{"row":9,"col":0}},{"cursor":{"row":9,"col":2},"anchor":{"row":9,"col":2}},{"cursor":{"row":9,"col":4},"anchor":{"row":9,"col":4}},{"cursor":{"row":9,"col":6},"anchor":{"row":9,"col":6}},{"cursor":{"row":9,"col":8},"anchor":{"row":9,"col":8}},{"cursor":{"row":9,"col":10},"anchor":{"row":9,"col":10}},{"cursor":{"row":9,"col":12},"anchor":{"row":9,"col":12}},{"cursor":{"row":9,"col":14},"anchor":{"row":9,"col":14}},{"cursor":{"row":9,"col":16},"anchor":{"row":9,"col":16}},{"cursor":{"row":9,"col":18},"anchor":{"row":9,"col":18}},{"cursor":{"row":9,"col":20},"anchor":{"row":9,"col":20}},{"cursor":{"row":9,"col":22},"anchor":{"row":9,"col":22}},{"cursor":{"row":9,"col":24},"anchor":{"row":9,"col":24}},{"cursor":{"row":9,"col":26},"anchor":{"row":9,"col":26}},{"cursor":{"row":9,"col":28},"anchor":{"row":9,"col":28}},{"cursor":{"row":9,"col":30},"anchor":{"row":9,"col":30}},{"cursor":{"row":9,"col":32},"anchor":{"row":9,"col":32}},{"cursor":{"row":9,"col":34},"anchor":{"row":9,"col":34}},{"cursor":{"row":9,"col":36},"anchor":{"row":9,"col":36}},{"cursor":{"row":9,"col":38},"anchor":{"row":9,"col":38}}],"primary":0} +{"name":"sel-many-ranges-edit","text":"xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":0,"col":8},"anchor":{"row":0,"col":8}},{"cursor":{"row":0,"col":11},"anchor":{"row":0,"col":11}},{"cursor":{"row":0,"col":14},"anchor":{"row":0,"col":14}},{"cursor":{"row":0,"col":17},"anchor":{"row":0,"col":17}},{"cursor":{"row":0,"col":20},"anchor":{"row":0,"col":20}},{"cursor":{"row":0,"col":23},"anchor":{"row":0,"col":23}},{"cursor":{"row":0,"col":26},"anchor":{"row":0,"col":26}},{"cursor":{"row":0,"col":29},"anchor":{"row":0,"col":29}},{"cursor":{"row":0,"col":32},"anchor":{"row":0,"col":32}},{"cursor":{"row":0,"col":35},"anchor":{"row":0,"col":35}},{"cursor":{"row":0,"col":38},"anchor":{"row":0,"col":38}},{"cursor":{"row":0,"col":41},"anchor":{"row":0,"col":41}},{"cursor":{"row":0,"col":44},"anchor":{"row":0,"col":44}},{"cursor":{"row":0,"col":47},"anchor":{"row":0,"col":47}},{"cursor":{"row":0,"col":50},"anchor":{"row":0,"col":50}},{"cursor":{"row":0,"col":53},"anchor":{"row":0,"col":53}},{"cursor":{"row":0,"col":56},"anchor":{"row":0,"col":56}},{"cursor":{"row":0,"col":59},"anchor":{"row":0,"col":59}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":8},"anchor":{"row":1,"col":8}},{"cursor":{"row":1,"col":11},"anchor":{"row":1,"col":11}},{"cursor":{"row":1,"col":14},"anchor":{"row":1,"col":14}},{"cursor":{"row":1,"col":17},"anchor":{"row":1,"col":17}},{"cursor":{"row":1,"col":20},"anchor":{"row":1,"col":20}},{"cursor":{"row":1,"col":23},"anchor":{"row":1,"col":23}},{"cursor":{"row":1,"col":26},"anchor":{"row":1,"col":26}},{"cursor":{"row":1,"col":29},"anchor":{"row":1,"col":29}},{"cursor":{"row":1,"col":32},"anchor":{"row":1,"col":32}},{"cursor":{"row":1,"col":35},"anchor":{"row":1,"col":35}},{"cursor":{"row":1,"col":38},"anchor":{"row":1,"col":38}},{"cursor":{"row":1,"col":41},"anchor":{"row":1,"col":41}},{"cursor":{"row":1,"col":44},"anchor":{"row":1,"col":44}},{"cursor":{"row":1,"col":47},"anchor":{"row":1,"col":47}},{"cursor":{"row":1,"col":50},"anchor":{"row":1,"col":50}},{"cursor":{"row":1,"col":53},"anchor":{"row":1,"col":53}},{"cursor":{"row":1,"col":56},"anchor":{"row":1,"col":56}},{"cursor":{"row":1,"col":59},"anchor":{"row":1,"col":59}},{"cursor":{"row":2,"col":2},"anchor":{"row":2,"col":2}},{"cursor":{"row":2,"col":5},"anchor":{"row":2,"col":5}},{"cursor":{"row":2,"col":8},"anchor":{"row":2,"col":8}},{"cursor":{"row":2,"col":11},"anchor":{"row":2,"col":11}},{"cursor":{"row":2,"col":14},"anchor":{"row":2,"col":14}},{"cursor":{"row":2,"col":17},"anchor":{"row":2,"col":17}},{"cursor":{"row":2,"col":20},"anchor":{"row":2,"col":20}},{"cursor":{"row":2,"col":23},"anchor":{"row":2,"col":23}},{"cursor":{"row":2,"col":26},"anchor":{"row":2,"col":26}},{"cursor":{"row":2,"col":29},"anchor":{"row":2,"col":29}},{"cursor":{"row":2,"col":32},"anchor":{"row":2,"col":32}},{"cursor":{"row":2,"col":35},"anchor":{"row":2,"col":35}},{"cursor":{"row":2,"col":38},"anchor":{"row":2,"col":38}},{"cursor":{"row":2,"col":41},"anchor":{"row":2,"col":41}},{"cursor":{"row":2,"col":44},"anchor":{"row":2,"col":44}},{"cursor":{"row":2,"col":47},"anchor":{"row":2,"col":47}},{"cursor":{"row":2,"col":50},"anchor":{"row":2,"col":50}},{"cursor":{"row":2,"col":53},"anchor":{"row":2,"col":53}},{"cursor":{"row":2,"col":56},"anchor":{"row":2,"col":56}},{"cursor":{"row":2,"col":59},"anchor":{"row":2,"col":59}},{"cursor":{"row":3,"col":2},"anchor":{"row":3,"col":2}},{"cursor":{"row":3,"col":5},"anchor":{"row":3,"col":5}},{"cursor":{"row":3,"col":8},"anchor":{"row":3,"col":8}},{"cursor":{"row":3,"col":11},"anchor":{"row":3,"col":11}},{"cursor":{"row":3,"col":14},"anchor":{"row":3,"col":14}},{"cursor":{"row":3,"col":17},"anchor":{"row":3,"col":17}},{"cursor":{"row":3,"col":20},"anchor":{"row":3,"col":20}},{"cursor":{"row":3,"col":23},"anchor":{"row":3,"col":23}},{"cursor":{"row":3,"col":26},"anchor":{"row":3,"col":26}},{"cursor":{"row":3,"col":29},"anchor":{"row":3,"col":29}},{"cursor":{"row":3,"col":32},"anchor":{"row":3,"col":32}},{"cursor":{"row":3,"col":35},"anchor":{"row":3,"col":35}},{"cursor":{"row":3,"col":38},"anchor":{"row":3,"col":38}},{"cursor":{"row":3,"col":41},"anchor":{"row":3,"col":41}},{"cursor":{"row":3,"col":44},"anchor":{"row":3,"col":44}},{"cursor":{"row":3,"col":47},"anchor":{"row":3,"col":47}},{"cursor":{"row":3,"col":50},"anchor":{"row":3,"col":50}},{"cursor":{"row":3,"col":53},"anchor":{"row":3,"col":53}},{"cursor":{"row":3,"col":56},"anchor":{"row":3,"col":56}},{"cursor":{"row":3,"col":59},"anchor":{"row":3,"col":59}},{"cursor":{"row":4,"col":2},"anchor":{"row":4,"col":2}},{"cursor":{"row":4,"col":5},"anchor":{"row":4,"col":5}},{"cursor":{"row":4,"col":8},"anchor":{"row":4,"col":8}},{"cursor":{"row":4,"col":11},"anchor":{"row":4,"col":11}},{"cursor":{"row":4,"col":14},"anchor":{"row":4,"col":14}},{"cursor":{"row":4,"col":17},"anchor":{"row":4,"col":17}},{"cursor":{"row":4,"col":20},"anchor":{"row":4,"col":20}},{"cursor":{"row":4,"col":23},"anchor":{"row":4,"col":23}},{"cursor":{"row":4,"col":26},"anchor":{"row":4,"col":26}},{"cursor":{"row":4,"col":29},"anchor":{"row":4,"col":29}},{"cursor":{"row":4,"col":32},"anchor":{"row":4,"col":32}},{"cursor":{"row":4,"col":35},"anchor":{"row":4,"col":35}},{"cursor":{"row":4,"col":38},"anchor":{"row":4,"col":38}},{"cursor":{"row":4,"col":41},"anchor":{"row":4,"col":41}},{"cursor":{"row":4,"col":44},"anchor":{"row":4,"col":44}},{"cursor":{"row":4,"col":47},"anchor":{"row":4,"col":47}},{"cursor":{"row":4,"col":50},"anchor":{"row":4,"col":50}},{"cursor":{"row":4,"col":53},"anchor":{"row":4,"col":53}},{"cursor":{"row":4,"col":56},"anchor":{"row":4,"col":56}},{"cursor":{"row":4,"col":59},"anchor":{"row":4,"col":59}},{"cursor":{"row":5,"col":2},"anchor":{"row":5,"col":2}},{"cursor":{"row":5,"col":5},"anchor":{"row":5,"col":5}},{"cursor":{"row":5,"col":8},"anchor":{"row":5,"col":8}},{"cursor":{"row":5,"col":11},"anchor":{"row":5,"col":11}},{"cursor":{"row":5,"col":14},"anchor":{"row":5,"col":14}},{"cursor":{"row":5,"col":17},"anchor":{"row":5,"col":17}},{"cursor":{"row":5,"col":20},"anchor":{"row":5,"col":20}},{"cursor":{"row":5,"col":23},"anchor":{"row":5,"col":23}},{"cursor":{"row":5,"col":26},"anchor":{"row":5,"col":26}},{"cursor":{"row":5,"col":29},"anchor":{"row":5,"col":29}},{"cursor":{"row":5,"col":32},"anchor":{"row":5,"col":32}},{"cursor":{"row":5,"col":35},"anchor":{"row":5,"col":35}},{"cursor":{"row":5,"col":38},"anchor":{"row":5,"col":38}},{"cursor":{"row":5,"col":41},"anchor":{"row":5,"col":41}},{"cursor":{"row":5,"col":44},"anchor":{"row":5,"col":44}},{"cursor":{"row":5,"col":47},"anchor":{"row":5,"col":47}},{"cursor":{"row":5,"col":50},"anchor":{"row":5,"col":50}},{"cursor":{"row":5,"col":53},"anchor":{"row":5,"col":53}},{"cursor":{"row":5,"col":56},"anchor":{"row":5,"col":56}},{"cursor":{"row":5,"col":59},"anchor":{"row":5,"col":59}},{"cursor":{"row":6,"col":2},"anchor":{"row":6,"col":2}},{"cursor":{"row":6,"col":5},"anchor":{"row":6,"col":5}},{"cursor":{"row":6,"col":8},"anchor":{"row":6,"col":8}},{"cursor":{"row":6,"col":11},"anchor":{"row":6,"col":11}},{"cursor":{"row":6,"col":14},"anchor":{"row":6,"col":14}},{"cursor":{"row":6,"col":17},"anchor":{"row":6,"col":17}},{"cursor":{"row":6,"col":20},"anchor":{"row":6,"col":20}},{"cursor":{"row":6,"col":23},"anchor":{"row":6,"col":23}},{"cursor":{"row":6,"col":26},"anchor":{"row":6,"col":26}},{"cursor":{"row":6,"col":29},"anchor":{"row":6,"col":29}},{"cursor":{"row":6,"col":32},"anchor":{"row":6,"col":32}},{"cursor":{"row":6,"col":35},"anchor":{"row":6,"col":35}},{"cursor":{"row":6,"col":38},"anchor":{"row":6,"col":38}},{"cursor":{"row":6,"col":41},"anchor":{"row":6,"col":41}},{"cursor":{"row":6,"col":44},"anchor":{"row":6,"col":44}},{"cursor":{"row":6,"col":47},"anchor":{"row":6,"col":47}},{"cursor":{"row":6,"col":50},"anchor":{"row":6,"col":50}},{"cursor":{"row":6,"col":53},"anchor":{"row":6,"col":53}},{"cursor":{"row":6,"col":56},"anchor":{"row":6,"col":56}},{"cursor":{"row":6,"col":59},"anchor":{"row":6,"col":59}},{"cursor":{"row":7,"col":2},"anchor":{"row":7,"col":2}},{"cursor":{"row":7,"col":5},"anchor":{"row":7,"col":5}},{"cursor":{"row":7,"col":8},"anchor":{"row":7,"col":8}},{"cursor":{"row":7,"col":11},"anchor":{"row":7,"col":11}},{"cursor":{"row":7,"col":14},"anchor":{"row":7,"col":14}},{"cursor":{"row":7,"col":17},"anchor":{"row":7,"col":17}},{"cursor":{"row":7,"col":20},"anchor":{"row":7,"col":20}},{"cursor":{"row":7,"col":23},"anchor":{"row":7,"col":23}},{"cursor":{"row":7,"col":26},"anchor":{"row":7,"col":26}},{"cursor":{"row":7,"col":29},"anchor":{"row":7,"col":29}},{"cursor":{"row":7,"col":32},"anchor":{"row":7,"col":32}},{"cursor":{"row":7,"col":35},"anchor":{"row":7,"col":35}},{"cursor":{"row":7,"col":38},"anchor":{"row":7,"col":38}},{"cursor":{"row":7,"col":41},"anchor":{"row":7,"col":41}},{"cursor":{"row":7,"col":44},"anchor":{"row":7,"col":44}},{"cursor":{"row":7,"col":47},"anchor":{"row":7,"col":47}},{"cursor":{"row":7,"col":50},"anchor":{"row":7,"col":50}},{"cursor":{"row":7,"col":53},"anchor":{"row":7,"col":53}},{"cursor":{"row":7,"col":56},"anchor":{"row":7,"col":56}},{"cursor":{"row":7,"col":59},"anchor":{"row":7,"col":59}},{"cursor":{"row":8,"col":2},"anchor":{"row":8,"col":2}},{"cursor":{"row":8,"col":5},"anchor":{"row":8,"col":5}},{"cursor":{"row":8,"col":8},"anchor":{"row":8,"col":8}},{"cursor":{"row":8,"col":11},"anchor":{"row":8,"col":11}},{"cursor":{"row":8,"col":14},"anchor":{"row":8,"col":14}},{"cursor":{"row":8,"col":17},"anchor":{"row":8,"col":17}},{"cursor":{"row":8,"col":20},"anchor":{"row":8,"col":20}},{"cursor":{"row":8,"col":23},"anchor":{"row":8,"col":23}},{"cursor":{"row":8,"col":26},"anchor":{"row":8,"col":26}},{"cursor":{"row":8,"col":29},"anchor":{"row":8,"col":29}},{"cursor":{"row":8,"col":32},"anchor":{"row":8,"col":32}},{"cursor":{"row":8,"col":35},"anchor":{"row":8,"col":35}},{"cursor":{"row":8,"col":38},"anchor":{"row":8,"col":38}},{"cursor":{"row":8,"col":41},"anchor":{"row":8,"col":41}},{"cursor":{"row":8,"col":44},"anchor":{"row":8,"col":44}},{"cursor":{"row":8,"col":47},"anchor":{"row":8,"col":47}},{"cursor":{"row":8,"col":50},"anchor":{"row":8,"col":50}},{"cursor":{"row":8,"col":53},"anchor":{"row":8,"col":53}},{"cursor":{"row":8,"col":56},"anchor":{"row":8,"col":56}},{"cursor":{"row":8,"col":59},"anchor":{"row":8,"col":59}},{"cursor":{"row":9,"col":2},"anchor":{"row":9,"col":2}},{"cursor":{"row":9,"col":5},"anchor":{"row":9,"col":5}},{"cursor":{"row":9,"col":8},"anchor":{"row":9,"col":8}},{"cursor":{"row":9,"col":11},"anchor":{"row":9,"col":11}},{"cursor":{"row":9,"col":14},"anchor":{"row":9,"col":14}},{"cursor":{"row":9,"col":17},"anchor":{"row":9,"col":17}},{"cursor":{"row":9,"col":20},"anchor":{"row":9,"col":20}},{"cursor":{"row":9,"col":23},"anchor":{"row":9,"col":23}},{"cursor":{"row":9,"col":26},"anchor":{"row":9,"col":26}},{"cursor":{"row":9,"col":29},"anchor":{"row":9,"col":29}},{"cursor":{"row":9,"col":32},"anchor":{"row":9,"col":32}},{"cursor":{"row":9,"col":35},"anchor":{"row":9,"col":35}},{"cursor":{"row":9,"col":38},"anchor":{"row":9,"col":38}},{"cursor":{"row":9,"col":41},"anchor":{"row":9,"col":41}},{"cursor":{"row":9,"col":44},"anchor":{"row":9,"col":44}},{"cursor":{"row":9,"col":47},"anchor":{"row":9,"col":47}},{"cursor":{"row":9,"col":50},"anchor":{"row":9,"col":50}},{"cursor":{"row":9,"col":53},"anchor":{"row":9,"col":53}},{"cursor":{"row":9,"col":56},"anchor":{"row":9,"col":56}},{"cursor":{"row":9,"col":59},"anchor":{"row":9,"col":59}}],"primary":0} {"name":"msel-align-basic","text":"a =1\nbbb=2\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":0} {"name":"msel-align-columns","text":"a ,b,c\nccc,d,e\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}}],"primary":0} {"name":"msel-align-uneven","text":"a ,b\nccc,d,eee,f\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}}],"primary":0} diff --git a/test/panes.zig b/test/panes.zig index f0e1ba3d..a56c07bf 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -305,7 +305,7 @@ const TagNavigationTests = struct { try std.testing.expectEqual(@as(usize, 2), got.n); for (ranges[0..got.n]) |range| try std.testing.expectEqualStrings("S", text[@min(range.anchor, range.head)..@max(range.anchor, range.head)]); // the body's selection is its own - try std.testing.expectEqual(@as(u8, 0), pane.body.nsel); + try std.testing.expectEqual(@as(u16, 0), pane.body.nsel); // `/` from the tag is the body's search, as acme's Look from a tag is key(p, ','); key(p, '/'); @@ -1984,8 +1984,8 @@ test "prompt cancellation restores the selection before regex previews" { p.update(.{ .key = .{ .cp = case.pattern[0], .text = case.pattern } }); try std.testing.expectEqual(case.secondary, pane.body.nsel); p.update(.{ .key = .{ .cp = Key.escape } }); - try std.testing.expectEqual(@as(u8, 0), pane.body.nsel); - try std.testing.expectEqual(@as(u8, 0), pane.nsel_snap); + try std.testing.expectEqual(@as(u16, 0), pane.body.nsel); + try std.testing.expectEqual(@as(u16, 0), pane.nsel_snap); try std.testing.expectEqual(@as(i32, 10), pane.body.cur_col); try std.testing.expectEqual(@as(i32, 0), pane.body.vsel.col); try std.testing.expect(pane.body.vsel.active and pane.body.vsel.explicit); @@ -2761,7 +2761,7 @@ const OutputTests = struct { try fillResults(p, 0, "/", .{ .query = .hover }, "", try p.gpa.dupe(u8, "short\n"), null); try std.testing.expectEqualStrings("short\n", result.file.?.content); try std.testing.expect(!result.body.vsel.active and !result.body.msel.active and !result.body.select); - try std.testing.expectEqual(@as(u8, 0), result.body.nsel); + try std.testing.expectEqual(@as(u16, 0), result.body.nsel); try std.testing.expectEqual(@as(i32, 0), result.body.cur_row); try std.testing.expectEqual(@as(i32, 0), result.body.cur_col); try std.testing.expectEqual(@as(usize, 0), result.file.?.scroll); @@ -4581,7 +4581,7 @@ const LookResetTests = struct { fn cleared(pane: *const Pane) !void { try std.testing.expect(!pane.body.msel.active); - try std.testing.expectEqual(@as(u8, 0), pane.body.nsel); + try std.testing.expectEqual(@as(u16, 0), pane.body.nsel); try std.testing.expect(!pane.body.select); try std.testing.expectEqual(modal.Normal.State{}, pane.body.normal); try std.testing.expect(!pane.body.restore_cursor); -- cgit v1.3 From 79cdf76fd2dd0ba03622bfe742b525b770d51684 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:29:36 -0300 Subject: K and Alt-K keep or drop the ranges a regex matches in; Alt-: turns every range forward K and Alt-K share the s/S prompt, which now carries a mode rather than a split flag: a range stays when a match starts inside it (K) or when none does (Alt-K), the primary becomes the first, and keeping none leaves the selection as it was (helix keep_or_remove_matches). Alt-: is ensure_selections_forward. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 4 ++-- docs/selections.md | 2 ++ src/Text.zig | 9 +++++++ src/config.zig | 5 ++++ src/edit.zig | 5 ++-- src/look.zig | 2 +- src/modal.zig | 15 +++++++++--- src/normal.zig | 58 ++++++++++++++++++++++++++++++++-------------- src/pardes.zig | 4 ++-- test/hxcases/cases.jsonl | 8 +++++++ test/hxcases/goldens.jsonl | 8 +++++++ 11 files changed, 92 insertions(+), 28 deletions(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 1c9e0763..5c3972a5 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -212,6 +212,7 @@ found. Pure text math in `modal.zig`: `findChar`, `matchBracket`, | --- | --- | --- | --- | | `;` | collapse selection to cursor | drops vsel/msel, cursor stays | helix-verified (phase 5) | | `Alt-;` | flip anchor and head | vsel: swap cursor ↔ anchor; msel: cursor to the other end (r0/r1 swapped to keep the cursor-at-r1 invariant) | helix-verified (phase 5) | +| `Alt-:` | make every range point forward | helix `ensure_selections_forward` | helix-verified | | `%` | select whole buffer | vsel anchor 0,0, cursor on the buffer's last char | helix-verified (phase 5) | | `X` | snap selection to line bounds | vsel → msel over its row span; bare cursor → 1-line msel; msel: already line-wise, no-op | helix-verified (phase 5) | | `Alt-x` | shrink selection to line bounds | vsel only: partial first/last lines drop out; nothing left collapses the selection; msel: no-op | helix-verified (phase 5) | @@ -277,7 +278,7 @@ keeps the matches it found so far. | `^` and `$` | hold at every line's start and end | helix compiles with `multi_line(true)`; pardes gets the same by searching each line as its own haystack (`src/regexp.zig`) | helix-verified | | `.` | never matches a newline | as the Rust regex crate: a pattern without `\n` searches line by line, and one that names `\n` has its `.`s made `[^\n]` (`src/regexp.zig`) | helix-verified | | many matches | up to `memory.limits.selections` ranges: 1024 on the desktop, 64 on the board; matches past it are dropped | helix has no limit. The ranges are held inline in every `Text` (about 20 KiB each on the desktop) | helix-verified up to the limit | -| `K` / `Alt-K` | keep / remove ranges matching a regex | **skipped**: the same prompt, filtering instead of splitting — worth adding next | +| `K` / `Alt-K` Enter | keep only the ranges a match starts inside / only those none does; primary 0, and keeping none leaves the selection alone | helix `keep_selections` / `remove_selections`, on the same prompt as `s`/`S` (markers `Keep /`, `Remove /`). The range is searched as `s` searches it, line by line with its lines' context, where helix matches the range's text alone: a `^` right at a range that starts mid-line matches in helix and not here | helix-verified | ### `Ctrl-c` — toggle comments @@ -357,7 +358,6 @@ text. | insert `Ctrl-x` | completion menu | completion exists, but not as a popup: insert-mode Tab straight after a `.` opens a buffer of candidate DECLARATIONS (section A). helix's menu itself is skipped | | `Ctrl-s` (normal) | save jumplist position | jumplist itself is implemented (`Ctrl-o`/`Ctrl-i`, section A); only the explicit save point is skipped | | `Alt-u` / `Alt-U` | undo-history earlier/later | history timeline — linear snapshot u/U covers pardes | -| `K Alt-K`, `Alt-:` | regex keep/remove, ensure-forward | `K`/`Alt-K` are the same prompt `s`/`S` now have, filtering instead of splitting (`s S` moved to "Regex selection" in A, the rest of the family to "Multiple cursors") | | `:` | command mode | side-effects/file-ops — pardes builtins live in the tag, and `:` is bound to focusing it (section A) | | `gf` | goto file under selection | covered by pardes Enter-look | | `Ctrl-z` | suspend | pardes IS the terminal multiplexer | diff --git a/docs/selections.md b/docs/selections.md index 59c4415b..203cf1de 100644 --- a/docs/selections.md +++ b/docs/selections.md @@ -44,6 +44,8 @@ range and drops the others. per line), `o`/`O`, and `Alt-J` (the spaces a join put in). `s`/`S` re-run on every keystroke from the selection the prompt opened on (`Pane.sel_snap`); Esc is that snapshot restored. +- `K`/`Alt-K` keep or drop the ranges a regex matches in, on the `s` prompt; + `Alt-:` turns every range forward. - `Alt-minus` merges all into one, `Alt-_` the consecutive ones, `_` trims whitespace (dropping all-blank ranges). - `)`/`(` rotate which range is primary; `Alt-)`/`Alt-(` rotate the TEXT diff --git a/src/Text.zig b/src/Text.zig index 56ee199b..411c2ec8 100644 --- a/src/Text.zig +++ b/src/Text.zig @@ -328,6 +328,15 @@ pub fn multiSelAction(t: *Text, text: []const u8, kind: modal.Normal.Multi, cnt: } return setRanges(t, text, out[0..m], &.{}, pri, expl); } + if (kind == .ensure_forward) { + // helix ensure_selections_forward: every range points forward + for (rs[0..n]) |*r| { + const from = @min(r.anchor, r.head); + r.head = @max(r.anchor, r.head); + r.anchor = from; + } + return setRanges(t, text, rs[0..n], &.{}, got.pri, expl); + } if (kind == .split_newline) { // helix selection::split_on_newline — one range per line the // selection covers, the newlines themselves left out diff --git a/src/config.zig b/src/config.zig index bc053176..8c5c9771 100644 --- a/src/config.zig +++ b/src/config.zig @@ -414,6 +414,8 @@ pub const symbol_marker = " WsSymbols /"; pub const save_marker = " Save "; pub const select_marker = " Select /"; pub const split_marker = " Split /"; +pub const keep_marker = " Keep /"; +pub const remove_marker = " Remove /"; pub const pipe_marker = " |"; pub const search_buffer = "+Search"; @@ -532,6 +534,9 @@ pub const trim_sels: []const Chord = &.{.{ .cp = '_' }}; pub const select_regex: []const Chord = &.{.{ .cp = 's' }}; pub const split_regex: []const Chord = &.{.{ .cp = 'S' }}; +pub const keep_regex: []const Chord = &.{.{ .cp = 'K' }}; +pub const remove_regex: []const Chord = &.{.{ .cp = 'K', .alt = true }}; +pub const ensure_forward: []const Chord = &.{.{ .cp = ':', .alt = true }}; pub const delete: []const Chord = &.{.{ .cp = 'd' }}; pub const delete_noyank: []const Chord = &.{.{ .cp = 'd', .alt = true }}; diff --git a/src/edit.zig b/src/edit.zig index a373741c..5e0d20a4 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -148,7 +148,7 @@ pub fn applyPaste(p: *Pardes, bytes: []const u8) void { n += 1; }; if (n > 0) insertKey(p, &pane.input, .{ .cp = line[0], .text = line[0..n] }); - if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.split); + if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.mode); return; } if (pane.focus == .tag) return pasteInto(p, &pane.tag, bytes); @@ -355,7 +355,8 @@ pub fn exitInsert(p: *Pardes, t: *Text) void { for (rs[0..got.n]) |*r| { const from = @min(r.anchor, r.head); const to = @max(r.anchor, r.head); - r.* = .{ .anchor = from, .head = if (r.head > r.anchor) @max(from, modal.prevGrapheme(text, to)) else to }; + const head = if (r.head > r.anchor) @max(from, modal.prevGrapheme(text, to)) else to; + r.* = .{ .anchor = from, .head = head }; } t.setRanges(text, rs[0..got.n], &.{}, got.pri, expl); } diff --git a/src/look.zig b/src/look.zig index 30fb0dab..3da2e044 100644 --- a/src/look.zig +++ b/src/look.zig @@ -505,7 +505,7 @@ pub fn submitSearch(p: *Pardes, id: usize) void { if (normal.selRegexArmed(pane)) |a| { // helix keeps the pattern in `/` if (a.pat.len > 0) p.registers.put(p.gpa, '/', a.pat, 0, 1, true); - return if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.split); + return if (p.promptTarget(pane)) |t| normal.applySelRegex(p, pane, t, a.pat, a.mode); } const armed = pane.promptText() orelse return; const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return; diff --git a/src/modal.zig b/src/modal.zig index 34c4dac7..f5f3081e 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -97,6 +97,9 @@ pub const Normal = struct { trim_sels, select_regex, split_regex, + keep_regex, + remove_regex, + ensure_forward, delete, delete_noyank, @@ -210,6 +213,8 @@ pub const Normal = struct { } }; + /// what the s/S/K/Alt-K prompt does with its pattern's matches + pub const RegexMode = enum { select, split, keep, remove }; pub const Scope = enum { once, per_selection }; pub const Direction = enum { backward, forward }; pub const Motion = enum { @@ -262,6 +267,7 @@ pub const Normal = struct { merge, merge_consecutive, trim, + ensure_forward, }; pub const Edit = enum { delete, @@ -314,7 +320,7 @@ pub const Normal = struct { insert: struct { kind: Insert, count: u32 }, select: struct { kind: Select, count: u32 }, multi: struct { kind: Multi, count: u32 }, - select_regex: bool, // false = select, true = split + select_regex: RegexMode, edit: struct { kind: Edit, count: u32 }, lsp: Lsp, adjust_number: i64, @@ -577,8 +583,11 @@ pub const Normal = struct { if (key.has(.merge_sels)) return resultAction(.{ .multi = .{ .kind = .merge, .count = count } }); if (key.has(.merge_consecutive_sels)) return resultAction(.{ .multi = .{ .kind = .merge_consecutive, .count = count } }); if (key.has(.trim_sels)) return resultAction(.{ .multi = .{ .kind = .trim, .count = count } }); - if (key.has(.select_regex)) return resultAction(.{ .select_regex = false }); - if (key.has(.split_regex)) return resultAction(.{ .select_regex = true }); + if (key.has(.select_regex)) return resultAction(.{ .select_regex = .select }); + if (key.has(.split_regex)) return resultAction(.{ .select_regex = .split }); + if (key.has(.keep_regex)) return resultAction(.{ .select_regex = .keep }); + if (key.has(.remove_regex)) return resultAction(.{ .select_regex = .remove }); + if (key.has(.ensure_forward)) return resultAction(.{ .multi = .{ .kind = .ensure_forward, .count = count } }); if (key.has(.delete)) return resultAction(.{ .edit = .{ .kind = .delete, .count = count } }); if (key.has(.delete_noyank)) return resultAction(.{ .edit = .{ .kind = .delete_noyank, .count = count } }); diff --git a/src/normal.zig b/src/normal.zig index 98ba80d6..d6c646ef 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -69,27 +69,38 @@ pub fn multiOnce(p: *Pardes) bool { return p.multi_first; } -fn startSelRegex(p: *Pardes, pane: *Pane, t: *Text, split: bool) void { +fn startSelRegex(p: *Pardes, pane: *Pane, t: *Text, mode: modal.Normal.RegexMode) void { const text = edit.flatSurface(p, t) catch return; const got = t.ranges(text, 0, &pane.sel_snap); pane.nsel_snap = @intCast(got.n); pane.sel_snap_pri = @intCast(got.pri); pane.sel_snap_expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; - p.startPrompt(pane, t, .{ .search = if (split) config.split_marker else config.select_marker }); + p.startPrompt(pane, t, .{ .search = regexMarker(mode) }); } -pub fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, split: bool } { +fn regexMarker(mode: modal.Normal.RegexMode) []const u8 { + return switch (mode) { + .select => config.select_marker, + .split => config.split_marker, + .keep => config.keep_marker, + .remove => config.remove_marker, + }; +} + +pub fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, mode: modal.Normal.RegexMode } { if (pane.prompt != .search or pane.nsel_snap == 0) return null; const armed = pane.input.own orelse ""; - const split = std.mem.startsWith(u8, armed, config.split_marker); - if (!split and !std.mem.startsWith(u8, armed, config.select_marker)) return null; + const mode: modal.Normal.RegexMode = for (std.enums.values(modal.Normal.RegexMode)) |m| { + if (std.mem.startsWith(u8, armed, regexMarker(m))) break m; + } else return null; const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return null; - return .{ .pat = armed[slash + 1 ..], .split = split }; + return .{ .pat = armed[slash + 1 ..], .mode = mode }; } -/// Select (or split on) `pat` inside the selections `t` had when s/S was -/// pressed; the prompt's snapshot of them lives on `pane`. -pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, split: bool) void { +/// Select (or split on, keep or remove by) `pat` inside the selections `t` +/// had when s/S/K/Alt-K was pressed; the prompt's snapshot of them lives on +/// `pane`. +pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, mode: modal.Normal.RegexMode) void { const text = edit.flatSurface(p, t) catch return; const snap = pane.sel_snap[0..pane.nsel_snap]; var out: [Text.max_selections]modal.Selection = undefined; @@ -108,6 +119,17 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, split: const from = @min(r.anchor, r.head); const to = @min(@max(r.anchor, r.head), text.len); if (from >= to) continue; + if (mode == .keep or mode == .remove) { + // helix keep_or_remove_matches: the range stays when a match + // starts inside it (K), or when none does (Alt-K) + const hit_at = re.find(hay_all, from, to, to) catch null; + const found = if (hit_at) |h| h.start < to else false; + if (found == (mode == .keep) and m < Text.max_selections) { + out[m] = r; + m += 1; + } + continue; + } // Searched as sam searches, the way addr does (regexp.zig): the // text around the selection still says where its lines begin. var at = from; @@ -115,7 +137,7 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, split: while (at < to and m < Text.max_selections) { // A search too slow to finish keeps what it found so far. const hit_at = (re.find(hay_all, at, to, to) catch break) orelse break; - if (split) { + if (mode == .split) { out[m] = .{ .anchor = piece, .head = hit_at.start }; m += 1; piece = hit_at.end; @@ -126,7 +148,7 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, split: // an empty match would otherwise never advance at = if (hit_at.end > hit_at.start) hit_at.end else hit_at.end + 1; } - if (split and piece < to and m < Text.max_selections) { + if (mode == .split and piece < to and m < Text.max_selections) { out[m] = .{ .anchor = piece, .head = to }; m += 1; } @@ -542,7 +564,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void }, // s, S and | answer for the text they are pressed in; `/` searches // the body wherever it is pressed, as acme's Look from a tag does. - .select_regex => |split| return startSelRegex(p, pane, t, split), + .select_regex => |mode| return startSelRegex(p, pane, t, mode), .edit => |op| switch (op.kind) { .delete => return edit.normalDelete(p, t, true), .delete_noyank => return edit.normalDelete(p, t, false), @@ -795,20 +817,20 @@ test "s and S search a multi-line selection as addr does: ^ and $ per line, . wi defer p.deinit(); const text = "alpha x\nbeta alpha\nalphabet\n"; const pane = try p.setTestFile(text); - const Case = struct { pat: []const u8, split: bool, want: []const [2]usize }; + const Case = struct { pat: []const u8, mode: modal.Normal.RegexMode, want: []const [2]usize }; for ([_]Case{ // ^ at each line's start, not only the selection's - .{ .pat = "^alpha", .split = false, .want = &.{ .{ 0, 5 }, .{ 19, 24 } } }, + .{ .pat = "^alpha", .mode = .select, .want = &.{ .{ 0, 5 }, .{ 19, 24 } } }, // $ at each line's end - .{ .pat = "alpha$", .split = false, .want = &.{.{ 13, 18 }} }, + .{ .pat = "alpha$", .mode = .select, .want = &.{.{ 13, 18 }} }, // . stops at the newline: one selection per line, not one for all - .{ .pat = "a.*", .split = false, .want = &.{ .{ 0, 7 }, .{ 11, 18 }, .{ 19, 27 } } }, + .{ .pat = "a.*", .mode = .select, .want = &.{ .{ 0, 7 }, .{ 11, 18 }, .{ 19, 27 } } }, // S splits on a line's end - .{ .pat = "$", .split = true, .want = &.{ .{ 0, 7 }, .{ 7, 18 }, .{ 18, 27 }, .{ 27, 28 } } }, + .{ .pat = "$", .mode = .split, .want = &.{ .{ 0, 7 }, .{ 7, 18 }, .{ 18, 27 }, .{ 27, 28 } } }, }) |c| { pane.sel_snap[0] = .{ .anchor = 0, .head = text.len }; pane.nsel_snap = 1; - applySelRegex(p, pane, &pane.body, c.pat, c.split); + applySelRegex(p, pane, &pane.body, c.pat, c.mode); var out: [Text.max_selections]modal.Selection = undefined; const got = pane.body.ranges(text, 0, &out); try std.testing.expectEqual(c.want.len, got.n); diff --git a/src/pardes.zig b/src/pardes.zig index a7bcb195..d43b38b1 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -5270,7 +5270,7 @@ pub const Pardes = struct { .search => if (submit) look.submitSearch(p, p.active) else if (normal.selRegexArmed(pane)) |_| if (target) |answered| - normal.applySelRegex(p, pane, answered, "", false), + normal.applySelRegex(p, pane, answered, "", .select), .none, .del_side, .repl_choice, .name => unreachable, } pane.closePrompt(); @@ -5281,7 +5281,7 @@ pub const Pardes = struct { return; } edit.handleInsert(p, &pane.input, key); - if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |answered| normal.applySelRegex(p, pane, answered, a.pat, a.split); + if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |answered| normal.applySelRegex(p, pane, answered, a.pat, a.mode); return; } if (t.mode == .normal and p.macro.replaying == 0 and (hit(key, config.look_key) or hit(key, config.exec_key))) { diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 7aeb12e8..43cbbf11 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -432,6 +432,14 @@ {"name": "msel-rotate-contents-lines", "pane": "file", "text": "aa\nbbb\n", "keys": "%"} {"name": "sel-many-ranges", "pane": "file", "text": "w w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\n", "keys": "%sw"} {"name": "sel-many-ranges-edit", "pane": "file", "text": "w w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\n", "keys": "%swcxy"} +{"name": "keep-regex", "pane": "file", "text": "foo bar baz\n", "keys": "%s\\w+Kba"} +{"name": "remove-regex", "pane": "file", "text": "foo bar baz\n", "keys": "%s\\w+ba"} +{"name": "keep-regex-none", "pane": "file", "text": "foo bar\n", "keys": "%s\\w+Kzz"} +{"name": "keep-regex-primary", "pane": "file", "text": "a1 b2 a3\n", "keys": "%s\\w+))Ka"} +{"name": "keep-regex-esc", "pane": "file", "text": "foo bar\n", "keys": "%s\\w+Kfo"} +{"name": "keep-regex-lines", "pane": "file", "text": "foo\nbar\nbaz\n", "keys": "%K^b"} +{"name": "ensure-forward", "pane": "file", "text": "ab cd\n", "keys": "%s\\w+"} +{"name": "ensure-forward-one", "pane": "file", "text": "abc\n", "keys": "vll"} {"name": "msel-align-basic", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=&"} {"name": "msel-align-columns", "pane": "file", "text": "a,b,c\nccc,d,e\n", "keys": "%s,&"} {"name": "msel-align-uneven", "pane": "file", "text": "a,b\nccc,d,eee,f\n", "keys": "%s,&"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index b8221c4c..9839335a 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -432,6 +432,14 @@ {"name":"msel-rotate-contents-lines","text":"bbb\naa\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1} {"name":"sel-many-ranges","text":"w w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\nw w w w w w w w w w w w w w w w w w w w\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}},{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}},{"cursor":{"row":0,"col":8},"anchor":{"row":0,"col":8}},{"cursor":{"row":0,"col":10},"anchor":{"row":0,"col":10}},{"cursor":{"row":0,"col":12},"anchor":{"row":0,"col":12}},{"cursor":{"row":0,"col":14},"anchor":{"row":0,"col":14}},{"cursor":{"row":0,"col":16},"anchor":{"row":0,"col":16}},{"cursor":{"row":0,"col":18},"anchor":{"row":0,"col":18}},{"cursor":{"row":0,"col":20},"anchor":{"row":0,"col":20}},{"cursor":{"row":0,"col":22},"anchor":{"row":0,"col":22}},{"cursor":{"row":0,"col":24},"anchor":{"row":0,"col":24}},{"cursor":{"row":0,"col":26},"anchor":{"row":0,"col":26}},{"cursor":{"row":0,"col":28},"anchor":{"row":0,"col":28}},{"cursor":{"row":0,"col":30},"anchor":{"row":0,"col":30}},{"cursor":{"row":0,"col":32},"anchor":{"row":0,"col":32}},{"cursor":{"row":0,"col":34},"anchor":{"row":0,"col":34}},{"cursor":{"row":0,"col":36},"anchor":{"row":0,"col":36}},{"cursor":{"row":0,"col":38},"anchor":{"row":0,"col":38}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}},{"cursor":{"row":1,"col":4},"anchor":{"row":1,"col":4}},{"cursor":{"row":1,"col":6},"anchor":{"row":1,"col":6}},{"cursor":{"row":1,"col":8},"anchor":{"row":1,"col":8}},{"cursor":{"row":1,"col":10},"anchor":{"row":1,"col":10}},{"cursor":{"row":1,"col":12},"anchor":{"row":1,"col":12}},{"cursor":{"row":1,"col":14},"anchor":{"row":1,"col":14}},{"cursor":{"row":1,"col":16},"anchor":{"row":1,"col":16}},{"cursor":{"row":1,"col":18},"anchor":{"row":1,"col":18}},{"cursor":{"row":1,"col":20},"anchor":{"row":1,"col":20}},{"cursor":{"row":1,"col":22},"anchor":{"row":1,"col":22}},{"cursor":{"row":1,"col":24},"anchor":{"row":1,"col":24}},{"cursor":{"row":1,"col":26},"anchor":{"row":1,"col":26}},{"cursor":{"row":1,"col":28},"anchor":{"row":1,"col":28}},{"cursor":{"row":1,"col":30},"anchor":{"row":1,"col":30}},{"cursor":{"row":1,"col":32},"anchor":{"row":1,"col":32}},{"cursor":{"row":1,"col":34},"anchor":{"row":1,"col":34}},{"cursor":{"row":1,"col":36},"anchor":{"row":1,"col":36}},{"cursor":{"row":1,"col":38},"anchor":{"row":1,"col":38}},{"cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}},{"cursor":{"row":2,"col":2},"anchor":{"row":2,"col":2}},{"cursor":{"row":2,"col":4},"anchor":{"row":2,"col":4}},{"cursor":{"row":2,"col":6},"anchor":{"row":2,"col":6}},{"cursor":{"row":2,"col":8},"anchor":{"row":2,"col":8}},{"cursor":{"row":2,"col":10},"anchor":{"row":2,"col":10}},{"cursor":{"row":2,"col":12},"anchor":{"row":2,"col":12}},{"cursor":{"row":2,"col":14},"anchor":{"row":2,"col":14}},{"cursor":{"row":2,"col":16},"anchor":{"row":2,"col":16}},{"cursor":{"row":2,"col":18},"anchor":{"row":2,"col":18}},{"cursor":{"row":2,"col":20},"anchor":{"row":2,"col":20}},{"cursor":{"row":2,"col":22},"anchor":{"row":2,"col":22}},{"cursor":{"row":2,"col":24},"anchor":{"row":2,"col":24}},{"cursor":{"row":2,"col":26},"anchor":{"row":2,"col":26}},{"cursor":{"row":2,"col":28},"anchor":{"row":2,"col":28}},{"cursor":{"row":2,"col":30},"anchor":{"row":2,"col":30}},{"cursor":{"row":2,"col":32},"anchor":{"row":2,"col":32}},{"cursor":{"row":2,"col":34},"anchor":{"row":2,"col":34}},{"cursor":{"row":2,"col":36},"anchor":{"row":2,"col":36}},{"cursor":{"row":2,"col":38},"anchor":{"row":2,"col":38}},{"cursor":{"row":3,"col":0},"anchor":{"row":3,"col":0}},{"cursor":{"row":3,"col":2},"anchor":{"row":3,"col":2}},{"cursor":{"row":3,"col":4},"anchor":{"row":3,"col":4}},{"cursor":{"row":3,"col":6},"anchor":{"row":3,"col":6}},{"cursor":{"row":3,"col":8},"anchor":{"row":3,"col":8}},{"cursor":{"row":3,"col":10},"anchor":{"row":3,"col":10}},{"cursor":{"row":3,"col":12},"anchor":{"row":3,"col":12}},{"cursor":{"row":3,"col":14},"anchor":{"row":3,"col":14}},{"cursor":{"row":3,"col":16},"anchor":{"row":3,"col":16}},{"cursor":{"row":3,"col":18},"anchor":{"row":3,"col":18}},{"cursor":{"row":3,"col":20},"anchor":{"row":3,"col":20}},{"cursor":{"row":3,"col":22},"anchor":{"row":3,"col":22}},{"cursor":{"row":3,"col":24},"anchor":{"row":3,"col":24}},{"cursor":{"row":3,"col":26},"anchor":{"row":3,"col":26}},{"cursor":{"row":3,"col":28},"anchor":{"row":3,"col":28}},{"cursor":{"row":3,"col":30},"anchor":{"row":3,"col":30}},{"cursor":{"row":3,"col":32},"anchor":{"row":3,"col":32}},{"cursor":{"row":3,"col":34},"anchor":{"row":3,"col":34}},{"cursor":{"row":3,"col":36},"anchor":{"row":3,"col":36}},{"cursor":{"row":3,"col":38},"anchor":{"row":3,"col":38}},{"cursor":{"row":4,"col":0},"anchor":{"row":4,"col":0}},{"cursor":{"row":4,"col":2},"anchor":{"row":4,"col":2}},{"cursor":{"row":4,"col":4},"anchor":{"row":4,"col":4}},{"cursor":{"row":4,"col":6},"anchor":{"row":4,"col":6}},{"cursor":{"row":4,"col":8},"anchor":{"row":4,"col":8}},{"cursor":{"row":4,"col":10},"anchor":{"row":4,"col":10}},{"cursor":{"row":4,"col":12},"anchor":{"row":4,"col":12}},{"cursor":{"row":4,"col":14},"anchor":{"row":4,"col":14}},{"cursor":{"row":4,"col":16},"anchor":{"row":4,"col":16}},{"cursor":{"row":4,"col":18},"anchor":{"row":4,"col":18}},{"cursor":{"row":4,"col":20},"anchor":{"row":4,"col":20}},{"cursor":{"row":4,"col":22},"anchor":{"row":4,"col":22}},{"cursor":{"row":4,"col":24},"anchor":{"row":4,"col":24}},{"cursor":{"row":4,"col":26},"anchor":{"row":4,"col":26}},{"cursor":{"row":4,"col":28},"anchor":{"row":4,"col":28}},{"cursor":{"row":4,"col":30},"anchor":{"row":4,"col":30}},{"cursor":{"row":4,"col":32},"anchor":{"row":4,"col":32}},{"cursor":{"row":4,"col":34},"anchor":{"row":4,"col":34}},{"cursor":{"row":4,"col":36},"anchor":{"row":4,"col":36}},{"cursor":{"row":4,"col":38},"anchor":{"row":4,"col":38}},{"cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}},{"cursor":{"row":5,"col":2},"anchor":{"row":5,"col":2}},{"cursor":{"row":5,"col":4},"anchor":{"row":5,"col":4}},{"cursor":{"row":5,"col":6},"anchor":{"row":5,"col":6}},{"cursor":{"row":5,"col":8},"anchor":{"row":5,"col":8}},{"cursor":{"row":5,"col":10},"anchor":{"row":5,"col":10}},{"cursor":{"row":5,"col":12},"anchor":{"row":5,"col":12}},{"cursor":{"row":5,"col":14},"anchor":{"row":5,"col":14}},{"cursor":{"row":5,"col":16},"anchor":{"row":5,"col":16}},{"cursor":{"row":5,"col":18},"anchor":{"row":5,"col":18}},{"cursor":{"row":5,"col":20},"anchor":{"row":5,"col":20}},{"cursor":{"row":5,"col":22},"anchor":{"row":5,"col":22}},{"cursor":{"row":5,"col":24},"anchor":{"row":5,"col":24}},{"cursor":{"row":5,"col":26},"anchor":{"row":5,"col":26}},{"cursor":{"row":5,"col":28},"anchor":{"row":5,"col":28}},{"cursor":{"row":5,"col":30},"anchor":{"row":5,"col":30}},{"cursor":{"row":5,"col":32},"anchor":{"row":5,"col":32}},{"cursor":{"row":5,"col":34},"anchor":{"row":5,"col":34}},{"cursor":{"row":5,"col":36},"anchor":{"row":5,"col":36}},{"cursor":{"row":5,"col":38},"anchor":{"row":5,"col":38}},{"cursor":{"row":6,"col":0},"anchor":{"row":6,"col":0}},{"cursor":{"row":6,"col":2},"anchor":{"row":6,"col":2}},{"cursor":{"row":6,"col":4},"anchor":{"row":6,"col":4}},{"cursor":{"row":6,"col":6},"anchor":{"row":6,"col":6}},{"cursor":{"row":6,"col":8},"anchor":{"row":6,"col":8}},{"cursor":{"row":6,"col":10},"anchor":{"row":6,"col":10}},{"cursor":{"row":6,"col":12},"anchor":{"row":6,"col":12}},{"cursor":{"row":6,"col":14},"anchor":{"row":6,"col":14}},{"cursor":{"row":6,"col":16},"anchor":{"row":6,"col":16}},{"cursor":{"row":6,"col":18},"anchor":{"row":6,"col":18}},{"cursor":{"row":6,"col":20},"anchor":{"row":6,"col":20}},{"cursor":{"row":6,"col":22},"anchor":{"row":6,"col":22}},{"cursor":{"row":6,"col":24},"anchor":{"row":6,"col":24}},{"cursor":{"row":6,"col":26},"anchor":{"row":6,"col":26}},{"cursor":{"row":6,"col":28},"anchor":{"row":6,"col":28}},{"cursor":{"row":6,"col":30},"anchor":{"row":6,"col":30}},{"cursor":{"row":6,"col":32},"anchor":{"row":6,"col":32}},{"cursor":{"row":6,"col":34},"anchor":{"row":6,"col":34}},{"cursor":{"row":6,"col":36},"anchor":{"row":6,"col":36}},{"cursor":{"row":6,"col":38},"anchor":{"row":6,"col":38}},{"cursor":{"row":7,"col":0},"anchor":{"row":7,"col":0}},{"cursor":{"row":7,"col":2},"anchor":{"row":7,"col":2}},{"cursor":{"row":7,"col":4},"anchor":{"row":7,"col":4}},{"cursor":{"row":7,"col":6},"anchor":{"row":7,"col":6}},{"cursor":{"row":7,"col":8},"anchor":{"row":7,"col":8}},{"cursor":{"row":7,"col":10},"anchor":{"row":7,"col":10}},{"cursor":{"row":7,"col":12},"anchor":{"row":7,"col":12}},{"cursor":{"row":7,"col":14},"anchor":{"row":7,"col":14}},{"cursor":{"row":7,"col":16},"anchor":{"row":7,"col":16}},{"cursor":{"row":7,"col":18},"anchor":{"row":7,"col":18}},{"cursor":{"row":7,"col":20},"anchor":{"row":7,"col":20}},{"cursor":{"row":7,"col":22},"anchor":{"row":7,"col":22}},{"cursor":{"row":7,"col":24},"anchor":{"row":7,"col":24}},{"cursor":{"row":7,"col":26},"anchor":{"row":7,"col":26}},{"cursor":{"row":7,"col":28},"anchor":{"row":7,"col":28}},{"cursor":{"row":7,"col":30},"anchor":{"row":7,"col":30}},{"cursor":{"row":7,"col":32},"anchor":{"row":7,"col":32}},{"cursor":{"row":7,"col":34},"anchor":{"row":7,"col":34}},{"cursor":{"row":7,"col":36},"anchor":{"row":7,"col":36}},{"cursor":{"row":7,"col":38},"anchor":{"row":7,"col":38}},{"cursor":{"row":8,"col":0},"anchor":{"row":8,"col":0}},{"cursor":{"row":8,"col":2},"anchor":{"row":8,"col":2}},{"cursor":{"row":8,"col":4},"anchor":{"row":8,"col":4}},{"cursor":{"row":8,"col":6},"anchor":{"row":8,"col":6}},{"cursor":{"row":8,"col":8},"anchor":{"row":8,"col":8}},{"cursor":{"row":8,"col":10},"anchor":{"row":8,"col":10}},{"cursor":{"row":8,"col":12},"anchor":{"row":8,"col":12}},{"cursor":{"row":8,"col":14},"anchor":{"row":8,"col":14}},{"cursor":{"row":8,"col":16},"anchor":{"row":8,"col":16}},{"cursor":{"row":8,"col":18},"anchor":{"row":8,"col":18}},{"cursor":{"row":8,"col":20},"anchor":{"row":8,"col":20}},{"cursor":{"row":8,"col":22},"anchor":{"row":8,"col":22}},{"cursor":{"row":8,"col":24},"anchor":{"row":8,"col":24}},{"cursor":{"row":8,"col":26},"anchor":{"row":8,"col":26}},{"cursor":{"row":8,"col":28},"anchor":{"row":8,"col":28}},{"cursor":{"row":8,"col":30},"anchor":{"row":8,"col":30}},{"cursor":{"row":8,"col":32},"anchor":{"row":8,"col":32}},{"cursor":{"row":8,"col":34},"anchor":{"row":8,"col":34}},{"cursor":{"row":8,"col":36},"anchor":{"row":8,"col":36}},{"cursor":{"row":8,"col":38},"anchor":{"row":8,"col":38}},{"cursor":{"row":9,"col":0},"anchor":{"row":9,"col":0}},{"cursor":{"row":9,"col":2},"anchor":{"row":9,"col":2}},{"cursor":{"row":9,"col":4},"anchor":{"row":9,"col":4}},{"cursor":{"row":9,"col":6},"anchor":{"row":9,"col":6}},{"cursor":{"row":9,"col":8},"anchor":{"row":9,"col":8}},{"cursor":{"row":9,"col":10},"anchor":{"row":9,"col":10}},{"cursor":{"row":9,"col":12},"anchor":{"row":9,"col":12}},{"cursor":{"row":9,"col":14},"anchor":{"row":9,"col":14}},{"cursor":{"row":9,"col":16},"anchor":{"row":9,"col":16}},{"cursor":{"row":9,"col":18},"anchor":{"row":9,"col":18}},{"cursor":{"row":9,"col":20},"anchor":{"row":9,"col":20}},{"cursor":{"row":9,"col":22},"anchor":{"row":9,"col":22}},{"cursor":{"row":9,"col":24},"anchor":{"row":9,"col":24}},{"cursor":{"row":9,"col":26},"anchor":{"row":9,"col":26}},{"cursor":{"row":9,"col":28},"anchor":{"row":9,"col":28}},{"cursor":{"row":9,"col":30},"anchor":{"row":9,"col":30}},{"cursor":{"row":9,"col":32},"anchor":{"row":9,"col":32}},{"cursor":{"row":9,"col":34},"anchor":{"row":9,"col":34}},{"cursor":{"row":9,"col":36},"anchor":{"row":9,"col":36}},{"cursor":{"row":9,"col":38},"anchor":{"row":9,"col":38}}],"primary":0} {"name":"sel-many-ranges-edit","text":"xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\nxy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy xy\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":0,"col":8},"anchor":{"row":0,"col":8}},{"cursor":{"row":0,"col":11},"anchor":{"row":0,"col":11}},{"cursor":{"row":0,"col":14},"anchor":{"row":0,"col":14}},{"cursor":{"row":0,"col":17},"anchor":{"row":0,"col":17}},{"cursor":{"row":0,"col":20},"anchor":{"row":0,"col":20}},{"cursor":{"row":0,"col":23},"anchor":{"row":0,"col":23}},{"cursor":{"row":0,"col":26},"anchor":{"row":0,"col":26}},{"cursor":{"row":0,"col":29},"anchor":{"row":0,"col":29}},{"cursor":{"row":0,"col":32},"anchor":{"row":0,"col":32}},{"cursor":{"row":0,"col":35},"anchor":{"row":0,"col":35}},{"cursor":{"row":0,"col":38},"anchor":{"row":0,"col":38}},{"cursor":{"row":0,"col":41},"anchor":{"row":0,"col":41}},{"cursor":{"row":0,"col":44},"anchor":{"row":0,"col":44}},{"cursor":{"row":0,"col":47},"anchor":{"row":0,"col":47}},{"cursor":{"row":0,"col":50},"anchor":{"row":0,"col":50}},{"cursor":{"row":0,"col":53},"anchor":{"row":0,"col":53}},{"cursor":{"row":0,"col":56},"anchor":{"row":0,"col":56}},{"cursor":{"row":0,"col":59},"anchor":{"row":0,"col":59}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":8},"anchor":{"row":1,"col":8}},{"cursor":{"row":1,"col":11},"anchor":{"row":1,"col":11}},{"cursor":{"row":1,"col":14},"anchor":{"row":1,"col":14}},{"cursor":{"row":1,"col":17},"anchor":{"row":1,"col":17}},{"cursor":{"row":1,"col":20},"anchor":{"row":1,"col":20}},{"cursor":{"row":1,"col":23},"anchor":{"row":1,"col":23}},{"cursor":{"row":1,"col":26},"anchor":{"row":1,"col":26}},{"cursor":{"row":1,"col":29},"anchor":{"row":1,"col":29}},{"cursor":{"row":1,"col":32},"anchor":{"row":1,"col":32}},{"cursor":{"row":1,"col":35},"anchor":{"row":1,"col":35}},{"cursor":{"row":1,"col":38},"anchor":{"row":1,"col":38}},{"cursor":{"row":1,"col":41},"anchor":{"row":1,"col":41}},{"cursor":{"row":1,"col":44},"anchor":{"row":1,"col":44}},{"cursor":{"row":1,"col":47},"anchor":{"row":1,"col":47}},{"cursor":{"row":1,"col":50},"anchor":{"row":1,"col":50}},{"cursor":{"row":1,"col":53},"anchor":{"row":1,"col":53}},{"cursor":{"row":1,"col":56},"anchor":{"row":1,"col":56}},{"cursor":{"row":1,"col":59},"anchor":{"row":1,"col":59}},{"cursor":{"row":2,"col":2},"anchor":{"row":2,"col":2}},{"cursor":{"row":2,"col":5},"anchor":{"row":2,"col":5}},{"cursor":{"row":2,"col":8},"anchor":{"row":2,"col":8}},{"cursor":{"row":2,"col":11},"anchor":{"row":2,"col":11}},{"cursor":{"row":2,"col":14},"anchor":{"row":2,"col":14}},{"cursor":{"row":2,"col":17},"anchor":{"row":2,"col":17}},{"cursor":{"row":2,"col":20},"anchor":{"row":2,"col":20}},{"cursor":{"row":2,"col":23},"anchor":{"row":2,"col":23}},{"cursor":{"row":2,"col":26},"anchor":{"row":2,"col":26}},{"cursor":{"row":2,"col":29},"anchor":{"row":2,"col":29}},{"cursor":{"row":2,"col":32},"anchor":{"row":2,"col":32}},{"cursor":{"row":2,"col":35},"anchor":{"row":2,"col":35}},{"cursor":{"row":2,"col":38},"anchor":{"row":2,"col":38}},{"cursor":{"row":2,"col":41},"anchor":{"row":2,"col":41}},{"cursor":{"row":2,"col":44},"anchor":{"row":2,"col":44}},{"cursor":{"row":2,"col":47},"anchor":{"row":2,"col":47}},{"cursor":{"row":2,"col":50},"anchor":{"row":2,"col":50}},{"cursor":{"row":2,"col":53},"anchor":{"row":2,"col":53}},{"cursor":{"row":2,"col":56},"anchor":{"row":2,"col":56}},{"cursor":{"row":2,"col":59},"anchor":{"row":2,"col":59}},{"cursor":{"row":3,"col":2},"anchor":{"row":3,"col":2}},{"cursor":{"row":3,"col":5},"anchor":{"row":3,"col":5}},{"cursor":{"row":3,"col":8},"anchor":{"row":3,"col":8}},{"cursor":{"row":3,"col":11},"anchor":{"row":3,"col":11}},{"cursor":{"row":3,"col":14},"anchor":{"row":3,"col":14}},{"cursor":{"row":3,"col":17},"anchor":{"row":3,"col":17}},{"cursor":{"row":3,"col":20},"anchor":{"row":3,"col":20}},{"cursor":{"row":3,"col":23},"anchor":{"row":3,"col":23}},{"cursor":{"row":3,"col":26},"anchor":{"row":3,"col":26}},{"cursor":{"row":3,"col":29},"anchor":{"row":3,"col":29}},{"cursor":{"row":3,"col":32},"anchor":{"row":3,"col":32}},{"cursor":{"row":3,"col":35},"anchor":{"row":3,"col":35}},{"cursor":{"row":3,"col":38},"anchor":{"row":3,"col":38}},{"cursor":{"row":3,"col":41},"anchor":{"row":3,"col":41}},{"cursor":{"row":3,"col":44},"anchor":{"row":3,"col":44}},{"cursor":{"row":3,"col":47},"anchor":{"row":3,"col":47}},{"cursor":{"row":3,"col":50},"anchor":{"row":3,"col":50}},{"cursor":{"row":3,"col":53},"anchor":{"row":3,"col":53}},{"cursor":{"row":3,"col":56},"anchor":{"row":3,"col":56}},{"cursor":{"row":3,"col":59},"anchor":{"row":3,"col":59}},{"cursor":{"row":4,"col":2},"anchor":{"row":4,"col":2}},{"cursor":{"row":4,"col":5},"anchor":{"row":4,"col":5}},{"cursor":{"row":4,"col":8},"anchor":{"row":4,"col":8}},{"cursor":{"row":4,"col":11},"anchor":{"row":4,"col":11}},{"cursor":{"row":4,"col":14},"anchor":{"row":4,"col":14}},{"cursor":{"row":4,"col":17},"anchor":{"row":4,"col":17}},{"cursor":{"row":4,"col":20},"anchor":{"row":4,"col":20}},{"cursor":{"row":4,"col":23},"anchor":{"row":4,"col":23}},{"cursor":{"row":4,"col":26},"anchor":{"row":4,"col":26}},{"cursor":{"row":4,"col":29},"anchor":{"row":4,"col":29}},{"cursor":{"row":4,"col":32},"anchor":{"row":4,"col":32}},{"cursor":{"row":4,"col":35},"anchor":{"row":4,"col":35}},{"cursor":{"row":4,"col":38},"anchor":{"row":4,"col":38}},{"cursor":{"row":4,"col":41},"anchor":{"row":4,"col":41}},{"cursor":{"row":4,"col":44},"anchor":{"row":4,"col":44}},{"cursor":{"row":4,"col":47},"anchor":{"row":4,"col":47}},{"cursor":{"row":4,"col":50},"anchor":{"row":4,"col":50}},{"cursor":{"row":4,"col":53},"anchor":{"row":4,"col":53}},{"cursor":{"row":4,"col":56},"anchor":{"row":4,"col":56}},{"cursor":{"row":4,"col":59},"anchor":{"row":4,"col":59}},{"cursor":{"row":5,"col":2},"anchor":{"row":5,"col":2}},{"cursor":{"row":5,"col":5},"anchor":{"row":5,"col":5}},{"cursor":{"row":5,"col":8},"anchor":{"row":5,"col":8}},{"cursor":{"row":5,"col":11},"anchor":{"row":5,"col":11}},{"cursor":{"row":5,"col":14},"anchor":{"row":5,"col":14}},{"cursor":{"row":5,"col":17},"anchor":{"row":5,"col":17}},{"cursor":{"row":5,"col":20},"anchor":{"row":5,"col":20}},{"cursor":{"row":5,"col":23},"anchor":{"row":5,"col":23}},{"cursor":{"row":5,"col":26},"anchor":{"row":5,"col":26}},{"cursor":{"row":5,"col":29},"anchor":{"row":5,"col":29}},{"cursor":{"row":5,"col":32},"anchor":{"row":5,"col":32}},{"cursor":{"row":5,"col":35},"anchor":{"row":5,"col":35}},{"cursor":{"row":5,"col":38},"anchor":{"row":5,"col":38}},{"cursor":{"row":5,"col":41},"anchor":{"row":5,"col":41}},{"cursor":{"row":5,"col":44},"anchor":{"row":5,"col":44}},{"cursor":{"row":5,"col":47},"anchor":{"row":5,"col":47}},{"cursor":{"row":5,"col":50},"anchor":{"row":5,"col":50}},{"cursor":{"row":5,"col":53},"anchor":{"row":5,"col":53}},{"cursor":{"row":5,"col":56},"anchor":{"row":5,"col":56}},{"cursor":{"row":5,"col":59},"anchor":{"row":5,"col":59}},{"cursor":{"row":6,"col":2},"anchor":{"row":6,"col":2}},{"cursor":{"row":6,"col":5},"anchor":{"row":6,"col":5}},{"cursor":{"row":6,"col":8},"anchor":{"row":6,"col":8}},{"cursor":{"row":6,"col":11},"anchor":{"row":6,"col":11}},{"cursor":{"row":6,"col":14},"anchor":{"row":6,"col":14}},{"cursor":{"row":6,"col":17},"anchor":{"row":6,"col":17}},{"cursor":{"row":6,"col":20},"anchor":{"row":6,"col":20}},{"cursor":{"row":6,"col":23},"anchor":{"row":6,"col":23}},{"cursor":{"row":6,"col":26},"anchor":{"row":6,"col":26}},{"cursor":{"row":6,"col":29},"anchor":{"row":6,"col":29}},{"cursor":{"row":6,"col":32},"anchor":{"row":6,"col":32}},{"cursor":{"row":6,"col":35},"anchor":{"row":6,"col":35}},{"cursor":{"row":6,"col":38},"anchor":{"row":6,"col":38}},{"cursor":{"row":6,"col":41},"anchor":{"row":6,"col":41}},{"cursor":{"row":6,"col":44},"anchor":{"row":6,"col":44}},{"cursor":{"row":6,"col":47},"anchor":{"row":6,"col":47}},{"cursor":{"row":6,"col":50},"anchor":{"row":6,"col":50}},{"cursor":{"row":6,"col":53},"anchor":{"row":6,"col":53}},{"cursor":{"row":6,"col":56},"anchor":{"row":6,"col":56}},{"cursor":{"row":6,"col":59},"anchor":{"row":6,"col":59}},{"cursor":{"row":7,"col":2},"anchor":{"row":7,"col":2}},{"cursor":{"row":7,"col":5},"anchor":{"row":7,"col":5}},{"cursor":{"row":7,"col":8},"anchor":{"row":7,"col":8}},{"cursor":{"row":7,"col":11},"anchor":{"row":7,"col":11}},{"cursor":{"row":7,"col":14},"anchor":{"row":7,"col":14}},{"cursor":{"row":7,"col":17},"anchor":{"row":7,"col":17}},{"cursor":{"row":7,"col":20},"anchor":{"row":7,"col":20}},{"cursor":{"row":7,"col":23},"anchor":{"row":7,"col":23}},{"cursor":{"row":7,"col":26},"anchor":{"row":7,"col":26}},{"cursor":{"row":7,"col":29},"anchor":{"row":7,"col":29}},{"cursor":{"row":7,"col":32},"anchor":{"row":7,"col":32}},{"cursor":{"row":7,"col":35},"anchor":{"row":7,"col":35}},{"cursor":{"row":7,"col":38},"anchor":{"row":7,"col":38}},{"cursor":{"row":7,"col":41},"anchor":{"row":7,"col":41}},{"cursor":{"row":7,"col":44},"anchor":{"row":7,"col":44}},{"cursor":{"row":7,"col":47},"anchor":{"row":7,"col":47}},{"cursor":{"row":7,"col":50},"anchor":{"row":7,"col":50}},{"cursor":{"row":7,"col":53},"anchor":{"row":7,"col":53}},{"cursor":{"row":7,"col":56},"anchor":{"row":7,"col":56}},{"cursor":{"row":7,"col":59},"anchor":{"row":7,"col":59}},{"cursor":{"row":8,"col":2},"anchor":{"row":8,"col":2}},{"cursor":{"row":8,"col":5},"anchor":{"row":8,"col":5}},{"cursor":{"row":8,"col":8},"anchor":{"row":8,"col":8}},{"cursor":{"row":8,"col":11},"anchor":{"row":8,"col":11}},{"cursor":{"row":8,"col":14},"anchor":{"row":8,"col":14}},{"cursor":{"row":8,"col":17},"anchor":{"row":8,"col":17}},{"cursor":{"row":8,"col":20},"anchor":{"row":8,"col":20}},{"cursor":{"row":8,"col":23},"anchor":{"row":8,"col":23}},{"cursor":{"row":8,"col":26},"anchor":{"row":8,"col":26}},{"cursor":{"row":8,"col":29},"anchor":{"row":8,"col":29}},{"cursor":{"row":8,"col":32},"anchor":{"row":8,"col":32}},{"cursor":{"row":8,"col":35},"anchor":{"row":8,"col":35}},{"cursor":{"row":8,"col":38},"anchor":{"row":8,"col":38}},{"cursor":{"row":8,"col":41},"anchor":{"row":8,"col":41}},{"cursor":{"row":8,"col":44},"anchor":{"row":8,"col":44}},{"cursor":{"row":8,"col":47},"anchor":{"row":8,"col":47}},{"cursor":{"row":8,"col":50},"anchor":{"row":8,"col":50}},{"cursor":{"row":8,"col":53},"anchor":{"row":8,"col":53}},{"cursor":{"row":8,"col":56},"anchor":{"row":8,"col":56}},{"cursor":{"row":8,"col":59},"anchor":{"row":8,"col":59}},{"cursor":{"row":9,"col":2},"anchor":{"row":9,"col":2}},{"cursor":{"row":9,"col":5},"anchor":{"row":9,"col":5}},{"cursor":{"row":9,"col":8},"anchor":{"row":9,"col":8}},{"cursor":{"row":9,"col":11},"anchor":{"row":9,"col":11}},{"cursor":{"row":9,"col":14},"anchor":{"row":9,"col":14}},{"cursor":{"row":9,"col":17},"anchor":{"row":9,"col":17}},{"cursor":{"row":9,"col":20},"anchor":{"row":9,"col":20}},{"cursor":{"row":9,"col":23},"anchor":{"row":9,"col":23}},{"cursor":{"row":9,"col":26},"anchor":{"row":9,"col":26}},{"cursor":{"row":9,"col":29},"anchor":{"row":9,"col":29}},{"cursor":{"row":9,"col":32},"anchor":{"row":9,"col":32}},{"cursor":{"row":9,"col":35},"anchor":{"row":9,"col":35}},{"cursor":{"row":9,"col":38},"anchor":{"row":9,"col":38}},{"cursor":{"row":9,"col":41},"anchor":{"row":9,"col":41}},{"cursor":{"row":9,"col":44},"anchor":{"row":9,"col":44}},{"cursor":{"row":9,"col":47},"anchor":{"row":9,"col":47}},{"cursor":{"row":9,"col":50},"anchor":{"row":9,"col":50}},{"cursor":{"row":9,"col":53},"anchor":{"row":9,"col":53}},{"cursor":{"row":9,"col":56},"anchor":{"row":9,"col":56}},{"cursor":{"row":9,"col":59},"anchor":{"row":9,"col":59}}],"primary":0} +{"name":"keep-regex","text":"foo bar baz\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":4},"sels":[{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":4}},{"cursor":{"row":0,"col":10},"anchor":{"row":0,"col":8}}],"primary":0} +{"name":"remove-regex","text":"foo bar baz\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} +{"name":"keep-regex-none","text":"foo bar\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":4}}],"primary":0} +{"name":"keep-regex-primary","text":"a1 b2 a3\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":7},"anchor":{"row":0,"col":6}}],"primary":0} +{"name":"keep-regex-esc","text":"foo bar\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":4}}],"primary":0} +{"name":"keep-regex-lines","text":"foo\nbar\nbaz\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":0}},{"cursor":{"row":2,"col":2},"anchor":{"row":2,"col":0}}],"primary":0} +{"name":"ensure-forward","text":"ab cd\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":3}}],"primary":0} +{"name":"ensure-forward-one","text":"abc\n","mode":"select","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} {"name":"msel-align-basic","text":"a =1\nbbb=2\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":0} {"name":"msel-align-columns","text":"a ,b,c\nccc,d,e\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}}],"primary":0} {"name":"msel-align-uneven","text":"a ,b\nccc,d,eee,f\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}}],"primary":0} -- cgit v1.3 From 0de2e7f04977b22df3f7b6ab1690eb8a6c905cd4 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 13:57:28 -0300 Subject: Paint tags, notices and headers once, and join them into the grid A pane's tag rows, each notice chip and the workspace and column tags are painted once, as their tag layers, and the grid's cells for them are copies of those layers (joinCells): the second paint of every tag, the grid's own notice pass and paintHeaders' grid text, hover, selection and caret are gone, and so is renderTagLayers. The grid's caret for a tag, a header or a prompt is the layer's, projected to grid columns. A wide glyph cut by the edge of the copy is left as the band's blank, as the grid's print never drew half of one (a test proves it, and fails without the re-clip). Where the two copies disagreed, the grid's behaviour is kept, so every snapshot golden is byte-identical: - a notice highlighted the hovered word only as a layer; now it never does; - a column tag's hover was suppressed during a column move only on the grid; now in both; - a header under edit showed the hovered word only on the grid; now in both, under the selection; - a notice's leading padding carried the band's ink only as a layer; the grid copy keeps it blank; - a notice whose band is wider in tagline pitch than in grid cells (a GUI core) now shows the band's right end on the grid, where its text is, and its prompt caret there. A sweep begun on a tag and dragged into the body still shows on the body rows it crossed, on the grid only (paintPointerSelection's from_row). --- src/body_layer.zig | 8 +- src/draw.zig | 565 +++++++++++++++++++++++++---------------------------- src/surface.zig | 4 +- 3 files changed, 271 insertions(+), 306 deletions(-) diff --git a/src/body_layer.zig b/src/body_layer.zig index 2b6ae773..e1a45058 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -228,6 +228,8 @@ pub fn paintPointerSelection( tag_y: u16, body_y: u16, sl: Pane.Sel, + /// Rows of the selection above this are left alone. + from_row: u16, bg: [3]u8, fg: ?[3]u8, ) void { @@ -238,7 +240,7 @@ pub fn paintPointerSelection( const c1 = @max(0, @max(sl.c0, sl.c1)); var row: u16 = 0; while (row < r.h) : (row += 1) { - if (@as(i32, row) < r0 or @as(i32, row) > r1) continue; + if (@as(i32, row) < r0 or @as(i32, row) > r1 or row < from_row) continue; const sy = if (row < pane.tag_rows) tag_y + row else body_y + row - pane.tag_rows; // File line numbers occupy a gutter only in the body. The tag is // row zero in Sel space and starts at its real first text cell. @@ -363,7 +365,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane if (preview.file_word) |word| panes.File.paintWordSelection(p, s, pane, r, word.row, word.lo, word.hi, preview_bg) else if (preview.sel) |sel| - paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); + paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, 0, preview_bg, null); } } const modal_hover = if (p.look_hover_preview) |preview| @@ -376,7 +378,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane // A select sweep over the tag is the tag's selection, painted there // in the tag's own selection colours. if (b == pardes.sel_slot and @min(sl.r0, sl.r1) < pane.tag_rows) continue; - paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sl, sel_btn[b], th.sel_fg); + paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sl, 0, sel_btn[b], th.sel_fg); } // modal line selection (helix `x`): whole body rows, normal mode only if ((pane.body.mode == .normal or modal_hover) and pane.body.msel.active) { diff --git a/src/draw.zig b/src/draw.zig index e87e0511..b596fc22 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -4,6 +4,7 @@ //! chips, the workspace and column tags, the tag layers, drag overlays, the //! debug box, then the presentation's tracks and the character effects. const std = @import("std"); +const vaxis = @import("vaxis"); const pardes = @import("pardes.zig"); const layout = @import("layout.zig"); const animation = @import("animation.zig"); @@ -179,7 +180,6 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { s.previous_tag_layers = &.{}; s.cell_diffs = &.{}; const chrome = p.chromeTheme(); - const th = p.theme(); place(p, s); // Separators have their own role; a scrollbar need not frame the page. const whole = s.regions[0].rect; @@ -194,69 +194,12 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { try renderPane(p, s, arena, pane, p.rects[id], id, id == p.active); try body_layer.renderBodyLayer(p, arena, pane, p.rects[id], id); } + // Each notice once, as a tag layer, and the grid's chip a copy of it. for (s.regionList()) |region| { - if (region.kind != .notice) continue; - const id: usize = region.owner; - const pane = p.panes[id].?; - const i: usize = region.notice; - const kind = pane.notices.kinds[i]; - const r = p.rects[id]; - const tx = r.x + config.GUTTER; - const tw = r.w - config.GUTTER; - // Painted the way the sticky context headers above them are: the - // tagline font and the tagline's own colours, so a notice reads as - // a band belonging to the pane rather than as a line of body text - // that happens to sit at the bottom of it. - const msg_style: CellStyle = .{ - .fg = .{ .rgb = chrome.tag_fg }, - .bg = .{ .rgb = chrome.tag_bg }, - .font_role = .tagline, - }; - // An OVERLAY over the body's top rows, at the right edge (placed - // by `place`) -- not a row taken out of the body the way a - // tree-sitter context header is. - var leader_buf: [16]u8 = undefined; - { - const row = region.rect.y; - // Every notice is painted on the canonical grid as well as - // emitted as a tag layer: the grid is what a terminal client - // draws, and a band it cannot see is a message it never gets. - // The GUI skips grid cells a tag layer covers, so nothing is - // drawn twice there. - const text = Messages.noticeText(p, pane, i, &leader_buf); - const chip = region.rect.w; - const cx = region.rect.x; - // A message coming or going blends toward the page it sits - // on; a grid cannot move it by less than a row. - var style = msg_style; - if (Messages.noticeLife(pane, i)) |life| { - // No page colour to fade to (a terminal's own default): - // the text dissolves into its chip instead. - const alpha = Messages.messageMotion(p, life).alpha; - const page = th.bg orelse chrome.tag_bg; - style.fg = .{ .rgb = Messages.blendRgb(chrome.tag_fg, page, alpha) }; - style.bg = .{ .rgb = Messages.blendRgb(chrome.tag_bg, page, alpha) }; - } - s.fill(cx, row, chip, 1, .{ .bg = style.bg, .font_role = .tagline }); - // One column short of the chip's right edge: a prompt's caret - // sits AFTER its last character the moment it opens, and flush - // right would put it past the pane, off the grid, and past what - // the detached wire will encode -- which drops every frame for - // as long as the prompt is up. - const printed = Messages.printRight(s, cx, row, chip -| 1, text, style); - if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue; - // The cursor follows the text to wherever it landed; a caret - // in the part a narrow band dropped has nowhere to be. - const prompt_col = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); - if (prompt_col < printed.dropped) continue; - const caret = printed.left + (prompt_col - printed.dropped); - if (caret < tx + tw) - s.cursor = .{ .x = @intCast(caret), .y = row, .bar = pane.input.mode == .insert }; - } + if (region.kind == .notice) try joinNotice(p, s, arena, region); } - try paintHeaders(p, s); - try renderTagLayers(p, arena); + try paintHeaders(p, s, arena); switch (p.drag) { .border_v => |d| { @@ -526,7 +469,7 @@ fn renderPane(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: .tty => config.tag_tty, }, box_style); - try paintPaneTag(p, s, arena, pane, r, id, active, p.tagCapacity(r.w -| TAG_TEXT_INSET)); + try joinPaneTag(p, s, arena, pane, r, id, active); // Folded panes retain their body state, but neither body pixels nor // its cursor belong outside the one-row tag rectangle. @@ -554,121 +497,85 @@ fn renderPane(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: try body_layer.renderBody(p, s, arena, pane, r, id, active, rail.*); } -/// The workspace and column tags as the grid shows them: their text, the -/// word under the pointer and, in the one being edited, its selection and -/// caret. Their tag layers are drawn by renderTagLayers. -pub fn paintHeaders(p: *Pardes, s: *Surface) !void { +/// The workspace and column tags: each line a tag layer, and the grid's +/// rows copies of those; the column grips, which are the grid's alone. +pub fn paintHeaders(p: *Pardes, s: *Surface, arena: std.mem.Allocator) !void { const chrome = p.chromeTheme(); - const th = p.theme(); // global tagbar: full width, top rows — unless a native shell has // taken its commands over, in which case the rows are not ours to draw tagline.revealHeader(p); - const global = tagline.topbar(p); - const global_rows = p.topBarHeight(); - const global_scroll = if (p.header_focus and p.header_column == null) p.header_scroll else 0; - const global_top = if (p.header_focus and p.header_column == null) p.header_top else 0; - if (p.settings.workspace_tag) { - s.fill(0, 0, s.cols, global_rows, .{ - .bg = .{ .rgb = chrome.tag_bg }, - .font_role = .tagline, - }); - for (0..global_rows) |row| { - const line = modal.lineSlice(global, row + global_top); - _ = s.print(0, @intCast(row), s.cols, line[panes.File.rawAtDisplay(line, global_scroll)..], .{ - .fg = .{ .rgb = chrome.tag_fg }, - .bg = .{ .rgb = chrome.tag_bg }, - .font_role = .tagline, - }); - } - } - if (p.pointer_inside and p.hover_row < global_rows) { - const bar = modal.lineSlice(global, p.hover_row + global_top); - if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col + global_scroll))) |bounds| { - var col = panes.File.rawDisplayCol(bar, bounds.lo) -| global_scroll; - const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| global_scroll; - while (col < hi and col < s.cols) : (col += 1) { - const cell = s.at(@intCast(col), p.hover_row); - cell.default = false; - cell.style.bg = .{ .rgb = th.sel_bg }; - cell.style.fg = .{ .rgb = th.sel_fg }; - } + if (s.region(.workspace_tag, Region.none)) |region| { + const top = if (p.header_focus and p.header_column == null) p.header_top else 0; + for (0..region.rect.h) |line| { + const index = if (line == 0) MAX_PANES + MAX_COLS else HEADER_LINE_LAYER_BASE + MAX_COLS * (MAX_TAG_ROWS - 1) + line - 1; + try renderHeaderLayer(p, arena, index, .workspace, 0, @intCast(line), .{ .x = region.rect.x, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w, .h = TOPBAR_H }, modal.lineSlice(tagline.topbar(p), line + top), chrome.tag_bg, chrome.tag_fg); + joinHeaderLine(s, &s.tag_layers[index], chrome.tag_fg); } } const column_rows = p.columnBarHeight(); if (column_rows > 0) for (0..p.ncol) |c| { - const grip = @min(config.GUTTER, p.col_w[c]); - const inset = @min(TAG_TEXT_INSET, p.col_w[c]); - const x = p.col_x[c] + inset; - const w = p.col_w[c] - inset; - const y0 = global_rows; - if (p.col_w[c] == 0 or p.col_x[c] >= s.cols) continue; - const active_col = if (layout.findPane(p, p.active)) |f| f.col == c else false; - const bg = if (active_col and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; - const fg = if (active_col and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; - // A grip is muted until it is grabbed; then it lights, as a pane's - // box does for the active pane, so the drag shows what it holds. - const grabbed = p.drag == .column_move and layout.columnBySerial(p, p.drag.column_move.serial) == c; - const grip_bg = if (grabbed) chrome.column_box else chrome.column_box_dim; - const lum = (@as(u16, grip_bg[0]) * 3 + @as(u16, grip_bg[1]) * 6 + @as(u16, grip_bg[2])) / 10; - const ink: [3]u8 = if (lum > 140) .{ 0, 0, 0 } else .{ 255, 255, 255 }; - const grip_style: CellStyle = .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline }; - s.fill(p.col_x[c], y0, grip, column_rows, grip_style); - s.fill(p.col_x[c] + grip, y0, inset - grip, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); - - s.fill(x, y0, w, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); - const bar_scroll = if (p.header_focus and p.header_column == c) p.header_scroll else 0; - const bar_top = if (p.header_focus and p.header_column == c) p.header_top else 0; - const tag = tagline.columnTag(p, c); - for (0..column_rows) |row| { - const line = modal.lineSlice(tag, row + bar_top); - _ = s.print(x, y0 + @as(u16, @intCast(row)), w, line[panes.File.rawAtDisplay(line, bar_scroll)..], .{ - .fg = .{ .rgb = fg }, - .bg = .{ .rgb = bg }, - .font_role = .tagline, - }); + const region = s.region(.column_tag, c) orelse continue; + const active = if (layout.findPane(p, p.active)) |found| found.col == c and p.settings.focus_tint else false; + const bg = if (active) chrome.tag_active_bg else chrome.tag_bg; + const fg = if (active) chrome.tag_active_fg else chrome.tag_fg; + if (p.col_x[c] < s.cols) { + // A grip is muted until it is grabbed; then it lights, as a + // pane's box does for the active pane, so the drag shows what it + // holds. + const grip = @min(config.GUTTER, p.col_w[c]); + const inset = @min(TAG_TEXT_INSET, p.col_w[c]); + const grabbed = p.drag == .column_move and layout.columnBySerial(p, p.drag.column_move.serial) == c; + const grip_bg = if (grabbed) chrome.column_box else chrome.column_box_dim; + const lum = (@as(u16, grip_bg[0]) * 3 + @as(u16, grip_bg[1]) * 6 + @as(u16, grip_bg[2])) / 10; + const ink: [3]u8 = if (lum > 140) .{ 0, 0, 0 } else .{ 255, 255, 255 }; + s.fill(p.col_x[c], region.rect.y, grip, column_rows, .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline }); + s.fill(p.col_x[c] + grip, region.rect.y, inset - grip, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); } - // A carried column's pointer is aiming at a place, not a word. - if (p.pointer_inside and p.drag != .column_move and p.hover_row >= y0 and p.hover_row < y0 + column_rows and p.hover_col >= x and p.hover_col < x + w) { - const bar = modal.lineSlice(tag, p.hover_row - y0 + bar_top); - if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col - x + bar_scroll))) |bounds| { - var col = panes.File.rawDisplayCol(bar, bounds.lo) -| bar_scroll; - const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| bar_scroll; - while (col < hi and col < w) : (col += 1) { - const cell = s.at(x + @as(u16, @intCast(col)), p.hover_row); - cell.style.bg = .{ .rgb = th.sel_bg }; - cell.style.fg = .{ .rgb = th.sel_fg }; - } - } + const top = if (p.header_focus and p.header_column == c) p.header_top else 0; + for (0..column_rows) |line| { + const index = if (line == 0) MAX_PANES + c else HEADER_LINE_LAYER_BASE + c * (MAX_TAG_ROWS - 1) + line - 1; + try renderHeaderLayer(p, arena, index, .column, @intCast(c), @intCast(line), .{ .x = region.rect.x, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w, .h = COLBAR_H }, modal.lineSlice(tagline.columnTag(p, c), line + top), bg, fg); + if (p.col_x[c] < s.cols) joinHeaderLine(s, &s.tag_layers[index], fg); + // Laid out at its full width; only shown short of the rail. + s.tag_layers[index].viewport.w -= region.clip; } }; - if (tagline.focusedHeader(p)) |t| { - const text = try edit.flatSurface(p, t); - const x = if (p.header_column) |c| p.col_x[c] + TAG_TEXT_INSET else 0; - const w = if (p.header_column) |c| p.col_w[c] -| TAG_TEXT_INSET else s.cols; - const y: u16 = if (p.header_column != null) global_rows else 0; - const rows = if (p.header_column != null) column_rows else global_rows; - for (0..rows) |row| { - const bar = modal.lineSlice(text, row + p.header_top); - const span = tagline.headerSelection(t, text, row + p.header_top) orelse continue; - var col = panes.File.rawDisplayCol(bar, span.lo) -| p.header_scroll; - const hi = panes.File.rawDisplayCol(bar, span.hi) -| p.header_scroll; - while (col < hi and col < w) : (col += 1) { - const cell = s.at(x + @as(u16, @intCast(col)), y + @as(u16, @intCast(row))); - cell.style.fg = .{ .rgb = th.sel_fg }; - cell.style.bg = .{ .rgb = th.sel_bg }; - } - } - // A prompt answering for the header has the caret on its band. - const asking = if (p.panes[p.active]) |active| active.prompt != .none else false; - const cur: usize = @intCast(@max(0, t.cur_row)); - if (!asking and cur >= p.header_top and cur < p.header_top + rows) { - const bar = modal.lineSlice(text, cur); - const caret = panes.File.rawDisplayCol(bar, @min(@as(usize, @intCast(@max(0, t.cur_col))), bar.len)) -| p.header_scroll; - if (caret < w) s.cursor = .{ .x = x + @as(u16, @intCast(caret)), .y = y + @as(u16, @intCast(cur - p.header_top)), .bar = t.mode == .insert }; - } +} + +/// Copy a header line's layer into the grid under it, and its caret. +fn joinHeaderLine(s: *Surface, layer: *const pardes.TagLayer, fg: [3]u8) void { + const v = layer.viewport; + const w = @min(v.w, s.cols -| v.x); + joinCells(s, v.x, v.y, layer.cells[0..layer.cols], 0, w, .{ .rgb = fg }); + if (layer.cursor) |caret| { + if (caret.col < v.w) s.cursor = .{ .x = v.x + caret.col, .y = v.y, .bar = caret.bar }; } } +/// Copy `w` cells of a layer row, from `from`, into the grid at (x, y) -- +/// the one place the grid gets a tag's, a header's or a notice's cells. A +/// wide glyph cut by either end is left as the band's blank, the way the +/// grid's own print never drew half of one: no head without its spacer, +/// no spacer without its head. `ink` is the band's text colour, which a +/// blank cell does not carry. +fn joinCells(s: *Surface, x: u16, y: u16, cells: []const Cell, from: usize, w: u16, ink: Color) void { + // A tagline wider than the body's cells can lay out fewer than `w`. + const n: u16 = @intCast(@min(w, cells.len -| from)); + if (n == 0 or y >= s.rows) return; + const row = s.cells[@as(usize, y) * s.cols ..][0..s.cols]; + @memcpy(row[x..][0..n], cells[from..][0..n]); + const blank = struct { + fn of(cell: *Cell, band_ink: Color) void { + cell.text[0] = ' '; + cell.len = 1; + if (std.meta.eql(cell.style.fg, band_ink)) cell.style.fg = .default; + } + }.of; + const last = from + n - 1; + if (last + 1 < cells.len and !cells[last + 1].default and cells[last + 1].len == 0) blank(&row[x + n - 1], ink); + if (!cells[from].default and cells[from].len == 0) blank(&row[x], ink); +} + fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { lo: usize, hi: usize } { if (pane.isTerminal()) { var words = std.mem.tokenizeAny(u8, tag[prefix_len..], " \t\r\n"); @@ -694,130 +601,6 @@ fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { return .{ .lo = start + basename, .hi = start + name.len }; } -pub fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { - const chrome = p.chromeTheme(); - for (p.panes, 0..) |slot, id| { - const pane = slot orelse continue; - const r = p.rects[id]; - if (r.w <= TAG_TEXT_INSET or r.h == 0) continue; - const region = p.surface.region(.tag, id).?; - const tag = try tagline.tagText(p, arena, pane); - const rows = pane.tag_rows; - // As wide as the widest line it shows, and never narrower than - // its viewport. - var widest: usize = 0; - var split = std.mem.splitScalar(u8, tag, '\n'); - for (0..pane.tag_top) |_| _ = split.next(); - for (0..rows) |_| widest = @max(widest, panes.File.displayWidth(split.next() orelse "")); - const columns: u16 = @intCast(@min(std.math.maxInt(u16) - TAG_TEXT_INSET, @max(p.tagCapacity(r.w - TAG_TEXT_INSET), widest + 1))); - const pitch = @as(usize, columns) + TAG_TEXT_INSET; - const storage = try arena.alloc(Cell, pitch * rows); - @memset(storage, .{}); - var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = rows, .cells = storage }; - try paintPaneTag(p, &temporary, arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, id == p.active, p.tagCapacity(r.w - TAG_TEXT_INSET)); - // A layer to a line: the first in the pane's own slot, the rest - // in the slots kept for the lines of taller tags. - for (0..rows) |line| { - const layer = &p.surface.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1]; - if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); - @memcpy(layer.cells, storage[line * pitch + TAG_TEXT_INSET ..][0..columns]); - layer.kind = .pane; - layer.id = @intCast(id); - layer.serial = pane.serial; - layer.line = @intCast(line); - layer.viewport = .{ .x = region.rect.x + TAG_GAP, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w - TAG_GAP - region.clip, .h = BOX_H }; - layer.cols = columns; - layer.bg = if (id == p.active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; - layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor| - (if (cursor.y == line) .{ .col = cursor.x - TAG_TEXT_INSET, .bar = cursor.bar } else null) - else - null; - } - } - // With the workspace tag row handed to native chrome there is no - // workspace layer to render; the menu bar carries its commands. - if (p.settings.workspace_tag) { - for (0..p.topBarHeight()) |line| { - const index = if (line == 0) MAX_PANES + MAX_COLS else HEADER_LINE_LAYER_BASE + MAX_COLS * (MAX_TAG_ROWS - 1) + line - 1; - const top = if (p.header_focus and p.header_column == null) p.header_top else 0; - try renderHeaderLayer(p, arena, index, .workspace, 0, @intCast(line), .{ .x = 0, .y = @intCast(line), .w = p.screen_w, .h = TOPBAR_H }, modal.lineSlice(tagline.topbar(p), line + top), chrome.tag_bg, chrome.tag_fg); - } - } - // Each notice is its own tagline-shaped chip over the top of the body. - // Going through the same layer machinery the pane and column tags use - // is what gives it the tagline's height, its small-font metrics, the - // band offset and the border -- none of which a body-grid row can have - // just by wearing a tagline font. The geometry is the one the grid - // pass already chose and recorded, so the two cannot disagree and the - // GUI's "skip the cells a tag layer covers" leaves no stub behind. - // A prompt is one of them too: left on the grid, a pixel shell drew - // its tagline glyphs one to a BODY cell, spaced out like a banner. - for (p.surface.regionList()) |region| { - if (region.kind != .notice) continue; - const id: usize = region.owner; - const pane = p.panes[id].?; - const i: usize = region.notice; - const kind = pane.notices.kinds[i]; - const r = p.rects[id]; - if (r.w <= TAG_TEXT_INSET) continue; - var leader_buf: [16]u8 = undefined; - { - const text = Messages.noticeText(p, pane, i, &leader_buf); - if (text.len == 0) continue; - const chip = region.rect.w; - // Right aligned inside the chip, a blank cell short of its - // edge: the same one the grid pass leaves for a prompt caret. - // Wider than the chip, the text loses its head, as on the grid. - const room = p.tagCapacity(chip) -| 1; - const shown = panes.File.displayWidth(text); - var kept = text; - if (shown > room) { - var start = panes.File.rawAtDisplay(text, shown - room); - if (panes.File.rawDisplayCol(text, start) < shown - room) start = panes.File.rawAtDisplay(text, shown - room + 1); - kept = text[start..]; - } - const pad = room -| panes.File.displayWidth(kept); - const line = try arena.alloc(u8, pad + kept.len); - @memset(line[0..pad], ' '); - @memcpy(line[pad..], kept); - // A pixel shell slides the message in as it fades up, and - // fades it on the way out. - const motion: Messages.MessageMotion = if (Messages.noticeLife(pane, i)) |life| Messages.messageMotion(p, life) else .{}; - const fade = motion.alpha; - const page = p.theme().bg orelse chrome.tag_bg; - const layer_index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i; - try renderHeaderLayer(p, arena, layer_index, .notice, @intCast(id), 0, .{ - .x = region.rect.x, - .y = region.rect.y, - .w = chip, - .h = BOX_H, - }, line, Messages.blendRgb(chrome.tag_bg, page, fade), Messages.blendRgb(chrome.tag_fg, page, fade)); - // Laid out at its full width; only shown short of the rail. - p.surface.tag_layers[layer_index].viewport.w -= region.clip; - p.surface.tag_layers[layer_index].slide = motion.slide; - p.surface.tag_layers[layer_index].fade = fade; - // The caret rides the band, where the prompt's glyphs are, - // at the same place in the text the grid pass puts it. - if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue; - const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); - const dropped = shown - panes.File.displayWidth(kept); - if (typed < dropped) continue; - p.surface.tag_layers[layer_index].cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.input.mode == .insert }; - } - } - if (p.columnBarHeight() > 0) for (0..p.ncol) |column| { - if (p.col_w[column] == 0) continue; - const active = if (layout.findPane(p, p.active)) |found| found.col == column and p.settings.focus_tint else false; - const top = if (p.header_focus and p.header_column == column) p.header_top else 0; - const region = p.surface.region(.column_tag, column) orelse continue; - for (0..p.columnBarHeight()) |line| { - const index = if (line == 0) MAX_PANES + column else HEADER_LINE_LAYER_BASE + column * (MAX_TAG_ROWS - 1) + line - 1; - try renderHeaderLayer(p, arena, index, .column, @intCast(column), @intCast(line), .{ .x = region.rect.x, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w, .h = COLBAR_H }, modal.lineSlice(tagline.columnTag(p, column), line + top), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg); - p.surface.tag_layers[index].viewport.w -= region.clip; - } - }; -} - /// One row of a header or a notice band as a tag layer: `text` is line /// `line` of what it shows. fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: TagKind, id: u16, line: u16, viewport: Rect, text: []const u8, bg: [3]u8, fg: [3]u8) !void { @@ -851,19 +634,11 @@ fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: T const scroll = if (editing != null) p.header_scroll else 0; surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); _ = surface.print(0, 0, columns, text[panes.File.rawAtDisplay(text, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); - if (editing) |t| { - if (tagline.headerSelection(t, try edit.flatSurface(p, t), line + p.header_top)) |span| { - var col = panes.File.rawDisplayCol(text, span.lo) -| scroll; - const end = panes.File.rawDisplayCol(text, span.hi) -| scroll; - while (col < end and col < columns) : (col += 1) { - surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg }; - surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg }; - } - } - const caret = panes.File.rawDisplayCol(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.len)) -| scroll; - const asking = if (p.panes[p.active]) |active| active.prompt != .none else false; - if (!asking and t.cur_row == line + p.header_top and caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = t.mode == .insert }; - } else if (p.pointer_inside) { + // The word under the pointer: on a header even while it is edited, but + // not on a notice (a message is not a command line), nor on a column a + // carried column's pointer is aiming at a place over. + const hovered = p.pointer_inside and kind != .notice and !(kind == .column and p.drag == .column_move); + if (hovered) { const tag_hit = p.pointer_tag_hit; const col: ?usize = if (tag_hit) |pointer| (if (pointer.kind == kind and pointer.id == id and pointer.line == line) pointer.col else null) @@ -880,6 +655,160 @@ fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: T } }; } + if (editing) |t| { + if (tagline.headerSelection(t, try edit.flatSurface(p, t), line + p.header_top)) |span| { + var col = panes.File.rawDisplayCol(text, span.lo) -| scroll; + const end = panes.File.rawDisplayCol(text, span.hi) -| scroll; + while (col < end and col < columns) : (col += 1) { + surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg }; + surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg }; + } + } + const caret = panes.File.rawDisplayCol(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.len)) -| scroll; + const asking = if (p.panes[p.active]) |active| active.prompt != .none else false; + if (!asking and t.cur_row == line + p.header_top and caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = t.mode == .insert }; + } +} + +/// A pane's tag rows, painted once into their layers, a layer to a line, +/// and copied into the grid; the grid's caret where the layer has it. +fn joinPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void { + const capacity = p.tagCapacity(r.w -| TAG_TEXT_INSET); + // Too narrow for a layer: only the grid's gap cell. + if (r.w <= TAG_TEXT_INSET) return paintPaneTag(p, s, arena, pane, r, id, active, capacity); + const chrome = p.chromeTheme(); + const region = s.region(.tag, id).?; + const tag = try tagline.tagText(p, arena, pane); + const rows = pane.tag_rows; + // As wide as the widest line it shows, and never narrower than its + // viewport. + var widest: usize = 0; + var split = std.mem.splitScalar(u8, tag, '\n'); + for (0..pane.tag_top) |_| _ = split.next(); + for (0..rows) |_| widest = @max(widest, panes.File.displayWidth(split.next() orelse "")); + const columns: u16 = @intCast(@min(std.math.maxInt(u16) - TAG_TEXT_INSET, @max(capacity, widest + 1))); + const pitch = @as(usize, columns) + TAG_TEXT_INSET; + const storage = try arena.alloc(Cell, pitch * rows); + @memset(storage, .{}); + var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = rows, .cells = storage }; + try paintPaneTag(p, &temporary, arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, active, capacity); + const tag_fg: Color = .{ .rgb = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg }; + // A layer to a line: the first in the pane's own slot, the rest in the + // slots kept for the lines of taller tags. + for (0..rows) |line| { + const layer = &s.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1]; + if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); + @memcpy(layer.cells, storage[line * pitch + TAG_TEXT_INSET ..][0..columns]); + layer.kind = .pane; + layer.id = @intCast(id); + layer.serial = pane.serial; + layer.line = @intCast(line); + layer.viewport = .{ .x = region.rect.x + TAG_GAP, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w - TAG_GAP - region.clip, .h = BOX_H }; + layer.cols = columns; + layer.bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; + layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor| + (if (cursor.y == line) .{ .col = cursor.x - TAG_TEXT_INSET, .bar = cursor.bar } else null) + else + null; + // The grid's row: the gap cell and the text, as far as the pane. + joinCells(s, region.rect.x, region.rect.y + @as(u16, @intCast(line)), storage[line * pitch ..][0..pitch], config.GUTTER, region.rect.w, tag_fg); + } + if (temporary.cursor) |cursor| if (cursor.x - TAG_TEXT_INSET < r.w - TAG_TEXT_INSET) { + s.cursor = .{ .x = r.x + cursor.x, .y = region.rect.y + cursor.y, .bar = cursor.bar }; + }; + // A sweep begun on the tag and dragged into the body shows on the body + // rows it crossed, which only the grid has. + const th = p.theme(); + const tx = r.x + TAG_TEXT_INSET; + const tw = r.w - TAG_TEXT_INSET; + const tag_y = region.rect.y; + const body_y = p.bodyTop(pane, r); + if (p.look_hover_preview) |preview| { + if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) { + const page_rgb = th.bg orelse th.tag_bg; + const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); + body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, rows, preview_bg, null); + }; + } + for (pane.sel, 0..) |selection, button| { + if (selection.state == .none or (selection.state == .done and button != 0)) continue; + if (@min(selection.r0, selection.r1) >= rows) continue; + body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, rows, th.sel_bg, th.sel_fg); + } +} + +/// A notice chip, painted once as a tag layer at the tagline's pitch, and the +/// grid's chip a copy of its right end, where its text is. +fn joinNotice(p: *Pardes, s: *Surface, arena: std.mem.Allocator, region: Region) !void { + const chrome = p.chromeTheme(); + const th = p.theme(); + const id: usize = region.owner; + const pane = p.panes[id].?; + const i: usize = region.notice; + const kind = pane.notices.kinds[i]; + const r = p.rects[id]; + const chip = region.rect.w; + var leader_buf: [16]u8 = undefined; + const text = Messages.noticeText(p, pane, i, &leader_buf); + // A message coming or going blends toward the page it sits on; a grid + // cannot move it by less than a row, a pixel shell slides it too. + const motion: Messages.MessageMotion = if (Messages.noticeLife(pane, i)) |life| Messages.messageMotion(p, life) else .{}; + const page = th.bg orelse chrome.tag_bg; + const bg = Messages.blendRgb(chrome.tag_bg, page, motion.alpha); + const fg = Messages.blendRgb(chrome.tag_fg, page, motion.alpha); + // Nothing to say, or too narrow for a layer: a blank chip on the grid. + if (text.len == 0 or r.w <= TAG_TEXT_INSET) { + s.fill(region.rect.x, region.rect.y, chip, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); + if (text.len == 0) return; + const printed = Messages.printRight(s, region.rect.x, region.rect.y, chip -| 1, text, .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); + if (kind != .prompt or id != p.active or pane.prompt == .del_side) return; + const prompt_col = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); + if (prompt_col < printed.dropped) return; + s.cursor = .{ .x = @intCast(printed.left + (prompt_col - printed.dropped)), .y = region.rect.y, .bar = pane.input.mode == .insert }; + return; + } + // Right aligned inside the chip, a blank cell short of its edge: a + // prompt's caret sits AFTER its last character the moment it opens, and + // flush right would put it past the pane and off the grid. Wider than + // the chip, the text loses its head: the end is the part that says + // something. + const capacity = p.tagCapacity(chip); + const room = capacity -| 1; + const shown = panes.File.displayWidth(text); + var kept = text; + if (shown > room) { + var start = panes.File.rawAtDisplay(text, shown - room); + if (panes.File.rawDisplayCol(text, start) < shown - room) start = panes.File.rawAtDisplay(text, shown - room + 1); + kept = text[start..]; + } + const pad = room -| panes.File.displayWidth(kept); + const line = try arena.alloc(u8, pad + kept.len); + @memset(line[0..pad], ' '); + @memcpy(line[pad..], kept); + const index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i; + try renderHeaderLayer(p, arena, index, .notice, @intCast(id), 0, region.rect, line, bg, fg); + const layer = &s.tag_layers[index]; + layer.slide = motion.slide; + layer.fade = motion.alpha; + // The caret rides the band, where the prompt's glyphs are. + if (kind == .prompt and id == p.active and pane.prompt != .del_side) caret: { + const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); + const dropped = shown - panes.File.displayWidth(kept); + if (typed < dropped) break :caret; + layer.cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.input.mode == .insert }; + } + // The grid shows the chip's last cells of the band, where the text ends + // (all of it where the tagline's pitch is the grid's). + const skip = capacity -| chip; + joinCells(s, region.rect.x, region.rect.y, layer.cells[0..layer.cols], skip, chip, .{ .rgb = fg }); + // The band's padding before the text is the chip's fill, which carries + // no ink of its own (the layer prints it as spaces in the band's ink). + for (skip..@min(pad, skip + chip)) |col| s.at(region.rect.x + @as(u16, @intCast(col - skip)), region.rect.y).style.fg = .default; + if (layer.cursor) |caret| { + if (caret.col >= skip) s.cursor = .{ .x = region.rect.x + caret.col - @as(u16, @intCast(skip)), .y = region.rect.y, .bar = caret.bar }; + } + // Laid out at its full width; only shown short of the rail. + layer.viewport.w -= region.clip; } pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void { @@ -957,7 +886,7 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa if (p.look_hover_preview) |preview| { if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) { const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); - body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); + body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, 0, preview_bg, null); }; } // The tag's own selections, while it has the keyboard, a line at a @@ -998,7 +927,7 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa for (pane.sel, 0..) |selection, button| { if (selection.state == .none or (selection.state == .done and button != 0)) continue; if (@min(selection.r0, selection.r1) >= rows) continue; - body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg); + body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, th.sel_bg, th.sel_fg); } } @@ -1075,3 +1004,35 @@ test "place puts every part where the painters and layers put it, in every layou } } } + +test "a wide glyph cut by a tag's or a notice's edge is joined into the grid as blank, never half drawn" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 41, .rows = 12 }); + defer p.deinit(); + _ = try p.setTestFile("wide\n"); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const pane = p.panes[p.active].?; + // Both alignments, so one of them puts a wide glyph across each edge. + for ([_][]const u8{ "\u{754c}" ** 30, "x" ++ "\u{754c}" ** 30 }) |wide| { + if (pane.tag.own) |own| std.testing.allocator.free(own); + pane.tag.own = try std.testing.allocator.dupe(u8, wide); + var message: [200]u8 = undefined; + try std.testing.expect(p.executeBuiltinLine(p.active, try std.fmt.bufPrint(&message, "Msg {s}", .{wide}))); + p.sync(); + _ = arena.reset(.retain_capacity); + const s = try p.render(arena.allocator()); + for (s.regionList()) |region| { + if (region.kind != .tag and region.kind != .notice) continue; + const row = s.cells[@as(usize, region.rect.y) * s.cols ..][region.rect.x..][0..region.rect.w]; + for (row, 0..) |cell, i| { + const spacer = !cell.default and cell.len == 0; + const head = !cell.default and cell.len > 1 and i + 1 < row.len and !row[i + 1].default and row[i + 1].len == 0; + // A spacer has its head before it; a head its spacer after. + if (spacer) try std.testing.expect(i > 0 and row[i - 1].len > 1); + if (i + 1 == row.len) try std.testing.expect(!head and !(cell.len > 1 and vaxis.gwidth.gwidth(cell.grapheme(), .unicode) == 2)); + } + // The right edge of each is the band, blank where a glyph was cut. + try std.testing.expectEqual(FontRole.tagline, row[row.len - 1].style.font_role); + } + } +} diff --git a/src/surface.zig b/src/surface.zig index b19c24c7..fec43e30 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -357,7 +357,9 @@ pub const Region = struct { /// The pane or column has the keyboard's focus. active: bool = false, /// A rail's thumb: rows from the rail's top, and how many. Zero rows is - /// a rail with no thumb (an image). + /// a rail with no thumb (an image). A native PDF's thumb is measured in + /// document pixels and filled in by Pdf.draw while it paints, so read + /// regions once render has returned. thumb_y: u16 = 0, thumb_h: u16 = 0, /// A notice's index in its pane's `notices`. -- cgit v1.3 From e87e16547a17864b2a0cd574c1aa42d44125bc6c Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:35:27 -0300 Subject: Alt-o, Alt-i and the other tree-sitter keys walk the file's syntax tree helix's expand/shrink, previous/next sibling, all siblings, all children and parent-node end/start (Alt-o/up, Alt-i/down, Alt-p/left, Alt-right, Alt-a, Alt-I, Alt-e, Alt-b; Alt-n stays the new terminal) walk a whole-file parse the file keeps (File.node_tree), made on the first such key after an edit and reused until the next, never per key. syntax.walkNodes follows helix's TreeCursor over every node, anonymous ones included, and Alt-i goes back to what Alt-o grew from while the selection still holds it. The harness runs without grammars, so the cases prove the plain-text no-op; a test replays eleven JSON walks whose results were taken from the installed hx. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 4 +- src/File.zig | 13 +++++ src/config.zig | 10 ++++ src/modal.zig | 20 ++++++++ src/normal.zig | 124 +++++++++++++++++++++++++++++++++++++++++++++ src/syntax.zig | 90 ++++++++++++++++++++++++++++++++ test/hxcases/cases.jsonl | 1 + test/hxcases/goldens.jsonl | 1 + 8 files changed, 261 insertions(+), 2 deletions(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 5c3972a5..f2901b92 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -127,7 +127,7 @@ language-backend queries, and the shell pipe. | `:` (normal, body or tag) | in the body, focuses the pane's tag in normal mode at its remembered cursor (the first time, on `Save`); in the tag, goes back to the body; in a column or workspace tag, back to the active pane. The tag's normal and insert modes ARE the body's: every motion, selection, edit and undo works there, and `0` goes to the line's start, the path's. Tab runs the word under the cursor or the selection and Enter looks it up (in a column or workspace tag Enter runs it too), and either hands the keyboard back to the body first. Clicks choose a new cursor position and type into the tag. | Each tag keeps its own cursor during the session. The computed path/marker/page is reachable and yankable but read-only: an edit into it is refused, and typing into a file's path drafts a new name. File-name changes are staged as described in [editable tags](tags.md). | pardes-specific | | `Ctrl-w k` / `SPC w k` (pane with nothing above) | focuses its column's tag, then the workspace tag; with `ColumnTags` disabled it goes directly to the workspace. `Ctrl-w j` walks back to the panes, `Ctrl-w h`/`l` walk the column tags. Headers edit exactly as a pane tag does. | Column commands target that column's active pane, or its first pane when coming from elsewhere. Workspace and column text are independently editable and persist in dumps. See [editable tags](tags.md). | pardes-specific | | `Ctrl-w` + `h/j/k/l`/arrows | directional pane focus prefix — editor normal mode only | pardes' own window handling (helix window mode skipped, section C). Runs the SAME `Left`/`Down`/`Up`/`Right` builtins `SPC w h/j/k/l` runs; Raw **tty** mode forwards Ctrl-w to the child. Insert mode owns `Ctrl-w` = delete-word-back, so a tag being TYPED into swallows it; from a tag in normal mode it moves focus to the neighbour's BODY, as from a body, and from the top pane `Up` reaches the column and workspace tags | pardes-specific | -| `Alt-n` | new terminal below (outside raw tty) | shadows helix `Alt-n` TS sibling-select — skipped anyway (tree-sitter) | pardes-specific | +| `Alt-n` | new terminal below (outside raw tty) | shadows helix `Alt-n` (select next sibling), which pardes spells `Alt-right` alone | pardes-specific | | `Alt-c` | move active terminal to a fresh column (outside raw tty) | helix `Alt-c` is change-noyank; the pardes window op wins (do-not-touch contract). `Alt-d` + `i` covers the behavior | waived (`alt-c-window-op`) | | `Space` (normal, body) | starts the pardes LEADER: a key path runs the same builtin words used by tags and the topbar. The main groups are `f` files, `h` docs, `c` columns, `t` toggles/effects, `a` panel animations, `s` session, `j` jumps, `l` language, and `w` directional focus; `SPC ?` lists every path and ` ?` lists one group in `+Help`. Neither `Exit` (quits the editor, acme's Exit) nor `Kill` (stops the commands pardes typed into terminals, acme's Kill) has a leader path: both are the topbar's and the root `ctl`'s. The pending path appears on the active pane's transient body/message row. Esc or an unmapped key abandons it. Paths, Help rows, and dispatch are all generated from the builtin registry at comptime; the leader applies only to a BODY in normal mode because tags and tty programs own their input | pardes-specific; helix spends Space on pickers/LSP (section C), while pardes uses acme-style executable words | | `SPC y` `SPC Y` `SPC p` `SPC P` `SPC R` | helix's clipboard menu on helix's own letters: yank the selection to the system clipboard (`ClipYank`) or the PRIMARY selection alone (`ClipYankMain`), paste the system clipboard after (`ClipPaste`) / before (`ClipPasteBefore`) the selection, replace the selection with it (`ClipReplace`) | the same as `"+y`, `"+p`, `"+P`, `"+R`: register `+` (and `*`) IS the desktop's clipboard, the only register that reaches it. `SPC y` writes `+` alone, as helix's does, not the default register too. Builtins rather than bare chords because a leader path names a builtin: they land in Help's index and are executable words like every other verb. The clipboard holds one text, so N values go out joined by newlines, as helix sends them. On a tty the write is OSC 52 out and the READ is OSC 52 back, which many terminals refuse or gate — so `SPC y` works there and `SPC p` can be a no-op | out of corpus | @@ -213,6 +213,7 @@ found. Pure text math in `modal.zig`: `findChar`, `matchBracket`, | `;` | collapse selection to cursor | drops vsel/msel, cursor stays | helix-verified (phase 5) | | `Alt-;` | flip anchor and head | vsel: swap cursor ↔ anchor; msel: cursor to the other end (r0/r1 swapped to keep the cursor-at-r1 invariant) | helix-verified (phase 5) | | `Alt-:` | make every range point forward | helix `ensure_selections_forward` | helix-verified | +| `Alt-o`/`Alt-up`, `Alt-i`/`Alt-down`, `Alt-p`/`Alt-left`, `Alt-right`, `Alt-a`, `Alt-I`, `Alt-e`, `Alt-b` | walk the file's syntax tree from every range: grow to the enclosing node (and remember what it grew from), shrink back to that or to the first child, the previous / next sibling (anonymous ones included, so often a comma), every named sibling or child, the enclosing named node's end / start (stretching to it in select mode) | helix `expand_selection` and kin, on a whole-file parse (`File.node_tree`) made on the first such key after an edit and reused until the next; a range the tree says nothing about, and every range of a file without a grammar, stays. Checked against the installed hx on JSON (`normal.zig` test): the harness runs without grammars, so it only proves the plain-text no-op | helix-verified (plain text), hx-checked (JSON) | | `%` | select whole buffer | vsel anchor 0,0, cursor on the buffer's last char | helix-verified (phase 5) | | `X` | snap selection to line bounds | vsel → msel over its row span; bare cursor → 1-line msel; msel: already line-wise, no-op | helix-verified (phase 5) | | `Alt-x` | shrink selection to line bounds | vsel only: partial first/last lines drop out; nothing left collapses the selection; msel: no-op | helix-verified (phase 5) | @@ -352,7 +353,6 @@ text. | `gw` | word-label jump | label-jump overlay machinery, not core editing | | `g.` | goto last modification | jumplist/history position tracking | | Window mode table: `Ctrl-w` + `w v s t f F h j k l q o H J K L ns nv` (+ Ctrl variants) | splits/window management | window mode — pardes has its own Ctrl-w focus + Alt-n/Alt-c + mouse layout drags | -| `Alt-o`/`Alt-up`, `Alt-i`/`Alt-down`, `Alt-p`/`Alt-left`, `Alt-n`/`Alt-right`, `Alt-a`, `Alt-I`, `Alt-e`, `Alt-b` | syntax-node selection | tree-sitter | | `]f [f ]t [t ]a [a ]c [c ]e [e ]T [T ]x [x` | TS unimpaired jumps | tree-sitter | | `]g [g ]G [G` | git change jumps | needs VCS diff state | | insert `Ctrl-x` | completion menu | completion exists, but not as a popup: insert-mode Tab straight after a `.` opens a buffer of candidate DECLARATIONS (section A). helix's menu itself is skipped | diff --git a/src/File.zig b/src/File.zig index 8ba58fbc..2c4f0aec 100644 --- a/src/File.zig +++ b/src/File.zig @@ -69,9 +69,19 @@ pub const State = struct { tree_context: bool = false, context_declarations: []syntax.ContextDeclaration = &.{}, context_revision: ?u32 = null, + /// The whole file's parse for the keys that walk its nodes (Alt-o and + /// kin), made on the first of them after an edit and kept until the next + /// one: `syntax.parseTree`'s, opaque here. + node_tree: ?*anyopaque = null, + node_tree_revision: u32 = 0, + /// helix's object_selections: what Alt-o grew from, for Alt-i to go back + /// to; gpa-owned ranges and the primary's index. + node_history: std.ArrayList(NodeStep) = .empty, history: *History, }; +pub const NodeStep = struct { ranges: []modal.Selection, primary: usize }; + pub fn dumpPane( arena: std.mem.Allocator, pane: *const Pane, @@ -816,6 +826,9 @@ pub fn deinit(p: *Pardes, pane: *Pane, file: *State) void { if (file.highlights.len > 0) p.tree_sitter_gpa.free(file.highlights); locations.freeRows(p.gpa, file.location_rows); if (file.context_declarations.len > 0) p.tree_sitter_gpa.free(file.context_declarations); + if (file.node_tree) |tree| syntax.freeTree(tree); + for (file.node_history.items) |step| p.gpa.free(step.ranges); + file.node_history.deinit(p.gpa); for (file.history.undo[0..file.history.undo_len]) |snap| p.gpa.free(snap.content); for (file.history.redo[0..file.history.redo_len]) |snap| p.gpa.free(snap.content); p.gpa.destroy(file.history); diff --git a/src/config.zig b/src/config.zig index 8c5c9771..959f4c4a 100644 --- a/src/config.zig +++ b/src/config.zig @@ -537,6 +537,16 @@ pub const split_regex: []const Chord = &.{.{ .cp = 'S' }}; pub const keep_regex: []const Chord = &.{.{ .cp = 'K' }}; pub const remove_regex: []const Chord = &.{.{ .cp = 'K', .alt = true }}; pub const ensure_forward: []const Chord = &.{.{ .cp = ':', .alt = true }}; +// helix's tree-sitter selection; Alt-n stays the new terminal, so the next +// sibling is Alt-right alone +pub const expand_node: []const Chord = &.{ .{ .cp = 'o', .alt = true }, .{ .cp = Key.up, .alt = true } }; +pub const shrink_node: []const Chord = &.{ .{ .cp = 'i', .alt = true }, .{ .cp = Key.down, .alt = true } }; +pub const prev_sibling: []const Chord = &.{ .{ .cp = 'p', .alt = true }, .{ .cp = Key.left, .alt = true } }; +pub const next_sibling: []const Chord = &.{.{ .cp = Key.right, .alt = true }}; +pub const all_siblings: []const Chord = &.{.{ .cp = 'a', .alt = true }}; +pub const all_children: []const Chord = &.{.{ .cp = 'I', .alt = true }}; +pub const parent_node_end: []const Chord = &.{.{ .cp = 'e', .alt = true }}; +pub const parent_node_start: []const Chord = &.{.{ .cp = 'b', .alt = true }}; pub const delete: []const Chord = &.{.{ .cp = 'd' }}; pub const delete_noyank: []const Chord = &.{.{ .cp = 'd', .alt = true }}; diff --git a/src/modal.zig b/src/modal.zig index f5f3081e..ae008f60 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -100,6 +100,14 @@ pub const Normal = struct { keep_regex, remove_regex, ensure_forward, + expand_node, + shrink_node, + next_sibling, + prev_sibling, + all_siblings, + all_children, + parent_node_end, + parent_node_start, delete, delete_noyank, @@ -213,6 +221,8 @@ pub const Normal = struct { } }; + /// helix's tree-sitter selection keys: where a range goes in the tree + pub const NodeWalk = enum { expand, shrink, next_sibling, prev_sibling, all_siblings, all_children, parent_end, parent_start }; /// what the s/S/K/Alt-K prompt does with its pattern's matches pub const RegexMode = enum { select, split, keep, remove }; pub const Scope = enum { once, per_selection }; @@ -331,6 +341,7 @@ pub const Normal = struct { search_step: Direction, macro_record, macro_replay: u32, + node: NodeWalk, repeat_insert: u32, pub fn scope(value: Action) Scope { @@ -345,6 +356,7 @@ pub const Normal = struct { .search_step, .macro_record, .macro_replay, + .node, .repeat_insert, => .once, .edit => |edit| switch (edit.kind) { @@ -588,6 +600,14 @@ pub const Normal = struct { if (key.has(.keep_regex)) return resultAction(.{ .select_regex = .keep }); if (key.has(.remove_regex)) return resultAction(.{ .select_regex = .remove }); if (key.has(.ensure_forward)) return resultAction(.{ .multi = .{ .kind = .ensure_forward, .count = count } }); + if (key.has(.expand_node)) return resultAction(.{ .node = .expand }); + if (key.has(.shrink_node)) return resultAction(.{ .node = .shrink }); + if (key.has(.next_sibling)) return resultAction(.{ .node = .next_sibling }); + if (key.has(.prev_sibling)) return resultAction(.{ .node = .prev_sibling }); + if (key.has(.all_siblings)) return resultAction(.{ .node = .all_siblings }); + if (key.has(.all_children)) return resultAction(.{ .node = .all_children }); + if (key.has(.parent_node_end)) return resultAction(.{ .node = .parent_end }); + if (key.has(.parent_node_start)) return resultAction(.{ .node = .parent_start }); if (key.has(.delete)) return resultAction(.{ .edit = .{ .kind = .delete, .count = count } }); if (key.has(.delete_noyank)) return resultAction(.{ .edit = .{ .kind = .delete_noyank, .count = count } }); diff --git a/src/normal.zig b/src/normal.zig index d6c646ef..bc450581 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -7,6 +7,7 @@ const exec = @import("exec.zig"); const look = @import("look.zig"); const std = @import("std"); const regexp = @import("regexp.zig"); +const syntax = @import("syntax.zig"); const modal = @import("modal.zig"); const panes = @import("panes.zig"); const edit = @import("edit.zig"); @@ -166,6 +167,83 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, mode: m t.setRanges(text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO) } +/// helix's tree-sitter selection keys: every range walks the file's parse, +/// which is made on the first of these keys after an edit and kept, never +/// made again for a key that changed nothing. A range the tree has nothing +/// to say about stays as it was; a text without a grammar, all of them. +fn nodeSelect(p: *Pardes, pane: *Pane, t: *Text, walk: modal.Normal.NodeWalk) void { + if (t.what != .body) return; + const f = if (pane.file) |*file| file else return; + if (f.node_tree == null or f.node_tree_revision != f.revision) { + if (f.node_tree) |old| syntax.freeTree(old); + f.node_tree = syntax.parseTree(f.path, f.content); + f.node_tree_revision = f.revision; + for (f.node_history.items) |step| p.gpa.free(step.ranges); + f.node_history.clearRetainingCapacity(); + } + const tree = f.node_tree orelse return; + const text = f.content; + const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; + const arena = p.scratch.allocator(); + const rs = arena.alloc(modal.Selection, Text.max_selections) catch return; + const got = t.ranges(text, 0, rs[0..Text.max_selections]); + if (walk == .shrink) if (f.node_history.pop()) |prev| { + defer p.gpa.free(prev.ranges); + // back to what Alt-o grew from, while the selection still holds it + const holds = for (prev.ranges) |pr| { + const pf = @min(pr.anchor, pr.head); + const pt = @max(pr.anchor, pr.head); + if (for (rs[0..got.n]) |r| { + if (@min(r.anchor, r.head) <= pf and pt <= @max(r.anchor, r.head)) break true; + } else false) continue; + break false; + } else true; + if (holds) return t.setRanges(text, prev.ranges, &.{}, prev.primary, expl); + for (f.node_history.items) |step| p.gpa.free(step.ranges); + f.node_history.clearRetainingCapacity(); + }; + const out = arena.alloc(modal.Selection, Text.max_selections) catch return; + const spans = arena.alloc(syntax.Span, Text.max_selections) catch return; + var m: usize = 0; + for (rs[0..got.n]) |r| { + if (m == out.len) break; + const from = @min(r.anchor, r.head); + const to = @max(r.anchor, r.head); + const back = r.head < r.anchor; + const n = syntax.walkNodes(tree, walk, from, to, modal.selectionCursor(text, r), spans[0 .. out.len - m]); + if (n == 0) { + out[m] = r; + m += 1; + continue; + } + for (spans[0..n]) |s| { + out[m] = switch (walk) { + .next_sibling => .{ .anchor = s.from, .head = s.to }, + .prev_sibling => .{ .anchor = s.to, .head = s.from }, + // helix move_parent_node_end: a one-character range on the + // bound, or stretched to it in select mode + .parent_end, .parent_start => bound: { + const head = s.from; + if (t.select) { + const anchor = r.anchor; + break :bound .{ .anchor = anchor, .head = if (head >= anchor) modal.nextGrapheme(text, head) else head }; + } + const next = modal.nextGrapheme(text, head); + break :bound if (back) .{ .anchor = next, .head = head } else .{ .anchor = head, .head = next }; + }, + else => if (back) .{ .anchor = s.to, .head = s.from } else .{ .anchor = s.from, .head = s.to }, + }; + m += 1; + } + } + const same = m == got.n and std.mem.eql(u8, std.mem.sliceAsBytes(out[0..m]), std.mem.sliceAsBytes(rs[0..m])); + if (walk == .expand and !same) remember: { + const ranges = p.gpa.dupe(modal.Selection, rs[0..got.n]) catch break :remember; + f.node_history.append(p.gpa, .{ .ranges = ranges, .primary = got.pri }) catch p.gpa.free(ranges); + } + t.setRanges(text, out[0..m], &.{}, @min(got.pri, m - 1), expl); +} + const KeyState = struct { mode: Text.Mode, select: bool, @@ -613,6 +691,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void p, if (direction == .forward) @as(i32, 1) else -1, ), + .node => |walk| return nodeSelect(p, pane, t, walk), .macro_record => { const m = &p.macro; if (m.recording == 0) { @@ -840,3 +919,48 @@ test "s and S search a multi-line selection as addr does: ^ and $ per line, . wi } } } + +test "tree-sitter selection keys do in a JSON file what hx 25.07.1 does" { + if (!syntax.supportsPath("a.json")) return; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + const text = "{\"a\": [1, 2, 3], \"b\": true}\n"; + // each checked against the installed hx through a pty: the keys, then + // the text it saved + const Case = struct { keys: []const u8, want: []const u8 }; + for ([_]Case{ + .{ .keys = "10l\x1bo" ++ "d", .want = "{\"a\": , \"b\": true}\n" }, + .{ .keys = "10l\x1bo\x1bod", .want = "{, \"b\": true}\n" }, + .{ .keys = "10l\x1bo\x1bo\x1bod", .want = "\n" }, + .{ .keys = "10l\x1bo\x1bIix\x1b", .want = "{\"a\": [x1, x2, x3], \"b\": true}\n" }, + .{ .keys = "10l\x1bo\x1bo\x1b>d", .want = "{\"a\": [1, 2, 3] \"b\": true}\n" }, + .{ .keys = "10l\x1bpd", .want = "{\"a\": [1 2, 3], \"b\": true}\n" }, + .{ .keys = "10l\x1baix\x1b", .want = "{\"a\": [x1, x2, x3], \"b\": true}\n" }, + .{ .keys = "10l\x1bo\x1bo\x1bid", .want = "{\"a\": , \"b\": true}\n" }, + .{ .keys = "6lx\x1bid", .want = "\n" }, + .{ .keys = "10l\x1beiX\x1b", .want = "{\"a\": [1, 2X, 3], \"b\": true}\n" }, + .{ .keys = "10l\x1bb\x1bbiX\x1b", .want = "{X\"a\": [1, 2, 3], \"b\": true}\n" }, + }) |c| { + const pane = try p.setTestFile(text); + p.gpa.free(pane.file.?.path); + pane.file.?.path = try p.gpa.dupe(u8, "/test.json"); + // ESC before a key is Alt on it; a lone ESC is Esc; `>` is Alt-right + var i: usize = 0; + while (i < c.keys.len) : (i += 1) { + const ch = c.keys[i]; + if (ch == 0x1b and i + 1 < c.keys.len) { + i += 1; + const k = c.keys[i]; + p.update(.{ .key = if (k == '>') .{ .cp = Key.right, .alt = true } else .{ .cp = k, .alt = true } }); + } else if (ch == 0x1b) { + p.update(.{ .key = .{ .cp = Key.escape } }); + } else p.update(.{ .key = .{ .cp = ch, .text = c.keys[i..][0..1] } }); + while (p.nextEffect()) |_| {} + } + std.testing.expectEqualStrings(c.want, pane.file.?.content) catch |err| { + std.debug.print("keys {s}\n", .{c.keys}); + return err; + }; + } +} diff --git a/src/syntax.zig b/src/syntax.zig index 9c1a4f4b..89fc4198 100644 --- a/src/syntax.zig +++ b/src/syntax.zig @@ -337,6 +337,96 @@ pub fn supportsPath(path: []const u8) bool { return false; } +/// A whole file's parse, kept by the file for the keys that walk its nodes +/// (helix's Alt-o and kin). Opaque outside this file; null without a grammar. +pub fn parseTree(path: []const u8, content: []const u8) ?*anyopaque { + if (!enabled) return null; + const selected = (forExt(std.fs.path.extension(path)) catch return null) orelse return null; + const tree = selected.parser.parseString(content, null) orelse return null; + return @ptrCast(tree); +} + +pub fn freeTree(tree: *anyopaque) void { + if (!enabled) return; + const t: *ts.Tree = @ptrCast(@alignCast(tree)); + t.destroy(); +} + +/// Where a node walk goes from a range (helix object.rs and movement.rs). +pub const NodeWalk = @import("modal.zig").Normal.NodeWalk; +pub const Span = struct { from: usize, to: usize }; + +/// The spans `walk` takes the byte range [from, to) to, into `out`; `at` is +/// the range's cursor. None when the tree has nothing to say there. +pub fn walkNodes(tree_ptr: *anyopaque, walk: NodeWalk, from: usize, to: usize, at: usize, out: []Span) usize { + if (!enabled or out.len == 0) return 0; + const tree: *const ts.Tree = @ptrCast(@alignCast(tree_ptr)); + const lo: u32 = @intCast(@min(from, std.math.maxInt(u32))); + const hi: u32 = @intCast(@min(to, std.math.maxInt(u32))); + if (walk == .parent_end or walk == .parent_start) { + var node = tree.rootNode().namedDescendantForByteRange(lo, hi) orelse return 0; + if (walk == .parent_end) { + out[0] = .{ .from = node.endByte(), .to = node.endByte() }; + return 1; + } + // already at the node's lo: its first ancestor that starts earlier + if (node.startByte() == at) { + const first = node.startByte(); + while (node.startByte() >= first or !node.isNamed()) node = node.parent() orelse break; + } + out[0] = .{ .from = node.startByte(), .to = node.startByte() }; + return 1; + } + var cursor = tree.walk(); + defer cursor.destroy(); + // helix TreeCursor.reset_to_byte_range: the smallest node holding the range + while (true) { + const node = cursor.node(); + if (lo < node.startByte() or hi > node.endByte()) { + _ = cursor.gotoParent(); + break; + } + if (cursor.gotoFirstChildForByte(lo) == null) break; + } + switch (walk) { + .expand => while (cursor.node().startByte() == lo and cursor.node().endByte() == hi) { + if (!cursor.gotoParent()) break; + }, + .shrink => _ = cursor.gotoFirstChild(), + .next_sibling => while (!cursor.gotoNextSibling()) { + if (!cursor.gotoParent()) break; + }, + .prev_sibling => while (!cursor.gotoPreviousSibling()) { + if (!cursor.gotoParent()) break; + }, + .all_siblings, .all_children => { + if (walk == .all_siblings) { + while (true) { + if (!cursor.gotoParent()) return 0; + if (cursor.node().childCount() > 1) break; + } + } + // the named children, or nothing to say + var n: usize = 0; + if (!cursor.gotoFirstChild()) return 0; + while (true) { + const child = cursor.node(); + if (child.isNamed() and n < out.len) { + out[n] = .{ .from = child.startByte(), .to = child.endByte() }; + n += 1; + } + if (!cursor.gotoNextSibling()) break; + } + return n; + }, + .parent_end, .parent_start => unreachable, + } + const node = cursor.node(); + out[0] = .{ .from = node.startByte(), .to = node.endByte() }; + return 1; +} + + /// Owned source analysis. Both slices use the allocator passed to analyzeSource. /// The parsed tree is released before returning; callers can cache this result. pub const SourceAnalysis = struct { diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 43cbbf11..8a8f8a94 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -440,6 +440,7 @@ {"name": "keep-regex-lines", "pane": "file", "text": "foo\nbar\nbaz\n", "keys": "%K^b"} {"name": "ensure-forward", "pane": "file", "text": "ab cd\n", "keys": "%s\\w+"} {"name": "ensure-forward-one", "pane": "file", "text": "abc\n", "keys": "vll"} +{"name": "node-keys-plain-text", "pane": "file", "text": "ab cd\n", "keys": "w"} {"name": "msel-align-basic", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=&"} {"name": "msel-align-columns", "pane": "file", "text": "a,b,c\nccc,d,e\n", "keys": "%s,&"} {"name": "msel-align-uneven", "pane": "file", "text": "a,b\nccc,d,eee,f\n", "keys": "%s,&"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index 9839335a..f1df5c0a 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -440,6 +440,7 @@ {"name":"keep-regex-lines","text":"foo\nbar\nbaz\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":0}},{"cursor":{"row":2,"col":2},"anchor":{"row":2,"col":0}}],"primary":0} {"name":"ensure-forward","text":"ab cd\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":3}}],"primary":0} {"name":"ensure-forward-one","text":"abc\n","mode":"select","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} +{"name":"node-keys-plain-text","text":"ab cd\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} {"name":"msel-align-basic","text":"a =1\nbbb=2\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":0} {"name":"msel-align-columns","text":"a ,b,c\nccc,d,e\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}}],"primary":0} {"name":"msel-align-uneven","text":"a ,b\nccc,d,eee,f\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}}],"primary":0} -- cgit v1.3 From 7284b72396398213356577345151bd79e497d27c Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 14:13:25 -0300 Subject: Paint a body with context rows once, into its layer, and join it joinBody replaces the second paint of a file body with compact tree-sitter context rows: the body is painted once into its body layer (more rows than the grid has) and the grid's body rows are copies of the layer's first ones; the rail is the grid's alone, from its region. Which rows are context rows is still decided by a text build before the paint, and a body without them reuses that text and paints straight onto the grid: every other body has no row the grid cannot show, so it gets no layer and no copy. Each part now paints only its own rows. The tag paints the tag rows of every pointer sweep that crosses them (the select sweep in the tag's colours, the look-hover affordance over it, a middle or right sweep over both in its button's tint), which the body used to paint over the tag on the grid alone; the body paints only body rows, and a select sweep begun on the tag marks the body rows it crossed before the text is laid down, as it did. Grid and layers agree, the snapshot goldens are byte-identical, and a test checks the joined grid rows and caret against a plain paint of the same body with the tag on top and at the bottom. The GUI goldens gain a terminal pane scene. --- docs/render-pipeline.md | 10 ++++++ src/body_layer.zig | 79 ++++++++++++++++++++++++++++++++----------- src/draw.zig | 90 +++++++++++++++++++++++++++++++++++++------------ test/gui-goldens.txt | 1 + test/gui_golden.py | 8 +++++ 5 files changed, 146 insertions(+), 42 deletions(-) diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index 590a7e72..f63b9a0b 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -842,6 +842,16 @@ goldens; ssh/tty byte budgets; wire version bump breaks mixed-version attach. 4. The macOS shell breaks at stages 7 and 8 (Layer ABI, mark_hover). Accepted, since the macOS build is ignored for now. 5. Stage-0 GPU goldens gate locally only. +6. Revisit at the first visual stage (from stage 5): the notice layer lost + its hover word to keep the grid's behaviour. Notice words are Look/Exec + targets, so bring the hover affordance back in BOTH the grid and the + layer then. +7. Revisit at stage 8 (from stage 6): a body without context rows has no + layer (paint once, straight onto the grid). If the GUI is to read every + body from a layer, give every body one then and measure the copy. The + context-row path also still builds the body's text twice (a pre-pass + with body_rows 0 decides the context rows, renderBody builds it again at + the layer's rows); the paint is single. Measure both there. ## 15. Questions for the user diff --git a/src/body_layer.zig b/src/body_layer.zig index e1a45058..ac87a07b 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -257,15 +257,33 @@ pub fn paintPointerSelection( } } -pub fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize) !void { - if (!p.settings.tree_context_tag_style or pane.context_rows == 0 or pane.file == null or pane.collapsed) return; - if (r.w <= config.GUTTER or r.h <= pane.tag_rows) return; +/// The selection colour of each pointer button's sweep: the select button's +/// and the look and exec buttons', each tinted toward its own accent. +pub fn buttonTints(th: *const colors.Theme) [3][3]u8 { + const accent2 = if (std.mem.eql(u8, &th.num, &th.str)) th.comment else th.num; + return .{ + colors.mix(th.sel_bg, colors.mix(th.sel_bg, th.kw)), + colors.mix(th.sel_bg, colors.mix(th.sel_bg, th.str)), + colors.mix(th.sel_bg, colors.mix(th.sel_bg, accent2)), + }; +} + +/// A pane's body, painted once. A file body with compact tree-sitter +/// context rows is painted into its body layer -- more rows than the grid +/// has, the context rows at the tagline's height -- and the grid's rows are +/// copies of that layer's first ones, at body height. Any other body has no +/// rows the grid cannot show, and is painted straight onto the grid. The +/// rail is the grid's alone. +pub fn joinBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, rail: pardes.Region) !void { + const file = if (pane.file) |*f| f else return renderBody(p, s, arena, pane, r, id, active, rail, null); + if (!p.settings.tree_context_tag_style or pane.collapsed or r.h <= pane.tag_rows) + return renderBody(p, s, arena, pane, r, id, active, rail, null); + // Which rows are context rows is decided by the text the body shows. + const text = try panes.File.bodyText(arena, pane, file, p.settings.wrap); const viewport_rows = r.h - pane.tag_rows; - if (viewport_rows == 0) return; - // The canonical fixed grid remains available to terminal clients. The - // separate body layer reserves enough rows for any compact font size. const logical_rows = @min(@as(usize, pane.rows) + pane.context_rows, pane.wrap_line.len); - if (logical_rows <= pane.context_rows) return; + if (pane.context_rows == 0 or logical_rows <= pane.context_rows) + return renderBody(p, s, arena, pane, r, id, active, rail, text); pane.body_rows = @intCast(logical_rows); const bh: usize = @max(1, p.row_metrics.body_h); const th: usize = std.math.clamp(p.row_metrics.tagline_h, 1, bh); @@ -274,14 +292,14 @@ pub fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rec const context_pixels = @as(usize, pane.context_row_limit.?) * th; pane.body_visible_rows = @intCast(@min(logical_rows, pane.context_row_limit.? + @max(1, (body_pixels -| context_pixels) / bh))); const width = r.w - config.GUTTER; - const layer = &p.surface.body_layers[id]; + const layer = &s.body_layers[id]; const count = logical_rows * width; if (layer.cells.len != count) layer.cells = try p.gpa.realloc(layer.cells, count); const temporary_cells = try arena.alloc(Cell, @as(usize, r.w) * (logical_rows + pane.tag_rows)); @memset(temporary_cells, .{}); var temporary: Surface = .{ .cols = r.w, .rows = @intCast(logical_rows + pane.tag_rows), .cells = temporary_cells }; const synthetic: Rect = .{ .x = 0, .y = 0, .w = r.w, .h = @intCast(logical_rows + pane.tag_rows) }; - try renderBody(p, &temporary, arena, pane, synthetic, id, id == p.active, null); + try renderBody(p, &temporary, arena, pane, synthetic, id, active, null, null); const first_row: usize = if (p.settings.tag_bottom) 0 else pane.tag_rows; for (0..logical_rows) |row| { const start = (row + first_row) * r.w + config.GUTTER; @@ -289,7 +307,7 @@ pub fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rec } layer.pane = @intCast(id); layer.serial = pane.serial; - const body = p.surface.region(.body, id).?; + const body = s.region(.body, id).?; layer.viewport = .{ .x = body.rect.x, .y = body.rect.y, .w = body.rect.w - body.clip, .h = body.rect.h }; layer.cols = width; layer.rows = @intCast(logical_rows); @@ -300,9 +318,29 @@ pub fn renderBodyLayer(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rec if (cursor.x >= config.GUTTER and cursor.y >= first_row) layer.cursor = .{ .x = cursor.x - config.GUTTER, .y = @intCast(cursor.y - first_row), .bar = cursor.bar }; } + // The grid: the layer's first rows, as many as the body has, each a + // body row high; its caret where one of them has it. (pane.rows is the + // body's height after sync, and context rows are only prepared while + // the rows fit the wrap table, so the layer always has these rows.) + std.debug.assert(layer.rows >= viewport_rows); + for (0..viewport_rows) |row| { + const grid_row = s.cells[(body.rect.y + row) * s.cols ..][body.rect.x..][0..width]; + @memcpy(grid_row, layer.cells[row * width ..][0..width]); + } + if (layer.cursor) |cursor| if (cursor.y < viewport_rows) { + s.cursor = .{ .x = body.rect.x + cursor.x, .y = body.rect.y + cursor.y, .bar = cursor.bar }; + }; + const chrome = p.chromeTheme(); + const theme = p.theme(); + const pane_bg: Color = if (theme.bg) |c| .{ .rgb = c } else .default; + if (rail.rect.h > 0) { + s.fill(rail.rect.x, rail.rect.y, 1, rail.rect.h, .{ .bg = .{ .rgb = chrome.scroll_track } }); + s.fill(rail.rect.x + 1, rail.rect.y, 1, rail.rect.h, .{ .bg = pane_bg }); + s.fill(rail.rect.x, rail.rect.y + rail.thumb_y, 1, rail.thumb_h, .{ .bg = .{ .rgb = chrome.scroll_thumb } }); + } } -pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, rail: ?pardes.Region) !void { +pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, rail: ?pardes.Region, built: ?[]const u8) !void { const th = p.theme(); const chrome = p.chromeTheme(); const tx = r.x + config.GUTTER; @@ -322,8 +360,14 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane .bg = pane_bg, }; const tz_body = tracy.zone(@src(), "bodyText"); - const body = try bodyText(p, arena, pane); + const body = built orelse try bodyText(p, arena, pane); tz_body.end(); + // A select sweep begun on the tag and dragged into the body marks the + // body rows it crossed only where no text is: it is laid down before + // the rows, which paint over it. + const sweep = pane.sel[pardes.sel_slot]; + if (sweep.state != .none and (sweep.state != .done or pardes.sel_slot == 0) and @min(sweep.r0, sweep.r1) < pane.tag_rows) + paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sweep, pane.tag_rows, th.sel_bg, th.sel_fg); { // The rows themselves. `bodyText` above is only the string BUILD; // this is what writes it into the surface. @@ -349,12 +393,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane } tz_color.end(); - const accent2 = if (std.mem.eql(u8, &th.num, &th.str)) th.comment else th.num; - const sel_btn = [3][3]u8{ - colors.mix(th.sel_bg, colors.mix(th.sel_bg, th.kw)), - colors.mix(th.sel_bg, colors.mix(th.sel_bg, th.str)), - colors.mix(th.sel_bg, colors.mix(th.sel_bg, accent2)), - }; + const sel_btn = buttonTints(th); if (p.look_hover_preview) |preview| { if (preview.pane == id and preview.serial == pane.serial) { // Preserve body ink, then let every real user selection below @@ -365,7 +404,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane if (preview.file_word) |word| panes.File.paintWordSelection(p, s, pane, r, word.row, word.lo, word.hi, preview_bg) else if (preview.sel) |sel| - paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, 0, preview_bg, null); + paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, pane.tag_rows, preview_bg, null); } } const modal_hover = if (p.look_hover_preview) |preview| @@ -378,7 +417,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane // A select sweep over the tag is the tag's selection, painted there // in the tag's own selection colours. if (b == pardes.sel_slot and @min(sl.r0, sl.r1) < pane.tag_rows) continue; - paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sl, 0, sel_btn[b], th.sel_fg); + paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sl, pane.tag_rows, sel_btn[b], th.sel_fg); } // modal line selection (helix `x`): whole body rows, normal mode only if ((pane.body.mode == .normal or modal_hover) and pane.body.msel.active) { diff --git a/src/draw.zig b/src/draw.zig index b596fc22..7d91ad31 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -192,7 +192,6 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { pane.context_row_limit = null; s.body_layers[id].rows = 0; try renderPane(p, s, arena, pane, p.rects[id], id, id == p.active); - try body_layer.renderBodyLayer(p, arena, pane, p.rects[id], id); } // Each notice once, as a tag layer, and the grid's chip a copy of it. for (s.regionList()) |region| { @@ -494,7 +493,7 @@ fn renderPane(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: return; } - try body_layer.renderBody(p, s, arena, pane, r, id, active, rail.*); + try body_layer.joinBody(p, s, arena, pane, r, id, active, rail.*); } /// The workspace and column tags: each line a tag layer, and the grid's @@ -716,25 +715,6 @@ fn joinPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r if (temporary.cursor) |cursor| if (cursor.x - TAG_TEXT_INSET < r.w - TAG_TEXT_INSET) { s.cursor = .{ .x = r.x + cursor.x, .y = region.rect.y + cursor.y, .bar = cursor.bar }; }; - // A sweep begun on the tag and dragged into the body shows on the body - // rows it crossed, which only the grid has. - const th = p.theme(); - const tx = r.x + TAG_TEXT_INSET; - const tw = r.w - TAG_TEXT_INSET; - const tag_y = region.rect.y; - const body_y = p.bodyTop(pane, r); - if (p.look_hover_preview) |preview| { - if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) { - const page_rgb = th.bg orelse th.tag_bg; - const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); - body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, rows, preview_bg, null); - }; - } - for (pane.sel, 0..) |selection, button| { - if (selection.state == .none or (selection.state == .done and button != 0)) continue; - if (@min(selection.r0, selection.r1) >= rows) continue; - body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, rows, th.sel_bg, th.sel_fg); - } } /// A notice chip, painted once as a tag layer at the tagline's pitch, and the @@ -924,11 +904,27 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y + @as(u16, @intCast(at.row)), .bar = mode == .insert }; } + // Pointer sweeps over the tag rows. A select sweep is the tag's own, in + // its selection colours; the look-hover affordance goes over it, and a + // middle or right sweep over both, tinted by its button the way the + // body tints it. for (pane.sel, 0..) |selection, button| { if (selection.state == .none or (selection.state == .done and button != 0)) continue; - if (@min(selection.r0, selection.r1) >= rows) continue; + if (button != pardes.sel_slot or @min(selection.r0, selection.r1) >= rows) continue; body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, th.sel_bg, th.sel_fg); } + if (p.look_hover_preview) |preview| { + if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < rows) { + const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); + body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, sel, 0, preview_bg, null); + }; + } + const sel_btn = body_layer.buttonTints(th); + for (pane.sel, 0..) |selection, button| { + if (selection.state == .none or (selection.state == .done and button != 0)) continue; + if (button == pardes.sel_slot or @min(selection.r0, selection.r1) >= rows) continue; + body_layer.paintPointerSelection(p, s, pane, r, tx, tw, tag_y, body_y, selection, 0, sel_btn[button], th.sel_fg); + } } test "place puts every part where the painters and layers put it, in every layout" { @@ -1036,3 +1032,53 @@ test "a wide glyph cut by a tag's or a notice's edge is joined into the grid as } } } + +test "a body with context rows is painted once, and the grid's rows are what a plain paint of it gives" { + if (comptime !@import("syntax.zig").enabled) return error.SkipZigTest; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 60, .rows = 16 }); + defer p.deinit(); + const pane = try p.setTestFile("pub const Box = struct {\n pub fn run() void {\n" ++ (" // body line\n" ** 30) ++ " }\n};\n"); + p.gpa.free(pane.file.?.path); + pane.file.?.path = try p.gpa.dupe(u8, "/test.zig"); + if (!panes.File.supportsContext(pane)) return error.SkipZigTest; + pane.file.?.tree_context = true; + pane.file.?.scroll = 8; + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + for ([_]bool{ false, true }) |bottom| { + p.settings.tag_bottom = bottom; + // A selection and the cursor on body rows, painted in the one pass. + pane.body.cur_row = 12; + pane.body.cur_col = 3; + pane.body.vsel = .{ .active = true, .row = 10, .col = 2 }; + pane.body.mode = .normal; + p.sync(); + _ = arena.reset(.retain_capacity); + const s = try p.render(arena.allocator()); + const layer = &s.body_layers[0]; + try std.testing.expect(layer.context_rows > 0); + const body = s.region(.body, 0).?; + // The plain paint the grid used to take, of the same frame. + const cells = try arena.allocator().alloc(Cell, s.cells.len); + @memset(cells, .{}); + var plain: Surface = .{ .cols = s.cols, .rows = s.rows, .cells = cells }; + const saved = .{ pane.body_rows, pane.body_visible_rows, pane.context_row_limit }; + pane.body_rows = 0; + pane.body_visible_rows = 0; + pane.context_row_limit = null; + try body_layer.renderBody(p, &plain, arena.allocator(), pane, p.rects[0], 0, true, null, null); + pane.body_rows, pane.body_visible_rows, pane.context_row_limit = saved; + for (0..body.rect.h) |row| { + const y = body.rect.y + row; + for (0..body.rect.w) |col| { + const x = body.rect.x + col; + const got = s.cells[y * s.cols + x]; + const want = plain.cells[y * s.cols + x]; + try std.testing.expect(got.visuallyEqual(&want)); + } + } + try std.testing.expectEqual(plain.cursor, s.cursor); + // The layer's own rows run past the grid's. + try std.testing.expect(layer.rows > body.rect.h); + } +} diff --git a/test/gui-goldens.txt b/test/gui-goldens.txt index 73aa1f86..e52155a3 100644 --- a/test/gui-goldens.txt +++ b/test/gui-goldens.txt @@ -14,3 +14,4 @@ 14-theme-ink 6e733d4212373c90b860b2e41102ced8719b07480591b5da3905267c8358db18 15-theme-acme-light e099dc1a351de8efa63ecd0ec0899636ea31b722a945eb40137a18abd87a82e2 16-debug e97ce74b7a3d4a08f6a529b1f0de111d62e5532c829a6b1633b966dd45218f55 +17-terminal 304a84d1ef871f581d0a3fade9bc1acdcc87a3612c607548ab0d2851871753ee diff --git a/test/gui_golden.py b/test/gui_golden.py index 1252014c..327911cc 100644 --- a/test/gui_golden.py +++ b/test/gui_golden.py @@ -188,6 +188,14 @@ def run(args): scene('15-theme-acme-light') client.write('/ctl', b'Debug\n') scene('16-debug') + client.write('/ctl', b'Debug\n') + # A terminal pane: raw rows, ANSI colours, a hidden prompt. + before = set(panes(client)) + client.write(f'/pane/{first}/exec', b'Tty\n') + terminal = require(wait_for(lambda: next((s for s in panes(client) if s not in before), None)), 'Tty missing') + client.write(f'/pane/{terminal}/pty/data', b"PS1='$ '; clear; printf '\\033[1;36mcolour\\033[0m plain \\033[32mgreen\\033[0m\\n'\r") + require(wait_for(lambda: b'green' in client.read(f'/pane/{terminal}/body')), 'terminal output missing') + scene('17-terminal') lines = ''.join(f'{label} {digest}\n' for label, digest in results) if args.update: GOLDENS.write_text(lines) -- cgit v1.3 From bdbab17204efdb785b6ab3cc2022484485c0ded2 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:38:06 -0300 Subject: g. goes to where the last edit ended helix goto_last_modification: every range moves to the end of the last change (the start of a deletion, the end of an insertion), extending in select mode. The position is kept on the Text in surface rows, set where setEditText hands a body its new text, so a terminal's typed text has one too and moves with its scrollback. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 2 +- src/Terminal.zig | 6 +++++- src/Text.zig | 3 +++ src/config.zig | 1 + src/edit.zig | 7 +++++++ src/modal.zig | 3 +++ src/normal.zig | 4 ++++ test/hxcases/cases.jsonl | 5 +++++ test/hxcases/goldens.jsonl | 5 +++++ 9 files changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index f2901b92..2928d78e 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -184,6 +184,7 @@ found. Pure text math in `modal.zig`: `findChar`, `matchBracket`, | `Alt-.` | repeat last `f`/`t`/`F`/`T` motion (`Pane.find_op`/`find_ch`), takes a count | decision: repeats ONLY the find family, not `m`/`[`/`]` (helix extends it there; marginal) | helix-verified (phase 5) | | `g|`, `g|` | goto column n (1 = line start), clamped to the line | | helix-verified (phase 5) | | `gs` | goto first non-whitespace | alias of the `^` handler | helix-verified (phase 5) | +| `g.` | go to where the last edit ended (a deletion's start, an insertion's end), every range there, extending in select mode | helix `goto_last_modification`. The position is `Text.last_edit`, set by `edit.setEditText` from where the old and new texts part; helix's is the last history revision's, so with nothing edited yet pardes stays put where the harness's helix goes to the end of its setup text | helix-verified | | `gt` / `gc` / `gb` | goto screen top / center / bottom | view-relative (`pane.scroll()` + `pane.rows`), column kept (clamped) | helix-verified (phase 5) | | `gj` / `gk` | VISUAL line down / up (+ count) | follows the wrapped body's own breaks (`file_pane.visualRow`, the same walk `fillBody` renders), keeping the goal column INSIDE the row; the last row of a line steps into the next line's first. Wrap off = one row per line, and this IS `j`/`k`. helix assigns the pair the other way round (its `j` is the visual one) | pardes-specific | | `PageUp` / `PageDown` | FULL page (was half) | `Ctrl-u`/`Ctrl-d` stay the half-page pair | helix-verified (phase 5) | @@ -351,7 +352,6 @@ text. | Picker table (all rows), Prompt table (all rows) | picker / prompt internals | pickers — pardes' tag line is its own one-line editor | | `gn` `gp` `ga` `gm` | next/prev/alternate buffer | buffer nav — pardes panes aren't a buffer list | | `gw` | word-label jump | label-jump overlay machinery, not core editing | -| `g.` | goto last modification | jumplist/history position tracking | | Window mode table: `Ctrl-w` + `w v s t f F h j k l q o H J K L ns nv` (+ Ctrl variants) | splits/window management | window mode — pardes has its own Ctrl-w focus + Alt-n/Alt-c + mouse layout drags | | `]f [f ]t [t ]a [a ]c [c ]e [e ]T [T ]x [x` | TS unimpaired jumps | tree-sitter | | `]g [g ]G [G` | git change jumps | needs VCS diff state | diff --git a/src/Terminal.zig b/src/Terminal.zig index 1ff561b1..4c29e151 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -720,7 +720,7 @@ pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void { if (comptime !enabled) return; if (pane.terminal == null) return; const has_positions = pane.ovl != null or pane.body.cur_pinned or pane.body.vsel.active or - pane.body.msel.active or pane.body.nsel > 0 or pane.look_at != null or + pane.body.msel.active or pane.body.nsel > 0 or pane.body.last_edit != null or pane.look_at != null or pane.body.ed_undo_len > 0 or pane.body.ed_redo_len > 0 or pointerRowCount(pane) > 0; // Sticky bottom, as every other terminal does it: output drags the // viewport down only when the viewport was already on the last row, so @@ -846,6 +846,10 @@ fn ingestWithPositions(pane: *Pane, bytes: []const u8) void { extra[nextra + 1] = &selection.arow; nextra += 2; } + if (pane.body.last_edit) |*at| { + extra[nextra] = &at.row; + nextra += 1; + } if (pane.look_at) |*at| { extra[nextra] = &at.row; nextra += 1; diff --git a/src/Text.zig b/src/Text.zig index 411c2ec8..70ec5369 100644 --- a/src/Text.zig +++ b/src/Text.zig @@ -69,6 +69,9 @@ sticky_col: i32 = -1, /// The insert session began with `a`: on Esc every range gives back the /// one character it was stretched by (helix restore_cursor). restore_cursor: bool = false, +/// Where the last edit ended, a deletion's start or an insertion's end, in +/// surface rows: for `g.` (helix last_edit_pos). +last_edit: ?struct { row: i32, col: i32 } = null, /// insert `Ctrl-r` was pressed: the next key names the register to insert reg_wait: bool = false, normal: modal.Normal.State = .{}, diff --git a/src/config.zig b/src/config.zig index 959f4c4a..993b6fe0 100644 --- a/src/config.zig +++ b/src/config.zig @@ -487,6 +487,7 @@ pub const goto_column: []const Chord = &.{.{ .cp = '|' }}; pub const goto_view_top: []const Chord = &.{.{ .cp = 't' }}; pub const goto_view_center: []const Chord = &.{.{ .cp = 'c' }}; pub const goto_view_bottom: []const Chord = &.{.{ .cp = 'b' }}; +pub const goto_last_modification: []const Chord = &.{.{ .cp = '.' }}; pub const goto_definition: []const Chord = &.{.{ .cp = 'd' }}; pub const goto_declaration: []const Chord = &.{.{ .cp = 'D' }}; pub const goto_type_definition: []const Chord = &.{.{ .cp = 'y' }}; diff --git a/src/edit.zig b/src/edit.zig index 5e0d20a4..9398674a 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -484,6 +484,13 @@ pub fn setEditText(p: *Pardes, t: *Text, new: []u8) void { return; } const pane = t.pane() orelse return retireEditText(p, new); + // what changed ended where the texts agree again from their ends + const old: []const u8, const row0: i32 = if (pane.file) |f| .{ f.content, 0 } else if (pane.ovl) |o| .{ o.text, o.row } else .{ "", 0 }; + const front = std.mem.indexOfDiff(u8, old, new) orelse @min(old.len, new.len); + var back: usize = 0; + while (back < @min(old.len, new.len) - front and old[old.len - 1 - back] == new[new.len - 1 - back]) back += 1; + const end = modal.positionAt(new, new.len - back); + t.last_edit = .{ .row = @as(i32, @intCast(end.row)) + row0, .col = @intCast(end.col) }; if (pane.file) |*f| return panes.File.setContent(p, f, new); panes.Terminal.setEditText(p, pane, new); } diff --git a/src/modal.zig b/src/modal.zig index ae008f60..0171da93 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -28,6 +28,7 @@ pub const Normal = struct { goto_view_top, goto_view_center, goto_view_bottom, + goto_last_modification, goto_definition, goto_declaration, goto_type_definition, @@ -251,6 +252,7 @@ pub const Normal = struct { view_top, view_center, view_bottom, + last_modification, }; pub const View = enum { top, center, bottom, scroll_down, scroll_up }; pub const Find = enum { forward, backward, till_forward, till_backward }; @@ -437,6 +439,7 @@ pub const Normal = struct { if (key.has(.goto_view_top)) return resultAction(.{ .goto = .{ .target = .view_top, .count = count, .explicit_count = counted.explicit } }); if (key.has(.goto_view_center)) return resultAction(.{ .goto = .{ .target = .view_center, .count = count, .explicit_count = counted.explicit } }); if (key.has(.goto_view_bottom)) return resultAction(.{ .goto = .{ .target = .view_bottom, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_last_modification)) return resultAction(.{ .goto = .{ .target = .last_modification, .count = count, .explicit_count = counted.explicit } }); if (key.has(.goto_definition)) return resultAction(.{ .lsp = .definition }); if (key.has(.goto_declaration)) return resultAction(.{ .lsp = .declaration }); if (key.has(.goto_type_definition)) return resultAction(.{ .lsp = .type_definition }); diff --git a/src/normal.zig b/src/normal.zig index bc450581..228c9c39 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -460,6 +460,10 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void .view_top => return t.gotoWindow(text, range, .top, go.count), .view_center => return t.gotoWindow(text, range, .center, go.count), .view_bottom => return t.gotoWindow(text, range, .bottom, go.count), + .last_modification => { + const at = t.last_edit orelse return; + return t.pointMove(text, range, modal.offsetAt(text, .{ .row = @intCast(@max(0, at.row)), .col = @intCast(@max(0, at.col)) })); + }, }, .view => |view| if (t.what == .body) switch (view) { .top => { diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 8a8f8a94..03b20ff5 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -441,6 +441,11 @@ {"name": "ensure-forward", "pane": "file", "text": "ab cd\n", "keys": "%s\\w+"} {"name": "ensure-forward-one", "pane": "file", "text": "abc\n", "keys": "vll"} {"name": "node-keys-plain-text", "pane": "file", "text": "ab cd\n", "keys": "w"} +{"name": "goto-last-mod", "pane": "file", "text": "ab\ncd\nef\n", "keys": "jliXYgg"} +{"name": "goto-last-mod-go", "pane": "file", "text": "ab\ncd\nef\n", "keys": "jliXYggg."} +{"name": "goto-last-mod-delete", "pane": "file", "text": "abc\ndef\n", "keys": "jlxdgg"} +{"name": "goto-last-mod-delete-go", "pane": "file", "text": "abc\ndef\n", "keys": "jldggg."} +{"name": "goto-last-mod-select", "pane": "file", "text": "ab\ncd\nef\n", "keys": "jjiXggvg."} {"name": "msel-align-basic", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=&"} {"name": "msel-align-columns", "pane": "file", "text": "a,b,c\nccc,d,e\n", "keys": "%s,&"} {"name": "msel-align-uneven", "pane": "file", "text": "a,b\nccc,d,eee,f\n", "keys": "%s,&"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index f1df5c0a..8e642073 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -441,6 +441,11 @@ {"name":"ensure-forward","text":"ab cd\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":3}}],"primary":0} {"name":"ensure-forward-one","text":"abc\n","mode":"select","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} {"name":"node-keys-plain-text","text":"ab cd\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} +{"name":"goto-last-mod","text":"ab\ncXYd\nef\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"goto-last-mod-go","text":"ab\ncXYd\nef\n","mode":"normal","cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}} +{"name":"goto-last-mod-delete","text":"abc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"goto-last-mod-delete-go","text":"abc\ndf\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}} +{"name":"goto-last-mod-select","text":"ab\ncd\nXef\n","mode":"select","cursor":{"row":2,"col":1},"anchor":{"row":0,"col":0}} {"name":"msel-align-basic","text":"a =1\nbbb=2\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":0} {"name":"msel-align-columns","text":"a ,b,c\nccc,d,e\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}}],"primary":0} {"name":"msel-align-uneven","text":"a ,b\nccc,d,eee,f\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}}],"primary":0} -- cgit v1.3 From d88e381793c6505bf3f2af43705bd2fa32f55ef6 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 14:37:29 -0300 Subject: One Layer for tags, notices, headers and bodies; a taller tag is one layer of rows; wire v8 src/Layer.zig merges TagLayer and BodyLayer. `rows` (0 = no layer) and a cursor at {x, y}. A tag of N rows is ONE layer of N grid rows: tagHit answers the row as `line`, bodyHit keeps its meaning, and the per-line layer bases (TAG_LINE_LAYER_BASE, HEADER_LINE_LAYER_BASE) are gone, not aliased. Wire v8, the one bump: tag layers carry rows and cursor y, and the frame carries the placed region list. v7 and v9 peers are refused in both directions (server test over both, a new frontend test over both). web: tag_layer_value 11 = rows, 12 = cursor y; app.mjs lays every row. macOS: the Zig side compiles against Layer; pardes.h still sees one row per tag layer (accepted, the macOS shell is ignored for now). No visual change: snapshot goldens and the 17 GUI goldens byte-identical. Shared files touched: pardes.zig, Messages.zig, gui.zig, macos.zig, detached/client.zig, detached/server.zig, detached/wire.zig (plus web.zig, app.mjs, edit.zig, look.zig). Not touched: mouse.zig, tagline.zig, colors.zig, tty.zig, dump.zig, exec.zig, host_io.zig, panes.zig. --- docs/render-pipeline.md | 2 +- src/Layer.zig | 198 ++++++++++++++++++++++++++++++++++++++++++++++++ src/Messages.zig | 6 +- src/Presentation.zig | 12 +-- src/body_layer.zig | 117 ++-------------------------- src/detached/client.zig | 30 ++++++-- src/detached/server.zig | 3 +- src/detached/wire.zig | 118 ++++++++++++++++++++--------- src/draw.zig | 187 +++++++++++++++++++++++---------------------- src/edit.zig | 2 +- src/gui/gui.zig | 139 +++++++++++++++++---------------- src/look.zig | 2 +- src/macos.zig | 12 +-- src/pardes.zig | 40 +++++----- src/surface.zig | 15 ++-- src/tag_layer.zig | 87 ++------------------- src/web.zig | 12 ++- src/web/app.mjs | 38 ++++++---- test/column_drag.zig | 10 +-- test/panes.zig | 30 ++++---- 20 files changed, 590 insertions(+), 470 deletions(-) create mode 100644 src/Layer.zig diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index f63b9a0b..a8b3f019 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -809,7 +809,7 @@ stripped. No visual change until stage 9 unless stated. | 4 | PLACE: Region list built once; renderPane/tags/notices read their rects from it | wait for tag→Text; the refactor is already replacing BOX_H with `pane.tag_rows` and `p.tagTop/bodyTop(pane, r)` — PLACE absorbs those into the region rects | | 5 | JOIN: paint tags/notices/headers once into layers, copy into grid; the grid wins where the copies disagree, and each disagreement is listed for a later decision | goldens are the oracle; wide-grapheme re-clip at the edge (§3.2) | | 6 | body layer joined the same way (paint once, copy visible rows) | riskiest join; A/B the old double paint in a temporary test with tag_bottom on and off, then delete it | -| 7 | Layer merge (TagLayer + BodyLayer), wire v8, web accessors | after the other agent lands; touches mouse hit paths; breaks the macOS shell's layer ABI (accepted: macOS build ignored for now) | +| 7 | Layer merge (TagLayer + BodyLayer), wire v8, web accessors | after the other agent lands; touches mouse hit paths; breaks the macOS shell's layer ABI (accepted: macOS build ignored for now); done: one `Layer` (src/Layer.zig) with `rows` (0 = no layer) and a `cursor{x, y}`; a tag of N rows is ONE layer of N grid rows (`tagHit` answers the row as `line`, `bodyHit` keeps its meaning), so the per-line layer bases are gone. Wire v8 ships rows, cursor y and the region list in the one bump; v7 and v9 peers are refused in both directions (tests). web: `tag_layer_value` 11 = rows, 12 = cursor y, and app.mjs lays every row. macOS: its Zig side compiles against `Layer`, but pardes.h still sees one row per tag layer (a taller tag shows its first row there) | | 8 | GUI draws from regions: role tiers with track groups in tiers 2-3, page cover, hard-edged snapped decor for rules/rails/grips (pane chrome in the pane's tier), per-instance clip; delete inference functions, `transient_on` and `mark_hover` (breaks macOS glass hover; accepted, macOS ignored for now) | stage-0 PPM goldens byte-identical (possible only because hard decor is snapped and not anti-aliased); pane chrome now also shows during transitions, which is the one allowed visible delta, listed | | 9 | post chain: glslang, Shadertoy prefix, ping-pong, ShaderAnimation, redraw levels A/B (§5.5); bundled CRT without barrel; delete scene_effects/crt.zig/crt.frag | first visual change (the CRT look) | | 10+ | `fx` bookmark: G1–G3 → feel review → G4 bundled → G5 lapis theme → P2s; tty T1–T2 → feel review → removals of audited effects (after user decision) → T3–T5 | each effect its own change, default off | diff --git a/src/Layer.zig b/src/Layer.zig new file mode 100644 index 00000000..e979eab7 --- /dev/null +++ b/src/Layer.zig @@ -0,0 +1,198 @@ +//! A band of cells a pixel shell draws over the grid at its own pitch: a +//! pane's, a column's or the workspace's tag rows, a notice chip, or a file +//! body whose compact tree-sitter context rows outnumber its grid rows. The +//! grid under it holds a copy of what fits there (draw.zig joins them), so a +//! terminal client, which has only the grid, loses nothing but the pitch. +//! +//! Its viewport is in grid cells, so compact text never moves a hit target. +//! A tag's rows are each one grid row, their glyphs at the tagline's size; a +//! body's first `context_rows` rows are stacked at the tagline's height, the +//! rest at the body's. `rows == 0` is no layer. +const std = @import("std"); +const pardes = @import("pardes.zig"); +const config = @import("config.zig"); +const limits = @import("memory.zig").limits; + +const Layer = @This(); + +/// `notice` is a pane's transient line -- a builtin's name, a pending leader +/// chord -- carried as a tagline-shaped band so it gets the tagline's height, +/// small-font metrics, band offset and border instead of being a body-grid row +/// wearing a tagline font. +pub const Kind = enum(u8) { workspace, column, pane, notice, body }; + +pub const Metrics = struct { + body_w: u16 = 1, + body_h: u16 = 1, + tagline_w: u16 = 1, + tagline_h: u16 = 1, +}; + +/// A point on a tag, notice or header layer: which row of it and which +/// column at the tagline's pitch. +pub const TagHit = struct { + kind: Kind, + id: u16, + serial: u32, + col: u16, + /// Which row of a taller tag. + line: u16 = 0, + pixel_x: f32, + pixel_y: f32, + metrics: Metrics, +}; + +kind: Kind = .body, +/// The pane slot, or the column, it belongs to. +id: u16 = 0, +serial: u32 = 0, +viewport: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, +cols: u16 = 0, +rows: u16 = 0, +/// A body's compact rows, from its top. +context_rows: u16 = 0, +context_separators: std.bit_set.StaticBitSet(limits.wrap_rows) = .initEmpty(), +/// `rows * cols`, row after row. +cells: []pardes.Cell = &.{}, +cursor: ?struct { x: u16, y: u16, bar: bool = false } = null, +/// A band's fill, where its cells run out. +bg: [3]u8 = .{ 0, 0, 0 }, +/// Vertical offset in rows (negative: above its viewport), for a band +/// that is moving into place. A pixel shell draws it offset and clipped +/// to the rows below the viewport's top; the grid has no such thing. +slide: f32 = 0, +/// How present a notice band is (1 = fully) while it fades up or +/// dissolves. `bg` and the cells are already blended by it; a pixel +/// shell uses it for what it draws around the band, like its rule. +fade: f32 = 1, + +/// Tag columns a viewport this wide holds at the tagline's pitch. +pub fn capacity(layer: *const Layer, metrics: Metrics) u16 { + return @intCast(@min(std.math.maxInt(u16), @max(1, @as(u32, layer.viewport.w) * @max(1, metrics.body_w) / @max(1, metrics.tagline_w)))); +} + +pub fn setContextLines(layer: *Layer, lines: []const i32) void { + layer.context_separators = .initEmpty(); + const count = @min(lines.len, layer.context_rows); + var index: usize = 0; + while (index + 1 < count and index < limits.wrap_rows) : (index += 1) { + if (lines[index + 1] != lines[index] +| 1) layer.context_separators.set(index); + } +} + +pub fn hasContextBorderAfter(layer: *const Layer, index: usize) bool { + if (index >= layer.context_rows or index >= limits.wrap_rows) return false; + return index + 1 == layer.context_rows or layer.context_separators.isSet(index); +} + +/// Pixel top of row `index`: a body's compact rows stacked at the tagline's +/// height, every other row a grid row. +pub fn rowTop(layer: *const Layer, index: u16, body_h: f32, tagline_h: f32) f32 { + const body = @max(1, body_h); + const tag = std.math.clamp(tagline_h, 1, body); + return @as(f32, @floatFromInt(layer.viewport.y)) * body + + @as(f32, @floatFromInt(@min(index, layer.context_rows))) * tag + + @as(f32, @floatFromInt(index -| layer.context_rows)) * body; +} + +pub fn rowHeight(layer: *const Layer, index: u16, body_h: f32, tagline_h: f32) f32 { + return if (index < layer.context_rows) std.math.clamp(tagline_h, 1, @max(1, body_h)) else @max(1, body_h); +} + +const Point = struct { index: u16, col: u16, metrics: Metrics }; + +/// The row and column a window pixel lands on, each row at its own pitch: +/// a tag's at the tagline's width, a body's compact rows too. +fn pointAt(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?Point { + if (layer.rows == 0 or !std.math.isFinite(x) or !std.math.isFinite(y)) return null; + // A band's hit is refused outright on metrics no host could have; a + // body's has always been floored to a pixel instead. + if (layer.kind != .body) for ([_]f32{ body_w, body_h, tagline_w, tagline_h }) |metric| + if (!std.math.isFinite(metric) or metric < 1 or metric > std.math.maxInt(u16)) return null; + const bw = @max(1, body_w); + const bh = @max(1, body_h); + const tw = std.math.clamp(tagline_w, 1, bw); + const th = std.math.clamp(tagline_h, 1, bh); + const dx = x - @as(f32, @floatFromInt(layer.viewport.x)) * bw; + const dy = y - @as(f32, @floatFromInt(layer.viewport.y)) * bh; + if (dx < 0 or dy < 0 or dx >= @as(f32, @floatFromInt(layer.viewport.w)) * bw or dy >= @as(f32, @floatFromInt(layer.viewport.h)) * bh) return null; + const headers = @as(f32, @floatFromInt(layer.context_rows)) * th; + const index: u16 = @intFromFloat(if (dy < headers) @floor(dy / th) else @as(f32, @floatFromInt(layer.context_rows)) + @floor((dy - headers) / bh)); + if (index >= layer.rows) return null; + const pitch = if (layer.kind != .body or index < layer.context_rows) tw else bw; + return .{ + .index = index, + .col = @intFromFloat(@min(@as(f32, @floatFromInt(layer.cols -| 1)), @floor(dx / pitch))), + .metrics = .{ .body_w = @intFromFloat(bw), .body_h = @intFromFloat(bh), .tagline_w = @intFromFloat(tw), .tagline_h = @intFromFloat(th) }, + }; +} + +/// A body layer's pixel as the body cell under it (grid columns, from the +/// pane's text edge). +pub fn bodyHit(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?pardes.Mouse.BodyHit { + if (layer.kind != .body) return null; + const point = layer.pointAt(x, y, body_w, body_h, tagline_w, tagline_h) orelse return null; + return .{ .serial = layer.serial, .row = point.index, .col = point.col + config.GUTTER, .pixel_x = x, .pixel_y = y, .metrics = point.metrics }; +} + +/// A band's pixel as the tag cell under it. +pub fn tagHit(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?TagHit { + if (layer.kind == .body) return null; + const point = layer.pointAt(x, y, body_w, body_h, tagline_w, tagline_h) orelse return null; + return .{ .kind = layer.kind, .id = layer.id, .serial = layer.serial, .line = point.index, .col = point.col, .pixel_x = x, .pixel_y = y, .metrics = point.metrics }; +} + +test "compact body layer rows fill reclaimed height and invert exact boundaries" { + const layer: Layer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 40, .h = 10 }, .cols = 40, .rows = 15, .context_rows = 5 }; + try std.testing.expectEqual(@as(f32, 120), layer.rowTop(5, 20, 12)); + try std.testing.expectEqual(@as(f32, 240), layer.rowTop(11, 20, 12)); + try std.testing.expectEqual(@as(f32, 12), layer.rowHeight(4, 20, 12)); + try std.testing.expectEqual(@as(f32, 20), layer.rowHeight(5, 20, 12)); + for (0..12) |index| { + const r: u16 = @intCast(index); + const y = layer.rowTop(r, 20, 12) + layer.rowHeight(r, 20, 12) / 2; + const width: f32 = if (r < 5) 6 else 10; + const hit = layer.bodyHit(40 + 3.5 * width, y, 10, 20, 6, 12).?; + try std.testing.expectEqual(r, hit.row); + try std.testing.expectEqual(@as(u16, 3 + config.GUTTER), hit.col); + } + try std.testing.expectEqual(@as(u16, 4), layer.bodyHit(45, 119.99, 10, 20, 6, 12).?.row); + try std.testing.expectEqual(@as(u16, 5), layer.bodyHit(45, 120, 10, 20, 6, 12).?.row); + try std.testing.expectEqual(@as(u16, 11), layer.bodyHit(45, 259.99, 10, 20, 6, 12).?.row); + try std.testing.expect(layer.bodyHit(45, 260, 10, 20, 6, 12) == null); + try std.testing.expect(layer.bodyHit(39.99, 80, 10, 20, 6, 12) == null); + try std.testing.expect(layer.bodyHit(std.math.nan(f32), 80, 10, 20, 6, 12) == null); +} + +test "compact context borders separate source gaps and preserve multiline headers" { + var layer: Layer = .{ .context_rows = 6 }; + layer.setContextLines(&.{ 3, 4, 8, 9, 10, 17 }); + const expected = [_]bool{ false, true, false, false, true, true, false }; + for (expected, 0..) |border, index| try std.testing.expectEqual(border, layer.hasContextBorderAfter(index)); + layer.context_rows = 2; + layer.setContextLines(&.{ 9, 10 }); + try std.testing.expect(!layer.hasContextBorderAfter(0)); + try std.testing.expect(layer.hasContextBorderAfter(1)); + try std.testing.expect(!layer.hasContextBorderAfter(4)); + try std.testing.expect(!layer.context_separators.isSet(4)); +} + +test "tag text capacity uses its own pitch and excludes the physical grip" { + const layer: Layer = .{ .kind = .pane, .id = 2, .serial = 7, .viewport = .{ .x = 12, .y = 4, .w = 38, .h = 1 }, .cols = 90, .rows = 1 }; + try std.testing.expectEqual(@as(u16, 63), layer.capacity(.{ .body_w = 10, .tagline_w = 6 })); + try std.testing.expect(layer.tagHit(119.99, 85, 10, 20, 6, 12) == null); + try std.testing.expectEqual(@as(u16, 0), layer.tagHit(120, 85, 10, 20, 6, 12).?.col); + try std.testing.expectEqual(@as(u16, 50), layer.tagHit(423, 85, 10, 20, 6, 12).?.col); + try std.testing.expect(layer.tagHit(500, 85, 10, 20, 6, 12) == null); + try std.testing.expect(layer.tagHit(120, 85, std.math.nan(f32), 20, 6, 12) == null); + try std.testing.expect(layer.tagHit(120, 85, 10, 20, 0, 12) == null); +} + +test "a taller tag's rows are grid rows, each hit on its own line" { + const layer: Layer = .{ .kind = .pane, .id = 2, .serial = 7, .viewport = .{ .x = 12, .y = 4, .w = 38, .h = 3 }, .cols = 90, .rows = 3 }; + try std.testing.expectEqual(@as(u16, 0), layer.tagHit(120, 80, 10, 20, 6, 12).?.line); + try std.testing.expectEqual(@as(u16, 1), layer.tagHit(120, 100, 10, 20, 6, 12).?.line); + try std.testing.expectEqual(@as(u16, 2), layer.tagHit(120, 139.99, 10, 20, 6, 12).?.line); + try std.testing.expect(layer.tagHit(120, 140, 10, 20, 6, 12) == null); + try std.testing.expect(layer.bodyHit(120, 100, 10, 20, 6, 12) == null); +} diff --git a/src/Messages.zig b/src/Messages.zig index 7334de95..b7153e5c 100644 --- a/src/Messages.zig +++ b/src/Messages.zig @@ -747,7 +747,7 @@ test "a builtin announces itself on the message row, and Verbose silences it" { _ = try p.render(frame.allocator()); const r = p.rects[p.active]; const band = for (p.surface.tagLayers()) |*layer| { - if (layer.cols != 0 and layer.kind == .notice) break layer; + if (layer.rows != 0 and layer.kind == .notice) break layer; } else return error.NoNoticeBand; try std.testing.expectEqual(@as(u16, 1), band.viewport.h); try std.testing.expectEqual(if (p.settings.tag_bottom) r.y else r.y + BOX_H, band.viewport.y); @@ -794,7 +794,7 @@ test "notices stack over the body's top rows without moving them" { var seen: usize = 0; for (p.surface.tagLayers()) |*layer| { - if (layer.cols == 0 or layer.kind != .notice) continue; + if (layer.rows == 0 or layer.kind != .notice) continue; try std.testing.expectEqual(@as(u16, 1), layer.viewport.h); // Sized to its own message, right up against the pane's right edge -- // not the width of the pane. @@ -814,7 +814,7 @@ test "notices stack over the body's top rows without moving them" { _ = frame.reset(.retain_capacity); _ = try p.render(frame.allocator()); for (p.surface.tagLayers()) |*layer| - try std.testing.expect(layer.cols == 0 or layer.kind != .notice); + try std.testing.expect(layer.rows == 0 or layer.kind != .notice); } test "an idle core wants no wake and a lingering message sleeps to its linger's end" { diff --git a/src/Presentation.zig b/src/Presentation.zig index 45b196c8..e1f1b585 100644 --- a/src/Presentation.zig +++ b/src/Presentation.zig @@ -42,8 +42,8 @@ shown_closing_len: usize = 0, acknowledged: bool = false, pending: bool = false, previous_cells: []pardes.Cell = &.{}, -previous_body_layers: [MAX_PANES]pardes.BodyLayer = @splat(.{}), -previous_tag_layers: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = @splat(.{}), +previous_body_layers: [MAX_PANES]pardes.Layer = @splat(.{}), +previous_tag_layers: [pardes.MAX_TAG_LAYERS]pardes.Layer = @splat(.{}), previous_cols: u16 = 0, previous_rows: u16 = 0, previous_valid: bool = false, @@ -335,7 +335,7 @@ pub fn capturePrevious(self: *Presentation, gpa: std.mem.Allocator, surface: *co old.cells = storage; } for (&self.previous_tag_layers, surface.tagLayers()) |*old, *current| { - const count = if (current.cols > 0) current.cells.len else 0; + const count = if (current.rows > 0) current.cells.len else 0; const storage = if (old.cells.len == count) old.cells else gpa.realloc(old.cells, count) catch { self.previous_valid = false; self.previous_layout = @splat(null); @@ -684,7 +684,7 @@ test "presentation submission failure preserves shown geometry until retry is ac } test "fractional panel inverse preserves compact header rows through slide and zoom" { - const layer: pardes.BodyLayer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 30, .h = 10 }, .cols = 30, .rows = 15, .context_rows = 4 }; + const layer: pardes.Layer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 30, .h = 10 }, .cols = 30, .rows = 15, .context_rows = 4 }; for ([_]Transition{ .slide, .zoom, .vertical }) |effect| { var presentation: Presentation = .{}; const track: Track = .{ .serial = 7, .effect = effect, .frame = 4, .frame_count = 10, .from = .{ .x = 2, .y = 2, .w = 20, .h = 8 }, .to = .{ .x = 3, .y = 2, .w = 32, .h = 12 } }; @@ -699,7 +699,7 @@ test "fractional panel inverse preserves compact header rows through slide and z const point = presentation.pointerFractional(80, 30, x, y).?; try std.testing.expectApproxEqAbs(source_x, point.x, 0.0001); try std.testing.expectApproxEqAbs(source_y, point.y, 0.0001); - const body_hit = layer.hitAt(point.x * 10, point.y * 20, 10, 20, 8, 10).?; + const body_hit = layer.bodyHit(point.x * 10, point.y * 20, 10, 20, 8, 10).?; try std.testing.expectEqual(row, body_hit.row); } } @@ -715,7 +715,7 @@ test "presentation snapshots own compact body and tag rows across later edits" { surface.body_layers[0] = .{ .serial = 9, .cols = 1, .rows = 2, .context_rows = 1, .cells = &body }; var tag: [1]pardes.Cell = .{.{}}; tag[0].text[0] = 'T'; - surface.tag_layers[0] = .{ .kind = .pane, .serial = 9, .cols = 1, .cells = &tag }; + surface.tag_layers[0] = .{ .kind = .pane, .serial = 9, .cols = 1, .rows = 1, .cells = &tag }; state.capturePrevious(std.testing.allocator, &surface); try std.testing.expect(state.previous_valid); tag[0].text[0] = 'U'; diff --git a/src/body_layer.zig b/src/body_layer.zig index ac87a07b..583a2e98 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -1,9 +1,6 @@ -//! A pane body as the surface shows it. A file body's logical rows can -//! outnumber its physical grid rows: hosts place the compact declaration rows -//! first and clip the remaining body rows. After the layer types come the -//! functions that draw a body into the surface: its text, colours, selections -//! and cursor; then the tags drawn beside it, a pane's and the column and -//! workspace ones, each line a tag layer of its own. +//! Drawing a pane body into a surface: its text, colours, selections and +//! cursor, and joining a body with compact context rows through its layer +//! (Layer.zig). const std = @import("std"); const pardes = @import("pardes.zig"); const normal = @import("normal.zig"); @@ -29,7 +26,7 @@ const tagline = @import("tagline.zig"); const Messages = @import("Messages.zig"); const Text = panes.Text; const MAX_PANES = pardes.MAX_PANES; -const TagKind = @import("tag_layer.zig").Kind; +const TagKind = @import("Layer.zig").Kind; const MAX_TAG_ROWS = pardes.MAX_TAG_ROWS; const NOTICE_LAYER_BASE = pardes.NOTICE_LAYER_BASE; const TAG_LINE_LAYER_BASE = pardes.TAG_LINE_LAYER_BASE; @@ -40,108 +37,7 @@ const COLBAR_H = pardes.COLBAR_H; const TAG_GAP = pardes.TAG_GAP; const wordBoundsAtCol = pardes.wordBoundsAtCol; -pub const Metrics = struct { - body_w: u16 = 1, - body_h: u16 = 1, - tagline_w: u16 = 1, - tagline_h: u16 = 1, -}; - -pub const Layer = struct { - pane: u16 = 0, - serial: u32 = 0, - viewport: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, - cols: u16 = 0, - rows: u16 = 0, - context_rows: u16 = 0, - context_separators: std.bit_set.StaticBitSet(limits.wrap_rows) = .initEmpty(), - cells: []pardes.Cell = &.{}, - cursor: ?struct { x: u16, y: u16, bar: bool = false } = null, - - pub fn setContextLines(layer: *Layer, lines: []const i32) void { - layer.context_separators = .initEmpty(); - const count = @min(lines.len, layer.context_rows); - var row: usize = 0; - while (row + 1 < count and row < limits.wrap_rows) : (row += 1) { - if (lines[row + 1] != lines[row] +| 1) layer.context_separators.set(row); - } - } - - pub fn hasContextBorderAfter(layer: *const Layer, row: usize) bool { - if (row >= layer.context_rows or row >= limits.wrap_rows) return false; - return row + 1 == layer.context_rows or layer.context_separators.isSet(row); - } - - pub fn rowTop(layer: *const Layer, row: u16, body_h: f32, tagline_h: f32) f32 { - const body = @max(1, body_h); - const tag = std.math.clamp(tagline_h, 1, body); - return @as(f32, @floatFromInt(layer.viewport.y)) * body + - @as(f32, @floatFromInt(@min(row, layer.context_rows))) * tag + - @as(f32, @floatFromInt(row -| layer.context_rows)) * body; - } - - pub fn rowHeight(layer: *const Layer, row: u16, body_h: f32, tagline_h: f32) f32 { - return if (row < layer.context_rows) std.math.clamp(tagline_h, 1, @max(1, body_h)) else @max(1, body_h); - } - - pub fn hitAt(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?pardes.Mouse.BodyHit { - if (layer.rows == 0 or !std.math.isFinite(x) or !std.math.isFinite(y)) return null; - const bw = @max(1, body_w); - const bh = @max(1, body_h); - const tw = std.math.clamp(tagline_w, 1, bw); - const th = std.math.clamp(tagline_h, 1, bh); - const dx = x - @as(f32, @floatFromInt(layer.viewport.x)) * bw; - const dy = y - @as(f32, @floatFromInt(layer.viewport.y)) * bh; - if (dx < 0 or dy < 0 or dx >= @as(f32, @floatFromInt(layer.viewport.w)) * bw or dy >= @as(f32, @floatFromInt(layer.viewport.h)) * bh) return null; - const headers = @as(f32, @floatFromInt(layer.context_rows)) * th; - const row: u16 = @intFromFloat(if (dy < headers) @floor(dy / th) else @as(f32, @floatFromInt(layer.context_rows)) + @floor((dy - headers) / bh)); - if (row >= layer.rows) return null; - const col: u16 = @intFromFloat(@min(@as(f32, @floatFromInt(layer.cols -| 1)), @floor(dx / (if (row < layer.context_rows) tw else bw)))); - return .{ - .serial = layer.serial, - .row = row, - .col = col + config.GUTTER, - .pixel_x = x, - .pixel_y = y, - .metrics = .{ .body_w = @intFromFloat(bw), .body_h = @intFromFloat(bh), .tagline_w = @intFromFloat(tw), .tagline_h = @intFromFloat(th) }, - }; - } -}; - -test "compact body layer rows fill reclaimed height and invert exact boundaries" { - const layer: Layer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 40, .h = 10 }, .cols = 40, .rows = 15, .context_rows = 5 }; - try std.testing.expectEqual(@as(f32, 120), layer.rowTop(5, 20, 12)); - try std.testing.expectEqual(@as(f32, 240), layer.rowTop(11, 20, 12)); - try std.testing.expectEqual(@as(f32, 12), layer.rowHeight(4, 20, 12)); - try std.testing.expectEqual(@as(f32, 20), layer.rowHeight(5, 20, 12)); - for (0..12) |index| { - const row: u16 = @intCast(index); - const y = layer.rowTop(row, 20, 12) + layer.rowHeight(row, 20, 12) / 2; - const width: f32 = if (row < 5) 6 else 10; - const hit = layer.hitAt(40 + 3.5 * width, y, 10, 20, 6, 12).?; - try std.testing.expectEqual(row, hit.row); - try std.testing.expectEqual(@as(u16, 3 + config.GUTTER), hit.col); - } - try std.testing.expectEqual(@as(u16, 4), layer.hitAt(45, 119.99, 10, 20, 6, 12).?.row); - try std.testing.expectEqual(@as(u16, 5), layer.hitAt(45, 120, 10, 20, 6, 12).?.row); - try std.testing.expectEqual(@as(u16, 11), layer.hitAt(45, 259.99, 10, 20, 6, 12).?.row); - try std.testing.expect(layer.hitAt(45, 260, 10, 20, 6, 12) == null); - try std.testing.expect(layer.hitAt(39.99, 80, 10, 20, 6, 12) == null); - try std.testing.expect(layer.hitAt(std.math.nan(f32), 80, 10, 20, 6, 12) == null); -} - -test "compact context borders separate source gaps and preserve multiline headers" { - var layer: Layer = .{ .context_rows = 6 }; - layer.setContextLines(&.{ 3, 4, 8, 9, 10, 17 }); - const expected = [_]bool{ false, true, false, false, true, true, false }; - for (expected, 0..) |border, row| try std.testing.expectEqual(border, layer.hasContextBorderAfter(row)); - layer.context_rows = 2; - layer.setContextLines(&.{ 9, 10 }); - try std.testing.expect(!layer.hasContextBorderAfter(0)); - try std.testing.expect(layer.hasContextBorderAfter(1)); - try std.testing.expect(!layer.hasContextBorderAfter(4)); - try std.testing.expect(!layer.context_separators.isSet(4)); -} +const Layer = @import("Layer.zig"); // ---- drawing a pane body into the surface ---- @@ -305,7 +201,8 @@ pub fn joinBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, const start = (row + first_row) * r.w + config.GUTTER; @memcpy(layer.cells[row * width ..][0..width], temporary_cells[start..][0..width]); } - layer.pane = @intCast(id); + layer.kind = .body; + layer.id = @intCast(id); layer.serial = pane.serial; const body = s.region(.body, id).?; layer.viewport = .{ .x = body.rect.x, .y = body.rect.y, .w = body.rect.w - body.clip, .h = body.rect.h }; diff --git a/src/detached/client.zig b/src/detached/client.zig index 4468c961..3347f609 100644 --- a/src/detached/client.zig +++ b/src/detached/client.zig @@ -103,8 +103,10 @@ pub const Client = struct { grid: std.ArrayListUnmanaged(pardes.Cell) = .empty, cursor: ?wire.Cursor = null, pointer_shape: pardes.Surface.PointerShape = .arrow, - body_layers: [pardes.MAX_PANES]pardes.BodyLayer = @splat(.{}), - tag_layers: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = @splat(.{}), + body_layers: [pardes.MAX_PANES]pardes.Layer = @splat(.{}), + tag_layers: [pardes.MAX_TAG_LAYERS]pardes.Layer = @splat(.{}), + /// The session's placed regions, as its last frame gave them. + regions: std.ArrayListUnmanaged(pardes.Region) = .empty, in: std.ArrayListUnmanaged(u8) = .empty, out: std.ArrayListUnmanaged(u8) = .empty, /// Bytes of `in` belonging to the message `next` returned last. Compacted at @@ -182,6 +184,7 @@ pub const Client = struct { layer.* = .{}; } for (&c.tag_layers) |*layer| c.gpa.free(layer.cells); + c.regions.deinit(c.gpa); c.in.deinit(c.gpa); c.out.deinit(c.gpa); } @@ -319,7 +322,7 @@ pub const Client = struct { try c.reshape(f.cols, f.rows); } try f.apply(c.grid.items); - f.applyLayers(c.gpa, &c.body_layers, &c.tag_layers) catch |err| return switch (err) { + f.applyLayers(c.gpa, &c.body_layers, &c.tag_layers, &c.regions) catch |err| return switch (err) { error.OutOfMemory => error.Closed, else => @as(wire.Error, @errorCast(err)), }; @@ -995,6 +998,12 @@ test "detached session: a frontend that dies takes nothing with it" { } test "detached session: a frontend speaking another protocol is refused, loudly" { + // The protocol before this one (v7 carried one-row tags and no regions) + // and the one after. + for ([_]u16{ wire.version - 1, wire.version + 1 }) |claimed| try refusedHello(claimed); +} + +fn refusedHello(claimed: u16) !void { var h: Harness = undefined; try h.init(60, 16); defer h.deinit(); @@ -1006,7 +1015,7 @@ test "detached session: a frontend speaking another protocol is refused, loudly" defer _ = libc.close(fd); var buf: [64]u8 = undefined; const hello = try wire.encodeClient(&buf, .{ - .hello = .{ .version = wire.version + 1, .cols = 60, .rows = 16 }, + .hello = .{ .version = claimed, .cols = 60, .rows = 16 }, }); try testing.expectEqual(@as(isize, @intCast(hello.len)), libc.send(fd, hello.ptr, hello.len, nosignal)); @@ -1024,6 +1033,17 @@ test "detached session: a frontend speaking another protocol is refused, loudly" try testing.expect(!h.core.quit); } +test "detached frontend: a session speaking another protocol is not attached to" { + for ([_]u16{ wire.version - 1, wire.version + 1 }) |claimed| { + var c: Client = .{ .gpa = testing.allocator }; + defer c.deinit(); + var buf: [64]u8 = undefined; + try c.in.appendSlice(testing.allocator, try wire.encodeServer(&buf, .{ .welcome = .{ .version = claimed, .slot = 0, .cols = 60, .rows = 16 } })); + try testing.expectError(error.Ungreeted, c.next()); + try testing.expect(!c.attached()); + } +} + test "detached session: a peer that sends garbage is dropped, not obeyed" { var h: Harness = undefined; try h.init(60, 16); @@ -1281,7 +1301,7 @@ fn pumpUntilNotice(h: *Harness, c: *Client, want: []const u8) !bool { try c.wait(5); while (try c.next()) |_| {} for (&c.tag_layers) |*layer| { - if (layer.cols == 0 or layer.kind != .notice) continue; + if (layer.rows == 0 or layer.kind != .notice) continue; var n: usize = 0; for (layer.cells[0..layer.cols]) |cell| { const g = cell.grapheme(); diff --git a/src/detached/server.zig b/src/detached/server.zig index 212c9a3d..46fb8fcf 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -739,7 +739,7 @@ pub const Session = struct { fn sendFrame(s: *Session, c: *Client, surface: *const pardes.Surface) void { const cells = surface.cells; - const want = wire.frameBound(surface.cols, surface.rows) + wire.layersBound(&surface.body_layers, &surface.tag_layers); + const want = wire.frameBound(surface.cols, surface.rows) + wire.layersBound(&surface.body_layers, &surface.tag_layers, surface.regionList()); s.scratch.ensureTotalCapacity(s.gpa, want) catch return s.close(c, .oom); const prev: []const pardes.Cell = if (c.need_full or c.mirror.items.len != cells.len) &.{} @@ -759,6 +759,7 @@ pub const Session = struct { prev, &surface.body_layers, &surface.tag_layers, + surface.regionList(), ) catch |err| { log.debug("frame {d}x{d} not encodable: {t}", .{ surface.cols, surface.rows, err }); return; diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 94bd7f0b..684c869b 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -7,7 +7,9 @@ const limits = @import("../memory.zig").limits; /// 7: tag layers gained the `.notice` kind, so a frame can carry a tag value /// an older frontend would reject mid-session. The greeting is where that has /// to be refused, which is what this number is for. -pub const version: u16 = 7; +/// 8: one layer type: a tag band carries all its rows (not a layer per +/// line), and a frame carries the core's placed regions after its layers. +pub const version: u16 = 8; pub const Error = error{ Truncated, Overlong, BadTag, BadValue, Trailing, NoSpace }; @@ -167,7 +169,8 @@ pub const Frame = struct { nruns: u32, runs: []const u8, - pub fn applyLayers(f: Frame, gpa: std.mem.Allocator, layers: *[pardes.MAX_PANES]pardes.BodyLayer, tags: *[pardes.MAX_TAG_LAYERS]pardes.TagLayer) !void { + pub fn applyLayers(f: Frame, gpa: std.mem.Allocator, layers: *[pardes.MAX_PANES]pardes.Layer, tags: *[pardes.MAX_TAG_LAYERS]pardes.Layer, regions: *std.ArrayListUnmanaged(pardes.Region)) !void { + regions.clearRetainingCapacity(); for (layers) |*layer| { gpa.free(layer.cells); layer.* = .{}; @@ -191,13 +194,14 @@ pub const Frame = struct { const count = try r.getU16(); if (count > layers.len) return error.Overlong; for (layers[0..count]) |*layer| { - layer.pane = try r.getU16(); + layer.kind = .body; + layer.id = try r.getU16(); layer.serial = try r.getU32(); layer.viewport = .{ .x = try r.getU16(), .y = try r.getU16(), .w = try r.getU16(), .h = try r.getU16() }; layer.cols = try r.getU16(); layer.rows = try r.getU16(); layer.context_rows = try r.getU16(); - if (layer.pane >= pardes.MAX_PANES or layer.cols == 0 or layer.cols > f.cols or + if (layer.id >= pardes.MAX_PANES or layer.cols == 0 or layer.cols > f.cols or layer.rows == 0 or layer.rows > 2 * max_rows or layer.context_rows > layer.rows or layer.context_rows > limits.wrap_rows or layer.viewport.x > f.cols or layer.viewport.w > f.cols - layer.viewport.x or layer.viewport.y > f.rows or layer.viewport.h > f.rows - layer.viewport.y) return error.BadValue; @@ -217,19 +221,40 @@ pub const Frame = struct { layer.serial = try r.getU32(); layer.viewport = .{ .x = try r.getU16(), .y = try r.getU16(), .w = try r.getU16(), .h = try r.getU16() }; layer.cols = try r.getU16(); + layer.rows = try r.getU16(); for (&layer.bg) |*channel| channel.* = try r.getByte(); - if (layer.cols == 0 or layer.viewport.w == 0 or layer.viewport.h != 1 or + if (layer.cols == 0 or layer.viewport.w == 0 or layer.rows == 0 or layer.rows > pardes.MAX_TAG_ROWS or + layer.viewport.h != layer.rows or layer.viewport.x > f.cols or layer.viewport.w > f.cols - layer.viewport.x or - layer.viewport.y >= f.rows or (switch (layer.kind) { + layer.viewport.y >= f.rows or layer.viewport.h > f.rows - layer.viewport.y or (switch (layer.kind) { .workspace => layer.id != 0, .column => layer.id >= pardes.MAX_COLS, .pane, .notice => layer.id >= pardes.MAX_PANES, + .body => true, })) return error.BadValue; - const cursor = try getCursor(&r, layer.cols, 1); - layer.cursor = if (cursor) |c| .{ .col = c.x, .bar = c.bar } else null; - layer.cells = try gpa.alloc(pardes.Cell, layer.cols); + const cursor = try getCursor(&r, layer.cols, layer.rows); + layer.cursor = if (cursor) |c| .{ .x = c.x, .y = c.y, .bar = c.bar } else null; + layer.cells = try gpa.alloc(pardes.Cell, @as(usize, layer.cols) * layer.rows); for (layer.cells) |*cell| cell.* = try decodeCell(&r); } + const region_count = try r.getU16(); + if (region_count > pardes.MAX_REGIONS) return error.Overlong; + try regions.ensureTotalCapacity(gpa, region_count); + for (0..region_count) |_| { + var region: pardes.Region = .{ .kind = try r.getTag(pardes.Region.Kind), .rect = undefined }; + region.owner = try r.getByte(); + region.serial = try r.getU32(); + region.rect = .{ .x = try r.getU16(), .y = try r.getU16(), .w = try r.getU16(), .h = try r.getU16() }; + region.active = try r.getBool(); + region.thumb_y = try r.getU16(); + region.thumb_h = try r.getU16(); + region.notice = try r.getByte(); + region.clip = try r.getU16(); + if (region.rect.x > f.cols or region.rect.w > f.cols - region.rect.x or + region.rect.y > f.rows or region.rect.h > f.rows - region.rect.y or + region.clip > region.rect.w or region.thumb_y > region.rect.h or region.thumb_h > region.rect.h - region.thumb_y) return error.BadValue; + regions.appendAssumeCapacity(region); + } try r.end(); } @@ -672,21 +697,23 @@ pub fn encodeFramePointer( cells: []const pardes.Cell, prev: []const pardes.Cell, ) Error![]const u8 { - return encodeFrameLayers(out, cols, rows, cursor, pointer_shape, cells, prev, &.{}, &.{}); + return encodeFrameLayers(out, cols, rows, cursor, pointer_shape, cells, prev, &.{}, &.{}, &.{}); } -pub fn layersBound(layers: []const pardes.BodyLayer, tags: []const pardes.TagLayer) usize { - var n: usize = 4; +pub fn layersBound(layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region) usize { + var n: usize = 6; for (layers) |layer| if (layer.rows != 0) { n += 26 + layer.context_rows + layer.cells.len * cell_max; }; - for (tags) |layer| if (layer.cols != 0) { - n += 26 + layer.cells.len * cell_max; + for (tags) |layer| if (layer.rows != 0) { + n += 28 + layer.cells.len * cell_max; }; - return n; + return n + regions.len * region_len; } -pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, pointer_shape: pardes.Surface.PointerShape, cells: []const pardes.Cell, prev: []const pardes.Cell, layers: []const pardes.BodyLayer, tags: []const pardes.TagLayer) Error![]const u8 { +const region_len = 1 + 1 + 4 + 8 + 1 + 2 + 2 + 1 + 2; + +pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, pointer_shape: pardes.Surface.PointerShape, cells: []const pardes.Cell, prev: []const pardes.Cell, layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region) Error![]const u8 { // The protocol's ceiling, enforced by the ENCODER too, and BEFORE the // assert below so a caller can be told rather than tripped. `max_payload` // is derived from these two, so a larger grid is a frame this decoder @@ -708,7 +735,7 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point const layers_len_at = w.n; try w.putU32(0); const layers_start = w.n; - if (layers.len != 0 or tags.len != 0) { + if (layers.len != 0 or tags.len != 0 or regions.len != 0) { var count: u16 = 0; for (layers) |layer| if (layer.rows != 0) { count += 1; @@ -717,7 +744,7 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point for (layers) |layer| { if (layer.rows == 0) continue; if (layer.context_rows > limits.wrap_rows or layer.context_rows > layer.rows) return error.BadValue; - try w.putU16(layer.pane); + try w.putU16(layer.id); try w.putU32(layer.serial); try w.putU16(layer.viewport.x); try w.putU16(layer.viewport.y); @@ -731,13 +758,13 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point for (layer.cells) |*cell| try putCell(&w, cell); } var tag_count: u16 = 0; - for (tags) |layer| if (layer.cols != 0) { + for (tags) |layer| if (layer.rows != 0) { tag_count += 1; }; try w.putU16(tag_count); for (tags) |layer| { - if (layer.cols == 0) continue; - if (layer.cells.len != layer.cols) return error.BadValue; + if (layer.rows == 0) continue; + if (layer.cells.len != @as(usize, layer.cols) * layer.rows) return error.BadValue; try w.putByte(@intFromEnum(layer.kind)); try w.putU16(layer.id); try w.putU32(layer.serial); @@ -746,10 +773,26 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point try w.putU16(layer.viewport.w); try w.putU16(layer.viewport.h); try w.putU16(layer.cols); + try w.putU16(layer.rows); for (layer.bg) |channel| try w.putByte(channel); - try putCursor(&w, if (layer.cursor) |c| .{ .x = c.col, .y = 0, .bar = c.bar } else null, layer.cols, 1); + try putCursor(&w, if (layer.cursor) |c| .{ .x = c.x, .y = c.y, .bar = c.bar } else null, layer.cols, layer.rows); for (layer.cells) |*cell| try putCell(&w, cell); } + try w.putU16(@intCast(regions.len)); + for (regions) |region| { + try w.putByte(@intFromEnum(region.kind)); + try w.putByte(region.owner); + try w.putU32(region.serial); + try w.putU16(region.rect.x); + try w.putU16(region.rect.y); + try w.putU16(region.rect.w); + try w.putU16(region.rect.h); + try w.putBool(region.active); + try w.putU16(region.thumb_y); + try w.putU16(region.thumb_h); + try w.putByte(region.notice); + try w.putU16(region.clip); + } } std.mem.writeInt(u32, w.buf[layers_len_at..][0..4], @intCast(w.n - layers_start), .little); const nruns_at = w.n; @@ -1820,22 +1863,26 @@ test "detached wire carries compact body rows and exact pointer metrics" { var cells: [8]pardes.Cell = @splat(.{}); var logical: [12]pardes.Cell = @splat(.{}); logical[10] = .{ .text = "z".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false }; - var layer: pardes.BodyLayer = .{ .pane = 0, .serial = 42, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 4 }, .cols = 2, .rows = 6, .context_rows = 2, .cells = &logical, .cursor = .{ .x = 0, .y = 5, .bar = true } }; + var layer: pardes.Layer = .{ .id = 0, .serial = 42, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 4 }, .cols = 2, .rows = 6, .context_rows = 2, .cells = &logical, .cursor = .{ .x = 0, .y = 5, .bar = true } }; layer.context_separators.set(0); var tag_cells: [3]pardes.Cell = @splat(.{}); tag_cells[2] = logical[10]; - const tag: pardes.TagLayer = .{ .kind = .column, .id = 1, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 1 }, .cols = 3, .bg = .{ 11, 22, 33 }, .cells = &tag_cells, .cursor = .{ .col = 2, .bar = true } }; - const out = try gpa.alloc(u8, frameBound(2, 4) + layersBound(&.{layer}, &.{tag})); + const tag: pardes.Layer = .{ .kind = .column, .id = 1, .viewport = .{ .x = 0, .y = 0, .w = 2, .h = 1 }, .cols = 3, .rows = 1, .bg = .{ 11, 22, 33 }, .cells = &tag_cells, .cursor = .{ .x = 2, .y = 0, .bar = true } }; + const placed = [_]pardes.Region{.{ .kind = .rail, .owner = 3, .serial = 42, .rect = .{ .x = 0, .y = 1, .w = 2, .h = 3 }, .active = true, .thumb_y = 1, .thumb_h = 2, .clip = 1 }}; + const out = try gpa.alloc(u8, frameBound(2, 4) + layersBound(&.{layer}, &.{tag}, &placed)); defer gpa.free(out); - const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag}); + const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag}, &placed); const packet = (try framed(encoded)).?; const frame = (try decodeServer(packet.tag, packet.payload)).frame; - var decoded: [pardes.MAX_PANES]pardes.BodyLayer = @splat(.{}); - var tags: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = @splat(.{}); + var decoded: [pardes.MAX_PANES]pardes.Layer = @splat(.{}); + var tags: [pardes.MAX_TAG_LAYERS]pardes.Layer = @splat(.{}); defer for (&tags) |*item| gpa.free(item.cells); defer for (&decoded) |*item| gpa.free(item.cells); try frame.apply(&cells); - try frame.applyLayers(gpa, &decoded, &tags); + var regions: std.ArrayListUnmanaged(pardes.Region) = .empty; + defer regions.deinit(gpa); + try frame.applyLayers(gpa, &decoded, &tags, ®ions); + try std.testing.expectEqualDeep(placed[0..], regions.items); try std.testing.expectEqual(@as(u16, 6), decoded[0].rows); try std.testing.expect(decoded[0].context_separators.isSet(0)); try std.testing.expect(!decoded[0].context_separators.isSet(1)); @@ -1847,10 +1894,10 @@ test "detached wire carries compact body rows and exact pointer metrics" { try std.testing.expectEqual(tag.id, tags[0].id); try std.testing.expectEqual(tag.bg, tags[0].bg); try std.testing.expectEqual(tag.cols, tags[0].cols); - try std.testing.expectEqual(@as(u16, 2), tags[0].cursor.?.col); + try std.testing.expectEqual(@as(u16, 2), tags[0].cursor.?.x); try std.testing.expectEqualStrings("z", tags[0].cells[2].grapheme()); - const pointer = layer.hitAt(4, 27, 8, 16, 6, 8).?; - const tag_pointer = tag.hitAt(11, 8, 8, 16, 4, 8).?; + const pointer = layer.bodyHit(4, 27, 8, 16, 6, 8).?; + const tag_pointer = tag.tagHit(11, 8, 8, 16, 4, 8).?; const message: ClientMsg = .{ .event = .{ .mouse = .{ .col = 0, .row = 1, .button = .left, .kind = .press, .body_hit = pointer, .tag_hit = tag_pointer } } }; var mouse_bytes: [256]u8 = undefined; const mouse_encoded = try encodeClient(&mouse_bytes, message); @@ -1861,19 +1908,20 @@ test "detached wire carries compact body rows and exact pointer metrics" { try std.testing.expect(mouse_encoded.len <= clientBound(message)); var truncated = frame; truncated.layers = frame.layers[0 .. frame.layers.len - 1]; - try std.testing.expectError(error.Truncated, truncated.applyLayers(gpa, &decoded, &tags)); + try std.testing.expectError(error.Truncated, truncated.applyLayers(gpa, &decoded, &tags, ®ions)); for (decoded) |item| { try std.testing.expectEqual(@as(u16, 0), item.rows); try std.testing.expectEqual(@as(usize, 0), item.cells.len); try std.testing.expectEqual(@as(usize, 0), item.context_separators.count()); } for (tags) |item| { - try std.testing.expectEqual(@as(u16, 0), item.cols); + try std.testing.expectEqual(@as(u16, 0), item.rows); try std.testing.expectEqual(@as(usize, 0), item.cells.len); } + try std.testing.expectEqual(@as(usize, 0), regions.items.len); const no_layers = try encodeFramePointer(out, 2, 4, null, .arrow, &cells, &cells); const cleared_packet = (try framed(no_layers)).?; const cleared = (try decodeServer(cleared_packet.tag, cleared_packet.payload)).frame; - try cleared.applyLayers(gpa, &decoded, &tags); + try cleared.applyLayers(gpa, &decoded, &tags, ®ions); try std.testing.expectEqual(@as(u16, 0), decoded[0].rows); } diff --git a/src/draw.zig b/src/draw.zig index 7d91ad31..7cfe7554 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -28,7 +28,7 @@ const Pane = panes.Pane; const Text = panes.Text; const Rect = layout.Rect; const Region = @import("surface.zig").Region; -const TagKind = @import("tag_layer.zig").Kind; +const TagKind = @import("Layer.zig").Kind; const pdf_enabled = panes.Pdf.enabled; const wordBoundsAtCol = pardes.wordBoundsAtCol; const BOX_H = pardes.BOX_H; @@ -40,8 +40,6 @@ const MAX_PANES = pardes.MAX_PANES; const MAX_COLS = pardes.MAX_COLS; const MAX_TAG_ROWS = pardes.MAX_TAG_ROWS; const NOTICE_LAYER_BASE = pardes.NOTICE_LAYER_BASE; -const TAG_LINE_LAYER_BASE = pardes.TAG_LINE_LAYER_BASE; -const HEADER_LINE_LAYER_BASE = pardes.HEADER_LINE_LAYER_BASE; /// Where every part of the frame goes, decided once before anything is /// painted: the page; each pane's grip, tag, body, rail (with its thumb) @@ -172,7 +170,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { // never set. for (s.cells) |*c| c.hover = false; for (&s.body_layers) |*layer| layer.rows = 0; - for (&s.tag_layers) |*layer| layer.cols = 0; + for (&s.tag_layers) |*layer| layer.rows = 0; s.nimages = 0; s.npanel_tracks = 0; s.previous_cells = &.{}; @@ -505,11 +503,9 @@ pub fn paintHeaders(p: *Pardes, s: *Surface, arena: std.mem.Allocator) !void { tagline.revealHeader(p); if (s.region(.workspace_tag, Region.none)) |region| { const top = if (p.header_focus and p.header_column == null) p.header_top else 0; - for (0..region.rect.h) |line| { - const index = if (line == 0) MAX_PANES + MAX_COLS else HEADER_LINE_LAYER_BASE + MAX_COLS * (MAX_TAG_ROWS - 1) + line - 1; - try renderHeaderLayer(p, arena, index, .workspace, 0, @intCast(line), .{ .x = region.rect.x, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w, .h = TOPBAR_H }, modal.lineSlice(tagline.topbar(p), line + top), chrome.tag_bg, chrome.tag_fg); - joinHeaderLine(s, &s.tag_layers[index], chrome.tag_fg); - } + const layer = &s.tag_layers[MAX_PANES + MAX_COLS]; + try renderHeaderLayer(p, arena, layer, .workspace, 0, region.rect, tagline.topbar(p), top, chrome.tag_bg, chrome.tag_fg); + joinHeaderRows(s, layer, chrome.tag_fg); } const column_rows = p.columnBarHeight(); if (column_rows > 0) for (0..p.ncol) |c| { @@ -531,23 +527,21 @@ pub fn paintHeaders(p: *Pardes, s: *Surface, arena: std.mem.Allocator) !void { s.fill(p.col_x[c] + grip, region.rect.y, inset - grip, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); } const top = if (p.header_focus and p.header_column == c) p.header_top else 0; - for (0..column_rows) |line| { - const index = if (line == 0) MAX_PANES + c else HEADER_LINE_LAYER_BASE + c * (MAX_TAG_ROWS - 1) + line - 1; - try renderHeaderLayer(p, arena, index, .column, @intCast(c), @intCast(line), .{ .x = region.rect.x, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w, .h = COLBAR_H }, modal.lineSlice(tagline.columnTag(p, c), line + top), bg, fg); - if (p.col_x[c] < s.cols) joinHeaderLine(s, &s.tag_layers[index], fg); - // Laid out at its full width; only shown short of the rail. - s.tag_layers[index].viewport.w -= region.clip; - } + const layer = &s.tag_layers[MAX_PANES + c]; + try renderHeaderLayer(p, arena, layer, .column, @intCast(c), region.rect, tagline.columnTag(p, c), top, bg, fg); + if (p.col_x[c] < s.cols) joinHeaderRows(s, layer, fg); + // Laid out at its full width; only shown short of the rail. + layer.viewport.w -= region.clip; }; } -/// Copy a header line's layer into the grid under it, and its caret. -fn joinHeaderLine(s: *Surface, layer: *const pardes.TagLayer, fg: [3]u8) void { +/// Copy a header's layer into the grid under it, row by row, and its caret. +fn joinHeaderRows(s: *Surface, layer: *const pardes.Layer, fg: [3]u8) void { const v = layer.viewport; const w = @min(v.w, s.cols -| v.x); - joinCells(s, v.x, v.y, layer.cells[0..layer.cols], 0, w, .{ .rgb = fg }); + for (0..layer.rows) |line| joinCells(s, v.x, v.y + @as(u16, @intCast(line)), layer.cells[line * layer.cols ..][0..layer.cols], 0, w, .{ .rgb = fg }); if (layer.cursor) |caret| { - if (caret.col < v.w) s.cursor = .{ .x = v.x + caret.col, .y = v.y, .bar = caret.bar }; + if (caret.x < v.w) s.cursor = .{ .x = v.x + caret.x, .y = v.y + caret.y, .bar = caret.bar }; } } @@ -600,77 +594,85 @@ fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { return .{ .lo = start + basename, .hi = start + name.len }; } -/// One row of a header or a notice band as a tag layer: `text` is line -/// `line` of what it shows. -fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: TagKind, id: u16, line: u16, viewport: Rect, text: []const u8, bg: [3]u8, fg: [3]u8) !void { +/// A header or a notice band as a tag layer: `viewport.h` rows, line `top` +/// of `text` and on, laid out at the tagline's pitch -- as wide as its +/// widest line and never narrower than its viewport. +fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, layer: *pardes.Layer, kind: TagKind, id: u16, viewport: Rect, text: []const u8, top: usize, bg: [3]u8, fg: [3]u8) !void { _ = arena; - const layer = &p.surface.tag_layers[index]; - if (viewport.w == 0) { - layer.cols = 0; + if (viewport.w == 0 or viewport.h == 0) { + layer.rows = 0; return; } - const columns: u16 = @intCast(@min(std.math.maxInt(u16), @max(p.tagCapacity(viewport.w), panes.File.displayWidth(text) + 1))); - if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); + var widest: usize = 0; + for (0..viewport.h) |line| widest = @max(widest, panes.File.displayWidth(modal.lineSlice(text, line + top))); + const columns: u16 = @intCast(@min(std.math.maxInt(u16), @max(p.tagCapacity(viewport.w), widest + 1))); + const count = @as(usize, columns) * viewport.h; + if (layer.cells.len != count) layer.cells = try p.gpa.realloc(layer.cells, count); layer.kind = kind; layer.id = id; - layer.line = line; layer.serial = if (kind == .column) p.col_serial[id] else 0; layer.viewport = viewport; layer.cols = columns; + layer.rows = viewport.h; + layer.context_rows = 0; layer.bg = bg; layer.cursor = null; layer.slide = 0; layer.fade = 1; - var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells }; // A notice band carries a PANE id, not a column index, so it must not // answer to the column being edited -- that would scroll and select // its text with the column tag's offsets and give it a second caret. const editing: ?*Text = if (p.header_focus and switch (kind) { .workspace => p.header_column == null, .column => p.header_column == id, - .pane, .notice => false, + .pane, .notice, .body => false, }) tagline.focusedHeader(p) else null; const scroll = if (editing != null) p.header_scroll else 0; - surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); - _ = surface.print(0, 0, columns, text[panes.File.rawAtDisplay(text, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); - // The word under the pointer: on a header even while it is edited, but - // not on a notice (a message is not a command line), nor on a column a - // carried column's pointer is aiming at a place over. - const hovered = p.pointer_inside and kind != .notice and !(kind == .column and p.drag == .column_move); - if (hovered) { - const tag_hit = p.pointer_tag_hit; - const col: ?usize = if (tag_hit) |pointer| - (if (pointer.kind == kind and pointer.id == id and pointer.line == line) pointer.col else null) - else if (p.hover_row == viewport.y and p.hover_col >= viewport.x and p.hover_col - viewport.x < viewport.w) - p.hover_col - viewport.x - else - null; - if (col) |hover| if (wordBoundsAtCol(text, panes.File.rawAtDisplay(text, hover + scroll))) |bounds| { - var cell = panes.File.rawDisplayCol(text, bounds.lo) -| scroll; - const end = panes.File.rawDisplayCol(text, bounds.hi) -| scroll; - while (cell < end and cell < columns) : (cell += 1) { - surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg }; - surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg }; - } - }; - } - if (editing) |t| { - if (tagline.headerSelection(t, try edit.flatSurface(p, t), line + p.header_top)) |span| { - var col = panes.File.rawDisplayCol(text, span.lo) -| scroll; - const end = panes.File.rawDisplayCol(text, span.hi) -| scroll; - while (col < end and col < columns) : (col += 1) { - surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg }; - surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg }; + for (0..viewport.h) |index| { + const line: u16 = @intCast(index); + const shown = modal.lineSlice(text, index + top); + var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells[index * columns ..][0..columns] }; + surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); + _ = surface.print(0, 0, columns, shown[panes.File.rawAtDisplay(shown, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); + // The word under the pointer: on a header even while it is edited, + // but not on a notice (a message is not a command line), nor on a + // column a carried column's pointer is aiming at a place over. + const hovered = p.pointer_inside and kind != .notice and !(kind == .column and p.drag == .column_move); + if (hovered) { + const tag_hit = p.pointer_tag_hit; + const col: ?usize = if (tag_hit) |pointer| + (if (pointer.kind == kind and pointer.id == id and pointer.line == line) pointer.col else null) + else if (p.hover_row == viewport.y + line and p.hover_col >= viewport.x and p.hover_col - viewport.x < viewport.w) + p.hover_col - viewport.x + else + null; + if (col) |hover| if (wordBoundsAtCol(shown, panes.File.rawAtDisplay(shown, hover + scroll))) |bounds| { + var cell = panes.File.rawDisplayCol(shown, bounds.lo) -| scroll; + const end = panes.File.rawDisplayCol(shown, bounds.hi) -| scroll; + while (cell < end and cell < columns) : (cell += 1) { + surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg }; + surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg }; + } + }; + } + if (editing) |t| { + if (tagline.headerSelection(t, try edit.flatSurface(p, t), line + p.header_top)) |span| { + var col = panes.File.rawDisplayCol(shown, span.lo) -| scroll; + const end = panes.File.rawDisplayCol(shown, span.hi) -| scroll; + while (col < end and col < columns) : (col += 1) { + surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg }; + surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg }; + } } + const caret = panes.File.rawDisplayCol(shown, @min(@as(usize, @intCast(@max(0, t.cur_col))), shown.len)) -| scroll; + const asking = if (p.panes[p.active]) |active| active.prompt != .none else false; + if (!asking and t.cur_row == line + p.header_top and caret < columns) layer.cursor = .{ .x = @intCast(caret), .y = line, .bar = t.mode == .insert }; } - const caret = panes.File.rawDisplayCol(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.len)) -| scroll; - const asking = if (p.panes[p.active]) |active| active.prompt != .none else false; - if (!asking and t.cur_row == line + p.header_top and caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = t.mode == .insert }; } } -/// A pane's tag rows, painted once into their layers, a layer to a line, -/// and copied into the grid; the grid's caret where the layer has it. +/// A pane's tag rows, painted once into their layer, a row to a line, and +/// copied into the grid; the grid's caret where the layer has it. fn joinPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void { const capacity = p.tagCapacity(r.w -| TAG_TEXT_INSET); // Too narrow for a layer: only the grid's gap cell. @@ -692,23 +694,26 @@ fn joinPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = rows, .cells = storage }; try paintPaneTag(p, &temporary, arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, active, capacity); const tag_fg: Color = .{ .rgb = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg }; - // A layer to a line: the first in the pane's own slot, the rest in the - // slots kept for the lines of taller tags. + // One layer, a row to each line the tag shows. + const layer = &s.tag_layers[id]; + const count = @as(usize, columns) * rows; + if (layer.cells.len != count) layer.cells = try p.gpa.realloc(layer.cells, count); + layer.kind = .pane; + layer.id = @intCast(id); + layer.serial = pane.serial; + layer.viewport = .{ .x = region.rect.x + TAG_GAP, .y = region.rect.y, .w = region.rect.w - TAG_GAP - region.clip, .h = rows }; + layer.cols = columns; + layer.rows = rows; + layer.context_rows = 0; + layer.bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; + layer.slide = 0; + layer.fade = 1; + layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor| + .{ .x = cursor.x - TAG_TEXT_INSET, .y = cursor.y, .bar = cursor.bar } + else + null; for (0..rows) |line| { - const layer = &s.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1]; - if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); - @memcpy(layer.cells, storage[line * pitch + TAG_TEXT_INSET ..][0..columns]); - layer.kind = .pane; - layer.id = @intCast(id); - layer.serial = pane.serial; - layer.line = @intCast(line); - layer.viewport = .{ .x = region.rect.x + TAG_GAP, .y = region.rect.y + @as(u16, @intCast(line)), .w = region.rect.w - TAG_GAP - region.clip, .h = BOX_H }; - layer.cols = columns; - layer.bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; - layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor| - (if (cursor.y == line) .{ .col = cursor.x - TAG_TEXT_INSET, .bar = cursor.bar } else null) - else - null; + @memcpy(layer.cells[line * columns ..][0..columns], storage[line * pitch + TAG_TEXT_INSET ..][0..columns]); // The grid's row: the gap cell and the text, as far as the pane. joinCells(s, region.rect.x, region.rect.y + @as(u16, @intCast(line)), storage[line * pitch ..][0..pitch], config.GUTTER, region.rect.w, tag_fg); } @@ -765,9 +770,8 @@ fn joinNotice(p: *Pardes, s: *Surface, arena: std.mem.Allocator, region: Region) const line = try arena.alloc(u8, pad + kept.len); @memset(line[0..pad], ' '); @memcpy(line[pad..], kept); - const index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i; - try renderHeaderLayer(p, arena, index, .notice, @intCast(id), 0, region.rect, line, bg, fg); - const layer = &s.tag_layers[index]; + const layer = &s.tag_layers[NOTICE_LAYER_BASE + id * Pane.Notices.max + i]; + try renderHeaderLayer(p, arena, layer, .notice, @intCast(id), region.rect, line, 0, bg, fg); layer.slide = motion.slide; layer.fade = motion.alpha; // The caret rides the band, where the prompt's glyphs are. @@ -775,7 +779,7 @@ fn joinNotice(p: *Pardes, s: *Surface, arena: std.mem.Allocator, region: Region) const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); const dropped = shown - panes.File.displayWidth(kept); if (typed < dropped) break :caret; - layer.cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.input.mode == .insert }; + layer.cursor = .{ .x = @intCast(pad + typed - dropped), .y = 0, .bar = pane.input.mode == .insert }; } // The grid shows the chip's last cells of the band, where the text ends // (all of it where the tagline's pitch is the grid's). @@ -785,7 +789,7 @@ fn joinNotice(p: *Pardes, s: *Surface, arena: std.mem.Allocator, region: Region) // no ink of its own (the layer prints it as spaces in the band's ink). for (skip..@min(pad, skip + chip)) |col| s.at(region.rect.x + @as(u16, @intCast(col - skip)), region.rect.y).style.fg = .default; if (layer.cursor) |caret| { - if (caret.col >= skip) s.cursor = .{ .x = region.rect.x + caret.col - @as(u16, @intCast(skip)), .y = region.rect.y, .bar = caret.bar }; + if (caret.x >= skip) s.cursor = .{ .x = region.rect.x + caret.x - @as(u16, @intCast(skip)), .y = region.rect.y, .bar = caret.bar }; } // Laid out at its full width; only shown short of the rail. layer.viewport.w -= region.clip; @@ -960,10 +964,11 @@ test "place puts every part where the painters and layers put it, in every layou try std.testing.expectEqual(FontRole.tagline, s.at(grip.rect.x, grip.rect.y).style.font_role); const tag = s.region(.tag, id).?; try std.testing.expectEqual(Rect{ .x = r.x + config.GUTTER, .y = p.tagTop(pane, r), .w = r.w - config.GUTTER, .h = pane.tag_rows }, tag.rect); - if (r.w > TAG_TEXT_INSET) for (0..pane.tag_rows) |line| { - const layer = &s.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1]; - try std.testing.expectEqual(Rect{ .x = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r) + @as(u16, @intCast(line)), .w = r.w - TAG_TEXT_INSET, .h = BOX_H }, layer.viewport); - }; + // One layer, a row to each line of the tag. + if (r.w > TAG_TEXT_INSET) { + try std.testing.expectEqual(Rect{ .x = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r), .w = r.w - TAG_TEXT_INSET, .h = pane.tag_rows }, s.tag_layers[id].viewport); + try std.testing.expectEqual(pane.tag_rows, s.tag_layers[id].rows); + } if (pane.collapsed) { try std.testing.expect(s.region(.body, id) == null); try std.testing.expect(s.region(.rail, id) == null); diff --git a/src/edit.zig b/src/edit.zig index b2d71ed2..9044d7ed 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -17,7 +17,7 @@ const platform = pardes.platform; const Pane = panes.Pane; const Text = panes.Text; const tag_layer = @import("tag_layer.zig"); -const TagHit = tag_layer.Hit; +const TagHit = pardes.TagHit; const BOX_H = pardes.BOX_H; const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET; const Mouse = pardes.Mouse; diff --git a/src/gui/gui.zig b/src/gui/gui.zig index a1e21dc0..ff99bd39 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -1779,8 +1779,8 @@ const Gui = struct { tagline_percent: u8, tagline_width: u32, tagline_height: u32, - attached_layers: ?*const [pardes.MAX_PANES]pardes.BodyLayer = null, - attached_tag_layers: ?*const [pardes.MAX_TAG_LAYERS]pardes.TagLayer = null, + attached_layers: ?*const [pardes.MAX_PANES]pardes.Layer = null, + attached_tag_layers: ?*const [pardes.MAX_TAG_LAYERS]pardes.Layer = null, cell_w: u32, cell_h: u32, ascent: i32, @@ -3517,7 +3517,7 @@ fn gridCellAtDimensions( var point: MouseCell = .{ .col = @intFromFloat(@min(@floor(@max(x, 0) / @max(body_w, 1)), 10_000)), .row = row }; 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.hitAt(mapped.x * body_w, mapped.y * body_h, body_w, body_h, tagline_w, @floatFromInt(p.row_metrics.tagline_h))) |hit| { + if (layer.tagHit(mapped.x * body_w, mapped.y * body_h, body_w, body_h, tagline_w, @floatFromInt(p.row_metrics.tagline_h))) |hit| { point.tag_hit = hit; break; } @@ -3529,19 +3529,19 @@ fn gridCellAtDimensions( fn gridCellAtPixels(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32) MouseCell { var point = gridCellAtDimensions(core, x, y, @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width)); if (core) |p| if (p.presentation.pointerFractional(p.screen_w, p.screen_h, x / @as(f32, @floatFromInt(g.cell_w)), y / @as(f32, @floatFromInt(g.cell_h)))) |mapped| for (p.surface.bodyLayers()) |*layer| { - if (layer.hitAt(mapped.x * @as(f32, @floatFromInt(g.cell_w)), mapped.y * @as(f32, @floatFromInt(g.cell_h)), @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |hit| { + if (layer.bodyHit(mapped.x * @as(f32, @floatFromInt(g.cell_w)), mapped.y * @as(f32, @floatFromInt(g.cell_h)), @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |hit| { point.body_hit = hit; break; } }; if (core == null) if (g.attached_layers) |layers| for (layers) |*layer| { - if (layer.hitAt(x, y, @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |body_hit| { + if (layer.bodyHit(x, y, @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |body_hit| { point.body_hit = body_hit; break; } }; if (core == null) if (g.attached_tag_layers) |layers| for (layers) |*layer| { - if (layer.hitAt(x, y, @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |hit| { + if (layer.tagHit(x, y, @floatFromInt(g.cell_w), @floatFromInt(g.cell_h), @floatFromInt(g.tagline_width), @floatFromInt(g.tagline_height))) |hit| { point.tag_hit = hit; break; } @@ -5203,18 +5203,18 @@ fn renderFrame( } } for (surface.tagLayers()) |*layer| { - if (layer.cols == 0) continue; + if (layer.rows == 0) continue; const batch = &paint_plan.batches[paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y)]; - batch.cell_count += tagLayerCellCount(g, layer) + 1; + batch.cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; } for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { const track = batch.track.?; const under = track.effect == .vertical and track.phase == .opening; if (!under and track.phase != .closing and track.effect != .dissolve) continue; for (surface.previous_tag_layers) |*layer| { - if (layer.cols == 0 or layer.kind != .pane or layer.serial != track.serial) continue; + if (layer.rows == 0 or layer.kind != .pane or layer.serial != track.serial) continue; const destination = if (under) &paint_plan.batches[0] else &paint_plan.batches[batch_index]; - destination.cell_count += tagLayerCellCount(g, layer) + 1; + destination.cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; } } var cell_next: [pardes.MAX_PANES * 2 + 1]u32 = @splat(0); @@ -5303,7 +5303,7 @@ fn renderFrame( emitBodyLayer(g, instances, &cell_next[batch_index], layer, win_w, win_h, paint_plan.batches[batch_index].track, page, paint_plan.len == 1, false, topbar_pane_border_rgb); } for (surface.tagLayers()) |*layer| { - if (layer.cols == 0) continue; + if (layer.rows == 0) continue; const batch_index = paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y); emitTagLayer(g, instances, &cell_next[batch_index], layer, win_w, win_h, paint_plan.batches[batch_index].track, page, paint_plan.len == 1, false); } @@ -5312,7 +5312,7 @@ fn renderFrame( const under = track.effect == .vertical and track.phase == .opening; if (!under and track.phase != .closing and track.effect != .dissolve) continue; for (surface.previous_tag_layers) |*layer| { - if (layer.cols == 0 or layer.kind != .pane or layer.serial != track.serial) continue; + if (layer.rows == 0 or layer.kind != .pane or layer.serial != track.serial) continue; const destination: usize = if (under) 0 else batch_index; emitTagLayer(g, instances, &cell_next[destination], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve); } @@ -5621,10 +5621,10 @@ test "compact column and pane underlays share focus tint without tinting the glo try std.testing.expectEqual(chrome.tag_bg, taglineBaseRgb(core, active.x, pardes.TOPBAR_H)); } -fn tagLayerIn(layers: []const pardes.TagLayer, col: u16, row: u16) ?*const pardes.TagLayer { +fn tagLayerIn(layers: []const pardes.Layer, col: u16, row: u16) ?*const pardes.Layer { for (layers) |*layer| { const r = layer.viewport; - if (layer.cols > 0 and row == r.y and col >= r.x and col - r.x < r.w) return layer; + if (layer.rows > 0 and row >= r.y and row - r.y < layer.rows and col >= r.x and col - r.x < r.w) return layer; } return null; } @@ -5633,9 +5633,9 @@ fn tagLayerIn(layers: []const pardes.TagLayer, col: u16, row: u16) ?*const parde /// themselves: the grid's cell loops skip them. Filling each layer's rectangle /// once replaces scanning every layer for every cell, which was a third of the /// gui's CPU on a full screen of text. It marks exactly the cells bodyLayerIn -/// and tagLayerIn find a layer for: a tag layer covers only its viewport's -/// first row, and a viewport past the grid covers the grid's part of it. -fn coverLayers(cover: []bool, cols: u16, rows: u16, body_layers: []const pardes.BodyLayer, tag_layers: []const pardes.TagLayer) void { +/// and tagLayerIn find a layer for: a tag layer covers its rows, and a +/// viewport past the grid covers the grid's part of it. +fn coverLayers(cover: []bool, cols: u16, rows: u16, body_layers: []const pardes.Layer, tag_layers: []const pardes.Layer) void { @memset(cover, false); for (body_layers) |*layer| { const r = layer.viewport; @@ -5647,12 +5647,13 @@ fn coverLayers(cover: []bool, cols: u16, rows: u16, body_layers: []const pardes. for (tag_layers) |*layer| { const r = layer.viewport; const right = @min(cols, @as(u32, r.x) + r.w); - if (layer.cols == 0 or r.y >= rows or r.x >= right) continue; - @memset(cover[@as(usize, r.y) * cols ..][r.x..right], true); + if (layer.rows == 0 or r.x >= right) continue; + var y: u32 = r.y; + while (y < @min(rows, @as(u32, r.y) + layer.rows)) : (y += 1) @memset(cover[y * cols ..][r.x..right], true); } } -fn expectCoverIsScan(cover: []const bool, cols: u16, rows: u16, body_layers: []const pardes.BodyLayer, tag_layers: []const pardes.TagLayer) !void { +fn expectCoverIsScan(cover: []const bool, cols: u16, rows: u16, body_layers: []const pardes.Layer, tag_layers: []const pardes.Layer) !void { for (0..rows) |row| for (0..cols) |col| { const scan = bodyLayerIn(body_layers, @intCast(col), @intCast(row)) != null or tagLayerIn(tag_layers, @intCast(col), @intCast(row)) != null; try std.testing.expectEqual(scan, cover[row * cols + col]); @@ -5685,7 +5686,7 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand @memset(&cover, true); // what an earlier frame left coverLayers(&cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers()); try expectCoverIsScan(&cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers()); - for (s.tagLayers()) |layer| notices = notices or (layer.cols != 0 and layer.kind == .notice); + for (s.tagLayers()) |layer| notices = notices or (layer.rows != 0 and layer.kind == .notice); } try std.testing.expect(notices); @@ -5702,8 +5703,8 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand }; var prng = std.Random.DefaultPrng.init(0x6c61796572); const random = prng.random(); - var body: [pardes.MAX_PANES]pardes.BodyLayer = undefined; - var tag: [pardes.MAX_TAG_LAYERS]pardes.TagLayer = undefined; + var body: [pardes.MAX_PANES]pardes.Layer = undefined; + var tag: [pardes.MAX_TAG_LAYERS]pardes.Layer = undefined; var grid: [64 * 40]bool = undefined; for (0..3000) |_| { const cols = random.intRangeAtMost(u16, 1, 64); @@ -5720,12 +5721,12 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand } } -fn tagLayerCellCount(g: *const Gui, layer: *const pardes.TagLayer) u32 { +fn tagLayerCellCount(g: *const Gui, layer: *const pardes.Layer) u32 { const pixels = @as(u32, layer.viewport.w) * g.cell_w; return @min(layer.cols, (pixels + g.tagline_width - 1) / g.tagline_width); } -fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.TagLayer, win_w: f32, win_h: f32, track: ?pardes.animation.Track, page: Ground, show_cursor: bool, old_layer: bool) void { +fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.Layer, win_w: f32, win_h: f32, track: ?pardes.animation.Track, page: Ground, show_cursor: bool, old_layer: bool) void { const bw: f32 = @floatFromInt(g.cell_w); const bh: f32 = @floatFromInt(g.cell_h); const tw: f32 = @floatFromInt(g.tagline_width); @@ -5735,20 +5736,25 @@ fn emitTagLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const p // A band sliding into place is drawn offset and cut at its row's top, so // it comes out from under whatever is above it. const slide = layer.slide * bh; - const top = @as(f32, @floatFromInt(layer.viewport.y)) * bh; // A window is rarely a whole number of cells wide: a band that ends at // the last column runs on through the leftover pixels to the edge. const band_right = if (win_w - right < bw) @max(right, win_w) else right; - emitInstance(g, instances, next.*, 0, layer.viewport.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); - if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h); - next.* += 1; - const line_cursor: ?usize = if (layer.cursor) |cu| cu.col else null; - for (0..tagLayerCellCount(g, layer)) |col| { - const cursor = if (layer.cursor) |cu| show_cursor and !cu.bar and cu.col == col else false; - emitInstance(g, instances, next.*, @intCast(col), layer.viewport.y, .{ .w = tw, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .tagline, layer.cells[0..layer.cols], col, line_cursor, old_layer, cursor, page); - clipInstanceRight(&instances[next.*], right, win_w); + // A row to each line, each a grid row with the tagline's glyphs. + for (0..layer.rows) |index| { + 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); if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h); next.* += 1; + const cells = layer.cells[index * layer.cols ..][0..layer.cols]; + const line_cursor: ?usize = if (layer.cursor) |cu| (if (cu.y == index) cu.x else null) else null; + for (0..tagLayerCellCount(g, layer)) |col| { + const cursor = if (layer.cursor) |cu| show_cursor and !cu.bar and cu.x == col and cu.y == index else false; + emitInstance(g, instances, next.*, @intCast(col), y, .{ .w = tw, .h = bh, .x_off = left, .y_off = slide }, win_w, win_h, track, .tagline, cells, col, line_cursor, old_layer, cursor, page); + clipInstanceRight(&instances[next.*], right, win_w); + if (slide != 0) clipInstanceTop(&instances[next.*], top, win_h); + next.* += 1; + } } } @@ -5760,11 +5766,11 @@ fn clipInstanceTop(instance: *CellInstance, top: f32, win_h: f32) void { instance.y0 = @max(instance.y1, limit); } -fn bodyLayerAt(surface: *const pardes.Surface, col: u16, row: u16) ?*const pardes.BodyLayer { +fn bodyLayerAt(surface: *const pardes.Surface, col: u16, row: u16) ?*const pardes.Layer { return bodyLayerIn(surface.bodyLayers(), col, row); } -fn bodyLayerIn(layers: []const pardes.BodyLayer, col: u16, row: u16) ?*const pardes.BodyLayer { +fn bodyLayerIn(layers: []const pardes.Layer, col: u16, row: u16) ?*const pardes.Layer { for (layers) |*layer| { const r = layer.viewport; if (layer.rows > 0 and col >= r.x and col - r.x < r.w and row >= r.y and row - r.y < r.h) return layer; @@ -5788,7 +5794,7 @@ fn clipBodyInstance(instance: *CellInstance, bottom: f32, win_h: f32) void { instance.y1 = limit; } -fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.BodyLayer, win_w: f32, win_h: f32, track: ?pardes.animation.Track, page: Ground, show_cursor: bool, old_layer: bool, border_rgb: [3]u8) void { +fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const pardes.Layer, win_w: f32, win_h: f32, track: ?pardes.animation.Track, page: Ground, show_cursor: bool, old_layer: bool, border_rgb: [3]u8) void { const bw: f32 = @floatFromInt(g.cell_w); const bh: f32 = @floatFromInt(g.cell_h); const tw: f32 = @floatFromInt(g.tagline_width); @@ -5851,11 +5857,11 @@ fn gripCellChanged(surface: *const pardes.Surface, col: u16, row: u16) bool { return false; } -fn paneGripCell(layers: []const pardes.TagLayer, cells: []const pardes.Cell, cols: u16, col: u16, row: u16) ?GripCell { +fn paneGripCell(layers: []const pardes.Layer, cells: []const pardes.Cell, cols: u16, col: u16, row: u16) ?GripCell { const at = @as(usize, row) * cols + col; if (at >= cells.len or cells[at].default or cells[at].style.font_role != .tagline) return null; for (layers) |layer| { - if (layer.cols == 0 or layer.kind != .pane or layer.viewport.x < pardes.TAG_TEXT_INSET or layer.viewport.y != row) continue; + if (layer.rows == 0 or layer.kind != .pane or layer.viewport.x < pardes.TAG_TEXT_INSET or row < layer.viewport.y or row - layer.viewport.y >= layer.rows) continue; const x = layer.viewport.x - pardes.TAG_TEXT_INSET; if (col < x or col - x >= config.GUTTER) continue; const index = @as(usize, row) * cols + x; @@ -6607,7 +6613,7 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" { cells[0].text[0] = '$'; var previous = cells; previous[0].text[0] = '^'; - var layers = [_]pardes.TagLayer{.{ .kind = .pane, .serial = 1, .viewport = .{ .x = pardes.TAG_TEXT_INSET, .y = 0, .w = 3, .h = 1 }, .cols = 3 }}; + var layers = [_]pardes.Layer{.{ .kind = .pane, .serial = 1, .viewport = .{ .x = pardes.TAG_TEXT_INSET, .y = 0, .w = 3, .h = 1 }, .cols = 3, .rows = 1 }}; var diffs: [12]pardes.PanelCellDiff = @splat(.unchanged); diffs[0] = pardes.PanelCellDiff.between(&previous[0], &cells[0]); var surface: pardes.Surface = .{ .cols = 6, .rows = 2, .cells = &cells, .previous_cells = &previous, .previous_tag_layers = &layers, .cell_diffs = &diffs }; @@ -7316,16 +7322,17 @@ fn buildOverlay( } }; if (transient_on) for (surface.tagLayers()) |*layer| { - if (layer.cols == 0) continue; + if (layer.rows == 0) continue; const cursor = layer.cursor orelse continue; - if (!cursor.bar or cursor.col >= layer.cols) continue; + if (!cursor.bar or cursor.x >= layer.cols or cursor.y >= layer.rows) continue; 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.col)) * width; + const x = left + @as(f32, @floatFromInt(cursor.x)) * width; if (x >= right) continue; - const top = @as(f32, @floatFromInt(layer.viewport.y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(layer.viewport.y, builder.win_h, g.cell_h, g.tagline_height))); - builder.addRect(x, top, @min(right, x + @max(1, width / 8)), top + @as(f32, @floatFromInt(g.tagline_height)), overlayRgb(cursorColor(core, &layer.cells[cursor.col], .tagline))); + const y = layer.viewport.y + cursor.y; + const top = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, g.cell_h, g.tagline_height))); + builder.addRect(x, top, @min(right, x + @max(1, width / 8)), top + @as(f32, @floatFromInt(g.tagline_height)), overlayRgb(cursorColor(core, &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x], .tagline))); }; if (transient_on) appendTouchOverlay(g, &builder, debug_on); return @intCast(builder.len); @@ -7506,7 +7513,7 @@ fn appendPaneChrome( // off from the text beneath it the way a tagline is: the same colour and // thickness along its bottom, running to the window edge when it does. for (surface.tagLayers()) |*layer| { - if (layer.cols == 0 or layer.kind != .notice) continue; + if (layer.rows == 0 or layer.kind != .notice) continue; const x0 = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; const x1 = x0 + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w; const rule_x1 = if (builder.win_w - x1 < layout.w) @max(x1, builder.win_w) else x1; @@ -7528,21 +7535,25 @@ fn appendPaneChrome( fn appendTagGrips(g: *const Gui, surface: *const pardes.Surface, layout: CellLayout, builder: *OverlayBuilder) void { if (g.tagline_height < 6 or g.tagline_width < 3) return; for (surface.tagLayers()) |layer| { - if (layer.cols == 0 or layer.kind != .pane or layer.viewport.x < pardes.TAG_TEXT_INSET) continue; + if (layer.rows == 0 or layer.kind != .pane or layer.viewport.x < pardes.TAG_TEXT_INSET) continue; const x = layer.viewport.x - pardes.TAG_TEXT_INSET; - const y = layer.viewport.y; - if (x >= surface.cols or y >= surface.rows) continue; - const cell = &surface.cells[@as(usize, y) * surface.cols + x]; - if (cell.default or cell.printableAscii() != ' ') continue; - const rgb = switch (cell.style.fg) { - .rgb => |rgb| rgb, - else => continue, - }; - const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w + @as(f32, @floatFromInt(anchorInset(g, y, builder.win_h) / 2)); - const center_y = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, g.cell_h, g.tagline_height))) + @as(f32, @floatFromInt(g.tagline_height)) / 2; - const half_w = @max(1, @min(3, layout.w - 2)); - builder.addRect(@floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), overlayRgb(rgb)); - builder.addRect(@floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), overlayRgb(rgb)); + // A mark on each of the tag's rows whose grip cell is blank: every + // row but the one with the mode's glyph. + for (0..layer.rows) |index| { + const y = layer.viewport.y + @as(u16, @intCast(index)); + if (x >= surface.cols or y >= surface.rows) continue; + const cell = &surface.cells[@as(usize, y) * surface.cols + x]; + if (cell.default or cell.printableAscii() != ' ') continue; + const rgb = switch (cell.style.fg) { + .rgb => |rgb| rgb, + else => continue, + }; + const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w + @as(f32, @floatFromInt(anchorInset(g, y, builder.win_h) / 2)); + const center_y = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, g.cell_h, g.tagline_height))) + @as(f32, @floatFromInt(g.tagline_height)) / 2; + const half_w = @max(1, @min(3, layout.w - 2)); + builder.addRect(@floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), overlayRgb(rgb)); + builder.addRect(@floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), overlayRgb(rgb)); + } } } @@ -7553,8 +7564,8 @@ test "column grips stay unmarked while pane marks retain physical centering" { g.cell_h = 20; var cells: [80]pardes.Cell = @splat(.{ .default = false, .style = .{ .fg = .{ .rgb = .{ 100, 120, 140 } } } }); var surface: pardes.Surface = .{ .cols = 20, .rows = 4, .cells = &cells }; - surface.tag_layers[0] = .{ .kind = .column, .viewport = .{ .x = 8, .y = 1, .w = 13, .h = 1 }, .cols = 20 }; - surface.tag_layers[1] = .{ .kind = .pane, .viewport = .{ .x = 8, .y = 3, .w = 13, .h = 1 }, .cols = 20 }; + surface.tag_layers[0] = .{ .kind = .column, .viewport = .{ .x = 8, .y = 1, .w = 13, .h = 1 }, .cols = 20, .rows = 1 }; + surface.tag_layers[1] = .{ .kind = .pane, .viewport = .{ .x = 8, .y = 3, .w = 13, .h = 1 }, .cols = 20, .rows = 1 }; var vertices: [24]OverlayVertex = undefined; var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 200, .win_h = 80 }; appendTagGrips(&g, &surface, .{ .w = 10, .h = 20, .x_off = 0, .y_off = 0 }, &builder); @@ -7927,7 +7938,7 @@ test "compact body layer rendering fills recovered rows and clips the final glyp g.tagline_height = 12; g.space_slot = .{ .u = 0, .v = 0 }; var cells: [24]pardes.Cell = @splat(.{}); - var layer: pardes.BodyLayer = .{ + var layer: pardes.Layer = .{ .viewport = .{ .x = 3, .y = 2, .w = 2, .h = 5 }, .cols = 2, .rows = 12, @@ -7967,7 +7978,7 @@ test "compact tag layers fill available width and clip at their physical edge" { g.tagline_height = 12; g.space_slot = .{ .u = 0, .v = 0 }; var cells: [6]pardes.Cell = @splat(.{}); - const layer: pardes.TagLayer = .{ .kind = .pane, .id = 0, .serial = 7, .viewport = .{ .x = 2, .y = 3, .w = 3, .h = 1 }, .cols = 6, .cells = &cells, .bg = .{ 20, 30, 40 } }; + const layer: pardes.Layer = .{ .kind = .pane, .id = 0, .serial = 7, .viewport = .{ .x = 2, .y = 3, .w = 3, .h = 1 }, .cols = 6, .rows = 1, .cells = &cells, .bg = .{ 20, 30, 40 } }; var instances: [8]CellInstance = undefined; var next: u32 = 0; emitTagLayer(&g, &instances, &next, &layer, 100, 100, null, Ground.opaqueRgb(bg_default), false, false); diff --git a/src/look.zig b/src/look.zig index 8f9e15f2..258cf8f5 100644 --- a/src/look.zig +++ b/src/look.zig @@ -21,7 +21,7 @@ const pdf = panes.Pdf.pdf; const Pane = panes.Pane; const MAX_PANES = pardes.MAX_PANES; const tag_layer = @import("tag_layer.zig"); -const TagHit = tag_layer.Hit; +const TagHit = pardes.TagHit; const BOX_H = pardes.BOX_H; const TAG_TEXT_INSET = pardes.TAG_TEXT_INSET; const pathUnder = pardes.pathUnder; diff --git a/src/macos.zig b/src/macos.zig index fce0a53e..f7e03cd4 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -1861,9 +1861,9 @@ fn clearFrame(st: *State) void { st.panel_diff_len = 0; } -fn blankTagCells(cells: []Cell, cols: u16, rows: u16, source: []const pardes.Cell, layers: []const pardes.TagLayer) void { +fn blankTagCells(cells: []Cell, cols: u16, rows: u16, source: []const pardes.Cell, layers: []const pardes.Layer) void { for (layers) |layer| { - if (layer.cols == 0 or layer.viewport.y >= rows or layer.viewport.x >= cols) continue; + if (layer.rows == 0 or layer.viewport.y >= rows or layer.viewport.x >= cols) continue; const start = @as(usize, layer.viewport.y) * cols + layer.viewport.x; const count = @min(layer.viewport.w, cols - layer.viewport.x); for (cells[start..][0..count], source[start..][0..count]) |*cell, was| { @@ -1879,7 +1879,7 @@ fn blankTagCells(cells: []Cell, cols: u16, rows: u16, source: []const pardes.Cel fn tagCursorCovered(core: *const pardes.Pardes) bool { const cursor = core.surface.cursor orelse return false; for (core.surface.tag_layers) |layer| { - if (layer.cols != 0 and cursor.y == layer.viewport.y and cursor.x >= layer.viewport.x and + if (layer.rows != 0 and cursor.y == layer.viewport.y and cursor.x >= layer.viewport.x and cursor.x - layer.viewport.x < layer.viewport.w) return true; } return false; @@ -2121,7 +2121,7 @@ export fn pardes_tag_layer_value(index: u32, field: u32) u32 { 5 => layer.viewport.y, 6 => layer.viewport.w, 7 => layer.viewport.h, - 8 => if (layer.cursor) |c| c.col else 65535, + 8 => if (layer.cursor) |c| c.x else 65535, 9 => if (layer.cursor) |c| @intFromBool(c.bar) else 0, 10 => @as(u32, layer.bg[0]) << 16 | @as(u32, layer.bg[1]) << 8 | layer.bg[2], 11 => @intFromBool(layer.kind == .pane and st.core.settings.tag_bottom), @@ -2139,7 +2139,7 @@ export fn pardes_tag_layer_cells(index: u32) ?[*]const Cell { const st = &(state orelse return null); if (index >= pardes.MAX_TAG_LAYERS) return null; const layer = &st.core.surface.tag_layers[index]; - if (layer.cols == 0) return null; + if (layer.rows == 0) return null; const cells = &st.tag_cells[index]; if (cells.len != layer.cells.len) cells.* = st.gpa.realloc(cells.*, layer.cells.len) catch return null; for (layer.cells, cells.*) |source, *out| out.* = encodeCell(source); @@ -3110,7 +3110,7 @@ test "mac tag layer ABI preserves logical capacity and physical grip" { logical[11].text[0] = 'Z'; logical[11].len = 1; logical[11].default = false; - core.surface.tag_layers[0] = .{ .kind = .pane, .id = 1, .serial = 42, .viewport = .{ .x = 2, .y = 1, .w = 6, .h = 1 }, .cols = 12, .cells = &logical, .bg = .{ 0x12, 0x34, 0x56 }, .cursor = .{ .col = 11, .bar = true } }; + core.surface.tag_layers[0] = .{ .kind = .pane, .id = 1, .serial = 42, .viewport = .{ .x = 2, .y = 1, .w = 6, .h = 1 }, .cols = 12, .rows = 1, .cells = &logical, .bg = .{ 0x12, 0x34, 0x56 }, .cursor = .{ .x = 11, .y = 0, .bar = true } }; defer core.surface.tag_layers[0] = .{}; try std.testing.expectEqual(@as(u32, 12), pardes_tag_layer_value(0, 0)); try std.testing.expectEqual(@as(u32, 0x123456), pardes_tag_layer_value(0, 10)); diff --git a/src/pardes.zig b/src/pardes.zig index 6946e123..f8087bdb 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -39,6 +39,7 @@ pub const ImagePatch = @import("surface.zig").ImagePatch; pub const ImagePlace = @import("surface.zig").ImagePlace; pub const Surface = @import("surface.zig").Surface; pub const Region = @import("surface.zig").Region; +pub const MAX_REGIONS = @import("surface.zig").MAX_REGIONS; pub const builtins = @import("builtins.zig"); const limits = memory.limits; const selection_pipe = @import("selection_pipe.zig"); @@ -247,21 +248,17 @@ pub const Pane = panes.Pane; const Text = panes.Text; pub const MAX_PANES = 16; -pub const BodyLayer = @import("body_layer.zig").Layer; -pub const RowMetrics = @import("body_layer.zig").Metrics; -pub const TagLayer = @import("tag_layer.zig").Layer; -pub const TagHit = @import("tag_layer.zig").Hit; -pub const TagKind = @import("tag_layer.zig").Kind; -/// Panes, then columns, then the workspace bar, then each pane's notice bands. +pub const Layer = @import("Layer.zig"); +pub const RowMetrics = Layer.Metrics; +pub const TagHit = Layer.TagHit; +pub const TagKind = Layer.Kind; /// A tag shows at most this many lines; past them it is cut, like a line /// too long for its width. pub const MAX_TAG_ROWS = 8; -pub const MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1 + MAX_PANES * Pane.Notices.max + (MAX_PANES + MAX_COLS + 1) * (MAX_TAG_ROWS - 1); +/// Panes, then columns, then the workspace bar, then each pane's notice +/// bands: a band to a tag, however many lines it has. +pub const MAX_TAG_LAYERS = MAX_PANES + MAX_COLS + 1 + MAX_PANES * Pane.Notices.max; pub const NOTICE_LAYER_BASE = MAX_PANES + MAX_COLS + 1; -/// Where the layers for a pane tag's second and later lines start. -pub const TAG_LINE_LAYER_BASE = NOTICE_LAYER_BASE + MAX_PANES * Pane.Notices.max; -/// ...and those of a column's (by index) or the workspace's (after them). -pub const HEADER_LINE_LAYER_BASE = TAG_LINE_LAYER_BASE + MAX_PANES * (MAX_TAG_ROWS - 1); pub const PDF_PAGE_GAP_PX = panes.Pdf.page_gap_px; pub const MAX_COLS = 6; const column_weight_unit = layout.column_weight_unit; @@ -787,7 +784,7 @@ test "TreeContext tag style preserves message rows and its opt-out survives rest try std.testing.expectEqual(full_height, p.surface.body_layers[0].viewport.h); const r = p.rects[0]; const band = for (p.surface.tagLayers()) |*layer| { - if (layer.cols != 0 and layer.kind == .notice) break layer; + if (layer.rows != 0 and layer.kind == .notice) break layer; } else return error.NoNoticeBand; try std.testing.expectEqual(p.bodyTop(pane, r), band.viewport.y); try std.testing.expectEqual(p.surface.body_layers[0].viewport.y, band.viewport.y); @@ -5832,15 +5829,14 @@ pub const Pardes = struct { pub fn reprojectTagHit(p: *const Pardes, tag_hit: TagHit) ?TagHit { for (p.surface.tagLayers()) |*layer| { - if (layer.cols == 0 or layer.kind != tag_hit.kind or layer.id != tag_hit.id or layer.serial != tag_hit.serial) continue; + if (layer.rows == 0 or layer.kind != tag_hit.kind or layer.id != tag_hit.id or layer.serial != tag_hit.serial) continue; if (layer.kind == .pane) { if (layer.id >= p.panes.len) return null; const pane = p.panes[layer.id] orelse return null; if (pane.serial != layer.serial) return null; } if (layer.kind == .column and (layer.id >= p.ncol or p.col_serial[layer.id] != layer.serial)) return null; - // A taller tag has a layer per line; the point is on one of them. - return layer.hitAt(tag_hit.pixel_x, tag_hit.pixel_y, @floatFromInt(tag_hit.metrics.body_w), @floatFromInt(tag_hit.metrics.body_h), @floatFromInt(tag_hit.metrics.tagline_w), @floatFromInt(tag_hit.metrics.tagline_h)) orelse continue; + return layer.tagHit(tag_hit.pixel_x, tag_hit.pixel_y, @floatFromInt(tag_hit.metrics.body_w), @floatFromInt(tag_hit.metrics.body_h), @floatFromInt(tag_hit.metrics.tagline_w), @floatFromInt(tag_hit.metrics.tagline_h)) orelse continue; } return null; } @@ -5858,14 +5854,14 @@ pub const Pardes = struct { } pub fn tagCapacity(p: *const Pardes, physical_width: u16) u16 { - const layer: TagLayer = .{ .viewport = .{ .x = 0, .y = 0, .w = physical_width, .h = 1 } }; + const layer: Layer = .{ .kind = .pane, .viewport = .{ .x = 0, .y = 0, .w = physical_width, .h = 1 } }; return layer.capacity(p.row_metrics); } pub fn reprojectBodyHit(p: *const Pardes, body_hit: Mouse.BodyHit) ?Mouse.BodyHit { for (&p.surface.body_layers) |*layer| { if (layer.rows == 0 or layer.serial != body_hit.serial) continue; - return layer.hitAt(body_hit.pixel_x, body_hit.pixel_y, @floatFromInt(body_hit.metrics.body_w), @floatFromInt(body_hit.metrics.body_h), @floatFromInt(body_hit.metrics.tagline_w), @floatFromInt(body_hit.metrics.tagline_h)); + return layer.bodyHit(body_hit.pixel_x, body_hit.pixel_y, @floatFromInt(body_hit.metrics.body_w), @floatFromInt(body_hit.metrics.body_h), @floatFromInt(body_hit.metrics.tagline_w), @floatFromInt(body_hit.metrics.tagline_h)); } return null; } @@ -6111,12 +6107,12 @@ test "a prompt chip is a band with its caret, still on the grid, and only the ch // A pixel shell draws the prompt from its band, in the tagline's pitch, // with the caret right after the marker's last glyph. const band = for (p.surface.tagLayers()) |*layer| { - if (layer.cols != 0 and layer.kind == .notice) break layer; + if (layer.rows != 0 and layer.kind == .notice) break layer; } else return error.NoPromptBand; try std.testing.expectEqual(row, band.viewport.y); const caret = band.cursor orelse return error.MissingBandCaret; try std.testing.expect(caret.bar); - try std.testing.expectEqualStrings("/", band.cells[caret.col - 1].grapheme()); + try std.testing.expectEqualStrings("/", band.cells[caret.x - 1].grapheme()); // The chip covers only its own columns. Left of it is ordinary body, and // a sweep there still selects. @@ -6162,11 +6158,11 @@ test "a prompt is typed on its own line in the notice band and leaves the tag ro try std.testing.expect(p.surface.tag_layers[0].cursor == null); // The band carries the answer and the caret, one cell back from its end. const band = for (p.surface.tagLayers()) |*layer| { - if (layer.cols != 0 and layer.kind == .notice) break layer; + if (layer.rows != 0 and layer.kind == .notice) break layer; } else return error.NoPromptBand; const caret = band.cursor orelse return error.MissingBandCaret; - try std.testing.expectEqualStrings("t", band.cells[caret.col - 1].grapheme()); - try std.testing.expectEqualStrings("w", band.cells[caret.col].grapheme()); + try std.testing.expectEqualStrings("t", band.cells[caret.x - 1].grapheme()); + try std.testing.expectEqualStrings("w", band.cells[caret.x].grapheme()); const grid = p.surface.cursor orelse return error.MissingPromptCursor; try std.testing.expectEqual(p.bodyTop(pane, p.rects[0]), grid.y); diff --git a/src/surface.zig b/src/surface.zig index fec43e30..db7dd468 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -11,8 +11,7 @@ const config = @import("config.zig"); const pdf_enabled = panes.Pdf.enabled; const image = @import("image.zig"); const MAX_PANES = pardes.MAX_PANES; -const BodyLayer = @import("body_layer.zig").Layer; -const TagLayer = @import("tag_layer.zig").Layer; +const Layer = @import("Layer.zig"); const MAX_TAG_LAYERS = pardes.MAX_TAG_LAYERS; const CellPixels = pardes.CellPixels; @@ -380,8 +379,8 @@ pub const Surface = struct { /// paint the quiet affordance can carry the hover bit through the ABI /// without every selection painter learning a new parameter. mark_hover: bool = false, - body_layers: [MAX_PANES]BodyLayer = @splat(.{}), - tag_layers: [MAX_TAG_LAYERS]TagLayer = @splat(.{}), + body_layers: [MAX_PANES]Layer = @splat(.{}), + tag_layers: [MAX_TAG_LAYERS]Layer = @splat(.{}), cols: u16 = 0, rows: u16 = 0, regions: [MAX_REGIONS]Region = undefined, @@ -394,15 +393,15 @@ pub const Surface = struct { panel_tracks: [MAX_PANES * 2]animation.Track = undefined, npanel_tracks: usize = 0, previous_cells: []const Cell = &.{}, - previous_body_layers: []const BodyLayer = &.{}, - previous_tag_layers: []const TagLayer = &.{}, + previous_body_layers: []const Layer = &.{}, + previous_tag_layers: []const Layer = &.{}, cell_diffs: []const PanelCellDiff = &.{}, - pub fn tagLayers(s: *const Surface) []const TagLayer { + pub fn tagLayers(s: *const Surface) []const Layer { return &s.tag_layers; } - pub fn bodyLayers(s: *const Surface) []const BodyLayer { + pub fn bodyLayers(s: *const Surface) []const Layer { return &s.body_layers; } diff --git a/src/tag_layer.zig b/src/tag_layer.zig index 7cfe5bbf..213ebc07 100644 --- a/src/tag_layer.zig +++ b/src/tag_layer.zig @@ -1,83 +1,10 @@ -//! Tag text has its own horizontal pitch. Its viewport stays in physical -//! body-grid coordinates so compact text never changes pane hit targets. +//! Pointer hits on tag bands (Layer.TagHit): comparing two, and bringing one +//! that left its band back onto it. const std = @import("std"); const pardes = @import("pardes.zig"); - -/// `notice` is a pane's transient line -- a builtin's name, a pending leader -/// chord -- carried as a tagline-shaped band so it gets the tagline's height, -/// small-font metrics, band offset and border instead of being a body-grid row -/// wearing a tagline font. -pub const Kind = enum(u8) { workspace, column, pane, notice }; -pub const Hit = struct { - kind: Kind, - id: u16, - serial: u32, - col: u16, - /// Which line of a taller tag the layer draws. - line: u16 = 0, - pixel_x: f32, - pixel_y: f32, - metrics: pardes.RowMetrics, -}; -pub const Layer = struct { - kind: Kind = .workspace, - id: u16 = 0, - serial: u32 = 0, - /// A pane's tag taller than a line is drawn a layer to a line; this is - /// which one, counted from its first. - line: u16 = 0, - viewport: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, - cols: u16 = 0, - bg: [3]u8 = .{ 0, 0, 0 }, - cells: []pardes.Cell = &.{}, - cursor: ?struct { col: u16, bar: bool = false } = null, - /// Vertical offset in rows (negative: above its viewport), for a band - /// that is moving into place. A pixel shell draws it offset and clipped - /// to the rows below the viewport's top; the grid has no such thing. - slide: f32 = 0, - /// How present a notice band is (1 = fully) while it fades up or - /// dissolves. `bg` and the cells are already blended by it; a pixel - /// shell uses it for what it draws around the band, like its rule. - fade: f32 = 1, - - pub fn capacity(layer: *const Layer, metrics: pardes.RowMetrics) u16 { - return @intCast(@min(std.math.maxInt(u16), @max(1, @as(u32, layer.viewport.w) * @max(1, metrics.body_w) / @max(1, metrics.tagline_w)))); - } - - pub fn hitAt(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?Hit { - if (layer.cols == 0 or !std.math.isFinite(x) or !std.math.isFinite(y)) return null; - for ([_]f32{ body_w, body_h, tagline_w, tagline_h }) |metric| - if (!std.math.isFinite(metric) or metric < 1 or metric > std.math.maxInt(u16)) return null; - const bw = @max(1, body_w); - const bh = @max(1, body_h); - const tw = std.math.clamp(tagline_w, 1, bw); - const th = std.math.clamp(tagline_h, 1, bh); - const dx = x - @as(f32, @floatFromInt(layer.viewport.x)) * bw; - const dy = y - @as(f32, @floatFromInt(layer.viewport.y)) * bh; - if (dx < 0 or dy < 0 or dx >= @as(f32, @floatFromInt(layer.viewport.w)) * bw or dy >= @as(f32, @floatFromInt(layer.viewport.h)) * bh) return null; - return .{ - .kind = layer.kind, - .id = layer.id, - .serial = layer.serial, - .line = layer.line, - .col = @intFromFloat(@min(@as(f32, @floatFromInt(layer.cols - 1)), @floor(dx / tw))), - .pixel_x = x, - .pixel_y = y, - .metrics = .{ .body_w = @intFromFloat(bw), .body_h = @intFromFloat(bh), .tagline_w = @intFromFloat(tw), .tagline_h = @intFromFloat(th) }, - }; - } -}; - -test "tag text capacity uses its own pitch and excludes the physical grip" { - const layer: Layer = .{ .kind = .pane, .id = 2, .serial = 7, .viewport = .{ .x = 12, .y = 4, .w = 38, .h = 1 }, .cols = 90 }; - try std.testing.expectEqual(@as(u16, 63), layer.capacity(.{ .body_w = 10, .tagline_w = 6 })); - try std.testing.expect(layer.hitAt(119.99, 85, 10, 20, 6, 12) == null); - try std.testing.expectEqual(@as(u16, 0), layer.hitAt(120, 85, 10, 20, 6, 12).?.col); - try std.testing.expectEqual(@as(u16, 50), layer.hitAt(423, 85, 10, 20, 6, 12).?.col); - try std.testing.expect(layer.hitAt(500, 85, 10, 20, 6, 12) == null); - try std.testing.expect(layer.hitAt(120, 85, std.math.nan(f32), 20, 6, 12) == null); - try std.testing.expect(layer.hitAt(120, 85, 10, 20, 0, 12) == null); -} +const Layer = @import("Layer.zig"); +const Hit = Layer.TagHit; +const Kind = Layer.Kind; /// Whether two pointer hits name the same cell of the same tag line. pub fn sameCell(a: ?Hit, b: ?Hit) bool { @@ -92,13 +19,13 @@ pub fn columnAt(p: *const pardes.Pardes, kind: Kind, id: usize, supplied: ?Hit, var point = supplied orelse return null; if (point.kind != kind or point.id != id) return null; if (clamp) for (p.surface.tagLayers()) |*layer| { - if (layer.cols == 0 or layer.kind != kind or layer.id != id or layer.serial != point.serial or layer.line != line) continue; + if (layer.rows == 0 or layer.kind != kind or layer.id != id or layer.serial != point.serial or line >= layer.rows) continue; const bw: f32 = @floatFromInt(point.metrics.body_w); const bh: f32 = @floatFromInt(point.metrics.body_h); const left = @as(f32, @floatFromInt(layer.viewport.x)) * bw; const right = @as(f32, @floatFromInt(layer.viewport.x + layer.viewport.w)) * bw; point.pixel_x = std.math.clamp(point.pixel_x, left, @max(left, right - 0.001)); - point.pixel_y = (@as(f32, @floatFromInt(layer.viewport.y)) + 0.5) * bh; + point.pixel_y = (@as(f32, @floatFromInt(layer.viewport.y + line)) + 0.5) * bh; break; }; return if (p.reprojectTagHit(point)) |mapped| mapped.col else null; diff --git a/src/web.zig b/src/web.zig index 532e3794..ac0ecbbf 100644 --- a/src/web.zig +++ b/src/web.zig @@ -224,13 +224,13 @@ export fn pardes_mouse_pixel(button_arg: u32, kind_arg: u32, col_arg: u32, row_a var hit: ?pardes.Mouse.BodyHit = null; for (&s.core.surface.body_layers) |*layer| { const point = source orelse break; - hit = layer.hitAt(point.x * bw, point.y * bh, bw, bh, tw, th); + hit = layer.bodyHit(point.x * bw, point.y * bh, bw, bh, tw, th); if (hit != null) break; } var tag_hit: ?pardes.TagHit = null; for (&s.core.surface.tag_layers) |*layer| { const point = source orelse break; - tag_hit = layer.hitAt(point.x * bw, point.y * bh, bw, bh, tw, th); + tag_hit = layer.tagHit(point.x * bw, point.y * bh, bw, bh, tw, th); if (tag_hit != null) break; } mouseWithHit(button_arg, kind_arg, col_arg, row_arg, mods, hit, tag_hit); @@ -341,6 +341,7 @@ export fn pardes_tag_layer_value(index: u32, field: u32) u32 { const s = &(state orelse return 0); if (index >= pardes.MAX_TAG_LAYERS) return 0; const layer = &s.core.surface.tag_layers[index]; + if (layer.rows == 0) return 0; return switch (field) { 0 => layer.cols, 1 => @intFromEnum(layer.kind), @@ -350,9 +351,12 @@ export fn pardes_tag_layer_value(index: u32, field: u32) u32 { 5 => layer.viewport.y, 6 => layer.viewport.w, 7 => layer.viewport.h, - 8 => if (layer.cursor) |c| c.col else 65535, + 8 => if (layer.cursor) |c| c.x else 65535, 9 => if (layer.cursor) |c| @intFromBool(c.bar) else 0, 10 => @as(u32, layer.bg[0]) << 16 | @as(u32, layer.bg[1]) << 8 | layer.bg[2], + // A band has a row to each line of its tag: `cols * rows` cells. + 11 => layer.rows, + 12 => if (layer.cursor) |c| c.y else 65535, else => 0, }; } @@ -361,7 +365,7 @@ export fn pardes_tag_layer_cells(index: u32) ?[*]const WebCell { const s = &(state orelse return null); if (index >= pardes.MAX_TAG_LAYERS) return null; const layer = &s.core.surface.tag_layers[index]; - if (layer.cols == 0) return null; + if (layer.rows == 0) return null; const cells = &s.tag_cells[index]; if (cells.len != layer.cells.len) cells.* = gpa.realloc(cells.*, layer.cells.len) catch return null; for (layer.cells, cells.*) |source, *out| out.* = encodeWebCell(source); diff --git a/src/web/app.mjs b/src/web/app.mjs index 280239d5..03829178 100644 --- a/src/web/app.mjs +++ b/src/web/app.mjs @@ -64,13 +64,16 @@ export class DomRenderer { if (!val(0)) continue; const gripCols = this.wasm.pardes_grip_columns?.() ?? 2; const textInset = this.wasm.pardes_tag_text_inset?.() ?? gripCols; - if (val(1) !== 0 && val(4) >= textInset && this.cellHeight) { - const offset = this.wasm.pardes_tagline_band_offset(val(5),rows*this.cellHeight*256,Math.round(this.cellHeight*256),Math.round(this.taglineHeight*256))/256; - for (let x = val(4)-textInset; x < val(4); x++) tagPrefixBands.set(val(5)*cols+x,offset); - if (val(1) === 2) paneIndicators.add(val(5)*cols+val(4)-textInset); - } - for (let x = val(4); x < Math.min(cols, val(4) + val(6)); x++) { - if (val(5) < rows) tagCovered[val(5) * cols + x] = 1; + // A row to each line of a taller tag. + for (let y = val(5); y < val(5) + Math.max(1, val(11)); y++) { + if (val(1) !== 0 && val(4) >= textInset && this.cellHeight) { + const offset = this.wasm.pardes_tagline_band_offset(y,rows*this.cellHeight*256,Math.round(this.cellHeight*256),Math.round(this.taglineHeight*256))/256; + for (let x = val(4)-textInset; x < val(4); x++) tagPrefixBands.set(y*cols+x,offset); + if (val(1) === 2) paneIndicators.add(y*cols+val(4)-textInset); + } + for (let x = val(4); x < Math.min(cols, val(4) + val(6)); x++) { + if (y < rows) tagCovered[y * cols + x] = 1; + } } } } @@ -118,27 +121,35 @@ export class DomRenderer { const nodes = []; const bw = this.cellWidth, bh = this.cellHeight, tw = this.taglineWidth, th = this.taglineHeight; for (let i = 0; i < this.wasm.pardes_tag_layer_limit(); i++) { - const values = Array.from({length:11}, (_,field) => this.wasm.pardes_tag_layer_value(i,field)); - const cols = values[0]; + const layerValues = Array.from({length:13}, (_,field) => this.wasm.pardes_tag_layer_value(i,field)); + const cols = layerValues[0]; if (!cols) continue; const ptr = this.wasm.pardes_tag_layer_cells(i); if (!ptr) continue; - const bytes = new Uint8Array(this.wasm.memory.buffer,ptr,cols*CELL_SIZE); + // A row to each line of a taller tag, each drawn as a band of its own. + for (let line = 0; line < Math.max(1, layerValues[11]); line++) { + const key = i*64+line; + const values = layerValues.slice(0,11); + values[5] = layerValues[5] + line; + values[7] = 1; + values[8] = layerValues[12] === line ? layerValues[8] : 65535; + const bytes = new Uint8Array(this.wasm.memory.buffer,ptr+line*cols*CELL_SIZE,cols*CELL_SIZE); const offset = this.wasm.pardes_tagline_band_offset(values[5],this.rowCount*bh*256,Math.round(bh*256),Math.round(th*256))/256; const gripCols = this.wasm.pardes_grip_columns?.() ?? 2; const gripX = values[4] - (this.wasm.pardes_tag_text_inset?.() ?? gripCols); const gripCell = canonical[values[5]*this.cols+gripX]; const grip = values[1] === 2 && gripX >= 0 && gripCell && th >= 1 && tw >= 1; const geometry = [bw,bh,tw,th,offset,...values,grip,gripCell?.fg,gripCell?.bg,gripCell?.attrs,gripCell?.text,globalThis.devicePixelRatio].join(":"); - const cached = this.tagNodes.get(i); + const cached = this.tagNodes.get(key); if (cached && cached.geometry === geometry && cached.bytes.length === bytes.length && cached.bytes.every((byte,index)=>byte===bytes[index])) { nodes.push(cached.node); if (cached.grip) nodes.push(cached.grip); continue; } - const view = new DataView(this.wasm.memory.buffer,ptr,cols*CELL_SIZE); + const view = new DataView(this.wasm.memory.buffer,ptr+line*cols*CELL_SIZE,cols*CELL_SIZE); const layer = this.root.ownerDocument.createElement("div"); layer.dataset.tagLayer = String(i); + layer.dataset.tagLine = String(line); layer.style.cssText = `position:absolute;overflow:hidden;white-space:pre;left:${values[4]*bw}px;top:${values[5]*bh+offset}px;width:${values[6]*bw}px;height:${th}px;background:${color(values[10],DEFAULT_BG)}`; for (let col = 0; col < cols; col++) { const off = col*CELL_SIZE; @@ -171,9 +182,10 @@ export class DomRenderer { gripNode.append(mark); } } - this.tagNodes.set(i,{geometry,bytes:bytes.slice(),node:layer,grip:gripNode}); + this.tagNodes.set(key,{geometry,bytes:bytes.slice(),node:layer,grip:gripNode}); nodes.push(layer); if (gripNode) nodes.push(gripNode); + } } if (nodes.length !== this.tagRoot.childNodes.length || nodes.some((node,index)=>node!==this.tagRoot.childNodes[index])) this.tagRoot.replaceChildren(...nodes); } diff --git a/test/column_drag.zig b/test/column_drag.zig index feea1bf7..3aea4286 100644 --- a/test/column_drag.zig +++ b/test/column_drag.zig @@ -8,17 +8,17 @@ fn mouse(p: *Pardes, kind: pardes.Mouse.Kind, col: u16) void { p.update(.{ .mouse = .{ .button = .left, .kind = kind, .col = col, .row = pardes.TOPBAR_H } }); } -fn tagLayer(p: *Pardes, column: usize) !*pardes.TagLayer { +fn tagLayer(p: *Pardes, column: usize) !*pardes.Layer { for (&p.surface.tag_layers) |*layer| { - if (layer.cols > 0 and layer.kind == .column and layer.id == column) return layer; + if (layer.rows > 0 and layer.kind == .column and layer.id == column) return layer; } return error.MissingColumnTagLayer; } -fn headerMouse(layer: *const pardes.TagLayer, display_col: u16, kind: pardes.Mouse.Kind) pardes.Mouse { +fn headerMouse(layer: *const pardes.Layer, display_col: u16, kind: pardes.Mouse.Kind) pardes.Mouse { const x = @as(f32, @floatFromInt(layer.viewport.x)) * 10 + (@as(f32, @floatFromInt(display_col)) + 0.5) * 5; const y = (@as(f32, @floatFromInt(layer.viewport.y)) + 0.5) * 20; - return .{ .button = .left, .kind = kind, .col = @intFromFloat(x / 10), .row = @intFromFloat(y / 20), .tag_hit = layer.hitAt(x, y, 10, 20, 5, 10) }; + return .{ .button = .left, .kind = kind, .col = @intFromFloat(x / 10), .row = @intFromFloat(y / 20), .tag_hit = layer.tagHit(x, y, 10, 20, 5, 10) }; } const Snapshot = struct { @@ -144,7 +144,7 @@ test "column grip is separate from compact text and the resize seam" { try std.testing.expectEqual(p.col_x[column], rect.x); try std.testing.expectEqual(rect.x + pardes.TAG_TEXT_INSET, (try tagLayer(p, column)).viewport.x); for (p.surface.tagLayers()) |pane_layer| { - if (pane_layer.cols > 0 and pane_layer.kind == .pane and pane_layer.id == pane_id) + if (pane_layer.rows > 0 and pane_layer.kind == .pane and pane_layer.id == pane_id) try std.testing.expectEqual((try tagLayer(p, column)).viewport.x, pane_layer.viewport.x); } const pane_tag_y = if (p.settings.tag_bottom) rect.y + rect.h - pardes.BOX_H else rect.y; diff --git a/test/panes.zig b/test/panes.zig index 11fb750a..5e26cdd0 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -19,17 +19,17 @@ fn setTag(pane: *Pane, text: []const u8) !void { } const TagLayerTests = struct { - const Kind = @FieldType(pardes.TagLayer, "kind"); - fn findLayer(p: *Pardes, kind: Kind, id: usize) !*pardes.TagLayer { + const Kind = @FieldType(pardes.Layer, "kind"); + fn findLayer(p: *Pardes, kind: Kind, id: usize) !*pardes.Layer { for (&p.surface.tag_layers) |*layer| { - if (layer.cols > 0 and layer.kind == kind and layer.id == id) return layer; + if (layer.rows > 0 and layer.kind == kind and layer.id == id) return layer; } return error.MissingTagLayer; } - fn mouse(layer: *const pardes.TagLayer, x: f32, kind: pardes.Mouse.Kind) pardes.Mouse { + fn mouse(layer: *const pardes.Layer, x: f32, kind: pardes.Mouse.Kind) pardes.Mouse { const y = (@as(f32, @floatFromInt(layer.viewport.y)) + 0.5) * 20; - return .{ .button = .left, .kind = kind, .col = @intFromFloat(x / 10), .row = @intFromFloat(y / 20), .tag_hit = layer.hitAt(x, y, 10, 20, 5, 10) }; + return .{ .button = .left, .kind = kind, .col = @intFromFloat(x / 10), .row = @intFromFloat(y / 20), .tag_hit = layer.tagHit(x, y, 10, 20, 5, 10) }; } test "compact tag layers render and edit Unicode text beyond body column capacity" { @@ -200,14 +200,16 @@ test "a tag with a newline in it is as tall as its lines, and each line is typed const body_y = p.bodyTop(pane, r); try std.testing.expectEqual(if (bottom) r.y else r.y + 2, body_y); try std.testing.expectEqualStrings("b", surface.at(r.x + config.GUTTER + panes.File.gutterWidth(pane), body_y).grapheme()); - // a layer to a line, for a pixel shell - var lines: usize = 0; + // one layer, a row to each line, for a pixel shell + var layers: usize = 0; for (p.surface.tagLayers()) |*layer| { - if (layer.cols == 0 or layer.kind != .pane or layer.id != 0) continue; - try std.testing.expectEqual(tag_y + layer.line, layer.viewport.y); - lines += 1; + if (layer.rows == 0 or layer.kind != .pane or layer.id != 0) continue; + try std.testing.expectEqual(tag_y, layer.viewport.y); + try std.testing.expectEqual(@as(u16, 2), layer.rows); + try std.testing.expectEqual(@as(u16, 2), layer.viewport.h); + layers += 1; } - try std.testing.expectEqual(@as(usize, 2), lines); + try std.testing.expectEqual(@as(usize, 1), layers); // a click on the second line puts the tag's cursor on it p.update(.{ .key = .{ .cp = Key.escape } }); p.update(.{ .key = .{ .cp = ':' } }); @@ -541,11 +543,11 @@ const TagNavigationTests = struct { }; const BodyHitTests = struct { - fn mouse(layer: *const pardes.BodyLayer, row: u16, col: u16, kind: pardes.Mouse.Kind) pardes.Mouse { + fn mouse(layer: *const pardes.Layer, row: u16, col: u16, kind: pardes.Mouse.Kind) pardes.Mouse { const width: f32 = if (row < layer.context_rows) 8 else 10; const x = @as(f32, @floatFromInt(layer.viewport.x)) * 10 + (@as(f32, @floatFromInt(col)) + 0.5) * width; const y = layer.rowTop(row, 20, 10) + layer.rowHeight(row, 20, 10) / 2; - return .{ .button = .left, .kind = kind, .col = @intFromFloat(x / 10), .row = @intFromFloat(y / 20), .body_hit = layer.hitAt(x, y, 10, 20, 8, 10).? }; + return .{ .button = .left, .kind = kind, .col = @intFromFloat(x / 10), .row = @intFromFloat(y / 20), .body_hit = layer.bodyHit(x, y, 10, 20, 8, 10).? }; } test "compact TreeContext mouse follows source rows in reclaimed space" { @@ -673,7 +675,7 @@ const TreeContextTests = struct { const y = layer.rowTop(row, 20, 10) + 9.5; const x = @as(f32, @floatFromInt(layer.viewport.x)) * 10 + 5; // A one-pixel separator remains inside its existing header row. - try std.testing.expectEqual(row, layer.hitAt(x, y, 10, 20, 8, 10).?.row); + try std.testing.expectEqual(row, layer.bodyHit(x, y, 10, 20, 8, 10).?.row); } pane.scrollBy(-5); _ = try p.render(frame.allocator()); -- cgit v1.3 From 2d1ae5034dfcfe9265689e7fa53c5a85b683a7a1 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:40:06 -0300 Subject: Alt-u and Alt-U undo and redo helix's earlier/later walk its history tree by time; pardes's history is a line, where earlier and later are undo and redo, so the keys are aliases. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 3 +-- src/config.zig | 6 ++++-- test/hxcases/cases.jsonl | 2 ++ test/hxcases/goldens.jsonl | 2 ++ 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 2928d78e..d2915cbb 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -118,7 +118,7 @@ language-backend queries, and the shell pipe. | `d` | delete selection (implicit or explicit); bare cursor = the 1-wide selection | always yanks (`Alt-d` noyank in B); terminals: drops typed runs only | helix-verified | | `c` | change (delete + insert mode); linewise selections open a fresh indented line | same shape as `d` | helix-verified | | `y` | yank each range's text into the register (`"` names one, else `"`), one value per range | line-yank vim-ism removed (phase 5); yank keeps selection AND cursor (helix). The system clipboard is `"+y` / `SPC y`, helix's own split: a `d` of one character never clobbers what the desktop holds | helix-verified | -| `u` / `U` | undo / redo | restores the pre-edit selection (helix); snapshot granularity, no `Alt-u`/`Alt-U` history walking (skipped) | helix-verified | +| `u` / `U` | undo / redo | restores the pre-edit selection (helix); snapshot granularity, `Alt-u`/`Alt-U` are the same two: helix walks a history tree by time with them, and pardes's history is linear, so earlier and later ARE undo and redo | helix-verified | | `p` (normal) | paste the register after each range: value i at range i, the last value repeated when there are fewer | helix `paste_impl`. `"+p` asks the system clipboard (`SPC p`); on a tty that read is OSC 52, which most terminals refuse: an honest no-op rather than a paste of the wrong text | helix-verified | | `Esc` (body normal) | clear a pending modal prefix / exit select mode, keeping the selection, then run `Last`: hop to the pane you were in before this one, whichever kind it was, exactly like `SPC j j` — so held down it alternates between two panes, two files as readily as a file and its shell. A PDF pane is the ONE exception: there Esc is the document's own cancel (drop the mouse selection and the search overlay, stay where you are reading) and `Shift-Esc` is the hop out, while raw tty only intercepts unmodified Esc at a detected shell prompt | Pardes-specific focus binding layered on helix's cleanup. A leader path, tag, topbar or search owns Esc while it is active; raw tty forwards it | pardes-specific (cleanup helix-verified) | | `Esc` (insert) | back to normal mode, cursor right after the insertion (no vim left-step) | | helix-verified | @@ -357,7 +357,6 @@ text. | `]g [g ]G [G` | git change jumps | needs VCS diff state | | insert `Ctrl-x` | completion menu | completion exists, but not as a popup: insert-mode Tab straight after a `.` opens a buffer of candidate DECLARATIONS (section A). helix's menu itself is skipped | | `Ctrl-s` (normal) | save jumplist position | jumplist itself is implemented (`Ctrl-o`/`Ctrl-i`, section A); only the explicit save point is skipped | -| `Alt-u` / `Alt-U` | undo-history earlier/later | history timeline — linear snapshot u/U covers pardes | | `:` | command mode | side-effects/file-ops — pardes builtins live in the tag, and `:` is bound to focusing it (section A) | | `gf` | goto file under selection | covered by pardes Enter-look | | `Ctrl-z` | suspend | pardes IS the terminal multiplexer | diff --git a/src/config.zig b/src/config.zig index 993b6fe0..80c08cce 100644 --- a/src/config.zig +++ b/src/config.zig @@ -569,8 +569,10 @@ pub const unindent: []const Chord = &.{.{ .cp = '<' }}; pub const format: []const Chord = &.{.{ .cp = '=' }}; pub const increment: []const Chord = &.{.{ .cp = 'a', .ctrl = true }}; pub const decrement: []const Chord = &.{.{ .cp = 'x', .ctrl = true }}; -pub const undo: []const Chord = &.{.{ .cp = 'u' }}; -pub const redo: []const Chord = &.{.{ .cp = 'U' }}; +// pardes's history is linear, so helix's earlier/later (Alt-u, Alt-U) are +// undo and redo themselves +pub const undo: []const Chord = &.{ .{ .cp = 'u' }, .{ .cp = 'u', .alt = true } }; +pub const redo: []const Chord = &.{ .{ .cp = 'U' }, .{ .cp = 'U', .alt = true } }; pub const comment_toggle: []const Chord = &.{.{ .cp = 'c', .ctrl = true }}; // Body-normal Esc runs Last; other modes cancel input or leave insert mode. diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 03b20ff5..f23a8b2f 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -446,6 +446,8 @@ {"name": "goto-last-mod-delete", "pane": "file", "text": "abc\ndef\n", "keys": "jlxdgg"} {"name": "goto-last-mod-delete-go", "pane": "file", "text": "abc\ndef\n", "keys": "jldggg."} {"name": "goto-last-mod-select", "pane": "file", "text": "ab\ncd\nef\n", "keys": "jjiXggvg."} +{"name": "alt-u-undo", "pane": "file", "text": "ab\n", "keys": "iX"} +{"name": "alt-U-redo", "pane": "file", "text": "ab\n", "keys": "iXu"} {"name": "msel-align-basic", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=&"} {"name": "msel-align-columns", "pane": "file", "text": "a,b,c\nccc,d,e\n", "keys": "%s,&"} {"name": "msel-align-uneven", "pane": "file", "text": "a,b\nccc,d,eee,f\n", "keys": "%s,&"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index 8e642073..4c565b53 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -446,6 +446,8 @@ {"name":"goto-last-mod-delete","text":"abc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} {"name":"goto-last-mod-delete-go","text":"abc\ndf\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}} {"name":"goto-last-mod-select","text":"ab\ncd\nXef\n","mode":"select","cursor":{"row":2,"col":1},"anchor":{"row":0,"col":0}} +{"name":"alt-u-undo","text":"ab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"alt-U-redo","text":"Xab\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} {"name":"msel-align-basic","text":"a =1\nbbb=2\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":0} {"name":"msel-align-columns","text":"a ,b,c\nccc,d,e\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}}],"primary":0} {"name":"msel-align-uneven","text":"a ,b\nccc,d,eee,f\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}}],"primary":0} -- cgit v1.3 From 360b72ade0acfa655a3740b638a0cc8e23238a05 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 14:59:21 -0300 Subject: The GUI draws in tiers from the regions; pane chrome moves with its pane Stage 8 of docs/render-pipeline.md. The frame is drawn in groups: tier 0, one per track in paint order (tiers 2 and 3), tier 4 (notices, then guides and the debug box), tier 5 (bar cursors), each cells, images, decor. Decor (rules, rails, thumbs, grip marks, spines, the workspace and column rules, the bottom band, notice rules, bar cursors) is whole-pixel rects through the new decor.frag over ui.vert, so it carries its track's transition and clip. A closing pane's chrome comes from Surface.previous_regions. Deleted: taglineBaseRgb, topbarPaneBorderHeight, bottomTaglinePresent, frameChromeBg, cellBackgroundIs and the rail inference, paneGripCell's scan, transient_on, PaintPlan. One cover map (coverFrame) is marked from the layers and regions once a frame. New regions column, guide and debug; Surface.chrome is the palette, carried in wire v8 (not shipped yet), so an attached GUI draws the same chrome (test). A per-instance clip replaces the vertical transition's scissor. Goldens: 01-12 byte-identical. 13-17 differ only past the grid: the image pass left its scissor at the grid's size, cutting rule ends, rail feet and the bottom band in the leftover pixels whenever a picture was on screen. 16-debug now shows the debug box, which a context-row layer had hidden. 18-mid-transition is new: virtual clock (PARDES_TEST_CLOCK in the GUI), PanelSlide Newcol with the picture, frame 6 of 12. Also: the GUI sleeps when idle instead of polling every 16 ms, and a minimized or occluded window sleeps through animation. Tracy 'gui frame build' at 200x60: 992/1015 us median before, 989/987 us after. Shared files touched: pardes.zig (one export), detached/client.zig, detached/server.zig, detached/wire.zig (all additive), gui.zig. Not touched: Messages.zig, mouse.zig, tagline.zig, colors.zig, tty.zig, host_io.zig, dump.zig, exec.zig, panes.zig. --- build.zig | 1 + docs/render-pipeline.md | 14 +- shaders/decor.frag.glsl | 24 + shaders/prebuilt/decor.frag.glsl | 24 + shaders/prebuilt/decor.frag.spv | Bin 0 -> 2100 bytes shaders/prebuilt/ui.frag.glsl | 3 + shaders/prebuilt/ui.frag.spv | Bin 4668 -> 5700 bytes shaders/prebuilt/ui.vert.glsl | 6 + shaders/prebuilt/ui.vert.spv | Bin 5468 -> 5612 bytes shaders/ui.frag.glsl | 3 + shaders/ui.vert.glsl | 6 + src/Presentation.zig | 5 + src/detached/client.zig | 4 +- src/detached/server.zig | 1 + src/detached/wire.zig | 41 +- src/draw.zig | 63 +- src/gui/gui.zig | 1476 +++++++++++++++++++------------------- src/pardes.zig | 1 + src/surface.zig | 40 +- test/gui-goldens.txt | 11 +- test/gui_golden.py | 18 +- 21 files changed, 978 insertions(+), 763 deletions(-) create mode 100644 shaders/decor.frag.glsl create mode 100644 shaders/prebuilt/decor.frag.glsl create mode 100644 shaders/prebuilt/decor.frag.spv diff --git a/build.zig b/build.zig index 666e792d..803f773d 100644 --- a/build.zig +++ b/build.zig @@ -47,6 +47,7 @@ pub const TreeSitterGrammars = enum { disabled, zig, minimal, full }; const gui_shaders = [_][]const u8{ "ui.vert", "ui.frag", + "decor.frag", "overlay.vert", "overlay.frag", "image.vert", "image.frag", "crt.vert", "crt.frag", diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index a8b3f019..69645c96 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -810,7 +810,7 @@ stripped. No visual change until stage 9 unless stated. | 5 | JOIN: paint tags/notices/headers once into layers, copy into grid; the grid wins where the copies disagree, and each disagreement is listed for a later decision | goldens are the oracle; wide-grapheme re-clip at the edge (§3.2) | | 6 | body layer joined the same way (paint once, copy visible rows) | riskiest join; A/B the old double paint in a temporary test with tag_bottom on and off, then delete it | | 7 | Layer merge (TagLayer + BodyLayer), wire v8, web accessors | after the other agent lands; touches mouse hit paths; breaks the macOS shell's layer ABI (accepted: macOS build ignored for now); done: one `Layer` (src/Layer.zig) with `rows` (0 = no layer) and a `cursor{x, y}`; a tag of N rows is ONE layer of N grid rows (`tagHit` answers the row as `line`, `bodyHit` keeps its meaning), so the per-line layer bases are gone. Wire v8 ships rows, cursor y and the region list in the one bump; v7 and v9 peers are refused in both directions (tests). web: `tag_layer_value` 11 = rows, 12 = cursor y, and app.mjs lays every row. macOS: its Zig side compiles against `Layer`, but pardes.h still sees one row per tag layer (a taller tag shows its first row there) | -| 8 | GUI draws from regions: role tiers with track groups in tiers 2-3, page cover, hard-edged snapped decor for rules/rails/grips (pane chrome in the pane's tier), per-instance clip; delete inference functions, `transient_on` and `mark_hover` (breaks macOS glass hover; accepted, macOS ignored for now) | stage-0 PPM goldens byte-identical (possible only because hard decor is snapped and not anti-aliased); pane chrome now also shows during transitions, which is the one allowed visible delta, listed | +| 8 | GUI draws from regions: role tiers with track groups in tiers 2-3, page cover, hard-edged snapped decor for rules/rails/grips (pane chrome in the pane's tier), per-instance clip; delete inference functions, `transient_on` and `mark_hover` (breaks macOS glass hover; accepted, macOS ignored for now) | stage-0 PPM goldens byte-identical (possible only because hard decor is snapped and not anti-aliased); pane chrome now also shows during transitions, which is the one allowed visible delta, listed Done: the frame is drawn in groups (makeGroups): tier 0, one group per track in paint order (tiers 2 and 3), tier 4 as two groups (the notices; then the guides and the debug box, which the core paints over them), tier 5 (bar cursors), each drawing cells, images, decor. Decor is every rule, rail, thumb, grip mark, spine, the workspace and column rules, the bottom band, notice rules and bar cursors: whole-pixel rects drawn by `decor.frag` over `ui.vert` as cell instances (colour in the ground, coverage in fg.r, blended like the overlay), so each carries its track's transition and clip; a closing pane's comes from the last frame's regions (`Surface.previous_regions`). Only `solid` exists yet: `decor.vert` and the other kinds (§5.1) come with the first effect that needs them. Deleted: taglineBaseRgb, topbarPaneBorderHeight, bottomTaglinePresent, frameChromeBg, cellBackgroundIs and the rail inference, paneGripCell's scan, transient_on, PaintPlan; one cover map (coverFrame: layer, grip and offset, focus, anchor, floating) is marked from layers and regions once a frame. New regions: `column` (spines, anchors, the focused column's tint), `guide`, `debug`; `Surface.chrome` is the palette, on the wire in v8 (not yet shipped, so no bump), and an attached GUI draws the same chrome (test). Per-instance clip (`CellInstance.clip_*`) replaces the vertical transition's scissor. Goldens: 01-12 byte-identical; 13-17 differ only in pixels past the grid (with a picture on screen the image pass left the scissor at the grid's size, cutting every rule end, rail foot and band that runs into the leftover pixels; they now run to the edge as in every scene without one); 16-debug shows the debug box (it was drawn from the grid under the source's context-row layer, so the GUI never showed it there); 18-mid-transition is new (virtual clock, PanelSlide Newcol with the picture, frame 6 of 12: chrome moves with its pane). Other deltas, outside the goldens: a guide over a tag or a context-row body now shows; with WindowOpacity < 100 a layer's bar cursor is ink like the grid's; an attached GUI gains the focus tint, notice rules, spines and the theme's page and caret colours. Deferrals fixed: the GUI sleeps when idle (SDL and queue events wake it; a smooth scroll, a gamepad, the test feed, a shell's kill deadline, a present to retry still poll), and a minimized or occluded window sleeps through animation. Tracy, `gui frame build`, 200x60, terminal output plus scrolling, ReleaseFast, two interleaved runs of ~180 frames: before 992/1015 µs median, after 989/987 µs | | 9 | post chain: glslang, Shadertoy prefix, ping-pong, ShaderAnimation, redraw levels A/B (§5.5); bundled CRT without barrel; delete scene_effects/crt.zig/crt.frag | first visual change (the CRT look) | | 10+ | `fx` bookmark: G1–G3 → feel review → G4 bundled → G5 lapis theme → P2s; tty T1–T2 → feel review → removals of audited effects (after user decision) → T3–T5 | each effect its own change, default off | @@ -845,13 +845,21 @@ goldens; ssh/tty byte budgets; wire version bump breaks mixed-version attach. 6. Revisit at the first visual stage (from stage 5): the notice layer lost its hover word to keep the grid's behaviour. Notice words are Look/Exec targets, so bring the hover affordance back in BOTH the grid and the - layer then. + layer then. Carried forward from stage 8 (a GUI-only stage) to the hover + change after it, which also retires `mark_hover`: the affordance is a + core paint every shell and snapshot sees. 7. Revisit at stage 8 (from stage 6): a body without context rows has no layer (paint once, straight onto the grid). If the GUI is to read every body from a layer, give every body one then and measure the copy. The context-row path also still builds the body's text twice (a pre-pass with body_rows 0 decides the context rows, renderBody builds it again at - the layer's rows); the paint is single. Measure both there. + the layer's rows); the paint is single. Measure both there. Decided at + stage 8, measured (Tracy, 200x60, TreeContext on a scrolled source, + ReleaseFast): the pre-pass text build is 5 µs median and the layer's + copies 10 µs per context-row body per frame. Kept as they are: nothing in + the GUI reads a layerless body from anything but the grid, which is + exact, so a layer for every body would be ~10 µs a body a frame for no + pixel. ## 15. Questions for the user diff --git a/shaders/decor.frag.glsl b/shaders/decor.frag.glsl new file mode 100644 index 00000000..4c2bd6d8 --- /dev/null +++ b/shaders/decor.frag.glsl @@ -0,0 +1,24 @@ +#version 450 + +// Decor: a flat, hard-edged fill (a rule, a rail, a grip mark, a bar cursor) +// that ui.vert.glsl places like a cell, its track's transition included. Its +// colour rides in the cell's ground (a_bg) and its coverage in a_fg.r. The +// pipeline blends it as the overlay pipeline does (backgroundLayerBlend), so +// a fading rule shows through, and the result is that pipeline's exactly. +// Ink (a cursor) is drawn with the opacity uniform at 1. + +layout(std140, set = 3, binding = 0) uniform UiUniforms { + float background_opacity; +} u_ui; + +layout(location = 1) in vec3 v_fg; +layout(location = 2) in vec3 v_bg; +layout(location = 5) flat in vec4 v_clip; + +layout(location = 0) out vec4 o_col; + +void main() { + if (v_clip.z >= v_clip.x && (gl_FragCoord.x < v_clip.x || gl_FragCoord.x >= v_clip.z || + gl_FragCoord.y < v_clip.y || gl_FragCoord.y >= v_clip.w)) discard; + o_col = vec4(v_bg * clamp(u_ui.background_opacity, 0.0, 1.0), v_fg.r); +} diff --git a/shaders/prebuilt/decor.frag.glsl b/shaders/prebuilt/decor.frag.glsl new file mode 100644 index 00000000..4c2bd6d8 --- /dev/null +++ b/shaders/prebuilt/decor.frag.glsl @@ -0,0 +1,24 @@ +#version 450 + +// Decor: a flat, hard-edged fill (a rule, a rail, a grip mark, a bar cursor) +// that ui.vert.glsl places like a cell, its track's transition included. Its +// colour rides in the cell's ground (a_bg) and its coverage in a_fg.r. The +// pipeline blends it as the overlay pipeline does (backgroundLayerBlend), so +// a fading rule shows through, and the result is that pipeline's exactly. +// Ink (a cursor) is drawn with the opacity uniform at 1. + +layout(std140, set = 3, binding = 0) uniform UiUniforms { + float background_opacity; +} u_ui; + +layout(location = 1) in vec3 v_fg; +layout(location = 2) in vec3 v_bg; +layout(location = 5) flat in vec4 v_clip; + +layout(location = 0) out vec4 o_col; + +void main() { + if (v_clip.z >= v_clip.x && (gl_FragCoord.x < v_clip.x || gl_FragCoord.x >= v_clip.z || + gl_FragCoord.y < v_clip.y || gl_FragCoord.y >= v_clip.w)) discard; + o_col = vec4(v_bg * clamp(u_ui.background_opacity, 0.0, 1.0), v_fg.r); +} diff --git a/shaders/prebuilt/decor.frag.spv b/shaders/prebuilt/decor.frag.spv new file mode 100644 index 00000000..7e162f5a Binary files /dev/null and b/shaders/prebuilt/decor.frag.spv differ diff --git a/shaders/prebuilt/ui.frag.glsl b/shaders/prebuilt/ui.frag.glsl index 3c366602..0b099db3 100644 --- a/shaders/prebuilt/ui.frag.glsl +++ b/shaders/prebuilt/ui.frag.glsl @@ -17,6 +17,7 @@ layout(location = 1) in vec3 v_fg; layout(location = 2) in vec3 v_bg; layout(location = 3) in vec2 v_cell_uv; layout(location = 4) flat in uvec4 v_effect; +layout(location = 5) flat in vec4 v_clip; layout(location = 0) out vec4 o_col; @@ -31,6 +32,8 @@ float cellNoise(uint serial, uint col, uint row) { } void main() { + if (v_clip.z >= v_clip.x && (gl_FragCoord.x < v_clip.x || gl_FragCoord.x >= v_clip.z || + gl_FragCoord.y < v_clip.y || gl_FragCoord.y >= v_clip.w)) discard; float a = texture(u_atlas, v_uv).r; bool oldLayer = (v_effect.x & 0x80000000u) != 0u; // A block cursor is foreground chrome even though its fill occupies the diff --git a/shaders/prebuilt/ui.frag.spv b/shaders/prebuilt/ui.frag.spv index 120438e7..033c10ab 100644 Binary files a/shaders/prebuilt/ui.frag.spv and b/shaders/prebuilt/ui.frag.spv differ diff --git a/shaders/prebuilt/ui.vert.glsl b/shaders/prebuilt/ui.vert.glsl index d23b609c..c1779add 100644 --- a/shaders/prebuilt/ui.vert.glsl +++ b/shaders/prebuilt/ui.vert.glsl @@ -19,12 +19,17 @@ layout(location = 5) in vec4 a_present_rect; // the id is what is left below them; integer transport preserves the exact // cross-backend hash. layout(location = 6) in uvec4 a_effect; +// A clip rectangle in target pixels (x0, y0, x1, y1): the fragment shader +// drops what lies outside it. x1 < x0 is none. A vertical transition clips +// its pane's instances to the box the pane is growing into. +layout(location = 7) in vec4 a_clip; layout(location = 0) out vec2 v_uv; layout(location = 1) out vec3 v_fg; layout(location = 2) out vec3 v_bg; layout(location = 3) out vec2 v_cell_uv; layout(location = 4) flat out uvec4 v_effect; +layout(location = 5) flat out vec4 v_clip; void main() { vec2 pos; @@ -70,5 +75,6 @@ void main() { v_fg = a_fg; v_bg = a_bg; v_effect = a_effect; + v_clip = a_clip; gl_Position = vec4(pos, 0.0, 1.0); } diff --git a/shaders/prebuilt/ui.vert.spv b/shaders/prebuilt/ui.vert.spv index 659049dd..619a9690 100644 Binary files a/shaders/prebuilt/ui.vert.spv and b/shaders/prebuilt/ui.vert.spv differ diff --git a/shaders/ui.frag.glsl b/shaders/ui.frag.glsl index 3c366602..0b099db3 100644 --- a/shaders/ui.frag.glsl +++ b/shaders/ui.frag.glsl @@ -17,6 +17,7 @@ layout(location = 1) in vec3 v_fg; layout(location = 2) in vec3 v_bg; layout(location = 3) in vec2 v_cell_uv; layout(location = 4) flat in uvec4 v_effect; +layout(location = 5) flat in vec4 v_clip; layout(location = 0) out vec4 o_col; @@ -31,6 +32,8 @@ float cellNoise(uint serial, uint col, uint row) { } void main() { + if (v_clip.z >= v_clip.x && (gl_FragCoord.x < v_clip.x || gl_FragCoord.x >= v_clip.z || + gl_FragCoord.y < v_clip.y || gl_FragCoord.y >= v_clip.w)) discard; float a = texture(u_atlas, v_uv).r; bool oldLayer = (v_effect.x & 0x80000000u) != 0u; // A block cursor is foreground chrome even though its fill occupies the diff --git a/shaders/ui.vert.glsl b/shaders/ui.vert.glsl index d23b609c..c1779add 100644 --- a/shaders/ui.vert.glsl +++ b/shaders/ui.vert.glsl @@ -19,12 +19,17 @@ layout(location = 5) in vec4 a_present_rect; // the id is what is left below them; integer transport preserves the exact // cross-backend hash. layout(location = 6) in uvec4 a_effect; +// A clip rectangle in target pixels (x0, y0, x1, y1): the fragment shader +// drops what lies outside it. x1 < x0 is none. A vertical transition clips +// its pane's instances to the box the pane is growing into. +layout(location = 7) in vec4 a_clip; layout(location = 0) out vec2 v_uv; layout(location = 1) out vec3 v_fg; layout(location = 2) out vec3 v_bg; layout(location = 3) out vec2 v_cell_uv; layout(location = 4) flat out uvec4 v_effect; +layout(location = 5) flat out vec4 v_clip; void main() { vec2 pos; @@ -70,5 +75,6 @@ void main() { v_fg = a_fg; v_bg = a_bg; v_effect = a_effect; + v_clip = a_clip; gl_Position = vec4(pos, 0.0, 1.0); } diff --git a/src/Presentation.zig b/src/Presentation.zig index e1f1b585..3b5bb3e0 100644 --- a/src/Presentation.zig +++ b/src/Presentation.zig @@ -44,6 +44,8 @@ pending: bool = false, previous_cells: []pardes.Cell = &.{}, previous_body_layers: [MAX_PANES]pardes.Layer = @splat(.{}), previous_tag_layers: [pardes.MAX_TAG_LAYERS]pardes.Layer = @splat(.{}), +previous_regions: [pardes.MAX_REGIONS]pardes.Region = undefined, +previous_nregions: u16 = 0, previous_cols: u16 = 0, previous_rows: u16 = 0, previous_valid: bool = false, @@ -346,6 +348,8 @@ pub fn capturePrevious(self: *Presentation, gpa: std.mem.Allocator, surface: *co old.cells = storage; } @memcpy(self.previous_cells, cells); + @memcpy(self.previous_regions[0..surface.nregions], surface.regionList()); + self.previous_nregions = surface.nregions; self.previous_cols = surface.cols; self.previous_rows = surface.rows; self.previous_valid = true; @@ -584,6 +588,7 @@ pub fn submit(self: *Presentation, p: *Pardes, s: *pardes.Surface) !void { s.previous_cells = self.previous_cells; s.previous_body_layers = &self.previous_body_layers; s.previous_tag_layers = &self.previous_tag_layers; + s.previous_regions = self.previous_regions[0..self.previous_nregions]; s.cell_diffs = self.diffs; } s.npanel_tracks = paintOrder( diff --git a/src/detached/client.zig b/src/detached/client.zig index 3347f609..9fa85fb4 100644 --- a/src/detached/client.zig +++ b/src/detached/client.zig @@ -107,6 +107,8 @@ pub const Client = struct { tag_layers: [pardes.MAX_TAG_LAYERS]pardes.Layer = @splat(.{}), /// The session's placed regions, as its last frame gave them. regions: std.ArrayListUnmanaged(pardes.Region) = .empty, + /// The palette its chrome is drawn in, as its last frame gave it. + chrome: pardes.Chrome = .{}, in: std.ArrayListUnmanaged(u8) = .empty, out: std.ArrayListUnmanaged(u8) = .empty, /// Bytes of `in` belonging to the message `next` returned last. Compacted at @@ -322,7 +324,7 @@ pub const Client = struct { try c.reshape(f.cols, f.rows); } try f.apply(c.grid.items); - f.applyLayers(c.gpa, &c.body_layers, &c.tag_layers, &c.regions) catch |err| return switch (err) { + f.applyLayers(c.gpa, &c.body_layers, &c.tag_layers, &c.regions, &c.chrome) catch |err| return switch (err) { error.OutOfMemory => error.Closed, else => @as(wire.Error, @errorCast(err)), }; diff --git a/src/detached/server.zig b/src/detached/server.zig index 46fb8fcf..9e18e125 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -760,6 +760,7 @@ pub const Session = struct { &surface.body_layers, &surface.tag_layers, surface.regionList(), + surface.chrome, ) catch |err| { log.debug("frame {d}x{d} not encodable: {t}", .{ surface.cols, surface.rows, err }); return; diff --git a/src/detached/wire.zig b/src/detached/wire.zig index 684c869b..32cd5229 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -169,8 +169,9 @@ pub const Frame = struct { nruns: u32, runs: []const u8, - pub fn applyLayers(f: Frame, gpa: std.mem.Allocator, layers: *[pardes.MAX_PANES]pardes.Layer, tags: *[pardes.MAX_TAG_LAYERS]pardes.Layer, regions: *std.ArrayListUnmanaged(pardes.Region)) !void { + pub fn applyLayers(f: Frame, gpa: std.mem.Allocator, layers: *[pardes.MAX_PANES]pardes.Layer, tags: *[pardes.MAX_TAG_LAYERS]pardes.Layer, regions: *std.ArrayListUnmanaged(pardes.Region), chrome: *pardes.Chrome) !void { regions.clearRetainingCapacity(); + chrome.* = .{}; for (layers) |*layer| { gpa.free(layer.cells); layer.* = .{}; @@ -188,6 +189,8 @@ pub const Frame = struct { gpa.free(layer.cells); layer.* = .{}; } + regions.clearRetainingCapacity(); + chrome.* = .{}; } if (f.layers.len == 0) return; var r: Reader = .init(f.layers); @@ -255,6 +258,15 @@ pub const Frame = struct { region.clip > region.rect.w or region.thumb_y > region.rect.h or region.thumb_h > region.rect.h - region.thumb_y) return error.BadValue; regions.appendAssumeCapacity(region); } + inline for (@typeInfo(pardes.Chrome).@"struct".fields) |field| { + const rgb = &@field(chrome, field.name); + if (field.type == ?[3]u8) { + if (try r.getBool()) { + rgb.* = .{ 0, 0, 0 }; + for (&rgb.*.?) |*channel| channel.* = try r.getByte(); + } + } else for (rgb) |*channel| channel.* = try r.getByte(); + } try r.end(); } @@ -697,7 +709,7 @@ pub fn encodeFramePointer( cells: []const pardes.Cell, prev: []const pardes.Cell, ) Error![]const u8 { - return encodeFrameLayers(out, cols, rows, cursor, pointer_shape, cells, prev, &.{}, &.{}, &.{}); + return encodeFrameLayers(out, cols, rows, cursor, pointer_shape, cells, prev, &.{}, &.{}, &.{}, .{}); } pub fn layersBound(layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region) usize { @@ -708,12 +720,15 @@ pub fn layersBound(layers: []const pardes.Layer, tags: []const pardes.Layer, reg for (tags) |layer| if (layer.rows != 0) { n += 28 + layer.cells.len * cell_max; }; - return n + regions.len * region_len; + return n + regions.len * region_len + chrome_len; } const region_len = 1 + 1 + 4 + 8 + 1 + 2 + 2 + 1 + 2; +/// The palette: each colour three bytes, a theme's optional ones a flag +/// before them. +const chrome_len = @typeInfo(pardes.Chrome).@"struct".fields.len * 4; -pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, pointer_shape: pardes.Surface.PointerShape, cells: []const pardes.Cell, prev: []const pardes.Cell, layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region) Error![]const u8 { +pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, pointer_shape: pardes.Surface.PointerShape, cells: []const pardes.Cell, prev: []const pardes.Cell, layers: []const pardes.Layer, tags: []const pardes.Layer, regions: []const pardes.Region, chrome: pardes.Chrome) Error![]const u8 { // The protocol's ceiling, enforced by the ENCODER too, and BEFORE the // assert below so a caller can be told rather than tripped. `max_payload` // is derived from these two, so a larger grid is a frame this decoder @@ -793,6 +808,13 @@ pub fn encodeFrameLayers(out: []u8, cols: u16, rows: u16, cursor: ?Cursor, point try w.putByte(region.notice); try w.putU16(region.clip); } + inline for (@typeInfo(pardes.Chrome).@"struct".fields) |field| { + const rgb = @field(chrome, field.name); + if (field.type == ?[3]u8) { + try w.putBool(rgb != null); + if (rgb) |value| for (value) |channel| try w.putByte(channel); + } else for (rgb) |channel| try w.putByte(channel); + } } std.mem.writeInt(u32, w.buf[layers_len_at..][0..4], @intCast(w.n - layers_start), .little); const nruns_at = w.n; @@ -1871,7 +1893,8 @@ test "detached wire carries compact body rows and exact pointer metrics" { const placed = [_]pardes.Region{.{ .kind = .rail, .owner = 3, .serial = 42, .rect = .{ .x = 0, .y = 1, .w = 2, .h = 3 }, .active = true, .thumb_y = 1, .thumb_h = 2, .clip = 1 }}; const out = try gpa.alloc(u8, frameBound(2, 4) + layersBound(&.{layer}, &.{tag}, &placed)); defer gpa.free(out); - const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag}, &placed); + const palette: pardes.Chrome = .{ .page = .{ 1, 2, 3 }, .border = .{ 4, 5, 6 }, .tag_focus_bg = .{ 7, 8, 9 }, .scroll_thumb = .{ 10, 11, 12 } }; + const encoded = try encodeFrameLayers(out, 2, 4, null, .link, &cells, &cells, &.{layer}, &.{tag}, &placed, palette); const packet = (try framed(encoded)).?; const frame = (try decodeServer(packet.tag, packet.payload)).frame; var decoded: [pardes.MAX_PANES]pardes.Layer = @splat(.{}); @@ -1881,8 +1904,10 @@ test "detached wire carries compact body rows and exact pointer metrics" { try frame.apply(&cells); var regions: std.ArrayListUnmanaged(pardes.Region) = .empty; defer regions.deinit(gpa); - try frame.applyLayers(gpa, &decoded, &tags, ®ions); + var chrome: pardes.Chrome = .{}; + try frame.applyLayers(gpa, &decoded, &tags, ®ions, &chrome); try std.testing.expectEqualDeep(placed[0..], regions.items); + try std.testing.expectEqualDeep(palette, chrome); try std.testing.expectEqual(@as(u16, 6), decoded[0].rows); try std.testing.expect(decoded[0].context_separators.isSet(0)); try std.testing.expect(!decoded[0].context_separators.isSet(1)); @@ -1908,7 +1933,7 @@ test "detached wire carries compact body rows and exact pointer metrics" { try std.testing.expect(mouse_encoded.len <= clientBound(message)); var truncated = frame; truncated.layers = frame.layers[0 .. frame.layers.len - 1]; - try std.testing.expectError(error.Truncated, truncated.applyLayers(gpa, &decoded, &tags, ®ions)); + try std.testing.expectError(error.Truncated, truncated.applyLayers(gpa, &decoded, &tags, ®ions, &chrome)); for (decoded) |item| { try std.testing.expectEqual(@as(u16, 0), item.rows); try std.testing.expectEqual(@as(usize, 0), item.cells.len); @@ -1922,6 +1947,6 @@ test "detached wire carries compact body rows and exact pointer metrics" { const no_layers = try encodeFramePointer(out, 2, 4, null, .arrow, &cells, &cells); const cleared_packet = (try framed(no_layers)).?; const cleared = (try decodeServer(cleared_packet.tag, cleared_packet.payload)).frame; - try cleared.applyLayers(gpa, &decoded, &tags, ®ions); + try cleared.applyLayers(gpa, &decoded, &tags, ®ions, &chrome); try std.testing.expectEqual(@as(u16, 0), decoded[0].rows); } diff --git a/src/draw.zig b/src/draw.zig index 7cfe7554..4242a6e7 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -47,14 +47,7 @@ const NOTICE_LAYER_BASE = pardes.NOTICE_LAYER_BASE; /// painters below and every shell read these rectangles. pub fn place(p: *Pardes, s: *Surface) void { s.nregions = 0; - const add = struct { - fn region(surface: *Surface, r: Region) void { - std.debug.assert(surface.nregions < surface.regions.len); - surface.regions[surface.nregions] = r; - surface.nregions += 1; - } - }.region; - add(s, .{ .kind = .page, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = s.rows } }); + s.addRegion(.{ .kind = .page, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = s.rows } }); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; Messages.collectNotices(p, pane, p.rects[id], id); @@ -64,12 +57,12 @@ pub fn place(p: *Pardes, s: *Surface) void { const active = id == p.active; const tag_y = p.tagTop(pane, r); const body_y = p.bodyTop(pane, r); - add(s, .{ .kind = .grip, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = tag_y, .w = config.GUTTER, .h = pane.tag_rows } }); - add(s, .{ .kind = .tag, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = tag_y, .w = r.w - config.GUTTER, .h = pane.tag_rows } }); + s.addRegion(.{ .kind = .grip, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = tag_y, .w = config.GUTTER, .h = pane.tag_rows } }); + s.addRegion(.{ .kind = .tag, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = tag_y, .w = r.w - config.GUTTER, .h = pane.tag_rows } }); // A folded pane is its tag rows and nothing else. if (pane.collapsed) continue; const body_h = r.h -| pane.tag_rows; - add(s, .{ .kind = .body, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = body_y, .w = r.w - config.GUTTER, .h = body_h } }); + s.addRegion(.{ .kind = .body, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x + config.GUTTER, .y = body_y, .w = r.w - config.GUTTER, .h = body_h } }); var rail: Region = .{ .kind = .rail, .owner = owner, .serial = pane.serial, .active = active, .rect = .{ .x = r.x, .y = body_y, .w = config.GUTTER, .h = body_h } }; // An image's rail has no thumb; a native PDF's is measured as its // pages are placed (Pdf.draw); every other pane's is here. @@ -93,7 +86,7 @@ pub fn place(p: *Pardes, s: *Surface) void { rail.thumb_y = @intCast(@min(pos, track_h)); rail.thumb_h = @intCast(@min(len, track_h - rail.thumb_y)); } - add(s, rail); + s.addRegion(rail); // Each notice a chip at the right edge, one to a row in the order // collectNotices chose, as wide as its own message and no wider, so // the text beneath the rest of the row keeps reading as the file. @@ -106,7 +99,7 @@ pub fn place(p: *Pardes, s: *Surface) void { const chip = Messages.noticeCols(p, text, tw); if (chip == 0) continue; pane.notices.left[i] = tx + tw - chip; - add(s, .{ .kind = .notice, .owner = owner, .serial = pane.serial, .active = active, .notice = @intCast(i), .rect = .{ .x = tx + tw - chip, .y = body_y + @as(u16, pane.notices.row[i]), .w = chip, .h = BOX_H } }); + s.addRegion(.{ .kind = .notice, .owner = owner, .serial = pane.serial, .active = active, .notice = @intCast(i), .rect = .{ .x = tx + tw - chip, .y = body_y + @as(u16, pane.notices.row[i]), .w = chip, .h = BOX_H } }); } } const column_rows = p.columnBarHeight(); @@ -114,11 +107,16 @@ pub fn place(p: *Pardes, s: *Surface) void { if (p.col_w[c] == 0) continue; const active = if (layout.findPane(p, p.active)) |found| found.col == c else false; const inset = @min(TAG_TEXT_INSET, p.col_w[c]); - add(s, .{ .kind = .column_grip, .owner = @intCast(c), .serial = p.col_serial[c], .active = active, .rect = .{ .x = p.col_x[c], .y = p.topBarHeight(), .w = @min(config.GUTTER, p.col_w[c]), .h = column_rows } }); - add(s, .{ .kind = .column_tag, .owner = @intCast(c), .serial = p.col_serial[c], .active = active, .rect = .{ .x = p.col_x[c] + inset, .y = p.topBarHeight(), .w = p.col_w[c] - inset, .h = column_rows } }); + s.addRegion(.{ .kind = .column_grip, .owner = @intCast(c), .serial = p.col_serial[c], .active = active, .rect = .{ .x = p.col_x[c], .y = p.topBarHeight(), .w = @min(config.GUTTER, p.col_w[c]), .h = column_rows } }); + s.addRegion(.{ .kind = .column_tag, .owner = @intCast(c), .serial = p.col_serial[c], .active = active, .rect = .{ .x = p.col_x[c] + inset, .y = p.topBarHeight(), .w = p.col_w[c] - inset, .h = column_rows } }); }; + const active_col = if (layout.findPane(p, p.active)) |found| found.col else null; + for (0..p.ncol) |c| { + if (p.col_w[c] == 0) continue; + s.addRegion(.{ .kind = .column, .owner = @intCast(c), .serial = p.col_serial[c], .active = active_col == c, .rect = .{ .x = p.col_x[c], .y = p.topBarHeight(), .w = p.col_w[c], .h = s.rows -| p.topBarHeight() } }); + } if (p.settings.workspace_tag) - add(s, .{ .kind = .workspace_tag, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = p.topBarHeight() } }); + s.addRegion(.{ .kind = .workspace_tag, .rect = .{ .x = 0, .y = 0, .w = s.cols, .h = p.topBarHeight() } }); // A column carried to the window's right edge shows where it lands as a // rail in the last cell: the layers under it stop a cell short. if (p.drag == .column_move) rail: { @@ -176,8 +174,20 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { s.previous_cells = &.{}; s.previous_body_layers = &.{}; s.previous_tag_layers = &.{}; + s.previous_regions = &.{}; s.cell_diffs = &.{}; const chrome = p.chromeTheme(); + s.chrome = .{ + .page = p.theme().bg, + .fg = p.theme().fg, + .border = chrome.border, + .topbar_rule = config.gui_topbar_pane_border_rgb orelse chrome.border, + .tag_bg = chrome.tag_bg, + .tag_focus_bg = if (p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg, + .tag_fg = chrome.tag_fg, + .scroll_track = chrome.scroll_track, + .scroll_thumb = chrome.scroll_thumb, + }; place(p, s); // Separators have their own role; a scrollbar need not frame the page. const whole = s.regions[0].rect; @@ -203,16 +213,20 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { if (d.cur_x < s.cols) { var row: u16 = p.topBarHeight(); while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎"); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = d.cur_x, .y = p.topBarHeight(), .w = 1, .h = s.rows -| p.topBarHeight() } }); } if (d.corner) |k| if (d.cur_y < s.rows) { - var col = if (k.col == d.left_col) p.col_x[k.col] else d.cur_x +| 1; + const first = if (k.col == d.left_col) p.col_x[k.col] else d.cur_x +| 1; const end = if (k.col == d.left_col) d.cur_x else p.col_x[k.col] +| p.col_w[k.col] -| 1; + var col = first; while (col <= end and col < s.cols) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); + if (col > first) s.addRegion(.{ .kind = .guide, .rect = .{ .x = first, .y = d.cur_y, .w = col - first, .h = 1 } }); }; }, .border_h => |d| if (d.cur_y < s.rows) { var col = p.col_x[d.col]; while (col < p.col_x[d.col] + p.col_w[d.col]) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[d.col], .y = d.cur_y, .w = p.col_w[d.col], .h = 1 } }); }, .column_move => |d| rail: { const source = layout.columnBySerial(p, d.serial) orelse break :rail; @@ -232,13 +246,20 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { // color: this is where the grabbed column lands. const x = @min(if (edge == 0) config.GUTTER - 1 else edge - 1, s.cols -| 1); var row: u16 = p.topBarHeight(); + // A guide region for each run of it the tags do not break. + var run: u16 = row; while (row < s.rows) : (row += 1) { // Past the left edge's scroll rail the rail would cut // through column 0's grip and pane boxes: leave them whole. - if (edge == 0 and s.at(x, row).style.font_role == .tagline) continue; + if (edge == 0 and s.at(x, row).style.font_role == .tagline) { + if (row > run) s.addRegion(.{ .kind = .guide, .rect = .{ .x = x, .y = run, .w = 1, .h = row - run } }); + run = row + 1; + continue; + } s.overlayDash(x, row, "╎"); s.at(x, row).style.fg = .{ .rgb = chrome.column_box }; } + if (row > run) s.addRegion(.{ .kind = .guide, .rect = .{ .x = x, .y = run, .w = 1, .h = row - run } }); }, .move => |d| if (d.cur_x < s.cols) { if (layout.movePlacement(p, d.id, d.cur_x, d.cur_y)) |placement| { @@ -247,6 +268,8 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = chrome.lineno } }); } s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = chrome.lineno } }); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[placement.preview_col], .y = placement.row, .w = p.col_w[placement.preview_col], .h = 1 } }); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = d.cur_x, .y = placement.row, .w = 1, .h = 1 } }); } }, .none, .select => if (p.pointer_inside) { @@ -256,6 +279,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { if (p.hover_col == p.col_x[c] + p.col_w[c] -| 1) { var row: u16 = p.topBarHeight(); while (row < s.rows) : (row += 1) s.overlayDash(p.hover_col, row, "╎"); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.hover_col, .y = p.topBarHeight(), .w = 1, .h = s.rows -| p.topBarHeight() } }); } } for (0..p.ncol) |cc| { @@ -264,6 +288,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { if (mouse.seamIdxAt(p, cc, p.hover_row) != null) { var col = p.col_x[cc]; while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌"); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[cc], .y = p.hover_row, .w = p.col_w[cc], .h = 1 } }); } } for (0..p.ncol -| 1) |cn| { @@ -272,6 +297,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { if (mouse.seamIdxAt(p, cn + 1, p.hover_row) == null) continue; var col = p.col_x[cn + 1]; while (col < p.col_x[cn + 1] + p.col_w[cn + 1]) : (col += 1) s.overlayDash(col, p.hover_row, "╌"); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[cn + 1], .y = p.hover_row, .w = p.col_w[cn + 1], .h = 1 } }); } }, } @@ -309,6 +335,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { const oh: u16 = 10; const ox: u16 = if (s.cols > ow) s.cols - ow else 0; if (ox + ow <= s.cols and p.topBarHeight() + oh <= s.rows) { + s.addRegion(.{ .kind = .debug, .rect = .{ .x = ox, .y = p.topBarHeight(), .w = ow, .h = oh } }); // vaxis single_rounded border ring, default style s.set(ox, p.topBarHeight(), "╭", .{}); s.set(ox + ow - 1, p.topBarHeight(), "╮", .{}); diff --git a/src/gui/gui.zig b/src/gui/gui.zig index ff99bd39..35fbc99d 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -21,6 +21,7 @@ const c_heap = @import("c_heap"); const ninep_io = @import("../9p_io.zig"); const detached_client = @import("../detached/client.zig"); +const tracy = @import("../tracy.zig"); pub const c = @cImport({ @cDefine("SDL_DISABLE_OLD_NAMES", "1"); @@ -37,6 +38,7 @@ const log = std.log.scoped(.gui); const font_ttf = @embedFile("AdwaitaMono-Regular.ttf"); const vert_spv = @embedFile("ui.vert.spv"); const frag_spv = @embedFile("ui.frag.spv"); +const decor_frag_spv = @embedFile("decor.frag.spv"); const overlay_vert_spv = @embedFile("overlay.vert.spv"); const overlay_frag_spv = @embedFile("overlay.frag.spv"); const image_vert_spv = @embedFile("image.vert.spv"); @@ -443,6 +445,11 @@ const CellInstance = extern struct { progress_bits: u32 = @bitCast(@as(f32, 1)), serial: u32 = 0, cell_coord: u32 = 0, + /// A clip rectangle in target pixels; x1 < x0 is none (ui.vert.glsl). + clip_x0: f32 = 0, + clip_y0: f32 = 0, + clip_x1: f32 = -1, + clip_y1: f32 = -1, }; const OverlayVertex = extern struct { @@ -545,7 +552,8 @@ test "saved native placement retains identity and geometry but no producer bytes } test "shader instance ABI carries aligned transition vectors" { - try std.testing.expectEqual(@as(usize, 104), @sizeOf(CellInstance)); + try std.testing.expectEqual(@as(usize, 120), @sizeOf(CellInstance)); + try std.testing.expectEqual(@as(usize, 104), @offsetOf(CellInstance, "clip_x0")); try std.testing.expectEqual(@as(usize, 56), @offsetOf(CellInstance, "panel_x0")); try std.testing.expectEqual(@as(usize, 72), @offsetOf(CellInstance, "present_x0")); try std.testing.expectEqual(@as(usize, 88), @offsetOf(CellInstance, "effect")); @@ -560,12 +568,17 @@ test "shader instance ABI carries aligned transition vectors" { .{ .pane = 1, .phase = .moving, .effect = .slide }, .{ .pane = 2, .phase = .moving, .effect = .slide }, }; - const plan = makePaintPlan(&tracks, true); - try std.testing.expectEqual(@as(usize, 4), plan.len); - try std.testing.expect(plan.batches[0].track == null); - try std.testing.expectEqual(@as(u8, 1), plan.batches[1].track.?.pane); - try std.testing.expectEqual(@as(u8, 2), plan.batches[2].track.?.pane); - try std.testing.expectEqual(@as(u8, 0), plan.batches[3].track.?.pane); + // Tier 0, the tracks as the core ordered them, then tiers 4 and 5. + const groups = makeGroups(&tracks, true); + try std.testing.expectEqual(@as(usize, 7), groups.len); + try std.testing.expect(groups.items[0].track == null); + try std.testing.expectEqual(@as(u8, 0), groups.items[1].track.?.pane); + try std.testing.expectEqual(@as(u8, 1), groups.items[2].track.?.pane); + try std.testing.expectEqual(@as(u8, 2), groups.items[3].track.?.pane); + try std.testing.expectEqual(@as(usize, 4), groups.notices); + try std.testing.expectEqual(@as(usize, 5), groups.guides); + try std.testing.expectEqual(@as(usize, 6), groups.ink); + for ([_]usize{ groups.notices, groups.guides, groups.ink }) |index| try std.testing.expect(groups.items[index].track == null); } test "GUI paint plan snaps history effects without a frozen grid" { @@ -573,11 +586,11 @@ test "GUI paint plan snaps history effects without a frozen grid" { .{ .pane = 0, .phase = .opening, .effect = .ascii }, .{ .pane = 1, .phase = .closing, .effect = .vertical }, }; - try std.testing.expectEqual(@as(usize, 1), makePaintPlan(&tracks, false).len); - const ready = makePaintPlan(&tracks, true); - try std.testing.expectEqual(@as(usize, 3), ready.len); - try std.testing.expectEqual(pardes.animation.Phase.opening, ready.batches[1].track.?.phase); - try std.testing.expectEqual(pardes.animation.Phase.closing, ready.batches[2].track.?.phase); + try std.testing.expectEqual(@as(usize, 0), makeGroups(&tracks, false).tracks); + const ready = makeGroups(&tracks, true); + try std.testing.expectEqual(@as(usize, 2), ready.tracks); + try std.testing.expectEqual(pardes.animation.Phase.opening, ready.items[1].track.?.phase); + try std.testing.expectEqual(pardes.animation.Phase.closing, ready.items[2].track.?.phase); } test "closing tombstone overlays but never owns canonical cells" { @@ -588,10 +601,10 @@ test "closing tombstone overlays but never owns canonical cells" { .from = .{ .x = 2, .y = 3, .w = 10, .h = 4 }, .to = .{ .x = 2, .y = -1, .w = 10, .h = 4 }, }; - const plan = makePaintPlan(&.{closing}, true); - try std.testing.expectEqual(@as(usize, 0), paintBatchAt(&plan, 4, 4)); - try std.testing.expectEqual(@as(usize, 2), plan.len); - try std.testing.expectEqual(pardes.animation.Phase.closing, plan.batches[1].track.?.phase); + const groups = makeGroups(&.{closing}, true); + try std.testing.expectEqual(@as(usize, 0), groupAt(&groups, 4, 4)); + try std.testing.expectEqual(@as(usize, 1), groups.tracks); + try std.testing.expectEqual(pardes.animation.Phase.closing, groups.items[1].track.?.phase); } fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cell_h: u32, tag_w: u32, tag_h: u32) bool { @@ -1745,6 +1758,7 @@ const Gui = struct { device: *c.SDL_GPUDevice, swapchain_format: c.SDL_GPUTextureFormat, pipeline: *c.SDL_GPUGraphicsPipeline, + decor_pipeline: *c.SDL_GPUGraphicsPipeline, overlay_pipeline: *c.SDL_GPUGraphicsPipeline, image_pipeline: *c.SDL_GPUGraphicsPipeline, crt_pipeline: *c.SDL_GPUGraphicsPipeline, @@ -1764,9 +1778,11 @@ const Gui = struct { native_images: std.AutoHashMapUnmanaged(pardes.ImageCacheKey, *c.SDL_GPUTexture) = .empty, presented_images: std.ArrayListUnmanaged(SavedImagePlace) = .empty, prepared_images: std.ArrayListUnmanaged(PreparedImage) = .empty, - /// renderFrame's cover of the grid by layers (coverLayers): this frame's - /// layers, then the layers a panel transition is leaving. - layer_cover: std.ArrayListUnmanaged(bool) = .empty, + /// renderFrame's cover of the grid (coverFrame): this frame's, then the + /// frame a panel transition is leaving. + cover: std.ArrayListUnmanaged(Cover) = .empty, + /// renderFrame's decor instances, group by group (buildDecor). + decor: std.ArrayListUnmanaged(CellInstance) = .empty, font: *c.UIFont, font_bytes: []u8 = &.{}, @@ -1996,47 +2012,127 @@ fn taglineLayoutForCell(g: *const Gui, col: u16) CellLayout { const boxContains = pardes.animation.Box.contains; -const PaintBatch = struct { +/// What one tier draws, or one track in tiers 2 and 3 (docs/render-pipeline.md +/// §3.4), in this order: its cells, its images, then its decor. +const Group = struct { track: ?pardes.animation.Track = null, cell_start: u32 = 0, cell_count: u32 = 0, image_start: u32 = 0, image_count: u32 = 0, + decor_start: u32 = 0, + decor_count: u32 = 0, }; -const PaintPlan = struct { - batches: [pardes.MAX_PANES * 2 + 1]PaintBatch = @splat(.{}), - len: usize = 1, +/// The frame's draw groups, bottom to top: tier 0 (the page and the panes +/// standing still), a group for each track in paint order (tier 2 moving and +/// opening, tier 3 closing), tier 4 (floating: the notices, then the guides +/// and the debug box, which the core paints over them) and tier 5 (ink: the +/// bar cursors; the pet and the touch overlay draw after it). Tier 1 is the +/// focus lift, which does not exist yet. +const Groups = struct { + items: [pardes.MAX_PANES * 2 + 4]Group = @splat(.{}), + /// Groups 1 through `tracks` are the tracks'. + tracks: usize = 0, + notices: usize = 1, + guides: usize = 2, + ink: usize = 3, + len: usize = 4, }; -fn makePaintPlan(tracks: []const pardes.animation.Track, has_diff: bool) PaintPlan { - var plan: PaintPlan = .{}; - for ([_]pardes.animation.Phase{ .moving, .opening, .closing }) |phase| { - for (tracks) |track| { - if (!track.active() or track.phase != phase) continue; - if (track.effect.needsPreviousGrid() and !has_diff) continue; - std.debug.assert(plan.len < plan.batches.len); - plan.batches[plan.len].track = track; - plan.len += 1; - } +fn makeGroups(tracks: []const pardes.animation.Track, has_diff: bool) Groups { + var groups: Groups = .{}; + for (tracks) |track| { + if (!track.active()) continue; + if (track.effect.needsPreviousGrid() and !has_diff) continue; + std.debug.assert(groups.tracks < pardes.MAX_PANES * 2); + groups.tracks += 1; + groups.items[groups.tracks].track = track; } - return plan; + groups.notices = groups.tracks + 1; + groups.guides = groups.tracks + 2; + groups.ink = groups.tracks + 3; + groups.len = groups.tracks + 4; + return groups; } -fn paintBatchAt(plan: *const PaintPlan, col: u16, row: u16) usize { - for (plan.batches[1..plan.len], 1..) |batch, index| { - if (batch.track.?.phase == .closing) continue; - if (boxContains(batch.track.?.to, col, row)) return index; +/// The group of the track whose pane covers this cell, else tier 0. +fn groupAt(groups: *const Groups, col: u16, row: u16) usize { + for (groups.items[1..][0..groups.tracks], 1..) |group, index| { + if (group.track.?.phase == .closing) continue; + if (boxContains(group.track.?.to, col, row)) return index; } return 0; } -fn paintBatchForSerial(plan: *const PaintPlan, serial: u32) usize { - for (plan.batches[1..plan.len], 1..) |batch, index| - if (batch.track.?.serial == serial) return index; +fn groupOf(groups: *const Groups, serial: u32) usize { + for (groups.items[1..][0..groups.tracks], 1..) |group, index| + if (group.track.?.serial == serial) return index; return 0; } +/// What draws a grid cell, marked once a frame from the layers and the +/// regions (coverFrame), so the cell loops read it rather than search. +const Cover = packed struct(u16) { + /// A layer draws it; the grid's copy is not drawn. + layer: bool = false, + /// Under a guide or the debug box: drawn from the grid over the panes. + floating: bool = false, + /// In a pane's grip, `grip_offset` cells from its left: the grip's + /// glyph is the cell that far to the left. + grip: bool = false, + grip_offset: u4 = 0, + /// In the focused pane's or column's tag rows. + focus: bool = false, + /// At a column's left edge, where a tag's anchor is inset. + anchor: bool = false, + _: u7 = 0, +}; + +/// Marks the grid cells a body or tag layer covers, which the layers paint +/// themselves, and what the regions say of the rest. Filling each rectangle +/// once replaces scanning every layer for every cell, which was a third of +/// the gui's CPU on a full screen of text. +fn coverFrame(cover: []Cover, cols: u16, rows: u16, body_layers: []const pardes.Layer, tag_layers: []const pardes.Layer, regions: []const pardes.Region) void { + @memset(cover, .{}); + const mark = struct { + fn rect(into: []Cover, width: u16, height: u16, r: pardes.Rect, comptime field: []const u8, value: anytype) void { + const right = @min(width, @as(u32, r.x) + r.w); + if (r.x >= right) return; + var y: u32 = r.y; + while (y < @min(height, @as(u32, r.y) + r.h)) : (y += 1) + for (into[y * width ..][r.x..right]) |*cell| { + @field(cell, field) = value; + }; + } + }; + for (body_layers) |*layer| { + if (layer.rows != 0) mark.rect(cover, cols, rows, layer.viewport, "layer", true); + } + for (tag_layers) |*layer| { + if (layer.rows != 0) mark.rect(cover, cols, rows, .{ .x = layer.viewport.x, .y = layer.viewport.y, .w = layer.viewport.w, .h = layer.rows }, "layer", true); + } + // The column bar's rows, which the focused column's tint spans. + var bar: ?pardes.Rect = null; + for (regions) |region| { + if (region.kind == .column_grip) bar = region.rect; + } + for (regions) |region| switch (region.kind) { + .grip => { + mark.rect(cover, cols, rows, region.rect, "grip", true); + for (0..@min(region.rect.w, 16)) |offset| mark.rect(cover, cols, rows, .{ .x = region.rect.x +| @as(u16, @intCast(offset)), .y = region.rect.y, .w = 1, .h = region.rect.h }, "grip_offset", @as(u4, @intCast(offset))); + if (region.active) mark.rect(cover, cols, rows, region.rect, "focus", true); + }, + .tag => if (region.active) mark.rect(cover, cols, rows, region.rect, "focus", true), + .column => { + mark.rect(cover, cols, rows, .{ .x = region.rect.x, .y = 0, .w = 1, .h = rows }, "anchor", true); + if (region.active) if (bar) |b| mark.rect(cover, cols, rows, .{ .x = region.rect.x, .y = b.y, .w = region.rect.w, .h = b.h }, "focus", true); + }, + .guide, .debug => mark.rect(cover, cols, rows, region.rect, "floating", true), + else => {}, + }; +} + fn panelCellCoord(track: pardes.animation.Track, col: u16, row: u16) u32 { const source = track.contentBox(); const x0: u16 = @intFromFloat(@max(0.0, @floor(source.x))); @@ -2101,6 +2197,15 @@ fn setTransitionFields( instance.progress_bits = @bitCast(active.amount()); instance.serial = active.serial; instance.cell_coord = cell_coord; + // A vertical transition shows its pane only inside the box it grows + // into, in whole pixels. + if (@hasField(@TypeOf(instance.*), "clip_x0") and active.effect == .vertical) { + const box = active.contentBox(); + instance.clip_x0 = std.math.clamp(@floor(box.x * layout.w), 0, win_w); + instance.clip_y0 = std.math.clamp(@floor(box.y * layout.h), 0, win_h); + instance.clip_x1 = std.math.clamp(@ceil((box.x + box.w) * layout.w), instance.clip_x0, win_w); + instance.clip_y1 = std.math.clamp(@ceil((box.y + box.h) * layout.h), instance.clip_y0, win_h); + } } pub const run = runNative; @@ -2276,7 +2381,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u var ax_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = atlas_w * atlas_h, .props = 0 }; const atlas_xfer = c.SDL_CreateGPUTransferBuffer(device, &ax_info) orelse return error.GpuCreate; - const pipeline = try makePipeline(device, swapchain_format); + const pipeline = try makePipeline(device, swapchain_format, frag_spv, 1, std.mem.zeroes(c.SDL_GPUColorTargetBlendState)); + 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); const crt_pipeline = try makeCrtPipeline(device, swapchain_format); @@ -2306,6 +2412,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u .device = device, .swapchain_format = swapchain_format, .pipeline = pipeline, + .decor_pipeline = decor_pipeline, .overlay_pipeline = overlay_pipeline, .image_pipeline = image_pipeline, .crt_pipeline = crt_pipeline, @@ -2343,6 +2450,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.decor_pipeline); c.SDL_ReleaseGPUGraphicsPipeline(device, g.overlay_pipeline); c.SDL_ReleaseGPUGraphicsPipeline(device, g.crt_pipeline); c.SDL_ReleaseGPUTexture(device, g.atlas_tex); @@ -2368,7 +2476,8 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u g.native_images.deinit(gpa); g.presented_images.deinit(gpa); g.prepared_images.deinit(gpa); - g.layer_cover.deinit(gpa); + g.cover.deinit(gpa); + g.decor.deinit(gpa); } defer c.SDL_ReleaseGPUTransferBuffer(device, g.image_vxfer); defer c.SDL_ReleaseGPUBuffer(device, g.image_vbuf); @@ -2479,6 +2588,7 @@ fn localSession( .watches = &watches, .fs = fs, .test_mode = test_mode, + .test_clock = if (test_mode) host_io.testClock() else null, }; defer shell.shutdownPtys(); const host = shell.host(); @@ -2668,24 +2778,12 @@ fn attachedLoop(gpa: std.mem.Allocator, g: *Gui, client: *detached_client.Client } fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Client) void { - var surface: pardes.Surface = .{ - .cols = client.cols, - .rows = client.rows, - .cells = client.grid.items, - .cursor = if (client.cursor) |cu| .{ .x = cu.x, .y = cu.y, .bar = cu.bar } else null, - .pointer_shape = client.pointer_shape, - .body_layers = client.body_layers, - .tag_layers = client.tag_layers, - }; - const chrome = frameChromeBg(&surface); + var surface = attachedSurface(client); _ = renderFrame( g, gpa, null, &surface, - null, - chrome, - chrome, .{}, false, ) catch |err| blk: { @@ -2694,17 +2792,67 @@ fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Clien }; } -fn frameChromeBg(surface: *const pardes.Surface) [3]u8 { - if (surface.rows == 0 or surface.cols == 0) return bg_default; - for (surface.cells[0..surface.cols]) |cell| { - if (cell.default or cell.style.font_role != .tagline) continue; - return switch (cell.style.bg) { - .default => bg_default, - .index => |i| palColor(i), - .rgb => |rgb| rgb, - }; - } - return bg_default; +/// The session's frame as a surface: its cells, layers, regions and palette, +/// so its chrome is drawn from them as a local one's is. +fn attachedSurface(client: *const detached_client.Client) pardes.Surface { + var surface: pardes.Surface = .{ + .cols = client.cols, + .rows = client.rows, + .cells = client.grid.items, + .cursor = if (client.cursor) |cu| .{ .x = cu.x, .y = cu.y, .bar = cu.bar } else null, + .pointer_shape = client.pointer_shape, + .body_layers = client.body_layers, + .tag_layers = client.tag_layers, + .chrome = client.chrome, + }; + const placed = client.regions.items[0..@min(client.regions.items.len, surface.regions.len)]; + @memcpy(surface.regions[0..placed.len], placed); + surface.nregions = @intCast(placed.len); + return surface; +} + +test "an attached frontend draws the session's chrome from its frame alone" { + const gpa = std.testing.allocator; + const core = try pardes.Pardes.init(gpa, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + // A gui-shaped core, whose tags and notices are layers. + core.update(.{ .resize = .{ .cols = 80, .rows = 24, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } }); + _ = try core.newShell(1, ""); + try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); + core.settings.focus_tint = true; + try std.testing.expect(core.executeBuiltinLine(0, "Msg a notice with a rule under it")); + pardes.test_api.sync(core); + // Past its slide into place, where its rule shows. + for (0..30) |_| core.update(.tick); + const s = try core.render(core.scratch.allocator()); + + const wire = @import("../detached/wire.zig"); + var client: detached_client.Client = .{ .gpa = gpa }; + defer client.deinit(); + var welcome: [64]u8 = undefined; + try client.in.appendSlice(gpa, try wire.encodeServer(&welcome, .{ .welcome = .{ .slot = 0, .cols = s.cols, .rows = s.rows } })); + const out = try gpa.alloc(u8, wire.frameBound(s.cols, s.rows) + wire.layersBound(&s.body_layers, &s.tag_layers, s.regionList())); + defer gpa.free(out); + try client.in.appendSlice(gpa, try wire.encodeFrameLayers(out, s.cols, s.rows, null, s.pointer_shape, s.cells, &.{}, &s.body_layers, &s.tag_layers, s.regionList(), s.chrome)); + _ = try client.next(); + _ = try client.next(); + const attached = attachedSurface(&client); + + var g: Gui = undefined; + g.cell_w = 10; + g.cell_h = 20; + g.tagline_width = 6; + g.tagline_height = 12; + g.decor = .empty; + defer g.decor.deinit(gpa); + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, s, &groups, fixedCellLayout(&g), 800, 480, true); + const local = try gpa.dupe(CellInstance, g.decor.items); + defer gpa.free(local); + try std.testing.expect(local.len > 8); + try std.testing.expect(groups.items[groups.notices].decor_count != 0); + try buildDecor(&g, gpa, &attached, &groups, fixedCellLayout(&g), 800, 480, true); + try std.testing.expectEqualSlices(u8, std.mem.sliceAsBytes(local), std.mem.sliceAsBytes(g.decor.items)); } fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { @@ -3708,6 +3856,8 @@ const Shell = struct { saw_event: bool = false, dump_err: ?anyerror = null, retired_shells: [pardes.MAX_PANES]RetiredShell = @splat(.{}), + /// PARDES_TEST_CLOCK's virtual time (host_io.testClock), in test mode. + test_clock: ?u64 = null, fn reap(s: *Shell) void { const now = shellClock(); @@ -3898,13 +4048,31 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { var in: Input = .{ .core = core }; if (s.gui) |g| { var sev = std.mem.zeroes(c.SDL_Event); + // Idle is asleep until an event: SDL's own, and the user events the + // shell's queue raises for a terminal's output, a completion, a file + // system request. What the shell moves or times itself polls every + // frame: a smooth scroll, a gamepad's sticks, the test stdin feed, + // a retired shell waiting to be killed, a present to retry. A + // minimized window sleeps through the core's animation too: nothing + // shows it, and it is caught up at once when the window is back. + const retiring = for (s.retired_shells) |child| { + if (child.kill_at != 0) break true; + } 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; + 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: // waiting here too would make every tick cost two frames. - const ms: c_int = if (latency_fd >= 0) 0 else if (timeout_ms != 0) @intCast(timeout_ms) else 16; + const ms: c_int = if (latency_fd >= 0) 0 else if (minimized) -1 else if (timeout_ms != 0) @intCast(timeout_ms) else if (polls) 16 else -1; // The wait is the 9P connections' turn with the core. pardes.turn.rest(); const got = c.SDL_WaitEventTimeout(&sev, ms); pardes.turn.wake(); + // The core's wake ran out: a virtual clock moves exactly there. + if (!got and timeout_ms != 0) if (s.test_clock) |*virtual| { + virtual.* = @max(virtual.*, core.nextWake() orelse virtual.*); + }; if (got) { // A run of queued motions is one move to its last position (the // handler reads only absolute x/y and held buttons): acting on @@ -4044,9 +4212,6 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void { s.gpa, core, frame, - core.theme().bg, - config.gui_topbar_pane_border_rgb orelse core.chromeTheme().border, - core.chromeTheme().tag_bg, core.settings.scene_effects, core.settings.debug, ) catch |err| blk: { @@ -4078,8 +4243,8 @@ fn postPresent(ctx: ?*anyopaque) void { s.core.acknowledgePanelPresentation(frame.panelTracks()); } -fn guiNow(_: ?*anyopaque) u64 { - return c.SDL_GetTicksNS(); +fn guiNow(ctx: ?*anyopaque) u64 { + return shellOf(ctx).test_clock orelse c.SDL_GetTicksNS(); } fn gridPollFrame(ctx: ?*anyopaque) void { @@ -4846,7 +5011,7 @@ fn prepareNativeImages( gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, surface: *pardes.Surface, - plan: *PaintPlan, + groups: *Groups, sw: u32, sh: u32, ) bool { @@ -4872,9 +5037,9 @@ fn prepareNativeImages( } g.prepared_images.clearRetainingCapacity(); - for (plan.batches[0..plan.len], 0..) |*batch, batch_index| { - batch.image_start = std.math.cast(u32, g.prepared_images.items.len) orelse return false; - const track = batch.track; + for (groups.items[0..groups.len], 0..) |*group, group_index| { + group.image_start = std.math.cast(u32, g.prepared_images.items.len) orelse return false; + const track = group.track; if (track) |active| { if (active.phase == .closing) { @@ -4894,7 +5059,7 @@ fn prepareNativeImages( for (surface.images[0..surface.nimages]) |maybe| { const current = maybe orelse continue; if (validImageBytes(current) == null or - paintBatchForSerial(plan, current.serial) != batch_index) continue; + groupOf(groups, current.serial) != group_index) continue; const saved = SavedImagePlace.from(current); const current_track = if (track) |active| switch (active.effect) { .dissolve => if (snapshotContainsPlacement(g, current)) null else active, @@ -4903,7 +5068,7 @@ fn prepareNativeImages( appendPreparedImage(g, gpa, saved, current_track, null, false); } } - if (batch_index == 0) for (plan.batches[1..plan.len]) |tracked| { + if (group_index == 0) for (groups.items[1..][0..groups.tracks]) |tracked| { const active = tracked.track.?; if (active.effect != .vertical or active.phase != .opening) continue; for (g.presented_images.items) |saved| { @@ -4912,7 +5077,7 @@ fn prepareNativeImages( } }; const image_end = std.math.cast(u32, g.prepared_images.items.len) orelse return false; - batch.image_count = image_end - batch.image_start; + group.image_count = image_end - group.image_start; } const capacity = std.math.cast(u32, g.prepared_images.items.len) orelse return false; @@ -4981,11 +5146,10 @@ fn drawNativeImagesGpu( g: *Gui, rp: ?*c.SDL_GPURenderPass, surface: *pardes.Surface, - plan: *const PaintPlan, - batch_index: usize, + batch: Group, + whole: c.SDL_Rect, ) void { const pass = rp orelse return; - const batch = plan.batches[batch_index]; c.SDL_BindGPUGraphicsPipeline(pass, g.image_pipeline); const max_w = @as(u32, surface.cols) * g.cell_w; const max_h = @as(u32, surface.rows) * g.cell_h; @@ -5004,12 +5168,8 @@ fn drawNativeImagesGpu( idx += 1; } std.debug.assert(idx == batch.image_start + batch.image_count); - const whole = c.SDL_Rect{ - .x = 0, - .y = 0, - .w = @intCast(@as(u32, surface.cols) * g.cell_w), - .h = @intCast(@as(u32, surface.rows) * g.cell_h), - }; + // Back to the whole target: what draws after a picture may run on past + // the grid, as a rule and a band do. c.SDL_SetGPUScissor(pass, &whole); } @@ -5018,9 +5178,6 @@ fn renderFrame( gpa: std.mem.Allocator, core: ?*const pardes.Pardes, surface: *pardes.Surface, - theme_bg: ?[3]u8, - topbar_pane_border_rgb: [3]u8, - tagline_rgb: [3]u8, scene_effects: pardes.animation.SceneEffect, debug_on: bool, ) !bool { @@ -5084,45 +5241,39 @@ fn renderFrame( }; const scene: *c.SDL_GPUTexture = if (scene_on) g.scene_tex.? else target; + // The frame's CPU side: groups, decor, cover and every instance, up to + // their upload. + const build_zone = tracy.zone(@src(), "gui frame build"); + var building = true; + defer if (building) build_zone.end(); const layout = fixedCellLayout(g); - var paint_plan = makePaintPlan(surface.panelTracks(), surface.hasPanelDiff()); - var overlay_count = buildOverlay( - g, - core, - surface, - layout, - sw, - sh, - topbar_pane_border_rgb, - tagline_rgb, - paint_plan.len == 1, - debug_on, - ); + const chrome = &surface.chrome; + var groups = makeGroups(surface.panelTracks(), surface.hasPanelDiff()); + // Cursors stand still while panes move, as they always have. + const cursors_shown = groups.tracks == 0; + var overlay_count = buildOverlay(g, core, surface, sw, sh, debug_on); if (overlay_count != 0 and !uploadOverlayGpu(g, cmd, overlay_count)) overlay_count = 0; - if (!prepareNativeImages(g, gpa, cmd, surface, &paint_plan, sw, sh)) { + if (!prepareNativeImages(g, gpa, cmd, surface, &groups, sw, sh)) { g.prepared_images.clearRetainingCapacity(); - for (paint_plan.batches[0..paint_plan.len]) |*batch| { - batch.image_start = 0; - batch.image_count = 0; + for (groups.items[0..groups.len]) |*group| { + group.image_start = 0; + group.image_count = 0; } } const cells: u32 = @as(u32, surface.cols) * surface.rows; - try g.layer_cover.resize(gpa, @as(usize, cells) * 2); - const covered = g.layer_cover.items[0..cells]; - coverLayers(covered, surface.cols, surface.rows, surface.bodyLayers(), surface.tagLayers()); + try g.cover.resize(gpa, @as(usize, cells) * 2); + const covered = g.cover.items[0..cells]; + coverFrame(covered, surface.cols, surface.rows, surface.bodyLayers(), surface.tagLayers(), surface.regionList()); // Only a panel transition paints the grid under the layers it leaves. - const previous_covered = g.layer_cover.items[cells..]; - if (paint_plan.len > 1) - coverLayers(previous_covered, surface.cols, surface.rows, surface.previous_body_layers, surface.previous_tag_layers); + const previous_covered = g.cover.items[cells..]; + if (groups.tracks != 0) + coverFrame(previous_covered, surface.cols, surface.rows, surface.previous_body_layers, surface.previous_tag_layers, surface.previous_regions); + try buildDecor(g, gpa, surface, &groups, layout, win_w, win_h, cursors_shown); - const page = ground(theme_bg, g.transparent); + const page = ground(chrome.page, g.transparent); const background_opacity = backgroundOpacity(g.applied_window_opacity); - const tagline_base = pardes.Cell{ - .style = .{ .bg = .{ .rgb = tagline_rgb }, .font_role = .tagline }, - .default = false, - }; var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo); color_target.texture = scene; color_target.clear_color = if (page.clear) .{ .r = 0, .g = 0, .b = 0, .a = 0 } else .{ @@ -5139,28 +5290,21 @@ fn renderFrame( while (row < surface.rows) : (row += 1) { var col: u16 = 0; while (col < surface.cols) : (col += 1) { - if (covered[@as(usize, row) * surface.cols + col]) continue; - const batch_index = paintBatchAt(&paint_plan, col, row); - const track = paint_plan.batches[batch_index].track; - const data_effect = if (track) |active| - active.effect == .dissolve - else - false; - const data_diff = data_effect and gripCellChanged(surface, col, row); - const logical_idx: u32 = @as(u32, row) * surface.cols + col; + const at = @as(usize, row) * surface.cols + col; + if (covered[at].layer and !covered[at].floating) continue; + const group_index = if (covered[at].floating) groups.guides else groupAt(&groups, col, row); + 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); const instance_count = if (data_diff) - cellInstanceCount(&surface.previous_cells[logical_idx]) + cellInstanceCount(surface.at(col, row)) + cellInstanceCount(&surface.previous_cells[at]) + cellInstanceCount(&surface.cells[at]) else - cellInstanceCount(surface.at(col, row)); - paint_plan.batches[batch_index].cell_count = std.math.add( - u32, - paint_plan.batches[batch_index].cell_count, - instance_count, - ) catch return error.GpuCapacity; + cellInstanceCount(&surface.cells[at]); + groups.items[group_index].cell_count = std.math.add(u32, groups.items[group_index].cell_count, instance_count) catch return error.GpuCapacity; } } - for (paint_plan.batches[1..paint_plan.len]) |*batch| { - const track = batch.track.?; + for (groups.items[1..][0..groups.tracks]) |*group| { + const track = group.track.?; const duplicate_under = track.effect == .vertical and track.phase == .opening; if (!duplicate_under and track.phase != .closing) continue; row = 0; @@ -5168,33 +5312,29 @@ fn renderFrame( var col: u16 = 0; while (col < surface.cols) : (col += 1) { if (!boxContains(track.contentBox(), col, row)) continue; - if (previous_covered[@as(usize, row) * surface.cols + col]) continue; - const destination = if (duplicate_under) &paint_plan.batches[0] else batch; - const logical_idx: u32 = @as(u32, row) * surface.cols + col; - destination.cell_count = std.math.add( - u32, - destination.cell_count, - cellInstanceCount(&surface.previous_cells[logical_idx]), - ) catch return error.GpuCapacity; + const at = @as(usize, row) * surface.cols + col; + if (previous_covered[at].layer) continue; + const destination = if (duplicate_under) &groups.items[0] else group; + destination.cell_count = std.math.add(u32, destination.cell_count, cellInstanceCount(&surface.previous_cells[at])) catch return error.GpuCapacity; } } } for (surface.bodyLayers()) |*layer| { if (layer.rows == 0) continue; - const batch = &paint_plan.batches[paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y)]; + const group = &groups.items[groupAt(&groups, layer.viewport.x, layer.viewport.y)]; const bottom = @as(f32, @floatFromInt(layer.viewport.y + layer.viewport.h)) * layout.h; for (0..layer.rows) |lr| { if (layer.rowTop(@intCast(lr), layout.h, @floatFromInt(g.tagline_height)) >= bottom) break; - batch.cell_count += layer.cols + @as(u32, if (lr < layer.context_rows) 1 else 0) + @as(u32, @intFromBool(layer.hasContextBorderAfter(lr))); + group.cell_count += layer.cols + @as(u32, if (lr < layer.context_rows) 1 else 0) + @as(u32, @intFromBool(layer.hasContextBorderAfter(lr))); } } - for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { - const track = batch.track.?; + for (groups.items[1..][0..groups.tracks], 1..) |group, index| { + const track = group.track.?; const under = track.effect == .vertical and track.phase == .opening; if (!under and track.phase != .closing and track.effect != .dissolve) continue; for (surface.previous_body_layers) |*layer| { if (layer.rows == 0 or layer.serial != track.serial) continue; - const destination = if (under) &paint_plan.batches[0] else &paint_plan.batches[batch_index]; + const destination = &groups.items[if (under) 0 else index]; const bottom = @as(f32, @floatFromInt(layer.viewport.y + layer.viewport.h)) * layout.h; for (0..layer.rows) |lr| { if (layer.rowTop(@intCast(lr), layout.h, @floatFromInt(g.tagline_height)) >= bottom) break; @@ -5204,28 +5344,28 @@ fn renderFrame( } for (surface.tagLayers()) |*layer| { if (layer.rows == 0) continue; - const batch = &paint_plan.batches[paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y)]; - batch.cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; + const group_index = if (layer.kind == .notice) groups.notices else groupAt(&groups, layer.viewport.x, layer.viewport.y); + groups.items[group_index].cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; } - for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { - const track = batch.track.?; + for (groups.items[1..][0..groups.tracks], 1..) |group, index| { + const track = group.track.?; const under = track.effect == .vertical and track.phase == .opening; if (!under and track.phase != .closing and track.effect != .dissolve) continue; for (surface.previous_tag_layers) |*layer| { if (layer.rows == 0 or layer.kind != .pane or layer.serial != track.serial) continue; - const destination = if (under) &paint_plan.batches[0] else &paint_plan.batches[batch_index]; - destination.cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; + groups.items[if (under) 0 else index].cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; } } - var cell_next: [pardes.MAX_PANES * 2 + 1]u32 = @splat(0); + var cell_next: [pardes.MAX_PANES * 2 + 4]u32 = @splat(0); var cell_total: u32 = 0; - for (paint_plan.batches[0..paint_plan.len], 0..) |*batch, batch_index| { - batch.cell_start = cell_total; - cell_next[batch_index] = cell_total; - cell_total = std.math.add(u32, cell_total, batch.cell_count) catch return error.GpuCapacity; - } - if (cell_total != 0) { - const capacity = std.math.add(u32, cell_total, cells) catch return error.GpuCapacity; + for (groups.items[0..groups.len], 0..) |*group, index| { + group.cell_start = cell_total; + cell_next[index] = cell_total; + cell_total = std.math.add(u32, cell_total, group.cell_count) catch return error.GpuCapacity; + } + 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; try ensureVbuf(g, capacity); const vptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.vxfer.?, false) orelse { @@ -5239,45 +5379,40 @@ fn renderFrame( row = 0; while (row < surface.rows) : (row += 1) { const line = surface.cells[@as(usize, row) * surface.cols ..][0..surface.cols]; + const line_cover = covered[@as(usize, row) * surface.cols ..][0..surface.cols]; const line_cursor: ?usize = if (surface.cursor) |cu| if (cu.y == row) cu.x else null else null; var col: u16 = 0; while (col < surface.cols) : (col += 1) { - if (covered[@as(usize, row) * surface.cols + col]) continue; - const batch_index = paintBatchAt(&paint_plan, col, row); - const track = paint_plan.batches[batch_index].track; - const logical_idx: u32 = @as(u32, row) * surface.cols + col; - const data_effect = if (track) |active| - active.effect == .dissolve - else - false; - const data_diff = data_effect and gripCellChanged(surface, col, row); - if (data_diff) { - emitSurfaceCell( - g, - core, - instances, - &cell_next[batch_index], - col, - row, - layout, - win_w, - win_h, - track, - surface.previous_cells[@as(usize, row) * surface.cols ..][0..surface.cols], - col, - null, - &tagline_base, - paneGripCell(surface.previous_tag_layers, surface.previous_cells, surface.cols, col, row), - true, - false, - page, - ); - } + const at = @as(usize, row) * surface.cols + col; + if (covered[at].layer and !covered[at].floating) continue; + const group_index = if (covered[at].floating) groups.guides else groupAt(&groups, col, row); + 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( + g, + chrome, + instances, + &cell_next[group_index], + col, + row, + 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], + col, + null, + true, + false, + page, + ); emitSurfaceCell( g, - core, + chrome, instances, - &cell_next[batch_index], + &cell_next[group_index], col, row, layout, @@ -5285,12 +5420,11 @@ fn renderFrame( win_h, if (data_effect and !data_diff) null else track, line, + line_cover, col, line_cursor, - &tagline_base, - paneGripCell(surface.tagLayers(), surface.cells, surface.cols, col, row), false, - paint_plan.len == 1 and logical_idx == cursor_idx and !cursor_bar and bodyLayerAt(surface, col, row) == null, + cursors_shown and at == cursor_idx and !cursor_bar and bodyLayerAt(surface, col, row) == null, page, ); } @@ -5299,36 +5433,36 @@ fn renderFrame( // is a tag layer, and instances paint in emission order. for (surface.bodyLayers()) |*layer| { if (layer.rows == 0) continue; - const batch_index = paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y); - emitBodyLayer(g, instances, &cell_next[batch_index], layer, win_w, win_h, paint_plan.batches[batch_index].track, page, paint_plan.len == 1, false, topbar_pane_border_rgb); + const group_index = groupAt(&groups, layer.viewport.x, layer.viewport.y); + emitBodyLayer(g, instances, &cell_next[group_index], layer, win_w, win_h, groups.items[group_index].track, page, cursors_shown, false, chrome.topbar_rule); } for (surface.tagLayers()) |*layer| { if (layer.rows == 0) continue; - const batch_index = paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y); - emitTagLayer(g, instances, &cell_next[batch_index], layer, win_w, win_h, paint_plan.batches[batch_index].track, page, paint_plan.len == 1, false); + const track_index = groupAt(&groups, layer.viewport.x, layer.viewport.y); + // A notice floats over the panes, moving with its own. + const group_index = if (layer.kind == .notice) groups.notices else track_index; + emitTagLayer(g, instances, &cell_next[group_index], layer, win_w, win_h, groups.items[track_index].track, page, cursors_shown, false); } - for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { - const track = batch.track.?; + for (groups.items[1..][0..groups.tracks], 1..) |group, index| { + const track = group.track.?; const under = track.effect == .vertical and track.phase == .opening; if (!under and track.phase != .closing and track.effect != .dissolve) continue; for (surface.previous_tag_layers) |*layer| { if (layer.rows == 0 or layer.kind != .pane or layer.serial != track.serial) continue; - const destination: usize = if (under) 0 else batch_index; - emitTagLayer(g, instances, &cell_next[destination], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve); + emitTagLayer(g, instances, &cell_next[if (under) 0 else index], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve); } } - for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { - const track = batch.track.?; + for (groups.items[1..][0..groups.tracks], 1..) |group, index| { + const track = group.track.?; const under = track.effect == .vertical and track.phase == .opening; if (!under and track.phase != .closing and track.effect != .dissolve) continue; for (surface.previous_body_layers) |*layer| { if (layer.rows == 0 or layer.serial != track.serial) continue; - const destination: usize = if (under) 0 else batch_index; - emitBodyLayer(g, instances, &cell_next[destination], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve, topbar_pane_border_rgb); + emitBodyLayer(g, instances, &cell_next[if (under) 0 else index], layer, win_w, win_h, if (under) null else track, page, false, track.effect == .dissolve, chrome.topbar_rule); } } - for (paint_plan.batches[1..paint_plan.len], 1..) |batch, batch_index| { - const track = batch.track.?; + for (groups.items[1..][0..groups.tracks], 1..) |group, index| { + const track = group.track.?; const duplicate_under = track.effect == .vertical and track.phase == .opening; if (!duplicate_under and track.phase != .closing) continue; row = 0; @@ -5336,13 +5470,12 @@ fn renderFrame( var col: u16 = 0; while (col < surface.cols) : (col += 1) { if (!boxContains(track.contentBox(), col, row)) continue; - if (previous_covered[@as(usize, row) * surface.cols + col]) continue; - const destination: usize = if (duplicate_under) 0 else batch_index; + if (previous_covered[@as(usize, row) * surface.cols + col].layer) continue; emitSurfaceCell( g, - core, + chrome, instances, - &cell_next[destination], + &cell_next[if (duplicate_under) 0 else index], col, row, layout, @@ -5350,10 +5483,9 @@ fn renderFrame( win_h, if (duplicate_under) null else track, surface.previous_cells[@as(usize, row) * surface.cols ..][0..surface.cols], + previous_covered[@as(usize, row) * surface.cols ..][0..surface.cols], col, null, - &tagline_base, - paneGripCell(surface.previous_tag_layers, surface.previous_cells, surface.cols, col, row), false, false, page, @@ -5361,57 +5493,56 @@ fn renderFrame( } } } - for (paint_plan.batches[0..paint_plan.len], 0..) |batch, batch_index| - std.debug.assert(cell_next[batch_index] == batch.cell_start + batch.cell_count); + for (groups.items[0..groups.len], 0..) |group, index| + std.debug.assert(cell_next[index] == group.cell_start + group.cell_count); shifted = emitScrollRows(g, instances, cell_total, surface, layout, win_w, win_h, page); + @memcpy(instances[cell_total + shifted ..][0..decor_len], g.decor.items); c.SDL_UnmapGPUTransferBuffer(g.device, g.vxfer.?); if (g.atlas_dirty_top < g.atlas_dirty_bottom) uploadAtlas(g, cmd); const copy = c.SDL_BeginGPUCopyPass(cmd); const src = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.vxfer.?, .offset = 0 }; - const dst = c.SDL_GPUBufferRegion{ .buffer = g.vbuf.?, .offset = 0, .size = (cell_total + shifted) * @sizeOf(CellInstance) }; + const dst = c.SDL_GPUBufferRegion{ .buffer = g.vbuf.?, .offset = 0, .size = (cell_total + shifted + decor_len) * @sizeOf(CellInstance) }; c.SDL_UploadToGPUBuffer(copy, &src, &dst, false); c.SDL_EndGPUCopyPass(copy); } + build_zone.end(); + building = false; const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null); pushLayerOpacity(cmd, rp, background_opacity); const whole = c.SDL_Rect{ .x = 0, .y = 0, .w = @intCast(sw), .h = @intCast(sh) }; - for (paint_plan.batches[0..paint_plan.len], 0..) |batch, batch_index| { - const has_shifted = batch_index == 0 and shifted != 0; - if ((batch.cell_count != 0 or has_shifted) and g.vbuf != null) { + const decor_base = cell_total + shifted; + for (groups.items[0..groups.len], 0..) |group, index| { + 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); const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.atlas_tex, .sampler = g.atlas_sampler }; c.SDL_BindGPUFragmentSamplers(rp, 0, &samp_binding, 1); - if (batch.cell_count != 0) { - const panel_clipped = if (batch.track) |active| active.effect == .vertical else false; - if (panel_clipped) { - const clip = panelBoxScissor(g, batch.track.?.contentBox(), sw, sh); - c.SDL_SetGPUScissor(rp, &clip); - } - const binding = c.SDL_GPUBufferBinding{ - .buffer = g.vbuf.?, - .offset = batch.cell_start * @sizeOf(CellInstance), - }; + if (group.cell_count != 0) { + const binding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = group.cell_start * @sizeOf(CellInstance) }; c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1); - c.SDL_DrawGPUPrimitives(rp, 6, batch.cell_count, 0, 0); - if (panel_clipped) c.SDL_SetGPUScissor(rp, &whole); + c.SDL_DrawGPUPrimitives(rp, 6, group.cell_count, 0, 0); } if (has_shifted) { const clip = scrollScissor(g, layout, sw, sh); c.SDL_SetGPUScissor(rp, &clip); - const binding = c.SDL_GPUBufferBinding{ - .buffer = g.vbuf.?, - .offset = cell_total * @sizeOf(CellInstance), - }; + const binding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = cell_total * @sizeOf(CellInstance) }; c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1); c.SDL_DrawGPUPrimitives(rp, 6, shifted, 0, 0); c.SDL_SetGPUScissor(rp, &whole); } } - if (batch.image_count != 0) - drawNativeImagesGpu(g, rp, surface, &paint_plan, batch_index); + if (group.image_count != 0) drawNativeImagesGpu(g, rp, surface, group, whole); + if (group.decor_count != 0 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) }; + c.SDL_BindGPUVertexBuffers(rp, 0, &binding, 1); + c.SDL_DrawGPUPrimitives(rp, 6, group.decor_count, 0, 0); + } } drawOverlayGpu(g, cmd, rp, overlay_count); c.SDL_EndGPURenderPass(rp); @@ -5455,23 +5586,23 @@ fn renderFrame( } if (g.capture) { command_consumed = true; - try captureFrame(g, gpa, cmd, target, sw, sh); + try captureFrame(g, gpa, cmd, target, sw, sh, if (groups.tracks != 0) groups.items[1].track.?.frame else null); g.presented_scene = rendered_scene; - if (paint_plan.len == 1) rememberPresentedImages(g, gpa, surface); + if (groups.tracks == 0) rememberPresentedImages(g, gpa, surface); return true; } if (g.soft_present) { command_consumed = true; try softPresentFrame(g, cmd, target, sw, sh); g.presented_scene = rendered_scene; - if (paint_plan.len == 1) rememberPresentedImages(g, gpa, surface); + if (groups.tracks == 0) rememberPresentedImages(g, gpa, surface); return true; } command_consumed = true; const submitted = c.SDL_SubmitGPUCommandBuffer(cmd); if (submitted) { g.presented_scene = rendered_scene; - if (paint_plan.len == 1) rememberPresentedImages(g, gpa, surface); + if (groups.tracks == 0) rememberPresentedImages(g, gpa, surface); } return submitted; } @@ -5487,24 +5618,6 @@ const ResolvedCell = struct { const topbarPaneBorderPixels = pardes.topbarPaneBorderPixels; const taglineBandOffset = pardes.taglineBandOffset; -fn topbarPaneBorderHeight(surface: *const pardes.Surface, top: u16, cell_h: u32, tagline_h: u32) ?u32 { - if (top == 0 or surface.rows <= top) return null; - const height = topbarPaneBorderPixels(cell_h, tagline_h); - if (height == 0) return null; - const base = @as(usize, top) * surface.cols; - for (surface.cells[base..][0..surface.cols]) |cell| - if (!cell.default and cell.style.font_role == .tagline) return height; - return null; -} - -fn bottomTaglinePresent(surface: *const pardes.Surface) bool { - if (surface.rows == 0) return false; - const base = @as(usize, surface.rows - 1) * surface.cols; - for (surface.cells[base..][0..surface.cols]) |cell| - if (!cell.default and cell.style.font_role == .tagline) return true; - return false; -} - test "tagline bands center in their cells and Tagbottom faces the window edge" { const cell_h: u32 = 20; const tagline_h: u32 = 16; @@ -5587,40 +5700,6 @@ fn cellInstanceCount(cell: *const pardes.Cell) u32 { return if (cellFontRole(cell) == .tagline) 2 else 1; } -fn taglineBaseRgb(core: *const pardes.Pardes, col: u16, row: u16) [3]u8 { - const chrome = core.chromeTheme(); - if (!core.settings.focus_tint) return chrome.tag_bg; - if (row >= core.topBarHeight() and row < core.topBarHeight() + core.columnBarHeight()) { - for (0..core.ncol) |column| for (core.col_panes[column][0..core.col_n[column]]) |id| { - if (id != core.active) continue; - const x = core.col_x[column]; - if (col >= x and col - x < core.col_w[column]) return chrome.tag_active_bg; - }; - } - const r = core.rects[core.active]; - const pane = core.panes[core.active] orelse return chrome.tag_bg; - const tag_y = core.tagTop(pane, r); - return if (r.h > 0 and row >= tag_y and row < tag_y + pane.tag_rows and col >= r.x and col - r.x < r.w) chrome.tag_active_bg else chrome.tag_bg; -} - -test "compact column and pane underlays share focus tint without tinting the global tag" { - const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 24 }); - defer core.deinit(); - _ = try core.newShell(1, ""); - try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); - pardes.test_api.sync(core); - core.active = 1; - const active = core.rects[1]; - const inactive = core.rects[0]; - const chrome = core.chromeTheme(); - try std.testing.expectEqual(chrome.tag_bg, taglineBaseRgb(core, active.x, 0)); - try std.testing.expectEqual(chrome.tag_active_bg, taglineBaseRgb(core, active.x, pardes.TOPBAR_H)); - try std.testing.expectEqual(chrome.tag_bg, taglineBaseRgb(core, inactive.x, pardes.TOPBAR_H)); - try std.testing.expectEqual(chrome.tag_active_bg, taglineBaseRgb(core, active.x, active.y)); - core.settings.focus_tint = false; - try std.testing.expectEqual(chrome.tag_bg, taglineBaseRgb(core, active.x, pardes.TOPBAR_H)); -} - fn tagLayerIn(layers: []const pardes.Layer, col: u16, row: u16) ?*const pardes.Layer { for (layers) |*layer| { const r = layer.viewport; @@ -5629,34 +5708,10 @@ fn tagLayerIn(layers: []const pardes.Layer, col: u16, row: u16) ?*const pardes.L return null; } -/// Marks the grid cells a body or tag layer covers, which the layers paint -/// themselves: the grid's cell loops skip them. Filling each layer's rectangle -/// once replaces scanning every layer for every cell, which was a third of the -/// gui's CPU on a full screen of text. It marks exactly the cells bodyLayerIn -/// and tagLayerIn find a layer for: a tag layer covers its rows, and a -/// viewport past the grid covers the grid's part of it. -fn coverLayers(cover: []bool, cols: u16, rows: u16, body_layers: []const pardes.Layer, tag_layers: []const pardes.Layer) void { - @memset(cover, false); - for (body_layers) |*layer| { - const r = layer.viewport; - const right = @min(cols, @as(u32, r.x) + r.w); - if (layer.rows == 0 or r.x >= right) continue; - var y: u32 = r.y; - while (y < @min(rows, @as(u32, r.y) + r.h)) : (y += 1) @memset(cover[y * cols ..][r.x..right], true); - } - for (tag_layers) |*layer| { - const r = layer.viewport; - const right = @min(cols, @as(u32, r.x) + r.w); - if (layer.rows == 0 or r.x >= right) continue; - var y: u32 = r.y; - while (y < @min(rows, @as(u32, r.y) + layer.rows)) : (y += 1) @memset(cover[y * cols ..][r.x..right], true); - } -} - -fn expectCoverIsScan(cover: []const bool, cols: u16, rows: u16, body_layers: []const pardes.Layer, tag_layers: []const pardes.Layer) !void { +fn expectCoverIsScan(cover: []const Cover, cols: u16, rows: u16, body_layers: []const pardes.Layer, tag_layers: []const pardes.Layer) !void { for (0..rows) |row| for (0..cols) |col| { const scan = bodyLayerIn(body_layers, @intCast(col), @intCast(row)) != null or tagLayerIn(tag_layers, @intCast(col), @intCast(row)) != null; - try std.testing.expectEqual(scan, cover[row * cols + col]); + try std.testing.expectEqual(scan, cover[row * cols + col].layer); }; } @@ -5670,7 +5725,7 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); pardes.layout.insert(core, 1, 1, 2); pardes.layout.insert(core, 0, 1, 3); - var cover: [97 * 31]bool = undefined; + var cover: [97 * 31]Cover = undefined; var notices = false; for (0..32) |step| { core.settings.tag_bottom = step & 1 != 0; @@ -5683,9 +5738,20 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand } const s = try core.render(core.scratch.allocator()); try std.testing.expectEqual(@as(usize, cover.len), @as(usize, s.cols) * s.rows); - @memset(&cover, true); // what an earlier frame left - coverLayers(&cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers()); + @memset(&cover, .{ .layer = true, .floating = true, .grip = true, .focus = true }); // what an earlier frame left + coverFrame(&cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers(), s.regionList()); try expectCoverIsScan(&cover, s.cols, s.rows, s.bodyLayers(), s.tagLayers()); + // A grip's cells know their way to its glyph, and the focused + // pane's tag rows are the focused ones. + for (s.regionList()) |region| { + if (region.kind != .grip) continue; + for (0..region.rect.w) |offset| { + const cell = cover[@as(usize, region.rect.y) * s.cols + region.rect.x + offset]; + try std.testing.expect(cell.grip); + try std.testing.expectEqual(offset, cell.grip_offset); + try std.testing.expectEqual(region.owner == core.active, cell.focus); + } + } for (s.tagLayers()) |layer| notices = notices or (layer.rows != 0 and layer.kind == .notice); } try std.testing.expect(notices); @@ -5705,18 +5771,18 @@ test "the layer cover is the per-cell layer scan, over a core's layouts and rand const random = prng.random(); var body: [pardes.MAX_PANES]pardes.Layer = undefined; var tag: [pardes.MAX_TAG_LAYERS]pardes.Layer = undefined; - var grid: [64 * 40]bool = undefined; + var grid: [64 * 40]Cover = undefined; for (0..3000) |_| { const cols = random.intRangeAtMost(u16, 1, 64); const rows = random.intRangeAtMost(u16, 1, 40); const empty = random.intRangeAtMost(u8, 2, 5); // about one layer in `empty` is empty for (&body) |*layer| layer.* = .{ .rows = @intFromBool(random.uintLessThan(u8, empty) != 0), .viewport = gen.rect(random, cols, rows) }; - for (&tag) |*layer| layer.* = .{ .cols = @intFromBool(random.uintLessThan(u8, empty) != 0), .viewport = gen.rect(random, cols, rows) }; + for (&tag) |*layer| layer.* = .{ .rows = @intFromBool(random.uintLessThan(u8, empty) != 0), .viewport = gen.rect(random, cols, rows) }; const bodies = body[0..random.uintAtMost(usize, body.len)]; const tags = tag[0..random.uintAtMost(usize, tag.len)]; const cells = grid[0 .. @as(usize, cols) * rows]; - @memset(cells, random.boolean()); - coverLayers(cells, cols, rows, bodies, tags); + @memset(cells, .{ .layer = random.boolean() }); + coverFrame(cells, cols, rows, bodies, tags, &.{}); try expectCoverIsScan(cells, cols, rows, bodies, tags); } } @@ -5844,42 +5910,23 @@ fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const } } -const GripCell = struct { cell: *const pardes.Cell, offset: u16 }; - -fn gripCellChanged(surface: *const pardes.Surface, col: u16, row: u16) bool { +/// A dissolving cell changes with its grip's glyph, which is the cell its +/// grip offset to the left, in this frame's grip or the last one's. +fn gripCellChanged(surface: *const pardes.Surface, covered: []const Cover, previous_covered: []const Cover, col: u16, row: u16) bool { if (surface.panelCellChanged(col, row)) return true; - if (paneGripCell(surface.tagLayers(), surface.cells, surface.cols, col, row)) |grip| { - if (surface.panelCellChanged(col - grip.offset, row)) return true; - } - if (paneGripCell(surface.previous_tag_layers, surface.previous_cells, surface.cols, col, row)) |grip| { - if (surface.panelCellChanged(col - grip.offset, row)) return true; - } + const at = @as(usize, row) * surface.cols + col; + for ([_]Cover{ covered[at], previous_covered[at] }) |cover| + if (cover.grip and surface.panelCellChanged(col -| cover.grip_offset, row)) return true; return false; } -fn paneGripCell(layers: []const pardes.Layer, cells: []const pardes.Cell, cols: u16, col: u16, row: u16) ?GripCell { - const at = @as(usize, row) * cols + col; - if (at >= cells.len or cells[at].default or cells[at].style.font_role != .tagline) return null; - for (layers) |layer| { - if (layer.rows == 0 or layer.kind != .pane or layer.viewport.x < pardes.TAG_TEXT_INSET or row < layer.viewport.y or row - layer.viewport.y >= layer.rows) continue; - const x = layer.viewport.x - pardes.TAG_TEXT_INSET; - if (col < x or col - x >= config.GUTTER) continue; - const index = @as(usize, row) * cols + x; - if (index >= cells.len) return null; - const cell = &cells[index]; - if (cell.default or cell.printableAscii() == ' ' or cell.style.invisible) return null; - return .{ .cell = cell, .offset = col - x }; - } - return null; -} - fn anchorInset(g: *const Gui, row: u16, win_h: f32) u32 { return taglineBandOffset(row, win_h, g.cell_h, g.tagline_height); } fn emitSurfaceCell( g: *Gui, - core: ?*const pardes.Pardes, + chrome: *const pardes.Chrome, instances: [*]CellInstance, next: *u32, col: u16, @@ -5889,18 +5936,20 @@ fn emitSurfaceCell( win_h: f32, track: ?pardes.animation.Track, line: []const pardes.Cell, + line_cover: []const Cover, at: usize, cursor: ?usize, - tagline_base: *const pardes.Cell, - grip: ?GripCell, old_layer: bool, is_cursor: bool, page: Ground, ) void { const role = cellFontRole(&line[at]); if (role == .tagline) { - var band_base = tagline_base.*; - if (core) |p| band_base.style.bg = .{ .rgb = taglineBaseRgb(p, col, row) }; + const here = line_cover[at]; + const band_base: pardes.Cell = .{ + .style = .{ .bg = .{ .rgb = if (here.focus) chrome.tag_focus_bg else chrome.tag_bg }, .font_role = .tagline }, + .default = false, + }; // The smaller font changes the ink, not the chrome's coverage. Painting // this underlay at tagline height leaves page-colored gutters between // adjacent workspace, column and pane tags at TaglineSize < 100. @@ -5908,27 +5957,32 @@ fn emitSurfaceCell( next.* += 1; // Tag glyphs keep their font size inside a full physical-width grip. emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, line, at, cursor, old_layer, is_cursor, page); + // A grip's glyph is the cell at its left edge, and a blank one is + // no glyph: its marks are decor. + const glyph = &line[at - @min(here.grip_offset, at)]; + const grip = here.grip and !glyph.default and glyph.printableAscii() != ' ' and !glyph.style.invisible; + const offset: u16 = if (grip) @min(here.grip_offset, at) else 0; // An anchor starts at its column's rule: its left edge is inset by // its top margin so the band frames it on the left as above and // below, and its mark moves right by half that to stay centered. - const inset: u32 = if (core) |p| if (std.mem.indexOfScalar(u16, p.col_x[0..p.ncol], col -| (if (grip) |indicator| indicator.offset else 0)) != null) anchorInset(g, row, win_h) else 0 else 0; - if (grip) |indicator| { + const inset: u32 = if (line_cover[at - offset].anchor) anchorInset(g, row, win_h) else 0; + if (grip) { // A grip is one glyph centered across the gutter, not text: it is // looked up by its codepoint's glyph, never shaped. - const drawn = glyphForCodepoint(g, cellCodepoint(indicator.cell)); - const cached = cachedGlyph(g, .{ .face = drawn.face, .glyph = drawn.glyph, .role = .tagline, .decoration = GlyphDecoration.fromCell(indicator.cell), .centered = true }); + const drawn = glyphForCodepoint(g, cellCodepoint(glyph)); + const cached = cachedGlyph(g, .{ .face = drawn.face, .glyph = drawn.glyph, .role = .tagline, .decoration = GlyphDecoration.fromCell(glyph), .centered = true }); const slot = cached orelse g.space_slot; - const offset: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, indicator.offset) * g.cell_w)) - @as(f32, @floatFromInt(inset / 2)) else 0; - const resolved = resolveCell(g, indicator.cell[0..1], 0, null, .tagline, false, page); + const shift: f32 = if (cached != null) @as(f32, @floatFromInt(@as(u32, offset) * g.cell_w)) - @as(f32, @floatFromInt(inset / 2)) else 0; + const resolved = resolveCell(g, glyph[0..1], 0, null, .tagline, false, page); instances[next.*].fr = @as(f32, @floatFromInt(resolved.fg[0])) / 255; instances[next.*].fg = @as(f32, @floatFromInt(resolved.fg[1])) / 255; instances[next.*].fb = @as(f32, @floatFromInt(resolved.fg[2])) / 255; - instances[next.*].u0 = (@as(f32, @floatFromInt(slot.u)) + offset) / atlas_w; + instances[next.*].u0 = (@as(f32, @floatFromInt(slot.u)) + shift) / atlas_w; instances[next.*].v0 = @as(f32, @floatFromInt(slot.v)) / atlas_h; instances[next.*].v1 = @as(f32, @floatFromInt(slot.v + g.tagline_height)) / atlas_h; } instances[next.*].u1 = instances[next.*].u0 + @as(f32, @floatFromInt(g.cell_w)) / atlas_w; - if (inset > 0 and (grip == null or grip.?.offset == 0)) { + if (inset > 0 and (!grip or offset == 0)) { instances[next.*].x0 += @as(f32, @floatFromInt(inset)) / win_w * 2; instances[next.*].u0 += @as(f32, @floatFromInt(inset)) / atlas_w; } @@ -5940,8 +5994,7 @@ fn emitSurfaceCell( } test "small tagline fonts leave no background gutters between adjacent tags" { - const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); - defer core.deinit(); + const chrome: pardes.Chrome = .{}; var g: Gui = undefined; g.words = .{}; g.cell_w = 10; @@ -5954,7 +6007,7 @@ test "small tagline fonts leave no background gutters between adjacent tags" { var next: u32 = 0; for (0..3) |row| { // Column 1: past the anchor's inset edge at the column's left. - emitSurfaceCell(&g, core, &instances, &next, 1, @intCast(row), fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], 0, null, &cell, null, false, false, Ground.opaqueRgb(bg_default)); + emitSurfaceCell(&g, &chrome, &instances, &next, 1, @intCast(row), fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], &.{.{}}, 0, null, false, false, Ground.opaqueRgb(bg_default)); const base = instances[row * 2]; const ink = instances[row * 2 + 1]; try std.testing.expectApproxEqAbs(@as(f32, 20.0 / 480.0 * 2), base.y0 - base.y1, 0.0001); @@ -5967,12 +6020,12 @@ test "small tagline fonts leave no background gutters between adjacent tags" { // Column 0 is an anchor's edge: its ink is inset by the band's margin // (2 px here) and its underlay still covers the whole cell. next = 0; - emitSurfaceCell(&g, core, &instances, &next, 0, 1, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], 0, null, &cell, null, false, false, Ground.opaqueRgb(bg_default)); + emitSurfaceCell(&g, &chrome, &instances, &next, 0, 1, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], &.{.{ .anchor = true }}, 0, null, false, false, Ground.opaqueRgb(bg_default)); try std.testing.expectApproxEqAbs(@as(f32, 10.0 / 800.0 * 2), instances[0].x1 - instances[0].x0, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 8.0 / 800.0 * 2), instances[1].x1 - instances[1].x0, 0.0001); try std.testing.expectApproxEqAbs(@as(f32, 8) / atlas_w, instances[1].u1 - instances[1].u0, 0.0001); next = 0; - emitSurfaceCell(&g, null, &instances, &next, 0, 2, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], 0, null, &cell, null, true, false, Ground.opaqueRgb(bg_default)); + emitSurfaceCell(&g, &chrome, &instances, &next, 0, 2, fixedCellLayout(&g), 800, 480, null, (&cell)[0..1], &.{.{}}, 0, null, true, false, Ground.opaqueRgb(bg_default)); try std.testing.expectEqual(@as(u32, 2), next); try std.testing.expectApproxEqAbs(@as(f32, 16.0 / 480.0 * 2), instances[1].y0 - instances[1].y1, 0.0001); } @@ -6086,24 +6139,25 @@ test "insert cursor overlays without replacing the character beneath it" { }; try std.testing.expectEqual(@as(u32, 'x'), cellCodepoint(&cell)); - var vertices: [6]OverlayVertex = undefined; - var builder: OverlayBuilder = .{ - .vertices = &vertices, - .win_w = 100, - .win_h = 100, - }; - addCursorBar(&builder, 2, 3, true, 8, .{ - .x_off = 0, - .y_off = 0, - .w = 10, - .h = 20, - }, .{ 32, 48, 64 }); - try std.testing.expectEqual(@as(usize, 6), builder.len); - try std.testing.expectApproxEqAbs(@as(f32, -0.32), vertices[0].y, 0.0001); - try std.testing.expectApproxEqAbs(@as(f32, -0.48), vertices[2].y, 0.0001); - try std.testing.expectApproxEqAbs(@as(f32, 32.0 / 255.0), vertices[0].r, 0.0001); - for (vertices) |vertex| try std.testing.expectEqual(@as(u32, 1), vertex.ink); - try std.testing.expect(!builder.ink); + // A bar at a tag cell: tagline-tall, centered in its row, in the + // cell's ink, drawn as decor over it. + var cells: [25]pardes.Cell = @splat(.{}); + cells[3 * 5 + 2] = .{ .default = false, .style = .{ .fg = .{ .rgb = .{ 32, 48, 64 } }, .font_role = .tagline } }; + const surface: pardes.Surface = .{ .cols = 5, .rows = 5, .cells = &cells, .cursor = .{ .x = 2, .y = 3, .bar = true } }; + var g: Gui = undefined; + g.decor = .empty; + defer g.decor.deinit(std.testing.allocator); + g.cell_w = 10; + g.cell_h = 20; + g.tagline_width = 8; + g.tagline_height = 8; + try cursorDecor(&g, std.testing.allocator, &surface, fixedCellLayout(&g), 100, 100); + try std.testing.expectEqual(@as(usize, 1), g.decor.items.len); + const bar = g.decor.items[0]; + try std.testing.expectApproxEqAbs(@as(f32, -0.32), bar.y0, 0.0001); + try std.testing.expectApproxEqAbs(@as(f32, -0.48), bar.y1, 0.0001); + try std.testing.expectApproxEqAbs(@as(f32, 32.0 / 255.0), bar.br, 0.0001); + try std.testing.expectEqual(@as(f32, 1), bar.fr); } fn palColor(idx: u8) [3]u8 { @@ -6613,17 +6667,24 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" { cells[0].text[0] = '$'; var previous = cells; previous[0].text[0] = '^'; - var layers = [_]pardes.Layer{.{ .kind = .pane, .serial = 1, .viewport = .{ .x = pardes.TAG_TEXT_INSET, .y = 0, .w = 3, .h = 1 }, .cols = 3, .rows = 1 }}; var diffs: [12]pardes.PanelCellDiff = @splat(.unchanged); diffs[0] = pardes.PanelCellDiff.between(&previous[0], &cells[0]); - var surface: pardes.Surface = .{ .cols = 6, .rows = 2, .cells = &cells, .previous_cells = &previous, .previous_tag_layers = &layers, .cell_diffs = &diffs }; - surface.tag_layers[0] = layers[0]; - try std.testing.expect(gripCellChanged(&surface, 0, 0)); - try std.testing.expect(gripCellChanged(&surface, 1, 0)); - try std.testing.expect(!gripCellChanged(&surface, 2, 0)); - try std.testing.expect(paneGripCell(surface.tagLayers(), &cells, 6, 2, 0) == null); - layers[0].kind = .column; - try std.testing.expect(paneGripCell(&layers, &cells, 6, 0, 0) == null); + const surface: pardes.Surface = .{ .cols = 6, .rows = 2, .cells = &cells, .previous_cells = &previous, .cell_diffs = &diffs }; + // A grip two cells wide at the top left, in both frames. + const grip = [_]pardes.Region{.{ .kind = .grip, .serial = 1, .rect = .{ .x = 0, .y = 0, .w = 2, .h = 1 } }}; + var cover: [12]Cover = undefined; + var previous_cover: [12]Cover = undefined; + coverFrame(&cover, 6, 2, &.{}, &.{}, &grip); + coverFrame(&previous_cover, 6, 2, &.{}, &.{}, &grip); + try std.testing.expect(gripCellChanged(&surface, &cover, &previous_cover, 0, 0)); + try std.testing.expect(gripCellChanged(&surface, &cover, &previous_cover, 1, 0)); + try std.testing.expect(!gripCellChanged(&surface, &cover, &previous_cover, 2, 0)); + try std.testing.expect(!cover[2].grip); + // A column's grip has no glyph of a pane's. + coverFrame(&cover, 6, 2, &.{}, &.{}, &.{.{ .kind = .column_grip, .rect = .{ .x = 0, .y = 0, .w = 2, .h = 1 } }}); + try std.testing.expect(!cover[0].grip); + coverFrame(&cover, 6, 2, &.{}, &.{}, &grip); + const chrome: pardes.Chrome = .{}; const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; defer c.ui_font_free(font); @@ -6647,7 +6708,7 @@ test "pane indicator halves share dissolve changes and safe exhausted atlas" { var instances: [4]CellInstance = undefined; var next: u32 = 0; for (0..2) |col| { - emitSurfaceCell(&g, null, &instances, &next, @intCast(col), 0, fixedCellLayout(&g), 100, 100, null, cells[0..6], col, null, &cells[2], .{ .cell = &cells[0], .offset = @intCast(col) }, old, false, Ground.opaqueRgb(bg_default)); + emitSurfaceCell(&g, &chrome, &instances, &next, @intCast(col), 0, fixedCellLayout(&g), 100, 100, null, cells[0..6], cover[0..6], col, null, old, false, Ground.opaqueRgb(bg_default)); try std.testing.expectEqual(@as(f32, 0), instances[col * 2 + 1].u0); try std.testing.expectEqual(old, instances[col * 2 + 1].effect & old_layer_bit != 0); } @@ -6802,10 +6863,12 @@ fn ensureVbuf(g: *Gui, cells: u32) !void { g.vbuf_cells = capacity; } -fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { +/// A pipeline over cell instances: the cells' own (ui.frag, the atlas, no +/// blending) or the decor's (decor.frag, blended like the overlay). +fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat, frag: []const u8, samplers: u32, blend: c.SDL_GPUColorTargetBlendState) !*c.SDL_GPUGraphicsPipeline { const vs = try makeShader(device, vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0); defer c.SDL_ReleaseGPUShader(device, vs); - const fs = try makeShader(device, frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1); + const fs = try makeShader(device, frag, c.SDL_GPU_SHADERSTAGE_FRAGMENT, samplers, 1); defer c.SDL_ReleaseGPUShader(device, fs); var vbuf_desc = c.SDL_GPUVertexBufferDescription{ @@ -6822,8 +6885,9 @@ fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) .{ .location = 4, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "panel_x0") }, .{ .location = 5, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "present_x0") }, .{ .location = 6, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_UINT4, .offset = @offsetOf(CellInstance, "effect") }, + .{ .location = 7, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "clip_x0") }, }; - var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) }; + var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = blend }; var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); info.vertex_shader = vs; @@ -7135,7 +7199,10 @@ fn latencyNote(comptime fmt: []const u8, args: anytype) void { _ = libc.write(latency_fd, line.ptr, line.len); } -fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32) !void { +/// Writes the frame to latest.ppm in the capture directory, and a panel +/// transition's frame `transition` to transition-.ppm too, so a test +/// can hold one partway (test/gui_golden.py). +fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32, transition: ?u16) !void { const size = c.SDL_CalculateGPUTextureFormatSize(g.swapchain_format, sw, sh, 1); if (size == 0) { _ = c.SDL_SubmitGPUCommandBuffer(cmd); @@ -7168,10 +7235,12 @@ fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, t const mapped = c.SDL_MapGPUTransferBuffer(g.device, xfer, false) orelse return error.GpuMap; defer c.SDL_UnmapGPUTransferBuffer(g.device, xfer); const pixels: [*]const u8 = @ptrCast(mapped); - try writeCapturePpm(g, gpa, pixels[0..size], sw, sh); + try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, "latest"); + var name: [24]u8 = undefined; + if (transition) |frame| try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, std.fmt.bufPrint(&name, "transition-{d}", .{frame}) catch unreachable); } -fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32) !void { +fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32, name: []const u8) !void { // A latency trace times frames, and an 8 MB file write per frame would // stall the loop it is timing. if (g.capture_dir.len == 0 or latency_fd >= 0) return; @@ -7185,8 +7254,8 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u var tmp_buf: [4096]u8 = undefined; var final_buf: [4096]u8 = undefined; - const tmp_path = std.fmt.bufPrintSentinel(&tmp_buf, "{s}/latest.ppm.tmp", .{g.capture_dir}, 0) catch return error.CapturePathTooLong; - const final_path = std.fmt.bufPrintSentinel(&final_buf, "{s}/latest.ppm", .{g.capture_dir}, 0) catch return error.CapturePathTooLong; + const tmp_path = std.fmt.bufPrintSentinel(&tmp_buf, "{s}/{s}.ppm.tmp", .{ g.capture_dir, name }, 0) catch return error.CapturePathTooLong; + const final_path = std.fmt.bufPrintSentinel(&final_buf, "{s}/{s}.ppm", .{ g.capture_dir, name }, 0) catch return error.CapturePathTooLong; const fd = libc.open(tmp_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); if (fd < 0) return error.CaptureWriteFailed; @@ -7212,116 +7281,263 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u if (libc.rename(tmp_path, final_path) != 0) return error.CaptureWriteFailed; } -fn addCursorBar( - builder: *OverlayBuilder, - x: u16, - y: u16, - bar: bool, - visual_height: f32, - layout: CellLayout, - rgb: [3]u8, -) void { - if (!bar) return; - const previous_ink = builder.ink; - builder.ink = true; - defer builder.ink = previous_ink; - const x0 = layout.x_off + @as(f32, @floatFromInt(x)) * layout.w; - const height = std.math.clamp(visual_height, 1.0, layout.h); - const y0 = layout.y_off + @as(f32, @floatFromInt(y)) * layout.h + - @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, @intFromFloat(layout.h), @intFromFloat(height)))); - builder.addRect( - x0, - y0, - x0 + @max(1.0, layout.w / 8.0), - y0 + height, - .{ - .r = @as(f32, @floatFromInt(rgb[0])) / 255.0, - .g = @as(f32, @floatFromInt(rgb[1])) / 255.0, - .b = @as(f32, @floatFromInt(rgb[2])) / 255.0, - .a = 1.0, - }, - ); +/// One flat, hard-edged rectangle of decor, in window pixels: clipped to the +/// window and dropped when empty, as the triangle overlay drew it, and moved +/// by `track` as its pane's cells are. +fn addDecor(g: *Gui, gpa: std.mem.Allocator, x0_in: f32, y0_in: f32, x1_in: f32, y1_in: f32, rgb: [3]u8, alpha: f32, track: ?pardes.animation.Track, win_w: f32, win_h: f32) !void { + const x0 = std.math.clamp(@min(x0_in, x1_in), 0.0, win_w); + const x1 = std.math.clamp(@max(x0_in, x1_in), 0.0, win_w); + const y0 = std.math.clamp(@min(y0_in, y1_in), 0.0, win_h); + const y1 = std.math.clamp(@max(y0_in, y1_in), 0.0, win_h); + if (x1 <= x0 or y1 <= y0) return; + // decor.frag.glsl reads the colour from the ground and the coverage + // from the ink's red. + var instance: CellInstance = .{ + .x0 = (x0 / win_w) * 2.0 - 1.0, + .y0 = 1.0 - (y0 / win_h) * 2.0, + .x1 = (x1 / win_w) * 2.0 - 1.0, + .y1 = 1.0 - (y1 / win_h) * 2.0, + .u0 = 0, + .v0 = 0, + .u1 = 0, + .v1 = 0, + .fr = alpha, + .fg = 0, + .fb = 0, + .br = @as(f32, @floatFromInt(rgb[0])) / 255.0, + .bg = @as(f32, @floatFromInt(rgb[1])) / 255.0, + .bb = @as(f32, @floatFromInt(rgb[2])) / 255.0, + }; + setTransitionFields(&instance, track, fixedCellLayout(g), win_w, win_h, 0); + try g.decor.append(gpa, instance); +} + +/// The frame's decor, group by group (docs/render-pipeline.md §3.4), all of it +/// read from the regions and the palette: the page's rules and the still +/// panes' rails, rules and grip marks in tier 0, a moving or closing pane's +/// in its track's group, notice rules in tier 4, bar cursors in tier 5. +fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, groups: *Groups, layout: CellLayout, win_w: f32, win_h: f32, cursors_shown: bool) !void { + g.decor.clearRetainingCapacity(); + const chrome = &surface.chrome; + const regions = surface.regionList(); + 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; + if (index == 0) { + const top = for (regions) |region| { + if (region.kind == .workspace_tag) break region.rect.h; + } else 0; + // The workspace tag is ruled off from the tags right under it. + const tag_under = for (regions) |region| switch (region.kind) { + .grip, .tag, .column_grip, .column_tag => if (region.rect.y == top) break true, + else => {}, + } else false; + const height = topbarPaneBorderPixels(g.cell_h, g.tagline_height); + 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); + } + // The column bar is ruled off from the panes under it. + for (regions) |bar| { + if (bar.kind != .column_grip) continue; + const y = @as(f32, @floatFromInt(bar.rect.y + bar.rect.h)) * layout.h; + try addDecor(g, gpa, 0, y - 1, win_w, y, chrome.border, 1, null, win_w, win_h); + break; + } + // A tag on the last row runs on through the pixels under the grid. + const grid_bottom = @as(f32, @floatFromInt(surface.rows)) * layout.h; + const tag_last = for (regions) |region| switch (region.kind) { + .grip, .tag, .column_grip, .column_tag, .workspace_tag => if (region.rect.h != 0 and region.rect.y + region.rect.h == surface.rows) break true, + else => {}, + } else false; + if (grid_bottom < win_h and tag_last) try addDecor(g, gpa, 0, grid_bottom, win_w, win_h, chrome.tag_bg, 1, null, win_w, win_h); + } + if (index <= groups.tracks) { + const track = group.track; + // A closing pane is gone: its chrome is the last frame's. + const closing = if (track) |active| active.phase == .closing else false; + const placed = if (closing) surface.previous_regions else regions; + const grid = if (closing) surface.previous_cells else surface.cells; + try paneDecor(g, gpa, placed, groups, index, chrome, layout, surface.cols, surface.rows, win_w, win_h, track); + // A single spine runs down each column, through every tag and body. + if (index == 0) for (regions) |column| { + if (column.kind != .column) continue; + const x = @as(f32, @floatFromInt(column.rect.x)) * layout.w; + const y0 = @as(f32, @floatFromInt(column.rect.y)) * layout.h; + const grid_y1 = @as(f32, @floatFromInt(surface.rows)) * layout.h; + const y1 = if (win_h - grid_y1 < layout.h) @max(grid_y1, win_h) else grid_y1; + if (y1 > y0) try addDecor(g, gpa, x, y0, x + 1, y1, chrome.border, 1, null, win_w, win_h); + }; + try gripDecor(g, gpa, placed, grid, groups, index, layout, surface.cols, surface.rows, win_w, win_h, track); + } + // A notice chip is tagline hung over the body's top rows, so it is + // ruled off from the text beneath it the way a tag is: the same + // colour and thickness along its bottom, running to the window's + // edge when it does, fading up and dissolving with its band. + if (index == groups.notices) for (surface.tagLayers()) |*layer| { + if (layer.rows == 0 or layer.kind != .notice) continue; + const x0 = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; + const x1 = x0 + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w; + const rule_x1 = if (win_w - x1 < layout.w) @max(x1, win_w) else x1; + const top = @as(f32, @floatFromInt(layer.viewport.y)) * layout.h; + // 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; + 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); + }; + if (index == groups.ink and cursors_shown) try cursorDecor(g, gpa, surface, layout, win_w, win_h); + } +} + +/// Each pane's rule between its tag and body, and its scroll rail: a page +/// coloured gutter with a thin track and its thumb, running down through the +/// pixels past the last whole row. Only the panes of group `index`. +fn paneDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, groups: *const Groups, index: usize, chrome: *const pardes.Chrome, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void { + const page = chrome.page orelse bg_default; + for (regions) |rail| { + if (rail.kind != .rail or rail.rect.h == 0 or groupOf(groups, rail.serial) != index) continue; + const tag = for (regions) |region| { + if (region.kind == .tag and region.owner == rail.owner) break region; + } else continue; + if (@as(u32, tag.rect.x) + tag.rect.w > cols or @as(u32, tag.rect.y) + tag.rect.h > rows or + @as(u32, rail.rect.y) + rail.rect.h > rows) continue; + const x0 = @as(f32, @floatFromInt(rail.rect.x)) * layout.w; + const x1 = @as(f32, @floatFromInt(tag.rect.x + tag.rect.w)) * layout.w; + // Like the band above it, the rule of a pane at the right edge runs + // on through the pixels past the last whole cell. Under a tag on + // top, over one below its body. + const rule_x1 = if (win_w - x1 < layout.w) @max(x1, win_w) else x1; + const tag_bottom = tag.rect.y > rail.rect.y; + const rule_y = @as(f32, @floatFromInt(if (tag_bottom) tag.rect.y else tag.rect.y + tag.rect.h)) * layout.h; + try addDecor(g, gpa, x0, if (tag_bottom) rule_y else rule_y - 1, rule_x1, if (tag_bottom) rule_y + 1 else rule_y, chrome.border, 1, track, win_w, win_h); + + const y0 = @as(f32, @floatFromInt(rail.rect.y)) * layout.h; + const body_end = rail.rect.y + rail.rect.h; + const body_y1 = @as(f32, @floatFromInt(body_end)) * layout.h; + const y1 = if (body_end == rows and win_h - body_y1 < layout.h) @max(body_y1, win_h) else body_y1; + try addDecor(g, gpa, x0, y0, x0 + layout.w, y1, page, 1, track, win_w, win_h); + try addDecor(g, gpa, x0, y0, x0 + @min(layout.w, 2), y1, chrome.scroll_track, 1, track, win_w, win_h); + if (rail.thumb_h != 0) { + const thumb_y0 = @as(f32, @floatFromInt(rail.rect.y + rail.thumb_y)) * layout.h; + const thumb_y1 = if (rail.thumb_y + rail.thumb_h == rail.rect.h) y1 else @as(f32, @floatFromInt(rail.rect.y + rail.thumb_y + rail.thumb_h)) * layout.h; + try addDecor(g, gpa, x0, thumb_y0, x0 + @min(layout.w, @max(2, @floor(layout.w * 0.5))), thumb_y1, chrome.scroll_thumb, 1, track, win_w, win_h); + } + } } -fn buildOverlay( - g: *Gui, - core: ?*const pardes.Pardes, - surface: *const pardes.Surface, - layout: CellLayout, - sw: u32, - sh: u32, - topbar_pane_border_rgb: [3]u8, - tagline_rgb: [3]u8, - transient_on: bool, - debug_on: bool, -) u32 { - if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0; - const win_w: f32 = @floatFromInt(sw); - const win_h: f32 = @floatFromInt(sh); - var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = win_w, .win_h = win_h }; - appendWorkspacePet(g, core, surface, &builder); - // The workspace tag grows a row per line, so the border follows its foot. - const top_rows: u16 = if (core) |p| p.topBarHeight() else pardes.TOPBAR_H; - if (topbarPaneBorderHeight(surface, top_rows, g.cell_h, g.tagline_height)) |height| { - const y0: f32 = @floatFromInt(top_rows * g.cell_h - height / 2); - const rgb = topbar_pane_border_rgb; - builder.addRect(0, y0, win_w, y0 + @as(f32, @floatFromInt(height)), .{ - .r = @as(f32, @floatFromInt(rgb[0])) / 255.0, - .g = @as(f32, @floatFromInt(rgb[1])) / 255.0, - .b = @as(f32, @floatFromInt(rgb[2])) / 255.0, - .a = 1.0, - }); +/// The two short bars on each row of a grip with no mode glyph there, +/// centered in its anchor. Only the panes of group `index`. +fn gripDecor(g: *Gui, gpa: std.mem.Allocator, regions: []const pardes.Region, cells: []const pardes.Cell, groups: *const Groups, index: usize, layout: CellLayout, cols: u16, rows: u16, win_w: f32, win_h: f32, track: ?pardes.animation.Track) !void { + if (g.tagline_height < 6 or g.tagline_width < 3) return; + for (regions) |grip| { + if (grip.kind != .grip or groupOf(groups, grip.serial) != index) continue; + const x = grip.rect.x; + for (0..grip.rect.h) |line| { + const y = grip.rect.y + @as(u16, @intCast(line)); + if (x >= cols or y >= rows) continue; + const cell = &cells[@as(usize, y) * cols + x]; + if (cell.default or cell.printableAscii() != ' ') continue; + const rgb = switch (cell.style.fg) { + .rgb => |rgb| rgb, + else => continue, + }; + const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w + @as(f32, @floatFromInt(anchorInset(g, y, win_h) / 2)); + const center_y = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, win_h, g.cell_h, g.tagline_height))) + @as(f32, @floatFromInt(g.tagline_height)) / 2; + const half_w = @max(1, @min(3, layout.w - 2)); + 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); + } } - if (core) |p| if (p.columnBarHeight() > 0) { - const y = @as(f32, @floatFromInt(top_rows + p.columnBarHeight())) * layout.h; - builder.addRect(0, y - 1, win_w, y, overlayRgb(p.chromeTheme().border)); +} + +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 }); + defer core.deinit(); + _ = try core.newShell(1, ""); + try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); + 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 ndc = struct { + fn x(px: f32) f32 { + return px / 800 * 2 - 1; + } }; - const grid_bottom = @as(f32, @floatFromInt(surface.rows)) * layout.h; - if (grid_bottom < win_h and bottomTaglinePresent(surface)) { - const rgb = tagline_rgb; - builder.addRect(0, grid_bottom, win_w, win_h, .{ - .r = @as(f32, @floatFromInt(rgb[0])) / 255.0, - .g = @as(f32, @floatFromInt(rgb[1])) / 255.0, - .b = @as(f32, @floatFromInt(rgb[2])) / 255.0, - .a = 1.0, - }); - } - if (transient_on) { - if (core) |p| appendPaneChrome(p, surface, layout, &builder); - appendTagGrips(g, surface, layout, &builder); - if (surface.cursor) |cursor| { - if (cursor.x < surface.cols and cursor.y < surface.rows and bodyLayerAt(surface, cursor.x, cursor.y) == null and tagLayerIn(surface.tagLayers(), cursor.x, cursor.y) == null) { - const cell = surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x]; - const role = cellFontRole(&cell); - const visual_height: f32 = if (role == .tagline) - @floatFromInt(g.tagline_height) - else - layout.h; - const cursor_layout = if (role == .tagline) - taglineLayoutForCell(g, cursor.x) - else - layout; - addCursorBar(&builder, cursor.x, cursor.y, cursor.bar, visual_height, cursor_layout, cursorColor(core, &cell, role)); - } + var groups = makeGroups(&.{}, false); + try buildDecor(&g, gpa, s, &groups, layout, 800, 480, true); + // A spine one pixel wide at each column's left edge, down from under the + // workspace tag; a rail two pixels wide at each pane's. + for (core.col_x[0..core.ncol]) |x| { + const left = ndc.x(@as(f32, @floatFromInt(x)) * 10); + const spine = for (g.decor.items) |item| { + if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 1)) < 0.0001) break item; + } else return error.TestExpectedSpine; + try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), spine.y0, 0.0001); + try std.testing.expectEqual(@as(f32, -1), spine.y1); + const rail = for (g.decor.items) |item| { + if (item.x0 == left and @abs(item.x1 - ndc.x(@as(f32, @floatFromInt(x)) * 10 + 2)) < 0.0001) break item; + } else return error.TestExpectedRail; + try std.testing.expectApproxEqAbs(@as(f32, s.chrome.scroll_track[0]) / 255, rail.br, 0.0001); + } + try std.testing.expectEqual(@as(u32, 0), groups.items[groups.notices].decor_count); + const still = groups.items[0].decor_count; + + // Pane 1 moving: its rule and rail leave tier 0 for its track's group, + // which moves them with it. + const serial = core.panes[1].?.serial; + const moving: pardes.animation.Track = .{ .pane = 1, .serial = serial, .phase = .moving, .effect = .slide, .from = .{ .x = 0, .y = 1, .w = 40, .h = 23 }, .to = .{ .x = 40, .y = 1, .w = 40, .h = 23 } }; + groups = makeGroups(&.{moving}, true); + try buildDecor(&g, gpa, s, &groups, layout, 800, 480, groups.tracks == 0); + const tracked = groups.items[1]; + try std.testing.expect(tracked.decor_count >= 4); + try std.testing.expectEqual(still, groups.items[0].decor_count + tracked.decor_count); + for (g.decor.items[tracked.decor_start..][0..tracked.decor_count]) |item| + try std.testing.expectEqual(@as(u32, @intFromEnum(pardes.animation.Transition.slide)), item.effect); + // Cursors stand still while panes move. + try std.testing.expectEqual(@as(u32, 0), groups.items[groups.ink].decor_count); +} + +/// The bar cursors: the grid's where no layer has the cell, and each layer's +/// at its own pitch. +fn cursorDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32) !void { + const chrome = &surface.chrome; + if (surface.cursor) |cursor| { + if (cursor.bar and cursor.x < surface.cols and cursor.y < surface.rows and bodyLayerAt(surface, cursor.x, cursor.y) == null and tagLayerIn(surface.tagLayers(), cursor.x, cursor.y) == null) { + const cell = &surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x]; + const role = cellFontRole(cell); + const width: f32 = if (role == .tagline) @floatFromInt(g.tagline_width) else layout.w; + 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)))); + try addDecor(g, gpa, x0, y0, x0 + @max(1.0, width / 8.0), y0 + height, cursorColor(chrome, cell, role), 1, null, win_w, win_h); } } - if (transient_on) for (surface.bodyLayers()) |*layer| { + for (surface.bodyLayers()) |*layer| { if (layer.rows == 0) continue; + const cursor = layer.cursor orelse continue; + if (!cursor.bar or cursor.y >= layer.rows or cursor.x >= layer.cols) continue; const left = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; const bottom = @as(f32, @floatFromInt(layer.viewport.y + layer.viewport.h)) * layout.h; - if (layer.cursor) |cursor| { - if (!cursor.bar or cursor.y >= layer.rows or cursor.x >= layer.cols) continue; - const top = layer.rowTop(cursor.y, layout.h, @floatFromInt(g.tagline_height)); - if (top >= bottom) continue; - const height = layer.rowHeight(cursor.y, layout.h, @floatFromInt(g.tagline_height)); - const role: pardes.FontRole = if (cursor.y < layer.context_rows) .tagline else .body; - 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]; - builder.addRect(x, top, x + @max(1, width / 8), @min(bottom, top + height), overlayRgb(cursorColor(core, cell, role))); - } - }; - if (transient_on) for (surface.tagLayers()) |*layer| { + const top = layer.rowTop(cursor.y, layout.h, @floatFromInt(g.tagline_height)); + if (top >= bottom) continue; + const height = layer.rowHeight(cursor.y, layout.h, @floatFromInt(g.tagline_height)); + const role: pardes.FontRole = if (cursor.y < layer.context_rows) .tagline else .body; + 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]; + try addDecor(g, gpa, x, top, x + @max(1, width / 8), @min(bottom, top + height), cursorColor(chrome, cell, role), 1, null, win_w, win_h); + } + for (surface.tagLayers()) |*layer| { if (layer.rows == 0) continue; const cursor = layer.cursor orelse continue; if (!cursor.bar or cursor.x >= layer.cols or cursor.y >= layer.rows) continue; @@ -7331,10 +7547,25 @@ fn buildOverlay( const x = left + @as(f32, @floatFromInt(cursor.x)) * width; if (x >= right) continue; const y = layer.viewport.y + cursor.y; - const top = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, g.cell_h, g.tagline_height))); - builder.addRect(x, top, @min(right, x + @max(1, width / 8)), top + @as(f32, @floatFromInt(g.tagline_height)), overlayRgb(cursorColor(core, &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x], .tagline))); - }; - if (transient_on) appendTouchOverlay(g, &builder, debug_on); + const top = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, win_h, g.cell_h, g.tagline_height))); + try addDecor(g, gpa, x, top, @min(right, x + @max(1, width / 8)), top + @as(f32, @floatFromInt(g.tagline_height)), cursorColor(chrome, &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x], .tagline), 1, null, win_w, win_h); + } +} + +/// The triangle overlay: the workspace pet and the touch debug view, over +/// everything. +fn buildOverlay( + g: *Gui, + core: ?*const pardes.Pardes, + surface: *const pardes.Surface, + sw: u32, + sh: u32, + debug_on: bool, +) u32 { + if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0; + var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = @floatFromInt(sw), .win_h = @floatFromInt(sh) }; + appendWorkspacePet(g, core, surface, &builder); + appendTouchOverlay(g, &builder, debug_on); return @intCast(builder.len); } @@ -7409,33 +7640,11 @@ test "pet overlay stays beyond workspace text and vanishes while its tag is edit try std.testing.expectEqual(@as(usize, 0), builder.len); } -fn cursorColor(core: ?*const pardes.Pardes, cell: *const pardes.Cell, role: pardes.FontRole) [3]u8 { - if (core) |p| return if (role == .tagline) switch (cell.style.fg) { - .rgb => |rgb| rgb, - else => p.chromeTheme().tag_fg, - } else p.theme().fg orelse fg_default; - return switch (cell.style.fg) { +fn cursorColor(chrome: *const pardes.Chrome, cell: *const pardes.Cell, role: pardes.FontRole) [3]u8 { + return if (role == .tagline) switch (cell.style.fg) { .rgb => |rgb| rgb, - .index => |index| palColor(index), - // Detached frames carry explicit page backgrounds but blank cells need - // not carry foreground ink. Keep their insertion caret visible too. - .default => switch (cell.style.bg) { - .rgb => |rgb| if ((@as(u16, rgb[0]) * 3 + @as(u16, rgb[1]) * 6 + rgb[2]) / 10 > 140) - .{ 32, 32, 32 } - else - fg_default, - else => fg_default, - }, - }; -} - -test "detached insertion caret contrasts with blank light and dark pages" { - var cell: pardes.Cell = .{ .default = false, .style = .{ .bg = .{ .rgb = .{ 248, 246, 237 } } } }; - try std.testing.expectEqual([3]u8{ 32, 32, 32 }, cursorColor(null, &cell, .body)); - cell.style.bg = .{ .rgb = .{ 24, 28, 32 } }; - try std.testing.expectEqual(fg_default, cursorColor(null, &cell, .body)); - cell.style.fg = .{ .rgb = .{ 122, 188, 160 } }; - try std.testing.expectEqual([3]u8{ 122, 188, 160 }, cursorColor(null, &cell, .body)); + else => chrome.tag_fg, + } else chrome.fg orelse fg_default; } fn overlayRgb(rgb: [3]u8) OverlayColor { @@ -7447,185 +7656,6 @@ fn overlayRgb(rgb: [3]u8) OverlayColor { }; } -fn cellBackgroundIs(cell: *const pardes.Cell, rgb: [3]u8) bool { - return !cell.default and switch (cell.style.bg) { - .rgb => |bg| std.mem.eql(u8, &bg, &rgb), - else => false, - }; -} - -// The canonical surface still owns scrollbar positions and hit targets. Only -// their pixel ink is inset here; no layout, selection or mouse coordinates move. -fn appendPaneChrome( - core: *const pardes.Pardes, - surface: *const pardes.Surface, - layout: CellLayout, - builder: *OverlayBuilder, -) void { - const chrome = core.chromeTheme(); - const page = overlayRgb(core.theme().bg orelse bg_default); - for (core.panes, core.rects) |slot, r| { - const pane = slot orelse continue; - if (r.w <= config.GUTTER or r.h <= pane.tag_rows) continue; - if (@as(u32, r.x) + r.w > surface.cols or @as(u32, r.y) + r.h > surface.rows) continue; - const tag_y = core.tagTop(pane, r); - const body_y = core.bodyTop(pane, r); - const body_end = body_y + (r.h -| pane.tag_rows); - const x0 = @as(f32, @floatFromInt(r.x)) * layout.w; - const x1 = x0 + @as(f32, @floatFromInt(r.w)) * layout.w; - const tag_cells = surface.cells[@as(usize, tag_y) * surface.cols + r.x + pardes.TAG_TEXT_INSET ..][0 .. r.w - pardes.TAG_TEXT_INSET]; - const tag_visible = for (tag_cells) |*cell| { - if (cellFontRole(cell) != .tagline) break false; - } else true; - if (tag_visible) { - const rule_y = @as(f32, @floatFromInt(tag_y + (if (core.settings.tag_bottom) 0 else pane.tag_rows))) * layout.h; - // Like the band above it, the rule of a pane at the right edge - // runs on through the pixels past the last whole cell. - const rule_x1 = if (builder.win_w - x1 < layout.w) @max(x1, builder.win_w) else x1; - builder.addRect(x0, rule_y - @as(f32, if (core.settings.tag_bottom) 0 else 1), rule_x1, rule_y + @as(f32, if (core.settings.tag_bottom) 1 else 0), overlayRgb(chrome.border)); - } - - // Do not paint over resize previews or debug text occupying the rail. - var thumb_start: ?u16 = null; - var thumb_end: u16 = body_y; - var row = body_y; - while (row < body_end) : (row += 1) { - const cell = &surface.cells[@as(usize, row) * surface.cols + r.x]; - if (cell.printableAscii() != ' ') break; - if (cellBackgroundIs(cell, chrome.scroll_thumb)) { - if (thumb_start == null) thumb_start = row; - thumb_end = row + 1; - } else if (!cellBackgroundIs(cell, chrome.scroll_track)) break; - } - if (row != body_end) continue; - const y0 = @as(f32, @floatFromInt(body_y)) * layout.h; - const body_y1 = @as(f32, @floatFromInt(body_end)) * layout.h; - const y1 = if (body_end == surface.rows and builder.win_h - body_y1 < layout.h) @max(body_y1, builder.win_h) else body_y1; - builder.addRect(x0, y0, x0 + layout.w, y1, page); - appendSquareRail(builder, x0, y0, y1, layout.w, false, overlayRgb(chrome.scroll_track)); - if (thumb_start) |first| { - const thumb_y0 = @as(f32, @floatFromInt(first)) * layout.h; - const thumb_y1 = if (thumb_end == body_end) y1 else @as(f32, @floatFromInt(thumb_end)) * layout.h; - appendSquareRail(builder, x0, thumb_y0, thumb_y1, layout.w, true, overlayRgb(chrome.scroll_thumb)); - } - } - // 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 tagline is: the same colour and - // thickness along its bottom, running to the window edge when it does. - for (surface.tagLayers()) |*layer| { - if (layer.rows == 0 or layer.kind != .notice) continue; - const x0 = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; - const x1 = x0 + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w; - const rule_x1 = if (builder.win_w - x1 < layout.w) @max(x1, builder.win_w) else x1; - const top = @as(f32, @floatFromInt(layer.viewport.y)) * layout.h; - // 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; - // ...and fades up and dissolves with it rather than standing alone. - var rule = overlayRgb(chrome.border); - rule.a = layer.fade; - builder.addRect(x0, rule_y - 1, rule_x1, rule_y, rule); - } - appendColumnSpines(core, surface.rows, layout, builder); -} - -// Tag handles use the canonical physical cells, including detached frames. -// Their tag text begins at the layer viewport and may use a narrower font. -fn appendTagGrips(g: *const Gui, surface: *const pardes.Surface, layout: CellLayout, builder: *OverlayBuilder) void { - if (g.tagline_height < 6 or g.tagline_width < 3) return; - for (surface.tagLayers()) |layer| { - if (layer.rows == 0 or layer.kind != .pane or layer.viewport.x < pardes.TAG_TEXT_INSET) continue; - const x = layer.viewport.x - pardes.TAG_TEXT_INSET; - // A mark on each of the tag's rows whose grip cell is blank: every - // row but the one with the mode's glyph. - for (0..layer.rows) |index| { - const y = layer.viewport.y + @as(u16, @intCast(index)); - if (x >= surface.cols or y >= surface.rows) continue; - const cell = &surface.cells[@as(usize, y) * surface.cols + x]; - if (cell.default or cell.printableAscii() != ' ') continue; - const rgb = switch (cell.style.fg) { - .rgb => |rgb| rgb, - else => continue, - }; - const center_x = (@as(f32, @floatFromInt(x)) + @as(f32, @floatFromInt(config.GUTTER)) / 2) * layout.w + @as(f32, @floatFromInt(anchorInset(g, y, builder.win_h) / 2)); - const center_y = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, builder.win_h, g.cell_h, g.tagline_height))) + @as(f32, @floatFromInt(g.tagline_height)) / 2; - const half_w = @max(1, @min(3, layout.w - 2)); - builder.addRect(@floor(center_x - half_w), @floor(center_y - 2), @floor(center_x + half_w), @floor(center_y - 1), overlayRgb(rgb)); - builder.addRect(@floor(center_x - half_w), @floor(center_y + 1), @floor(center_x + half_w), @floor(center_y + 2), overlayRgb(rgb)); - } - } -} - -test "column grips stay unmarked while pane marks retain physical centering" { - var g: Gui = undefined; - g.tagline_height = 12; - g.tagline_width = 6; - g.cell_h = 20; - var cells: [80]pardes.Cell = @splat(.{ .default = false, .style = .{ .fg = .{ .rgb = .{ 100, 120, 140 } } } }); - var surface: pardes.Surface = .{ .cols = 20, .rows = 4, .cells = &cells }; - surface.tag_layers[0] = .{ .kind = .column, .viewport = .{ .x = 8, .y = 1, .w = 13, .h = 1 }, .cols = 20, .rows = 1 }; - surface.tag_layers[1] = .{ .kind = .pane, .viewport = .{ .x = 8, .y = 3, .w = 13, .h = 1 }, .cols = 20, .rows = 1 }; - var vertices: [24]OverlayVertex = undefined; - var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 200, .win_h = 80 }; - appendTagGrips(&g, &surface, .{ .w = 10, .h = 20, .x_off = 0, .y_off = 0 }, &builder); - try std.testing.expectEqual(@as(usize, 12), builder.len); - // Centered in the anchor, whose left edge is inset by the band's offset - // (8 px on this last row): the mark moves right by half of it. - try std.testing.expectApproxEqAbs(@as(f32, 61.0 / 200.0 * 2.0 - 1.0), vertices[0].x, 0.0001); -} - -fn appendSquareRail(builder: *OverlayBuilder, x: f32, y0: f32, y1: f32, cell_w: f32, thumb: bool, color: OverlayColor) void { - const width = @min(cell_w, if (thumb) @max(2, @floor(cell_w * 0.5)) else 2); - builder.addRect(x, y0, x + width, y1, color); -} - -fn appendColumnSpines(core: *const pardes.Pardes, rows: u16, layout: CellLayout, builder: *OverlayBuilder) void { - const y0 = @as(f32, @floatFromInt(core.topBarHeight())) * layout.h; - const grid_y1 = @as(f32, @floatFromInt(rows)) * layout.h; - // Down through the pixels past the last whole row, as tags run right. - const y1 = if (builder.win_h - grid_y1 < layout.h) @max(grid_y1, builder.win_h) else grid_y1; - if (y1 <= y0) return; - // A single boundary spans all panes, including their tags. Its left edge - // agrees with the square scroll markers and existing move-box hit area. - for (core.col_x[0..core.ncol], core.col_w[0..core.ncol]) |col, width| { - if (width == 0) continue; - const x = @as(f32, @floatFromInt(col)) * layout.w; - builder.addRect(x, y0, x + 1, y1, overlayRgb(core.chromeTheme().border)); - } -} - -test "square scroll markers share the column edge and have no rounded end inset" { - var vertices: [12]OverlayVertex = undefined; - var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 800, .win_h = 480 }; - appendSquareRail(&builder, 400, 40, 480, 10, false, overlayRgb(.{ 40, 40, 40 })); - appendSquareRail(&builder, 400, 80, 160, 10, true, overlayRgb(.{ 100, 100, 100 })); - try std.testing.expectEqual(@as(usize, 12), builder.len); - try std.testing.expectEqual(vertices[0].x, vertices[6].x); - try std.testing.expectApproxEqAbs(@as(f32, 1 - 80.0 / 480.0 * 2), vertices[6].y, 0.0001); - try std.testing.expectApproxEqAbs(@as(f32, 1 - 160.0 / 480.0 * 2), vertices[8].y, 0.0001); - try std.testing.expectApproxEqAbs(@as(f32, 5.0 / 800.0 * 2), vertices[7].x - vertices[6].x, 0.0001); -} - -test "column spines remain continuous across every pane tag and body" { - const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); - defer core.deinit(); - _ = try core.newShell(1, ""); - try std.testing.expect(pardes.layout.splitColumn(core, 0, 1, false)); - pardes.test_api.sync(core); - var vertices: [12]OverlayVertex = undefined; - var builder: OverlayBuilder = .{ .vertices = &vertices, .win_w = 800, .win_h = 480 }; - appendColumnSpines(core, 24, .{ .w = 10, .h = 20, .x_off = 0, .y_off = 0 }, &builder); - try std.testing.expectEqual(@as(usize, 12), builder.len); - for (0..2) |col| { - const first = vertices[col * 6]; - const bottom = vertices[col * 6 + 2]; - try std.testing.expectApproxEqAbs(@as(f32, 1 - 20.0 / 480.0 * 2), first.y, 0.0001); - try std.testing.expectEqual(@as(f32, -1), bottom.y); - try std.testing.expectApproxEqAbs(@as(f32, @floatFromInt(core.col_x[col])) * 10.0 / 800.0 * 2 - 1, first.x, 0.0001); - } -} - fn appendTouchOverlay(g: *Gui, builder: *OverlayBuilder, debug_on: bool) void { if (!debug_on) { g.touch.click_flash_frames = 0; diff --git a/src/pardes.zig b/src/pardes.zig index f8087bdb..616747fa 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -39,6 +39,7 @@ pub const ImagePatch = @import("surface.zig").ImagePatch; pub const ImagePlace = @import("surface.zig").ImagePlace; pub const Surface = @import("surface.zig").Surface; pub const Region = @import("surface.zig").Region; +pub const Chrome = @import("surface.zig").Chrome; pub const MAX_REGIONS = @import("surface.zig").MAX_REGIONS; pub const builtins = @import("builtins.zig"); const limits = memory.limits; diff --git a/src/surface.zig b/src/surface.zig index db7dd468..07b593b1 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -344,6 +344,13 @@ pub const Region = struct { column_grip, column_tag, workspace_tag, + /// A column below the workspace tag, its bar and panes: its left + /// edge is the spine a pixel shell rules down it. + column, + /// A drag's guide or a seam's hover hint, drawn over the panes. + guide, + /// The debug stats box, drawn over the panes. + debug, }; pub const none = std.math.maxInt(u8); @@ -368,7 +375,30 @@ pub const Region = struct { clip: u16 = 0, }; -pub const MAX_REGIONS = 1 + MAX_PANES * (4 + panes.Pane.Notices.max) + pardes.MAX_COLS * 2 + 1; +/// Guides a frame can draw: a run of a column-move rail per pane it skips +/// the tags of, and a handful of border and hover dashes. +pub const MAX_GUIDES = MAX_PANES + 4; +pub const MAX_REGIONS = 1 + MAX_PANES * (4 + panes.Pane.Notices.max) + pardes.MAX_COLS * 3 + 1 + MAX_GUIDES + 1; + +/// The colours a pixel shell draws its chrome in (rules, rails, grips, the +/// tag underlay), resolved by the core each frame, so a frontend attached +/// to a detached session draws them from the frame alone. +pub const Chrome = struct { + /// The theme's ground and ink; null where it leaves them to the shell. + page: ?[3]u8 = null, + fg: ?[3]u8 = null, + /// Rules and column spines. + border: [3]u8 = .{ 0, 0, 0 }, + /// The rule under the workspace tag. + topbar_rule: [3]u8 = .{ 0, 0, 0 }, + tag_bg: [3]u8 = .{ 0, 0, 0 }, + /// The focused pane's and column's tag underlay: `tag_bg` unless + /// FocusTint is on. + tag_focus_bg: [3]u8 = .{ 0, 0, 0 }, + tag_fg: [3]u8 = .{ 0, 0, 0 }, + scroll_track: [3]u8 = .{ 0, 0, 0 }, + scroll_thumb: [3]u8 = .{ 0, 0, 0 }, +}; /// The canonical interface: what a frame of pardes IS. The tty shell writes /// these cells to vaxis one-to-one; the SDL shells rasterize them. @@ -385,6 +415,7 @@ pub const Surface = struct { rows: u16 = 0, regions: [MAX_REGIONS]Region = undefined, nregions: u16 = 0, + chrome: Chrome = .{}, cells: []Cell = &.{}, /// bar: draw an insert-style thin cursor instead of the block cursor: ?struct { x: u16, y: u16, bar: bool = false } = null, @@ -395,6 +426,7 @@ pub const Surface = struct { previous_cells: []const Cell = &.{}, previous_body_layers: []const Layer = &.{}, previous_tag_layers: []const Layer = &.{}, + previous_regions: []const Region = &.{}, cell_diffs: []const PanelCellDiff = &.{}, pub fn tagLayers(s: *const Surface) []const Layer { @@ -409,6 +441,12 @@ pub const Surface = struct { return s.regions[0..s.nregions]; } + pub fn addRegion(s: *Surface, r: Region) void { + std.debug.assert(s.nregions < s.regions.len); + s.regions[s.nregions] = r; + s.nregions += 1; + } + /// The first part of `kind` that `owner` has: its tag, its rail. pub fn region(s: *Surface, kind: Region.Kind, owner: usize) ?*Region { for (s.regions[0..s.nregions]) |*r| if (r.kind == kind and r.owner == owner) return r; diff --git a/test/gui-goldens.txt b/test/gui-goldens.txt index e52155a3..27c39150 100644 --- a/test/gui-goldens.txt +++ b/test/gui-goldens.txt @@ -10,8 +10,9 @@ 10-tagbottom dd8ba0d3ecabf3420d000b0c9591a57278179cbd2a9060f849d283f3881d1847 11-column-tags 6b1731d1611404597c96ad77f34c2d5cd5a81c1085ec68c6779388e07ce28614 12-multiline-tag 1b9ccd331814dc76111470f18448b1ca192e1edc3d79ea6eee19a6a78a4b1831 -13-image de18edee2ab2e0f2d4bf4162d86e3d8a1852fccfc344830491fa1a359095d4ea -14-theme-ink 6e733d4212373c90b860b2e41102ced8719b07480591b5da3905267c8358db18 -15-theme-acme-light e099dc1a351de8efa63ecd0ec0899636ea31b722a945eb40137a18abd87a82e2 -16-debug e97ce74b7a3d4a08f6a529b1f0de111d62e5532c829a6b1633b966dd45218f55 -17-terminal 304a84d1ef871f581d0a3fade9bc1acdcc87a3612c607548ab0d2851871753ee +13-image a52e2cf9f238447d885692cba01e68ef7fdc6ae3fc7c8fe1e95c8463c85f519c +14-theme-ink cc2ea256ddda24e1c97871f75b71ddf62fa825bd02f5d1432592d05e478c707a +15-theme-acme-light 2bde8a2bdfada7f061217f52cb2272913fc0657dbfa1d7448e1852f321f8e3bf +16-debug 129ad6f5f85b0e60e81bc6e8e78434629ee751f49731cf2eb28fd0553efd914a +17-terminal a1305fd755b6bc957228bbf28833f972c2355af9abf40b77f76b30741109574f +18-mid-transition d4f353ef5fda80411cd9de9729e5b23f59ff7511c10774e69214152ee8070725 diff --git a/test/gui_golden.py b/test/gui_golden.py index 327911cc..d97009c9 100644 --- a/test/gui_golden.py +++ b/test/gui_golden.py @@ -106,7 +106,9 @@ def run(args): display = Path(os.environ.get('WAYLAND_DISPLAY', 'wayland-0')) if not display.is_absolute(): display = Path(os.environ.get('XDG_RUNTIME_DIR', '/run/user/%d' % os.getuid())) / display - inherited = {'PARDES_TEST': '1', 'PARDES_TEST_COLS': '120', 'PARDES_TEST_ROWS': '40', + # The virtual clock: a transition's frames are the same frames on any + # machine, so one partway through can be hashed too. + inherited = {'PARDES_TEST': '1', 'PARDES_TEST_CLOCK': '1', 'PARDES_TEST_COLS': '120', 'PARDES_TEST_ROWS': '40', 'PARDES_TEST_PAD': '7', 'PARDES_TEST_CAPTURE_DIR': str(root), 'SDL_VIDEODRIVER': 'wayland', 'WAYLAND_DISPLAY': str(display)} launch = ['-c', 'stty cols 120 rows 40; exec "$@"', name, str(args.binary.resolve()), @@ -117,11 +119,12 @@ def run(args): first = require(wait_for(lambda: next((s for s, tag in panes(client).items() if 'shapes.zig' in tag), None)), 'source pane missing') - def scene(label): - results.append((label, settled(root))) + def scene(label, frame='latest.ppm'): + settled(root) + results.append((label, hashlib.sha256((root / frame).read_bytes()).hexdigest())) if args.keep: args.keep.mkdir(parents=True, exist_ok=True) - shutil.copyfile(root / 'latest.ppm', args.keep / (label + '.ppm')) + shutil.copyfile(root / frame, args.keep / (label + '.ppm')) print(label, results[-1][1][:16], flush=True) def mouse(kind, button, x, y): @@ -196,6 +199,13 @@ def run(args): client.write(f'/pane/{terminal}/pty/data', b"PS1='$ '; clear; printf '\\033[1;36mcolour\\033[0m plain \\033[32mgreen\\033[0m\\n'\r") require(wait_for(lambda: b'green' in client.read(f'/pane/{terminal}/body')), 'terminal output missing') scene('17-terminal') + # Partway through a transition: the panes, the picture among them, + # sliding over for a new column, frame 6 of PanelSlide's 12. Its + # chrome slides with each pane. + client.write('/ctl', b'PanelSlide\n') + client.write(f'/pane/{first}/exec', b'Newcol\n') + require(wait_for(lambda: (root / 'transition-6.ppm').exists()), 'transition frame 6 never drawn') + scene('18-mid-transition', 'transition-6.ppm') lines = ''.join(f'{label} {digest}\n' for label, digest in results) if args.update: GOLDENS.write_text(lines) -- cgit v1.3 From 686673bfb67ac80ed464351528d9ab1bceaa1d02 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:41:26 -0300 Subject: Insert Ctrl-s commits an undo checkpoint mid-session helix commit_undo_checkpoint: u after the insert session goes back to the text as it was at Ctrl-s, a second u to before the session. It pushes an undo snapshot where the session stands. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 2 +- src/config.zig | 1 + src/edit.zig | 2 ++ test/hxcases/cases.jsonl | 4 ++++ test/hxcases/goldens.jsonl | 4 ++++ test/hxcases/waivers.jsonl | 1 + 6 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index d2915cbb..bb260802 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -341,6 +341,7 @@ text. | `Home` / `End` | line start / line end past-the-last-char (`goto_line_end_newline`) | file panes (terminal insert cursor rides its run, as before) | helix-verified (phase 5) | | `PageUp` / `PageDown` | cursor page up / down, col kept (clamped to line) | file panes | helix-verified (phase 5) | | `Tab` | indent to the next 4-column stop with SPACES (`insertTab`, `edit.zig`: `pad = INDENT_W - col % INDENT_W`) — no `\t` byte ever reaches the file | helix's Spaces indent style; helix smart-tab skipped. After a `.` in a file the backend speaks, Tab instead asks for completion and only indents if the answer is empty (section A) | helix-verified (phase 5) | +| insert `Ctrl-s` | commit an undo checkpoint: `u` after the session goes back to the text as it was here, a second `u` to before the session | helix `commit_undo_checkpoint`; a snapshot pushed mid-session | helix-verified | ## C. Skipped @@ -360,7 +361,6 @@ text. | `:` | command mode | side-effects/file-ops — pardes builtins live in the tag, and `:` is bound to focusing it (section A) | | `gf` | goto file under selection | covered by pardes Enter-look | | `Ctrl-z` | suspend | pardes IS the terminal multiplexer | -| insert `Ctrl-s` | commit undo checkpoint | undo is per-insert-session snapshots; no sub-session checkpoints | | `Shift-Tab` (insert), smart-tab semantics | insert tab / smart tab | smart-tab machinery; plain Tab-inserts-tab lands in B | | `Z` (sticky view mode) | persistent view mode | marginal; `z` one-shots suffice | | `zm` (view) | align middle horizontally | marginal even with hscroll | diff --git a/src/config.zig b/src/config.zig index 80c08cce..ecaa3357 100644 --- a/src/config.zig +++ b/src/config.zig @@ -582,6 +582,7 @@ pub const insert_backspace_alias: []const Chord = &.{.{ .cp = 'h', .ctrl = true pub const insert_enter_alias: []const Chord = &.{.{ .cp = 'j', .ctrl = true }}; pub const insert_delete_alias: []const Chord = &.{.{ .cp = 'd', .ctrl = true }}; pub const insert_register: []const Chord = &.{.{ .cp = 'r', .ctrl = true }}; +pub const insert_checkpoint: []const Chord = &.{.{ .cp = 's', .ctrl = true }}; pub const delete_word_backward: []const Chord = &.{ .{ .cp = 'w', .ctrl = true }, .{ .cp = Key.backspace, .alt = true } }; pub const delete_word_forward: []const Chord = &.{ .{ .cp = 'd', .alt = true }, .{ .cp = Key.delete, .alt = true } }; pub const kill_to_line_start: []const Chord = &.{.{ .cp = 'u', .ctrl = true }}; diff --git a/src/edit.zig b/src/edit.zig index 9398674a..39050b9d 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -656,6 +656,8 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { t.reg_wait = true; return; } + // helix commit_undo_checkpoint: `u` after the session goes back to here + if (hit(key, config.insert_checkpoint)) return pushUndo(p, t); // helix maps every range through each insert-mode edit (Range::map): // the edits below work on the bare cursor, and the range the cursor // heads is carried through what they did to the text afterwards diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index f23a8b2f..15e53590 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -448,6 +448,10 @@ {"name": "goto-last-mod-select", "pane": "file", "text": "ab\ncd\nef\n", "keys": "jjiXggvg."} {"name": "alt-u-undo", "pane": "file", "text": "ab\n", "keys": "iX"} {"name": "alt-U-redo", "pane": "file", "text": "ab\n", "keys": "iXu"} +{"name": "ins-checkpoint-undo", "pane": "file", "text": "ab\n", "keys": "iXYu"} +{"name": "ins-checkpoint-undo-twice", "pane": "file", "text": "ab\n", "keys": "iXYuu"} +{"name": "ins-checkpoint-redo", "pane": "file", "text": "ab\n", "keys": "iXYuuU"} +{"name": "msel-ins-checkpoint", "pane": "file", "text": "ab\nab\n", "keys": "CiXYu"} {"name": "msel-align-basic", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=&"} {"name": "msel-align-columns", "pane": "file", "text": "a,b,c\nccc,d,e\n", "keys": "%s,&"} {"name": "msel-align-uneven", "pane": "file", "text": "a,b\nccc,d,eee,f\n", "keys": "%s,&"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index 4c565b53..7a195980 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -448,6 +448,10 @@ {"name":"goto-last-mod-select","text":"ab\ncd\nXef\n","mode":"select","cursor":{"row":2,"col":1},"anchor":{"row":0,"col":0}} {"name":"alt-u-undo","text":"ab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} {"name":"alt-U-redo","text":"Xab\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} +{"name":"ins-checkpoint-undo","text":"Xab\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} +{"name":"ins-checkpoint-undo-twice","text":"ab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"ins-checkpoint-redo","text":"Xab\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} +{"name":"msel-ins-checkpoint","text":"Xab\nXab\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1} {"name":"msel-align-basic","text":"a =1\nbbb=2\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":0} {"name":"msel-align-columns","text":"a ,b,c\nccc,d,e\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}}],"primary":0} {"name":"msel-align-uneven","text":"a ,b\nccc,d,eee,f\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}}],"primary":0} diff --git a/test/hxcases/waivers.jsonl b/test/hxcases/waivers.jsonl index 2d74e346..6abc7de2 100644 --- a/test/hxcases/waivers.jsonl +++ b/test/hxcases/waivers.jsonl @@ -1 +1,2 @@ {"name":"alt-c-window-op","reason":"Alt-c moves a pane to a new column instead of Helix's change-noyank.","reference":{"name":"alt-c-window-op","text":"bc def\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},"expected":{"name":"alt-c-window-op","text":"abc def\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}} +{"name":"msel-ins-checkpoint","reason":"Undo restores the primary range alone: a history entry keeps one range, not every one, so the others stay where the edit left them.","reference":{"name":"msel-ins-checkpoint","text":"Xab\nXab\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1},"expected":{"name":"msel-ins-checkpoint","text":"Xab\nXab\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1}} -- cgit v1.3 From 0465775799cff895e3e830d2aa19f57966cdf21c Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:40:11 -0300 Subject: A still pane's notice stays under a closing pane's tombstone Tier 4 draws notices over every group; while a pane closes, a notice goes back to its own pane's group instead, so the tombstone sliding over it covers it as it did before stage 8. Review follow-up to stage 8. Shared files touched: gui.zig. --- docs/render-pipeline.md | 2 +- src/gui/gui.zig | 22 +++++++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index 69645c96..6c5e4866 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -810,7 +810,7 @@ stripped. No visual change until stage 9 unless stated. | 5 | JOIN: paint tags/notices/headers once into layers, copy into grid; the grid wins where the copies disagree, and each disagreement is listed for a later decision | goldens are the oracle; wide-grapheme re-clip at the edge (§3.2) | | 6 | body layer joined the same way (paint once, copy visible rows) | riskiest join; A/B the old double paint in a temporary test with tag_bottom on and off, then delete it | | 7 | Layer merge (TagLayer + BodyLayer), wire v8, web accessors | after the other agent lands; touches mouse hit paths; breaks the macOS shell's layer ABI (accepted: macOS build ignored for now); done: one `Layer` (src/Layer.zig) with `rows` (0 = no layer) and a `cursor{x, y}`; a tag of N rows is ONE layer of N grid rows (`tagHit` answers the row as `line`, `bodyHit` keeps its meaning), so the per-line layer bases are gone. Wire v8 ships rows, cursor y and the region list in the one bump; v7 and v9 peers are refused in both directions (tests). web: `tag_layer_value` 11 = rows, 12 = cursor y, and app.mjs lays every row. macOS: its Zig side compiles against `Layer`, but pardes.h still sees one row per tag layer (a taller tag shows its first row there) | -| 8 | GUI draws from regions: role tiers with track groups in tiers 2-3, page cover, hard-edged snapped decor for rules/rails/grips (pane chrome in the pane's tier), per-instance clip; delete inference functions, `transient_on` and `mark_hover` (breaks macOS glass hover; accepted, macOS ignored for now) | stage-0 PPM goldens byte-identical (possible only because hard decor is snapped and not anti-aliased); pane chrome now also shows during transitions, which is the one allowed visible delta, listed Done: the frame is drawn in groups (makeGroups): tier 0, one group per track in paint order (tiers 2 and 3), tier 4 as two groups (the notices; then the guides and the debug box, which the core paints over them), tier 5 (bar cursors), each drawing cells, images, decor. Decor is every rule, rail, thumb, grip mark, spine, the workspace and column rules, the bottom band, notice rules and bar cursors: whole-pixel rects drawn by `decor.frag` over `ui.vert` as cell instances (colour in the ground, coverage in fg.r, blended like the overlay), so each carries its track's transition and clip; a closing pane's comes from the last frame's regions (`Surface.previous_regions`). Only `solid` exists yet: `decor.vert` and the other kinds (§5.1) come with the first effect that needs them. Deleted: taglineBaseRgb, topbarPaneBorderHeight, bottomTaglinePresent, frameChromeBg, cellBackgroundIs and the rail inference, paneGripCell's scan, transient_on, PaintPlan; one cover map (coverFrame: layer, grip and offset, focus, anchor, floating) is marked from layers and regions once a frame. New regions: `column` (spines, anchors, the focused column's tint), `guide`, `debug`; `Surface.chrome` is the palette, on the wire in v8 (not yet shipped, so no bump), and an attached GUI draws the same chrome (test). Per-instance clip (`CellInstance.clip_*`) replaces the vertical transition's scissor. Goldens: 01-12 byte-identical; 13-17 differ only in pixels past the grid (with a picture on screen the image pass left the scissor at the grid's size, cutting every rule end, rail foot and band that runs into the leftover pixels; they now run to the edge as in every scene without one); 16-debug shows the debug box (it was drawn from the grid under the source's context-row layer, so the GUI never showed it there); 18-mid-transition is new (virtual clock, PanelSlide Newcol with the picture, frame 6 of 12: chrome moves with its pane). Other deltas, outside the goldens: a guide over a tag or a context-row body now shows; with WindowOpacity < 100 a layer's bar cursor is ink like the grid's; an attached GUI gains the focus tint, notice rules, spines and the theme's page and caret colours. Deferrals fixed: the GUI sleeps when idle (SDL and queue events wake it; a smooth scroll, a gamepad, the test feed, a shell's kill deadline, a present to retry still poll), and a minimized or occluded window sleeps through animation. Tracy, `gui frame build`, 200x60, terminal output plus scrolling, ReleaseFast, two interleaved runs of ~180 frames: before 992/1015 µs median, after 989/987 µs | +| 8 | GUI draws from regions: role tiers with track groups in tiers 2-3, page cover, hard-edged snapped decor for rules/rails/grips (pane chrome in the pane's tier), per-instance clip; delete inference functions, `transient_on` and `mark_hover` (breaks macOS glass hover; accepted, macOS ignored for now) | stage-0 PPM goldens byte-identical (possible only because hard decor is snapped and not anti-aliased); pane chrome now also shows during transitions, which is the one allowed visible delta, listed Done: the frame is drawn in groups (makeGroups): tier 0, one group per track in paint order (tiers 2 and 3), tier 4 as two groups (the notices; then the guides and the debug box, which the core paints over them), tier 5 (bar cursors), each drawing cells, images, decor. Decor is every rule, rail, thumb, grip mark, spine, the workspace and column rules, the bottom band, notice rules and bar cursors: whole-pixel rects drawn by `decor.frag` over `ui.vert` as cell instances (colour in the ground, coverage in fg.r, blended like the overlay), so each carries its track's transition and clip; a closing pane's comes from the last frame's regions (`Surface.previous_regions`). Only `solid` exists yet: `decor.vert` and the other kinds (§5.1) come with the first effect that needs them. Deleted: taglineBaseRgb, topbarPaneBorderHeight, bottomTaglinePresent, frameChromeBg, cellBackgroundIs and the rail inference, paneGripCell's scan, transient_on, PaintPlan; one cover map (coverFrame: layer, grip and offset, focus, anchor, floating) is marked from layers and regions once a frame. New regions: `column` (spines, anchors, the focused column's tint), `guide`, `debug`; `Surface.chrome` is the palette, on the wire in v8 (not yet shipped, so no bump), and an attached GUI draws the same chrome (test). Per-instance clip (`CellInstance.clip_*`, 104 → 120 bytes an instance, ~15% more upload a frame) replaces the vertical transition's scissor. While a pane closes, notices stay in their own panes' groups, under the tombstone sliding over them. Goldens: 01-12 byte-identical; 13-17 differ only in pixels past the grid (with a picture on screen the image pass left the scissor at the grid's size, cutting every rule end, rail foot and band that runs into the leftover pixels; they now run to the edge as in every scene without one); 16-debug shows the debug box (it was drawn from the grid under the source's context-row layer, so the GUI never showed it there); 18-mid-transition is new (virtual clock, PanelSlide Newcol with the picture, frame 6 of 12: chrome moves with its pane). Other deltas, outside the goldens: a guide over a tag or a context-row body now shows; with WindowOpacity < 100 a layer's bar cursor is ink like the grid's; an attached GUI gains the focus tint, notice rules, spines and the theme's page and caret colours. Deferrals fixed: the GUI sleeps when idle (SDL and queue events wake it; a smooth scroll, a gamepad, the test feed, a shell's kill deadline, a present to retry still poll), and a minimized or occluded window sleeps through animation. Tracy, `gui frame build`, 200x60, terminal output plus scrolling, ReleaseFast, two interleaved runs of ~180 frames: before 992/1015 µs median, after 989/987 µs | | 9 | post chain: glslang, Shadertoy prefix, ping-pong, ShaderAnimation, redraw levels A/B (§5.5); bundled CRT without barrel; delete scene_effects/crt.zig/crt.frag | first visual change (the CRT look) | | 10+ | `fx` bookmark: G1–G3 → feel review → G4 bundled → G5 lapis theme → P2s; tty T1–T2 → feel review → removals of audited effects (after user decision) → T3–T5 | each effect its own change, default off | diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 35fbc99d..39d7f61e 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -603,6 +603,10 @@ test "closing tombstone overlays but never owns canonical cells" { }; const groups = makeGroups(&.{closing}, true); try std.testing.expectEqual(@as(usize, 0), groupAt(&groups, 4, 4)); + // A still pane's notice stays under the tombstone sliding over it. + const notice: pardes.Layer = .{ .kind = .notice, .viewport = .{ .x = 4, .y = 4, .w = 3, .h = 1 }, .rows = 1 }; + try std.testing.expectEqual(@as(usize, 0), noticeGroup(&groups, ¬ice)); + try std.testing.expectEqual(makeGroups(&.{}, false).notices, noticeGroup(&makeGroups(&.{}, false), ¬ice)); try std.testing.expectEqual(@as(usize, 1), groups.tracks); try std.testing.expectEqual(pardes.animation.Phase.closing, groups.items[1].track.?.phase); } @@ -2071,6 +2075,14 @@ fn groupOf(groups: *const Groups, serial: u32) usize { return 0; } +/// A notice floats over the panes (tier 4), except while a pane closes: then +/// it stays in its own pane's group, under the tombstone sliding over it. +fn noticeGroup(groups: *const Groups, layer: *const pardes.Layer) usize { + for (groups.items[1..][0..groups.tracks]) |group| + if (group.track.?.phase == .closing) return groupAt(groups, layer.viewport.x, layer.viewport.y); + return groups.notices; +} + /// What draws a grid cell, marked once a frame from the layers and the /// regions (coverFrame), so the cell loops read it rather than search. const Cover = packed struct(u16) { @@ -5344,7 +5356,7 @@ fn renderFrame( } for (surface.tagLayers()) |*layer| { if (layer.rows == 0) continue; - const group_index = if (layer.kind == .notice) groups.notices else groupAt(&groups, layer.viewport.x, layer.viewport.y); + const group_index = if (layer.kind == .notice) noticeGroup(&groups, layer) else groupAt(&groups, layer.viewport.x, layer.viewport.y); groups.items[group_index].cell_count += (tagLayerCellCount(g, layer) + 1) * layer.rows; } for (groups.items[1..][0..groups.tracks], 1..) |group, index| { @@ -5440,7 +5452,7 @@ fn renderFrame( if (layer.rows == 0) continue; const track_index = groupAt(&groups, layer.viewport.x, layer.viewport.y); // A notice floats over the panes, moving with its own. - const group_index = if (layer.kind == .notice) groups.notices else track_index; + const group_index = if (layer.kind == .notice) noticeGroup(&groups, layer) else track_index; emitTagLayer(g, instances, &cell_next[group_index], layer, win_w, win_h, groups.items[track_index].track, page, cursors_shown, false); } for (groups.items[1..][0..groups.tracks], 1..) |group, index| { @@ -7374,8 +7386,8 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g // ruled off from the text beneath it the way a tag is: the same // colour and thickness along its bottom, running to the window's // edge when it does, fading up and dissolving with its band. - if (index == groups.notices) for (surface.tagLayers()) |*layer| { - if (layer.rows == 0 or layer.kind != .notice) continue; + for (surface.tagLayers()) |*layer| { + if (layer.rows == 0 or layer.kind != .notice or noticeGroup(groups, layer) != index) continue; const x0 = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; const x1 = x0 + @as(f32, @floatFromInt(layer.viewport.w)) * layout.w; const rule_x1 = if (win_w - x1 < layout.w) @max(x1, win_w) else x1; @@ -7386,7 +7398,7 @@ fn buildDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, g if (rule_y - 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); - }; + } if (index == groups.ink and cursors_shown) try cursorDecor(g, gpa, surface, layout, win_w, win_h); } } -- cgit v1.3 From 78daa25e5c99cc0d71da36b6e0dcdd362b0cb284 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:41:37 -0300 Subject: Tab after text leaves the syntax node in insert mode; Shift-Tab indents helix smart_tab: in insert mode, Tab where text precedes the cursor on its line moves past the end of the node the cursor is in, the parent-node end of Alt-e; in leading blanks it indents. Only where the file has a grammar: the reference pins smart tab off, which a file without a grammar agrees with, so there Tab still indents. Shift-Tab is insert_tab, and the differential harness now reads the S- modifier. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 2 +- src/edit.zig | 12 ++++++++++++ src/normal.zig | 6 +++++- test/hxcases/cases.jsonl | 2 ++ test/hxcases/goldens.jsonl | 2 ++ test/hxdiff.zig | 3 +++ 6 files changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index bb260802..c0ffe0d7 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -341,6 +341,7 @@ text. | `Home` / `End` | line start / line end past-the-last-char (`goto_line_end_newline`) | file panes (terminal insert cursor rides its run, as before) | helix-verified (phase 5) | | `PageUp` / `PageDown` | cursor page up / down, col kept (clamped to line) | file panes | helix-verified (phase 5) | | `Tab` | indent to the next 4-column stop with SPACES (`insertTab`, `edit.zig`: `pad = INDENT_W - col % INDENT_W`) — no `\t` byte ever reaches the file | helix's Spaces indent style; helix smart-tab skipped. After a `.` in a file the backend speaks, Tab instead asks for completion and only indents if the answer is empty (section A) | helix-verified (phase 5) | +| insert `Tab` after text / `Shift-Tab` | in a file with a grammar, Tab after text on its line leaves the syntax node the cursor is in (to the enclosing named node's end); Shift-Tab always indents | helix `smart_tab` / `insert_tab`. The reference pins smart tab off, so a file without a grammar indents as the harness's helix does, where stock helix would do nothing there. Smart tab is checked against the installed hx on JSON (`normal.zig` test) | helix-verified (plain text), hx-checked (JSON) | | insert `Ctrl-s` | commit an undo checkpoint: `u` after the session goes back to the text as it was here, a second `u` to before the session | helix `commit_undo_checkpoint`; a snapshot pushed mid-session | helix-verified | ## C. Skipped @@ -361,7 +362,6 @@ text. | `:` | command mode | side-effects/file-ops — pardes builtins live in the tag, and `:` is bound to focusing it (section A) | | `gf` | goto file under selection | covered by pardes Enter-look | | `Ctrl-z` | suspend | pardes IS the terminal multiplexer | -| `Shift-Tab` (insert), smart-tab semantics | insert tab / smart tab | smart-tab machinery; plain Tab-inserts-tab lands in B | | `Z` (sticky view mode) | persistent view mode | marginal; `z` one-shots suffice | | `zm` (view) | align middle horizontally | marginal even with hscroll | | Select/extend mode section (prose) | `v` turns all motions into extenders, `n`/`N` keep selections | implemented for motions (phase 5: `pane.select` + the fixed anchor, differential-verified — see the `v` row in A); helix's search-`n`/`N` extension doesn't apply (pardes' `n`/`N` is the look-ring motion, which REPLACES the selection with the span it lands on) | diff --git a/src/edit.zig b/src/edit.zig index 39050b9d..01b34237 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -13,6 +13,7 @@ const body_layer = @import("body_layer.zig"); const config = @import("config.zig"); const pdf_enabled = panes.Pdf.enabled; const lsp = @import("lsp/lsp.zig"); +const syntax = @import("syntax.zig"); const platform = pardes.platform; const Pane = panes.Pane; const Text = panes.Text; @@ -929,6 +930,17 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void { p.lspRequest(p.active, .completion, ""); if (p.lsp_seq != seq) return; } + // helix smart_tab: after text, Tab leaves the syntax node the + // cursor is in; only where the file has a grammar, since the + // reference pins smart tab off, which a file without one agrees + // with. Shift-Tab always types the tab. + const after_text = std.mem.indexOfNone(u8, ln[0..@min(c.col, ln.len)], " \t") != null; + if (!key.shift and after_text and t.what == .body) smart: { + const pane = t.pane() orelse break :smart; + const f = pane.file orelse break :smart; + if (!syntax.supportsPath(f.path)) break :smart; + return normal.nodeSelect(p, pane, t, .parent_end); + } insertTab(p, t); }, else => {}, diff --git a/src/normal.zig b/src/normal.zig index 228c9c39..f9d7cf25 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -171,7 +171,7 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, mode: m /// which is made on the first of these keys after an edit and kept, never /// made again for a key that changed nothing. A range the tree has nothing /// to say about stays as it was; a text without a grammar, all of them. -fn nodeSelect(p: *Pardes, pane: *Pane, t: *Text, walk: modal.Normal.NodeWalk) void { +pub fn nodeSelect(p: *Pardes, pane: *Pane, t: *Text, walk: modal.Normal.NodeWalk) void { if (t.what != .body) return; const f = if (pane.file) |*file| file else return; if (f.node_tree == null or f.node_tree_revision != f.revision) { @@ -945,6 +945,8 @@ test "tree-sitter selection keys do in a JSON file what hx 25.07.1 does" { .{ .keys = "6lx\x1bid", .want = "\n" }, .{ .keys = "10l\x1beiX\x1b", .want = "{\"a\": [1, 2X, 3], \"b\": true}\n" }, .{ .keys = "10l\x1bb\x1bbiX\x1b", .want = "{X\"a\": [1, 2, 3], \"b\": true}\n" }, + // insert Tab after text leaves the node (smart tab) + .{ .keys = "8li\tX\x1b", .want = "{\"a\": [1, 2, 3]X, \"b\": true}\n" }, }) |c| { const pane = try p.setTestFile(text); p.gpa.free(pane.file.?.path); @@ -959,6 +961,8 @@ test "tree-sitter selection keys do in a JSON file what hx 25.07.1 does" { p.update(.{ .key = if (k == '>') .{ .cp = Key.right, .alt = true } else .{ .cp = k, .alt = true } }); } else if (ch == 0x1b) { p.update(.{ .key = .{ .cp = Key.escape } }); + } else if (ch == '\t') { + p.update(.{ .key = .{ .cp = Key.tab } }); } else p.update(.{ .key = .{ .cp = ch, .text = c.keys[i..][0..1] } }); while (p.nextEffect()) |_| {} } diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 15e53590..f589755a 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -452,6 +452,8 @@ {"name": "ins-checkpoint-undo-twice", "pane": "file", "text": "ab\n", "keys": "iXYuu"} {"name": "ins-checkpoint-redo", "pane": "file", "text": "ab\n", "keys": "iXYuuU"} {"name": "msel-ins-checkpoint", "pane": "file", "text": "ab\nab\n", "keys": "CiXYu"} +{"name": "ins-tab-after-text-plain", "pane": "file", "text": "ab cd\n", "keys": "2liX"} +{"name": "ins-shift-tab", "pane": "file", "text": "ab cd\n", "keys": "2liX"} {"name": "msel-align-basic", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=&"} {"name": "msel-align-columns", "pane": "file", "text": "a,b,c\nccc,d,e\n", "keys": "%s,&"} {"name": "msel-align-uneven", "pane": "file", "text": "a,b\nccc,d,eee,f\n", "keys": "%s,&"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index 7a195980..ce1b461c 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -452,6 +452,8 @@ {"name":"ins-checkpoint-undo-twice","text":"ab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} {"name":"ins-checkpoint-redo","text":"Xab\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} {"name":"msel-ins-checkpoint","text":"Xab\nXab\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1} +{"name":"ins-tab-after-text-plain","text":"ab X cd\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}} +{"name":"ins-shift-tab","text":"ab X cd\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}} {"name":"msel-align-basic","text":"a =1\nbbb=2\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":0} {"name":"msel-align-columns","text":"a ,b,c\nccc,d,e\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}}],"primary":0} {"name":"msel-align-uneven","text":"a ,b\nccc,d,eee,f\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}}],"primary":0} diff --git a/test/hxdiff.zig b/test/hxdiff.zig index 627bbac3..48c7ab93 100644 --- a/test/hxdiff.zig +++ b/test/hxdiff.zig @@ -419,6 +419,9 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result } else if (std.mem.startsWith(u8, name, "A-")) { key.alt = true; name = name[2..]; + } else if (std.mem.startsWith(u8, name, "S-")) { + key.shift = true; + name = name[2..]; } else break; } if (name.len == 1 or (name.len > 1 and name.len <= 4 and name[0] >= 0x80)) { -- cgit v1.3 From cd6ce6c2b3015c776644fb3866280f5dfd03addc Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:43:46 -0300 Subject: A notice's word under the pointer is lit, in its band and the grid; mark_hover is gone Open point 6 of docs/render-pipeline.md: notice words are Look and Exec targets, so the hover affordance headers have is back on notices, painted once in the notice's layer and joined into the grid from it. Deleted: Surface.mark_hover, Cell.hover and render's per-frame reset of it, none of which had been set since stage 5 (macOS loses its glass hover rect; its build is ignored for now). Snapshot goldens unchanged. Shared files touched: macos.zig (the hover bit's removal). Not touched: pardes.zig, Messages.zig, mouse.zig, gui.zig, detached/*. --- docs/render-pipeline.md | 8 +++++--- src/body_layer.zig | 4 +--- src/draw.zig | 47 +++++++++++++++++++++++++++++++++++++++-------- src/macos.zig | 18 +++++------------- src/surface.zig | 16 +--------------- 5 files changed, 51 insertions(+), 42 deletions(-) diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index 6c5e4866..7299b357 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -845,9 +845,11 @@ goldens; ssh/tty byte budgets; wire version bump breaks mixed-version attach. 6. Revisit at the first visual stage (from stage 5): the notice layer lost its hover word to keep the grid's behaviour. Notice words are Look/Exec targets, so bring the hover affordance back in BOTH the grid and the - layer then. Carried forward from stage 8 (a GUI-only stage) to the hover - change after it, which also retires `mark_hover`: the affordance is a - core paint every shell and snapshot sees. + layer then. Done right after stage 8: a notice's word under the pointer + is lit as a header's is, in the layer and so in the grid's copy joined + from it. The same change deleted `mark_hover`, the `Cell.hover` bit and + the per-frame reset of it (never set since stage 5; macOS loses its + glass hover rect, accepted with open point 4). 7. Revisit at stage 8 (from stage 6): a body without context rows has no layer (paint once, straight onto the grid). If the GUI is to read every body from a layer, give every body one then and measure the copy. The diff --git a/src/body_layer.zig b/src/body_layer.zig index 583a2e98..e9407719 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -294,9 +294,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane if (p.look_hover_preview) |preview| { if (preview.pane == id and preview.serial == pane.serial) { // Preserve body ink, then let every real user selection below - // win over this quiet affordance. mark_hover carries the - // cells this paints out to the hosts as the affordance rect — - // macOS composes its glass overlay over exactly these. + // win over this quiet affordance. const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); if (preview.file_word) |word| panes.File.paintWordSelection(p, s, pane, r, word.row, word.lo, word.hi, preview_bg) diff --git a/src/draw.zig b/src/draw.zig index 4242a6e7..3798bf59 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -163,10 +163,6 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { // the pointer per-text (the macOS I-beam) read this as "target" and // afford it the arrow instead. if (p.look_hover_preview != null) s.pointer_shape = .target; - // Stale hover bits must not survive into a frame whose pointer has - // moved on: the ordinary content paint does not clear fields it - // never set. - for (s.cells) |*c| c.hover = false; for (&s.body_layers) |*layer| layer.rows = 0; for (&s.tag_layers) |*layer| layer.rows = 0; s.nimages = 0; @@ -661,10 +657,11 @@ fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, layer: *pardes.Layer, var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells[index * columns ..][0..columns] }; surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); _ = surface.print(0, 0, columns, shown[panes.File.rawAtDisplay(shown, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); - // The word under the pointer: on a header even while it is edited, - // but not on a notice (a message is not a command line), nor on a - // column a carried column's pointer is aiming at a place over. - const hovered = p.pointer_inside and kind != .notice and !(kind == .column and p.drag == .column_move); + // The word under the pointer, a Look or Exec target: on a header even + // while it is edited, on a notice, and on the grid's copy of either + // (joined from this); not on a column a carried column's pointer is + // aiming at a place over. + const hovered = p.pointer_inside and !(kind == .column and p.drag == .column_move); if (hovered) { const tag_hit = p.pointer_tag_hit; const col: ?usize = if (tag_hit) |pointer| @@ -1033,6 +1030,40 @@ test "place puts every part where the painters and layers put it, in every layou } } +test "the word under the pointer on a notice is lit in its layer and in the grid's copy" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 41, .rows = 12 }); + defer p.deinit(); + _ = try p.setTestFile("text\n"); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + try std.testing.expect(p.executeBuiltinLine(p.active, "Msg Look here")); + p.sync(); + var s = try p.render(arena.allocator()); + const notice = for (s.regionList()) |region| { + if (region.kind == .notice) break region; + } else return error.TestExpectedNotice; + const row = s.cells[@as(usize, notice.rect.y) * s.cols ..][0..s.cols]; + // "here", the chip's last word, before its blank edge. + const here = notice.rect.x + notice.rect.w - 5; + try std.testing.expectEqualStrings("h", row[here].grapheme()); + p.pointer_inside = true; + p.hover_col = here + 1; + p.hover_row = notice.rect.y; + _ = arena.reset(.retain_capacity); + s = try p.render(arena.allocator()); + const lit = s.cells[@as(usize, notice.rect.y) * s.cols ..][0..s.cols]; + const sel: Color = .{ .rgb = p.theme().sel_bg }; + for (lit[here..][0..4]) |cell| try std.testing.expectEqual(sel, cell.style.bg); + try std.testing.expect(!std.meta.eql(sel, lit[here - 2].style.bg)); + const layer = for (s.tagLayers()) |*layer| { + if (layer.rows != 0 and layer.kind == .notice) break layer; + } else return error.TestExpectedNotice; + const lit_in_layer = for (layer.cells) |cell| { + if (std.meta.eql(sel, cell.style.bg)) break true; + } else false; + try std.testing.expect(lit_in_layer); +} + test "a wide glyph cut by a tag's or a notice's edge is joined into the grid as blank, never half drawn" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 41, .rows = 12 }); defer p.deinit(); diff --git a/src/macos.zig b/src/macos.zig index f7e03cd4..4d8f2883 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -1866,13 +1866,10 @@ fn blankTagCells(cells: []Cell, cols: u16, rows: u16, source: []const pardes.Cel if (layer.rows == 0 or layer.viewport.y >= rows or layer.viewport.x >= cols) continue; const start = @as(usize, layer.viewport.y) * cols + layer.viewport.x; const count = @min(layer.viewport.w, cols - layer.viewport.x); - for (cells[start..][0..count], source[start..][0..count]) |*cell, was| { - // The tag text is the layer's to draw, but a look-hover on a tag - // word is a canonical fact: carry the hover bit through so the - // glass overlay still finds the affordance rect. - cell.* = encodeCell(.{}); - cell.flags |= @as(u8, @intFromBool(was.hover)) * cell_flag_hover; - } + // The tag text is the layer's to draw. (The look-hover's glass + // rect went with the core's hover bit, stage 8's follow-up.) + _ = source; + for (cells[start..][0..count]) |*cell| cell.* = encodeCell(.{}); } } @@ -2574,7 +2571,7 @@ fn encodeCell(cell: pardes.Cell) Cell { .fg = encodeColor(cell.style.fg), .bg = encodeColor(cell.style.bg), .attrs = encodeAttrs(cell.style), - .flags = encodeCellFlags(cell.default, cell.style.font_role, cell.hover), + .flags = encodeCellFlags(cell.default, cell.style.font_role, false), .len = if (cell.default) 1 else cell.len, }; if (cell.default) @@ -3131,16 +3128,11 @@ test "mac tag layer ABI preserves logical capacity and physical grip" { try std.testing.expectEqual(@as(u32, 11), pardes_tag_layer_value(0, 8)); try std.testing.expectEqual(@as(u8, 'Z'), pardes_tag_layer_cells(0).?[11].text[0]); var canonical: [24]Cell = @splat(encodeCell(.{ .text = .{'G'} ++ .{0} ** 6, .len = 1, .default = false })); - // A look-hover under the layer is a canonical fact and survives the blank; - // the glyph it marks does not, because the layer redraws that text itself. var source: [24]pardes.Cell = @splat(.{}); - source[11].hover = true; blankTagCells(&canonical, 8, 3, &source, &core.surface.tag_layers); try std.testing.expectEqual(@as(u8, 'G'), canonical[9].text[0]); try std.testing.expectEqual(@as(u8, 'G'), canonical[18].text[0]); try std.testing.expectEqual(encodeCell(.{}).len, canonical[10].len); - try std.testing.expectEqual(@as(u8, 0), canonical[10].flags & cell_flag_hover); - try std.testing.expectEqual(cell_flag_hover, canonical[11].flags & cell_flag_hover); } test "mac window blur builtin reaches the ABI without changing opacity" { diff --git a/src/surface.zig b/src/surface.zig index 07b593b1..b6f41853 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -38,12 +38,6 @@ pub const Cell = struct { len: u8 = 1, style: CellStyle = .{}, default: bool = true, - /// The look-hover affordance is on this cell: the word under the pointer - /// is a real Look or Exec operand, and hosts that draw the quiet tint as - /// more than a flat fill — macOS renders it as a glass overlay — read - /// this instead of guessing from the background colour, which a real - /// selection shares. - hover: bool = false, pub fn grapheme(c: *const Cell) []const u8 { return c.text[0..c.len]; @@ -405,10 +399,6 @@ pub const Chrome = struct { pub const Surface = struct { pub const PointerShape = enum(u8) { arrow, link, target }; pointer_shape: PointerShape = .arrow, - /// Set only for the duration of the look-hover paint, so the cells that - /// paint the quiet affordance can carry the hover bit through the ABI - /// without every selection painter learning a new parameter. - mark_hover: bool = false, body_layers: [MAX_PANES]Layer = @splat(.{}), tag_layers: [MAX_TAG_LAYERS]Layer = @splat(.{}), cols: u16 = 0, @@ -474,11 +464,7 @@ pub const Surface = struct { pub fn at(s: *Surface, x: u16, y: u16) *Cell { std.debug.assert(x < s.cols and y < s.rows); - const cell = &s.cells[@as(usize, y) * s.cols + x]; - // The look-hover paint runs with mark_hover set, and its cells are - // exactly the ones this marks — see render's preview block. - if (s.mark_hover) cell.hover = true; - return cell; + return &s.cells[@as(usize, y) * s.cols + x]; } pub fn set(s: *Surface, x: u16, y: u16, text: []const u8, style: CellStyle) void { -- cgit v1.3 From 71c5b400ced873cddd77497b7a9fd06184954813 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:43:59 -0300 Subject: Z arms the view keys until Esc, and zm puts the cursor's column mid-view Z is helix's sticky view mode: the z keys stay armed after each until Esc, which leaves the mode and does nothing else (no pane hop). zm is align_view_middle: an unwrapped view scrolls sideways so the cursor's column is in its middle. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 7 ++++--- src/config.zig | 2 ++ src/modal.zig | 21 +++++++++++++++++---- src/normal.zig | 10 ++++++++++ test/hxcases/cases.jsonl | 5 +++++ test/hxcases/goldens.jsonl | 5 +++++ test/hxcases/parity-waivers.jsonl | 4 ++++ 7 files changed, 47 insertions(+), 7 deletions(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index c0ffe0d7..8ee81930 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -110,6 +110,8 @@ language-backend queries, and the shell pipe. | `Ctrl-b` | file panes: full page up. Terminal panes: **toggle raw tty/editor mode** (`opts.tty_toggle`, configurable; `Shift-Esc` also enters). In raw tty Shift-Esc goes to the child | tty toggle is pardes-specific and wins on terminals; harness `pane:"tty"` cases avoid `Ctrl-b` | helix-verified (file) / pardes-specific (tty) | | `PageUp` / `PageDown` | full page | matches helix `page_up`/`page_down` (view scroll + cursor snap to the scrolloff edge) | helix-verified | | `zt` / `zz` / `zb` | scroll current line to top / center / bottom | matches `align_view_top/center/bottom` (helix harness pins scrolloff to pardes' 3) | helix-verified | +| `Z` | sticky view mode: the `z` keys stay armed after each until Esc, which only leaves the mode; other keys do nothing meanwhile | helix's sticky view node | helix-verified | +| `zm` | scroll the unwrapped view so the cursor's column is in its middle | helix `align_view_middle`; with soft wrap on there is no horizontal scroll, and nothing happens | out of corpus (the harness's text never scrolls sideways) | | `i` `a` | insert at selection start / after selection end | helix semantics: `i` before the selection, `a` selects and appends after it | helix-verified | | `I` `A` | insert at first non-ws / line end | matches `insert_at_line_start` / `insert_at_line_end` | helix-verified | | `o` `O` | open line below / above, copying the current line's indent levels; `o` opens n | matches `open_below`/`open_above` | helix-verified | @@ -362,8 +364,6 @@ text. | `:` | command mode | side-effects/file-ops — pardes builtins live in the tag, and `:` is bound to focusing it (section A) | | `gf` | goto file under selection | covered by pardes Enter-look | | `Ctrl-z` | suspend | pardes IS the terminal multiplexer | -| `Z` (sticky view mode) | persistent view mode | marginal; `z` one-shots suffice | -| `zm` (view) | align middle horizontally | marginal even with hscroll | | Select/extend mode section (prose) | `v` turns all motions into extenders, `n`/`N` keep selections | implemented for motions (phase 5: `pane.select` + the fixed anchor, differential-verified — see the `v` row in A); helix's search-`n`/`N` extension doesn't apply (pardes' `n`/`N` is the look-ring motion, which REPLACES the selection with the span it lands on) | ## Differential testing (phase 5) @@ -403,7 +403,7 @@ Files (all in `test/hxcases/`): registers of one value per range, `sel-regex-caret` and `sel-regex-dot-newline` when `s`/`S` began searching line by line. - `parity.jsonl` — 80 further cases, used only by the parity gate below. -- `parity-waivers.jsonl` — 15 named exemptions for the parity gate, in three +- `parity-waivers.jsonl` — 19 named exemptions for the parity gate, in three classes: one deliberate pardes binding (`ctrl-b-page`, since `Ctrl-b` IS the tty toggle), eight pty VIEWPORT divergences (a terminal's view cannot scroll below the vt's live grid bottom, so the cursor snaps into a different @@ -412,6 +412,7 @@ Files (all in `test/hxcases/`): newline, an all-whitespace last row the dump trims). One more, `dot-append-count`: `a` at a file's end grows it by a newline (helix append_mode), which a terminal's text, not the editor's to grow, does not. + The sticky-view cases join the viewport class. - `golf.jsonl` + `golf-goldens.jsonl` — every example on helix-golf (github.com/nik-rev/helix-golf, imported at d78c18c): one case per numbered step of each walkthrough, its keys being the command up to and including diff --git a/src/config.zig b/src/config.zig index ecaa3357..daa40028 100644 --- a/src/config.zig +++ b/src/config.zig @@ -440,6 +440,7 @@ pub const errors_buffer = "+Errors"; // Modal bindings below are checked against Helix by hxdiff and hxparity. pub const goto_prefix = 'g'; pub const view_prefix = 'z'; +pub const view_prefix_sticky = 'Z'; pub const match_prefix = 'm'; pub const replace_prefix = 'r'; pub const next_prefix = ']'; @@ -473,6 +474,7 @@ pub const page_up: []const Chord = &.{ .{ .cp = 'b', .ctrl = true }, .{ .cp = Ke pub const view_top: []const Chord = &.{.{ .cp = 't' }}; pub const view_center: []const Chord = &.{ .{ .cp = 'z' }, .{ .cp = 'c' } }; pub const view_bottom: []const Chord = &.{.{ .cp = 'b' }}; +pub const view_middle: []const Chord = &.{.{ .cp = 'm' }}; pub const view_scroll_down: []const Chord = &.{ .{ .cp = 'j' }, .{ .cp = Key.down } }; pub const view_scroll_up: []const Chord = &.{ .{ .cp = 'k' }, .{ .cp = Key.up } }; diff --git a/src/modal.zig b/src/modal.zig index 0171da93..16196b4f 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -16,6 +16,7 @@ pub const Normal = struct { prefix_next, prefix_prev, prefix_register, + prefix_view_sticky, goto_file_start, goto_last_line, @@ -38,6 +39,7 @@ pub const Normal = struct { view_top, view_center, view_bottom, + view_middle, view_scroll_down, view_scroll_up, @@ -196,9 +198,12 @@ pub const Normal = struct { held_char: u21 = 0, /// `"`: the register the next command names, 0 for none register: u21 = 0, + /// `Z`: the view keys stay armed until Esc (helix's sticky view) + sticky: bool = false, + /// Drop what is pending; sticky view mode stays armed through it. pub fn clear(state: *State) void { - state.* = .{}; + state.* = if (state.sticky) .{ .sticky = true, .prefix = .view } else .{}; } }; @@ -254,7 +259,7 @@ pub const Normal = struct { view_bottom, last_modification, }; - pub const View = enum { top, center, bottom, scroll_down, scroll_up }; + pub const View = enum { top, center, bottom, middle, scroll_down, scroll_up }; pub const Find = enum { forward, backward, till_forward, till_backward }; pub const Line = enum { start, end, first_nonws }; pub const Page = enum { half_down, half_up, down, up }; @@ -407,7 +412,10 @@ pub const Normal = struct { pub fn parse(state: *State, key: Input) Result { if (key.has(.escape)) { - state.clear(); + // Esc out of sticky view mode leaves that mode and nothing else + const sticky = state.sticky; + state.* = .{}; + if (sticky) return .ignored; return resultAction(.escape); } @@ -448,8 +456,9 @@ pub const Normal = struct { return .ignored; }, .view => { - state.prefix = .none; + if (!state.sticky) state.prefix = .none; if (key.has(.view_top)) return resultAction(.{ .view = .top }); + if (key.has(.view_middle)) return resultAction(.{ .view = .middle }); if (key.has(.view_center)) return resultAction(.{ .view = .center }); if (key.has(.view_bottom)) return resultAction(.{ .view = .bottom }); if (key.has(.view_scroll_down)) return resultAction(.{ .view = .scroll_down }); @@ -543,6 +552,10 @@ pub const Normal = struct { // Prefix setters retain the count for their continuation. if (key.has(.prefix_goto)) return armPrefix(state, .goto, if (counted.explicit) count else 0); if (key.has(.prefix_view)) return armPrefix(state, .view, if (counted.explicit) count else 0); + if (key.has(.prefix_view_sticky)) { + state.sticky = true; + return armPrefix(state, .view, 0); + } if (key.has(.prefix_find_fwd)) return armPrefix(state, .find_fwd, if (counted.explicit) count else 0); if (key.has(.prefix_find_back)) return armPrefix(state, .find_back, if (counted.explicit) count else 0); if (key.has(.prefix_till_fwd)) return armPrefix(state, .till_fwd, if (counted.explicit) count else 0); diff --git a/src/normal.zig b/src/normal.zig index f9d7cf25..f7ba3810 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -29,6 +29,7 @@ fn roleBindingName(comptime role: modal.Normal.Role) []const u8 { return switch (role) { .prefix_goto => "goto_prefix", .prefix_view => "view_prefix", + .prefix_view_sticky => "view_prefix_sticky", .prefix_match => "match_prefix", .prefix_find_fwd => "find_char_fwd", .prefix_find_back => "find_char_back", @@ -478,6 +479,15 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void pane.scrollBy(t.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1)); pane.ensureCursorVisible(); }, + .middle => { + // helix align_view_middle: the cursor's column to the middle + // of an unwrapped view + if (pane.body_wrapped) return; + const line = text[modal.lineStartOffset(text, modal.lineAtOffset(text, cur))..modal.lineEndOffset(text, modal.lineAtOffset(text, cur))]; + const w: usize = @intCast(@max(1, @as(i32, pane.cols) - @as(i32, panes.File.gutterWidth(pane)))); + const col = panes.File.rawDisplayCol(line, @intCast(@max(0, t.cur_col))); + pane.hscroll = @intCast(panes.File.rawAtDisplay(line, col -| w / 2)); + }, .scroll_down => return t.scrollViewMove(text, range, 1), .scroll_up => return t.scrollViewMove(text, range, -1), }, diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index f589755a..15988ef6 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -454,6 +454,11 @@ {"name": "msel-ins-checkpoint", "pane": "file", "text": "ab\nab\n", "keys": "CiXYu"} {"name": "ins-tab-after-text-plain", "pane": "file", "text": "ab cd\n", "keys": "2liX"} {"name": "ins-shift-tab", "pane": "file", "text": "ab cd\n", "keys": "2liX"} +{"name": "sticky-view-scroll", "pane": "file", "text": "line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n", "keys": "Zjjj"} +{"name": "sticky-view-then-move", "pane": "file", "text": "line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n", "keys": "Zjjj"} +{"name": "sticky-view-ignores", "pane": "file", "text": "line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n", "keys": "Zjxj"} +{"name": "view-middle", "pane": "file", "text": "abcdef\n", "keys": "3lzm"} +{"name": "view-z-not-sticky", "pane": "file", "text": "line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n", "keys": "zjj"} {"name": "msel-align-basic", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=&"} {"name": "msel-align-columns", "pane": "file", "text": "a,b,c\nccc,d,e\n", "keys": "%s,&"} {"name": "msel-align-uneven", "pane": "file", "text": "a,b\nccc,d,eee,f\n", "keys": "%s,&"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index ce1b461c..d434c834 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -454,6 +454,11 @@ {"name":"msel-ins-checkpoint","text":"Xab\nXab\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1} {"name":"ins-tab-after-text-plain","text":"ab X cd\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}} {"name":"ins-shift-tab","text":"ab X cd\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}} +{"name":"sticky-view-scroll","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":6,"col":0},"anchor":{"row":6,"col":0}} +{"name":"sticky-view-then-move","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":6,"col":0},"anchor":{"row":6,"col":0}} +{"name":"sticky-view-ignores","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}} +{"name":"view-middle","text":"abcdef\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}} +{"name":"view-z-not-sticky","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}} {"name":"msel-align-basic","text":"a =1\nbbb=2\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":0} {"name":"msel-align-columns","text":"a ,b,c\nccc,d,e\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}}],"primary":0} {"name":"msel-align-uneven","text":"a ,b\nccc,d,eee,f\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}}],"primary":0} diff --git a/test/hxcases/parity-waivers.jsonl b/test/hxcases/parity-waivers.jsonl index 2393681e..e28f3ff5 100644 --- a/test/hxcases/parity-waivers.jsonl +++ b/test/hxcases/parity-waivers.jsonl @@ -13,3 +13,7 @@ {"name":"comment-only-blank","reason":"Terminal capture expands tabs and trims trailing blank rows.","reference":{"name":"comment-only-blank","text":" \n\t\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":0,"col":0}},"expected":{"name":"comment-only-blank","text":" \n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}}} {"name":"ins-backspace-indent-tab","reason":"The terminal expands literal tabs to spaces before editing.","reference":{"name":"ins-backspace-indent-tab","text":"\tx\n","mode":"insert","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},"expected":{"name":"ins-backspace-indent-tab","text":" x\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}}} {"name":"dot-append-count","reason":"A terminal's text is not the editor's to grow: a at its end adds no newline to append before, where a file gets one (helix append_mode).","reference":{"name":"dot-append-count","text":"aYZb\ncd\nYZYZYZ\n","mode":"normal","cursor":{"row":2,"col":5},"anchor":{"row":1,"col":2}},"expected":{"name":"dot-append-count","text":"aYZb\ncd\nYZYZYZ","mode":"normal","cursor":{"row":2,"col":6},"anchor":{"row":2,"col":6}}} +{"name":"sticky-view-scroll","reason":"Terminal scroll limits change where scrolloff places the cursor.","reference":{"name":"sticky-view-scroll","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":6,"col":0},"anchor":{"row":6,"col":0}},"expected":{"name":"sticky-view-scroll","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":43,"col":0},"anchor":{"row":43,"col":0}}} +{"name":"sticky-view-then-move","reason":"Terminal scroll limits change where scrolloff places the cursor.","reference":{"name":"sticky-view-then-move","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":6,"col":0},"anchor":{"row":6,"col":0}},"expected":{"name":"sticky-view-then-move","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":44,"col":0},"anchor":{"row":44,"col":0}}} +{"name":"sticky-view-ignores","reason":"Terminal scroll limits change where scrolloff places the cursor.","reference":{"name":"sticky-view-ignores","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}},"expected":{"name":"sticky-view-ignores","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":43,"col":0},"anchor":{"row":43,"col":0}}} +{"name":"view-z-not-sticky","reason":"Terminal scroll limits change where scrolloff places the cursor.","reference":{"name":"view-z-not-sticky","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}},"expected":{"name":"view-z-not-sticky","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":44,"col":0},"anchor":{"row":44,"col":0}}} -- cgit v1.3 From 7f179dc4d02cb726194dd73ea316230683f26494 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:50:55 -0300 Subject: While anything moves, a notice stays in its own pane's group Generalises vowtymmr (review): a pane sliding, zooming or opening over a neighbour covers that neighbour's notice as a closing one does. Tier 4 holds notices only while nothing moves. Shared files touched: gui.zig. --- docs/render-pipeline.md | 2 +- src/gui/gui.zig | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index 7299b357..d25323b6 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -810,7 +810,7 @@ stripped. No visual change until stage 9 unless stated. | 5 | JOIN: paint tags/notices/headers once into layers, copy into grid; the grid wins where the copies disagree, and each disagreement is listed for a later decision | goldens are the oracle; wide-grapheme re-clip at the edge (§3.2) | | 6 | body layer joined the same way (paint once, copy visible rows) | riskiest join; A/B the old double paint in a temporary test with tag_bottom on and off, then delete it | | 7 | Layer merge (TagLayer + BodyLayer), wire v8, web accessors | after the other agent lands; touches mouse hit paths; breaks the macOS shell's layer ABI (accepted: macOS build ignored for now); done: one `Layer` (src/Layer.zig) with `rows` (0 = no layer) and a `cursor{x, y}`; a tag of N rows is ONE layer of N grid rows (`tagHit` answers the row as `line`, `bodyHit` keeps its meaning), so the per-line layer bases are gone. Wire v8 ships rows, cursor y and the region list in the one bump; v7 and v9 peers are refused in both directions (tests). web: `tag_layer_value` 11 = rows, 12 = cursor y, and app.mjs lays every row. macOS: its Zig side compiles against `Layer`, but pardes.h still sees one row per tag layer (a taller tag shows its first row there) | -| 8 | GUI draws from regions: role tiers with track groups in tiers 2-3, page cover, hard-edged snapped decor for rules/rails/grips (pane chrome in the pane's tier), per-instance clip; delete inference functions, `transient_on` and `mark_hover` (breaks macOS glass hover; accepted, macOS ignored for now) | stage-0 PPM goldens byte-identical (possible only because hard decor is snapped and not anti-aliased); pane chrome now also shows during transitions, which is the one allowed visible delta, listed Done: the frame is drawn in groups (makeGroups): tier 0, one group per track in paint order (tiers 2 and 3), tier 4 as two groups (the notices; then the guides and the debug box, which the core paints over them), tier 5 (bar cursors), each drawing cells, images, decor. Decor is every rule, rail, thumb, grip mark, spine, the workspace and column rules, the bottom band, notice rules and bar cursors: whole-pixel rects drawn by `decor.frag` over `ui.vert` as cell instances (colour in the ground, coverage in fg.r, blended like the overlay), so each carries its track's transition and clip; a closing pane's comes from the last frame's regions (`Surface.previous_regions`). Only `solid` exists yet: `decor.vert` and the other kinds (§5.1) come with the first effect that needs them. Deleted: taglineBaseRgb, topbarPaneBorderHeight, bottomTaglinePresent, frameChromeBg, cellBackgroundIs and the rail inference, paneGripCell's scan, transient_on, PaintPlan; one cover map (coverFrame: layer, grip and offset, focus, anchor, floating) is marked from layers and regions once a frame. New regions: `column` (spines, anchors, the focused column's tint), `guide`, `debug`; `Surface.chrome` is the palette, on the wire in v8 (not yet shipped, so no bump), and an attached GUI draws the same chrome (test). Per-instance clip (`CellInstance.clip_*`, 104 → 120 bytes an instance, ~15% more upload a frame) replaces the vertical transition's scissor. While a pane closes, notices stay in their own panes' groups, under the tombstone sliding over them. Goldens: 01-12 byte-identical; 13-17 differ only in pixels past the grid (with a picture on screen the image pass left the scissor at the grid's size, cutting every rule end, rail foot and band that runs into the leftover pixels; they now run to the edge as in every scene without one); 16-debug shows the debug box (it was drawn from the grid under the source's context-row layer, so the GUI never showed it there); 18-mid-transition is new (virtual clock, PanelSlide Newcol with the picture, frame 6 of 12: chrome moves with its pane). Other deltas, outside the goldens: a guide over a tag or a context-row body now shows; with WindowOpacity < 100 a layer's bar cursor is ink like the grid's; an attached GUI gains the focus tint, notice rules, spines and the theme's page and caret colours. Deferrals fixed: the GUI sleeps when idle (SDL and queue events wake it; a smooth scroll, a gamepad, the test feed, a shell's kill deadline, a present to retry still poll), and a minimized or occluded window sleeps through animation. Tracy, `gui frame build`, 200x60, terminal output plus scrolling, ReleaseFast, two interleaved runs of ~180 frames: before 992/1015 µs median, after 989/987 µs | +| 8 | GUI draws from regions: role tiers with track groups in tiers 2-3, page cover, hard-edged snapped decor for rules/rails/grips (pane chrome in the pane's tier), per-instance clip; delete inference functions, `transient_on` and `mark_hover` (breaks macOS glass hover; accepted, macOS ignored for now) | stage-0 PPM goldens byte-identical (possible only because hard decor is snapped and not anti-aliased); pane chrome now also shows during transitions, which is the one allowed visible delta, listed Done: the frame is drawn in groups (makeGroups): tier 0, one group per track in paint order (tiers 2 and 3), tier 4 as two groups (the notices; then the guides and the debug box, which the core paints over them), tier 5 (bar cursors), each drawing cells, images, decor. Decor is every rule, rail, thumb, grip mark, spine, the workspace and column rules, the bottom band, notice rules and bar cursors: whole-pixel rects drawn by `decor.frag` over `ui.vert` as cell instances (colour in the ground, coverage in fg.r, blended like the overlay), so each carries its track's transition and clip; a closing pane's comes from the last frame's regions (`Surface.previous_regions`). Only `solid` exists yet: `decor.vert` and the other kinds (§5.1) come with the first effect that needs them. Deleted: taglineBaseRgb, topbarPaneBorderHeight, bottomTaglinePresent, frameChromeBg, cellBackgroundIs and the rail inference, paneGripCell's scan, transient_on, PaintPlan; one cover map (coverFrame: layer, grip and offset, focus, anchor, floating) is marked from layers and regions once a frame. New regions: `column` (spines, anchors, the focused column's tint), `guide`, `debug`; `Surface.chrome` is the palette, on the wire in v8 (not yet shipped, so no bump), and an attached GUI draws the same chrome (test). Per-instance clip (`CellInstance.clip_*`, 104 → 120 bytes an instance, ~15% more upload a frame) replaces the vertical transition's scissor. While any pane moves, opens or closes, notices stay in their own panes' groups, under whatever slides over them; tier 4 holds them only when nothing moves. Goldens: 01-12 byte-identical; 13-17 differ only in pixels past the grid (with a picture on screen the image pass left the scissor at the grid's size, cutting every rule end, rail foot and band that runs into the leftover pixels; they now run to the edge as in every scene without one); 16-debug shows the debug box (it was drawn from the grid under the source's context-row layer, so the GUI never showed it there); 18-mid-transition is new (virtual clock, PanelSlide Newcol with the picture, frame 6 of 12: chrome moves with its pane). Other deltas, outside the goldens: a guide over a tag or a context-row body now shows; with WindowOpacity < 100 a layer's bar cursor is ink like the grid's; an attached GUI gains the focus tint, notice rules, spines and the theme's page and caret colours. Deferrals fixed: the GUI sleeps when idle (SDL and queue events wake it; a smooth scroll, a gamepad, the test feed, a shell's kill deadline, a present to retry still poll), and a minimized or occluded window sleeps through animation. Tracy, `gui frame build`, 200x60, terminal output plus scrolling, ReleaseFast, two interleaved runs of ~180 frames: before 992/1015 µs median, after 989/987 µs | | 9 | post chain: glslang, Shadertoy prefix, ping-pong, ShaderAnimation, redraw levels A/B (§5.5); bundled CRT without barrel; delete scene_effects/crt.zig/crt.frag | first visual change (the CRT look) | | 10+ | `fx` bookmark: G1–G3 → feel review → G4 bundled → G5 lapis theme → P2s; tty T1–T2 → feel review → removals of audited effects (after user decision) → T3–T5 | each effect its own change, default off | diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 39d7f61e..2cde5edf 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -2075,11 +2075,11 @@ fn groupOf(groups: *const Groups, serial: u32) usize { return 0; } -/// A notice floats over the panes (tier 4), except while a pane closes: then -/// it stays in its own pane's group, under the tombstone sliding over it. +/// A notice floats over the panes and its pane's picture (tier 4) while +/// nothing moves; while anything does it stays in its own pane's group, under +/// whatever pane slides, opens or closes over it. fn noticeGroup(groups: *const Groups, layer: *const pardes.Layer) usize { - for (groups.items[1..][0..groups.tracks]) |group| - if (group.track.?.phase == .closing) return groupAt(groups, layer.viewport.x, layer.viewport.y); + if (groups.tracks > 0) return groupAt(groups, layer.viewport.x, layer.viewport.y); return groups.notices; } -- cgit v1.3 From ec4f300060ec0c93cc920dd5562eae1e3153d8b7 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:46:46 -0300 Subject: gw labels the words in view and jumps to the one whose label is typed helix goto_word: the words of two or more word characters between the view's first and last lines get two-letter labels, nearest the cursor first, one forward and one back in turn (modal.jumpWords). The next two keys choose one and select it, stretching the selection to it in select mode; any other key takes the labels away. The words live on Pane.jump, and renderBody paints their labels over the body's cells in the selection colours, the one change on the render side. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 2 +- src/body_layer.zig | 23 +++++++++++++++++++++ src/config.zig | 3 +++ src/modal.zig | 51 ++++++++++++++++++++++++++++++++++++++++++++++ src/normal.zig | 39 +++++++++++++++++++++++++++++++++++ src/panes.zig | 4 ++++ src/pardes.zig | 2 ++ test/hxcases/cases.jsonl | 7 +++++++ test/hxcases/goldens.jsonl | 7 +++++++ 9 files changed, 137 insertions(+), 1 deletion(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 8ee81930..fedf578d 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -187,6 +187,7 @@ found. Pure text math in `modal.zig`: `findChar`, `matchBracket`, | `g|`, `g|` | goto column n (1 = line start), clamped to the line | | helix-verified (phase 5) | | `gs` | goto first non-whitespace | alias of the `^` handler | helix-verified (phase 5) | | `g.` | go to where the last edit ended (a deletion's start, an insertion's end), every range there, extending in select mode | helix `goto_last_modification`. The position is `Text.last_edit`, set by `edit.setEditText` from where the old and new texts part; helix's is the last history revision's, so with nothing edited yet pardes stays put where the harness's helix goes to the end of its setup text | helix-verified | +| `gw` | label the words in view (two word characters or more) with two letters each, nearest the cursor first, one forward and one back in turn; typing a label selects its word (stretching the selection to it in select mode), any other key takes the labels away | helix `goto_word`, alphabet a-z (`config.jump_label_alphabet`). The labels are painted over the body's cells in `body_layer.renderBody`, in the selection colours; the words live on `Pane.jump` | helix-verified | | `gt` / `gc` / `gb` | goto screen top / center / bottom | view-relative (`pane.scroll()` + `pane.rows`), column kept (clamped) | helix-verified (phase 5) | | `gj` / `gk` | VISUAL line down / up (+ count) | follows the wrapped body's own breaks (`file_pane.visualRow`, the same walk `fillBody` renders), keeping the goal column INSIDE the row; the last row of a line steps into the next line's first. Wrap off = one row per line, and this IS `j`/`k`. helix assigns the pair the other way round (its `j` is the visual one) | pardes-specific | | `PageUp` / `PageDown` | FULL page (was half) | `Ctrl-u`/`Ctrl-d` stay the half-page pair | helix-verified (phase 5) | @@ -355,7 +356,6 @@ text. | Popup `Ctrl-u`/`Ctrl-d`, Completion menu, Signature help tables | LSP popups | LSP | | Picker table (all rows), Prompt table (all rows) | picker / prompt internals | pickers — pardes' tag line is its own one-line editor | | `gn` `gp` `ga` `gm` | next/prev/alternate buffer | buffer nav — pardes panes aren't a buffer list | -| `gw` | word-label jump | label-jump overlay machinery, not core editing | | Window mode table: `Ctrl-w` + `w v s t f F h j k l q o H J K L ns nv` (+ Ctrl variants) | splits/window management | window mode — pardes has its own Ctrl-w focus + Alt-n/Alt-c + mouse layout drags | | `]f [f ]t [t ]a [a ]c [c ]e [e ]T [T ]x [x` | TS unimpaired jumps | tree-sitter | | `]g [g ]G [G` | git change jumps | needs VCS diff state | diff --git a/src/body_layer.zig b/src/body_layer.zig index c8e229c4..a354512f 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -477,6 +477,29 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id } } + // `gw`'s labels, two letters over the start of each word + if (pane.jump.items.len > 0) labels: { + const flat = edit.flatSurface(p, &pane.body) catch break :labels; + const letters = config.jump_label_alphabet; + const style: CellStyle = .{ .fg = .{ .rgb = th.sel_fg }, .bg = .{ .rgb = th.sel_bg }, .bold = true }; + for (pane.jump.items, 0..) |word, i| { + if (pane.jump_first) |first| if (i / letters.len != first) continue; + var at = @min(word.anchor, word.head); + for ([_]u8{ letters[i / letters.len], letters[i % letters.len] }) |letter| { + const pos = modal.positionAt(flat, at); + const row: i32 = @intCast(pos.row); + const wp = pane.wrapRow(row, @intCast(pos.col)); + const cx = if (pane.file != null) + @as(i32, panes.File.gutterWidth(pane)) + panes.File.displayOffset(pane, row, wp.at, @intCast(pos.col)) + else + panes.File.lineDisplayOffset(modal.lineSlice(flat, pos.row), @intCast(@max(0, wp.at)), pos.col); + if (wp.row >= 0 and wp.row < body_h and cx >= 0 and cx < tw) + s.set(tx + @as(u16, @intCast(cx)), body_y + @as(u16, @intCast(wp.row)), &.{letter}, style); + at = modal.nextGrapheme(flat, at); + } + } + } + // cursor: tracks the shell cursor until pinned by a click or a key // (the tag's or a prompt's cursor wins while that is being typed into) if (active and pane.focus == .body and (pane.prompt == .none or pane.prompt == .del_side or pane.prompt == .repl_choice)) { diff --git a/src/config.zig b/src/config.zig index daa40028..3739d3f4 100644 --- a/src/config.zig +++ b/src/config.zig @@ -490,6 +490,9 @@ pub const goto_view_top: []const Chord = &.{.{ .cp = 't' }}; pub const goto_view_center: []const Chord = &.{.{ .cp = 'c' }}; pub const goto_view_bottom: []const Chord = &.{.{ .cp = 'b' }}; pub const goto_last_modification: []const Chord = &.{.{ .cp = '.' }}; +pub const goto_word: []const Chord = &.{.{ .cp = 'w' }}; +/// the jump labels' letters, helix's jump_label_alphabet +pub const jump_label_alphabet = "abcdefghijklmnopqrstuvwxyz"; pub const goto_definition: []const Chord = &.{.{ .cp = 'd' }}; pub const goto_declaration: []const Chord = &.{.{ .cp = 'D' }}; pub const goto_type_definition: []const Chord = &.{.{ .cp = 'y' }}; diff --git a/src/modal.zig b/src/modal.zig index 16196b4f..6371475c 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -30,6 +30,7 @@ pub const Normal = struct { goto_view_center, goto_view_bottom, goto_last_modification, + goto_word, goto_definition, goto_declaration, goto_type_definition, @@ -346,6 +347,7 @@ pub const Normal = struct { pipe_selection: PipeBehavior, search, search_step: Direction, + goto_word, macro_record, macro_replay: u32, node: NodeWalk, @@ -364,6 +366,7 @@ pub const Normal = struct { .macro_record, .macro_replay, .node, + .goto_word, .repeat_insert, => .once, .edit => |edit| switch (edit.kind) { @@ -447,6 +450,7 @@ pub const Normal = struct { if (key.has(.goto_view_top)) return resultAction(.{ .goto = .{ .target = .view_top, .count = count, .explicit_count = counted.explicit } }); if (key.has(.goto_view_center)) return resultAction(.{ .goto = .{ .target = .view_center, .count = count, .explicit_count = counted.explicit } }); if (key.has(.goto_view_bottom)) return resultAction(.{ .goto = .{ .target = .view_bottom, .count = count, .explicit_count = counted.explicit } }); + if (key.has(.goto_word)) return resultAction(.goto_word); if (key.has(.goto_last_modification)) return resultAction(.{ .goto = .{ .target = .last_modification, .count = count, .explicit_count = counted.explicit } }); if (key.has(.goto_definition)) return resultAction(.{ .lsp = .definition }); if (key.has(.goto_declaration)) return resultAction(.{ .lsp = .declaration }); @@ -1382,6 +1386,53 @@ fn wmIsPrev(t: WordTarget) bool { } /// w/b/e/W/B/E: helix word_move — each step selects the traversed span. +/// helix jump_to_word's candidates: the words of two or more word +/// characters between `start` and `end`, nearest the cursor first, taking one +/// forward and one back in turn, each over its word pointing the way it was +/// found, as many as `out` holds. +pub fn jumpWords(text: []const u8, cursor: usize, start: usize, end: usize, out: []Selection) usize { + var fwd: Selection = .{ .anchor = cursor, .head = cursor }; + var rev = fwd; + if (cursor < text.len and !isWordWhitespace(wordClassAt(text, cursor))) { + const word_end = moveWord(text, fwd, 1, .next_word_end); + if (word_end.anchor == cursor) fwd = word_end; // a word of one grapheme + const word_start = moveWord(text, rev, 1, .prev_word_start); + if (word_start.anchor == nextGrapheme(text, cursor)) rev = word_start; + } + var n: usize = 0; + while (n < out.len) { + var changed = false; + while (fwd.head < end) { + fwd = moveWord(text, fwd, 1, .next_word_end); + // the two graphemes before the head are word characters + const one = prevGrapheme(text, fwd.head); + if (fwd.head == 0 or one == 0 or wordClassAt(text, one) != .word or wordClassAt(text, prevGrapheme(text, one)) != .word) continue; + changed = true; + var from = fwd.anchor; + while (from < fwd.head and wordClassAt(text, from) != .word) from = nextGrapheme(text, from); + fwd.anchor = from; + out[n] = fwd; + n += 1; + break; + } + if (n == out.len) break; + while (rev.head > start) { + rev = moveWord(text, rev, 1, .prev_word_start); + // the two graphemes from the head are word characters + if (wordClassAt(text, rev.head) != .word or wordClassAt(text, nextGrapheme(text, rev.head)) != .word) continue; + changed = true; + var to = rev.anchor; + while (to > rev.head and wordClassAt(text, prevGrapheme(text, to)) != .word) to = prevGrapheme(text, to); + rev.anchor = to; + out[n] = rev; + n += 1; + break; + } + if (!changed) break; + } + return n; +} + pub fn moveWord(text: []const u8, r0: Selection, count: usize, target: WordTarget) Selection { const is_prev = wmIsPrev(target); if ((is_prev and r0.head == 0) or (!is_prev and r0.head == text.len)) return r0; diff --git a/src/normal.zig b/src/normal.zig index f7ba3810..4459ab53 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -245,6 +245,44 @@ pub fn nodeSelect(p: *Pardes, pane: *Pane, t: *Text, walk: modal.Normal.NodeWalk t.setRanges(text, out[0..m], &.{}, @min(got.pri, m - 1), expl); } +/// `gw` (helix goto_word): label the words in view, nearest the cursor first, +/// with two letters each; the next two keys choose one (`jumpKey`). +fn startJump(p: *Pardes, pane: *Pane, t: *Text) void { + if (t.what != .body) return; + const text = edit.flatSurface(p, t) catch return; + const cursor = modal.selectionCursor(text, t.primaryRange(text, 0)); + const top: usize = @intCast(@max(0, pane.scroll())); + const start = @min(modal.lineStartOffset(text, top), text.len); + const end = @min(modal.lineStartOffset(text, top + pane.rows), text.len); + const letters = config.jump_label_alphabet.len; + pane.jump.resize(p.gpa, letters * letters) catch return; + pane.jump.shrinkRetainingCapacity(modal.jumpWords(text, cursor, start, end, pane.jump.items)); + pane.jump_first = null; +} + +/// A key while `gw`'s labels are up: a label's first letter, then its +/// second, which selects that word; anything else takes the labels away. +pub fn jumpKey(p: *Pardes, pane: *Pane, t: *Text, key: Key) void { + const letters = config.jump_label_alphabet; + const at = if (key.ctrl or key.alt or key.cp > 0x7f) null else std.mem.indexOfScalar(u8, letters, @intCast(key.cp)); + const i = at orelse return pane.jump.clearRetainingCapacity(); + const first = pane.jump_first orelse { + if (i * letters.len > pane.jump.items.len) return pane.jump.clearRetainingCapacity(); + pane.jump_first = @intCast(i); + return; + }; + const chosen = first * letters.len + i; + defer pane.jump.clearRetainingCapacity(); + if (chosen >= pane.jump.items.len) return; + const r = pane.jump.items[chosen]; + const text = edit.flatSurface(p, t) catch return; + if (!t.select) return t.setRange(text, 0, .{ .anchor = @min(r.anchor, r.head), .head = @max(r.anchor, r.head) }, true); + // select mode stretches the primary to the word (helix Movement::Extend) + const pr = t.primaryRange(text, 0); + const anchor = if (r.anchor < r.head) @min(r.anchor, @min(pr.anchor, pr.head)) else @max(r.anchor, @max(pr.anchor, pr.head)); + t.setRange(text, 0, .{ .anchor = anchor, .head = r.head }, true); +} + const KeyState = struct { mode: Text.Mode, select: bool, @@ -706,6 +744,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void if (direction == .forward) @as(i32, 1) else -1, ), .node => |walk| return nodeSelect(p, pane, t, walk), + .goto_word => return startJump(p, pane, t), .macro_record => { const m = &p.macro; if (m.recording == 0) { diff --git a/src/panes.zig b/src/panes.zig index 67954abd..7114b68e 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -154,6 +154,10 @@ pub const Pane = struct { search_pane: ?usize = null, search_row: ?usize = null, look_at: ?LookSpot = null, + /// `gw`'s words, labelled in order while it waits for a label to be + /// typed (gpa-owned); `jump_first` is the label's first letter once typed. + jump: std.ArrayList(modal.Selection) = .empty, + jump_first: ?u8 = null, sel_snap: [Text.max_selections]modal.Selection = undefined, nsel_snap: u16 = 0, sel_snap_pri: u16 = 0, diff --git a/src/pardes.zig b/src/pardes.zig index d43b38b1..6b397017 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4395,6 +4395,7 @@ pub const Pardes = struct { pane.body.deinit(p.gpa); pane.tag.deinit(p.gpa); pane.input.deinit(p.gpa); + pane.jump.deinit(p.gpa); panes.Terminal.deinitEmulator(pane, p.gpa); pane.clearCwd(); pane.fs.deinit(p.gpa); @@ -5284,6 +5285,7 @@ pub const Pardes = struct { if (normal.selRegexArmed(pane)) |a| if (p.promptTarget(pane)) |answered| normal.applySelRegex(p, pane, answered, a.pat, a.mode); return; } + if (t == &pane.body and pane.jump.items.len > 0) return normal.jumpKey(p, pane, t, key); if (t.mode == .normal and p.macro.replaying == 0 and (hit(key, config.look_key) or hit(key, config.exec_key))) { // A header's words are the workspace's and column's commands, so // Enter runs them there, as the execute key does. diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 15988ef6..f36afe11 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -459,6 +459,13 @@ {"name": "sticky-view-ignores", "pane": "file", "text": "line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n", "keys": "Zjxj"} {"name": "view-middle", "pane": "file", "text": "abcdef\n", "keys": "3lzm"} {"name": "view-z-not-sticky", "pane": "file", "text": "line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n", "keys": "zjj"} +{"name": "goto-word-first", "pane": "file", "text": "alpha beta gamma\ndelta x\n", "keys": "gwaa"} +{"name": "goto-word-second", "pane": "file", "text": "alpha beta gamma\ndelta x\n", "keys": "gwab"} +{"name": "goto-word-back", "pane": "file", "text": "alpha beta gamma\ndelta x\n", "keys": "jwgwab"} +{"name": "goto-word-cancel", "pane": "file", "text": "alpha beta gamma\n", "keys": "gwzl"} +{"name": "goto-word-bad-second", "pane": "file", "text": "alpha beta\n", "keys": "gwa9l"} +{"name": "goto-word-extend", "pane": "file", "text": "alpha beta gamma\n", "keys": "wvgwab"} +{"name": "goto-word-mid", "pane": "file", "text": "one two three four five\n", "keys": "2wgwaa"} {"name": "msel-align-basic", "pane": "file", "text": "a=1\nbbb=2\n", "keys": "%s=&"} {"name": "msel-align-columns", "pane": "file", "text": "a,b,c\nccc,d,e\n", "keys": "%s,&"} {"name": "msel-align-uneven", "pane": "file", "text": "a,b\nccc,d,eee,f\n", "keys": "%s,&"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index d434c834..2dab15c0 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -459,6 +459,13 @@ {"name":"sticky-view-ignores","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}} {"name":"view-middle","text":"abcdef\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}} {"name":"view-z-not-sticky","text":"line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26\nline 27\nline 28\nline 29\nline 30\nline 31\nline 32\nline 33\nline 34\nline 35\nline 36\nline 37\nline 38\nline 39\nline 40\nline 41\nline 42\nline 43\nline 44\nline 45\nline 46\nline 47\nline 48\nline 49\nline 50\nline 51\nline 52\nline 53\nline 54\nline 55\nline 56\nline 57\nline 58\nline 59\n","mode":"normal","cursor":{"row":5,"col":0},"anchor":{"row":5,"col":0}} +{"name":"goto-word-first","text":"alpha beta gamma\ndelta x\n","mode":"normal","cursor":{"row":0,"col":9},"anchor":{"row":0,"col":6}} +{"name":"goto-word-second","text":"alpha beta gamma\ndelta x\n","mode":"normal","cursor":{"row":0,"col":15},"anchor":{"row":0,"col":11}} +{"name":"goto-word-back","text":"alpha beta gamma\ndelta x\n","mode":"normal","cursor":{"row":0,"col":15},"anchor":{"row":0,"col":11}} +{"name":"goto-word-cancel","text":"alpha beta gamma\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} +{"name":"goto-word-bad-second","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}} +{"name":"goto-word-extend","text":"alpha beta gamma\n","mode":"select","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":5}} +{"name":"goto-word-mid","text":"one two three four five\n","mode":"normal","cursor":{"row":0,"col":12},"anchor":{"row":0,"col":8}} {"name":"msel-align-basic","text":"a =1\nbbb=2\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}}],"primary":0} {"name":"msel-align-columns","text":"a ,b,c\nccc,d,e\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":5}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}}],"primary":0} {"name":"msel-align-uneven","text":"a ,b\nccc,d,eee,f\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":3}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":1,"col":9},"anchor":{"row":1,"col":9}}],"primary":0} -- cgit v1.3 From c6a5aaf5c4a020eaefe24943ecb6cf9d4eb26836 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 15:57:22 -0300 Subject: Post chain: Shadertoy passes compiled by glslc; Crt, Ripple and Glitch rewritten as Shadertoy files Stage 9 of docs/render-pipeline.md. src/gui/Post.zig runs the chain over the finished frame: each pass reads the one before, ping-pong textures between, the last writes the window; an empty chain is the direct path. The prefix (shaders/post/prefix.glsl) is ghostty's uniform block, name for name and offset for offset (tested against a copy of its Uniforms, the selection colours by their GLSL names), at SDL GPU's bindings, y down. A user's file (Shader ) is compiled by glslc with compileGlsl's own flags on a thread of its own, taken in at the next loop step; errors name the file's own lines; a failed compile keeps the last good pipeline; glslc missing is said once. The bundled passes compile with the build, through the same prefix. Crt, Ripple and Glitch are Shadertoy files, rewritten: no barrel (input is identity, tested), everything in linear light and dithered, keyed to iTime. scene_effects, crt.zig and crt.frag.glsl are gone; the core keeps no clock for the chain. ShaderAnimation off|on|always drives redraw level A: the chain alone over the retained frame (34 us CPU a redraw, measured). Shared files touched: pardes.zig (nextWake loses the scene line, disableSceneEffects empties the chain, two tests), builtins.zig (one switch arm), ninep/ctl.zig (two switch arms), macos.zig (flags from the chain), gui.zig. Not touched: Messages.zig, mouse.zig, detached/*, host_io, tty. --- build.zig | 28 +- docs/render-pipeline.md | 28 +- shaders/crt.frag.glsl | 175 ----------- shaders/crt.vert.glsl | 9 - shaders/post.vert.glsl | 10 + shaders/post/crt.glsl | 79 +++++ shaders/post/glitch.glsl | 60 ++++ shaders/post/prefix.glsl | 67 ++++ shaders/post/ripple.glsl | 49 +++ shaders/prebuilt/crt.frag.glsl | 175 ----------- shaders/prebuilt/crt.frag.spv | Bin 13832 -> 0 bytes shaders/prebuilt/crt.vert.glsl | 9 - shaders/prebuilt/crt.vert.spv | Bin 1208 -> 0 bytes shaders/prebuilt/post-crt.frag.glsl | 146 +++++++++ shaders/prebuilt/post-crt.frag.spv | Bin 0 -> 9480 bytes shaders/prebuilt/post-glitch.frag.glsl | 127 ++++++++ shaders/prebuilt/post-glitch.frag.spv | Bin 0 -> 8308 bytes shaders/prebuilt/post-ripple.frag.glsl | 116 +++++++ shaders/prebuilt/post-ripple.frag.spv | Bin 0 -> 7180 bytes shaders/prebuilt/post.vert.glsl | 10 + shaders/prebuilt/post.vert.spv | Bin 0 -> 1208 bytes src/animation.zig | 6 - src/builtins.zig | 2 +- src/config.zig | 150 ++++++++- src/effect_sources.zig | 23 +- src/gui/Post.zig | 559 +++++++++++++++++++++++++++++++++ src/gui/crt.zig | 101 ------ src/gui/gui.zig | 338 ++++++++++---------- src/macos.zig | 23 +- src/ninep/ctl.zig | 4 +- src/pardes.zig | 28 +- 31 files changed, 1611 insertions(+), 711 deletions(-) delete mode 100644 shaders/crt.frag.glsl delete mode 100644 shaders/crt.vert.glsl create mode 100644 shaders/post.vert.glsl create mode 100644 shaders/post/crt.glsl create mode 100644 shaders/post/glitch.glsl create mode 100644 shaders/post/prefix.glsl create mode 100644 shaders/post/ripple.glsl delete mode 100644 shaders/prebuilt/crt.frag.glsl delete mode 100644 shaders/prebuilt/crt.frag.spv delete mode 100644 shaders/prebuilt/crt.vert.glsl delete mode 100644 shaders/prebuilt/crt.vert.spv create mode 100644 shaders/prebuilt/post-crt.frag.glsl create mode 100644 shaders/prebuilt/post-crt.frag.spv create mode 100644 shaders/prebuilt/post-glitch.frag.glsl create mode 100644 shaders/prebuilt/post-glitch.frag.spv create mode 100644 shaders/prebuilt/post-ripple.frag.glsl create mode 100644 shaders/prebuilt/post-ripple.frag.spv create mode 100644 shaders/prebuilt/post.vert.glsl create mode 100644 shaders/prebuilt/post.vert.spv create mode 100644 src/gui/Post.zig delete mode 100644 src/gui/crt.zig diff --git a/build.zig b/build.zig index 803f773d..c1117593 100644 --- a/build.zig +++ b/build.zig @@ -50,7 +50,11 @@ const gui_shaders = [_][]const u8{ "decor.frag", "overlay.vert", "overlay.frag", "image.vert", "image.frag", - "crt.vert", "crt.frag", + "post.vert", + // The bundled post passes: shaders/post/.glsl behind the Shadertoy + // prefix (shaderSource). + "post-crt.frag", "post-ripple.frag", + "post-glitch.frag", }; pub fn build(b: *std.Build) void { @@ -161,7 +165,7 @@ pub fn build(b: *std.Build) void { for (gui_shaders) |name| { update_shaders.addCopyFileToSource(compileGlsl(b, name), b.fmt("shaders/prebuilt/{s}.spv", .{name})); update_shaders.addCopyFileToSource( - b.path(b.fmt("shaders/{s}.glsl", .{name})), + shaderSource(b, name), b.fmt("shaders/prebuilt/{s}.glsl", .{name}), ); } @@ -691,7 +695,7 @@ pub fn build(b: *std.Build) void { mod.addOptions("pardes_isolation", if (isolated_mod == mod) iso_on else iso_off); if (shell == .gui) for (gui_shaders) |name| mod.addAnonymousImport( b.fmt("effect-source-{s}.glsl", .{name}), - .{ .root_source_file = b.path(b.fmt("{s}/{s}.glsl", .{ gui_effect_source_dir, name })) }, + .{ .root_source_file = if (prebuilt_shaders) b.path(b.fmt("{s}/{s}.glsl", .{ gui_effect_source_dir, name })) else shaderSource(b, name) }, ); if (shell == .macos) mod.addAnonymousImport("effect-source-crt.ci.metal", .{ .root_source_file = b.path("shaders/crt.ci.metal"), @@ -756,6 +760,8 @@ pub fn build(b: *std.Build) void { gm.addAnonymousImport("AdwaitaMono-Regular.ttf", .{ .root_source_file = b.path("assets/AdwaitaMono-Regular.ttf"), }); + // Put before a user's Shadertoy file when the gui compiles it. + gm.addAnonymousImport("post-prefix.glsl", .{ .root_source_file = b.path("shaders/post/prefix.glsl") }); for (gui_shaders) |name| gm.addAnonymousImport(b.fmt("{s}.spv", .{name}), .{ .root_source_file = if (prebuilt_shaders) b.path(b.fmt("shaders/prebuilt/{s}.spv", .{name})) @@ -1444,7 +1450,6 @@ pub fn build(b: *std.Build) void { .{ .path = "src/esp32p4/input_rescue.zig", .libc = false }, .{ .path = "src/fonts.zig", .libc = true }, .{ .path = "src/c_heap.zig", .libc = false }, - .{ .path = "src/gui/crt.zig", .libc = false }, }) |entry| { const tests = b.addTest(.{ .root_module = b.createModule(.{ @@ -1626,10 +1631,23 @@ fn reindexed(b: *std.Build, archive: std.Build.LazyPath, index: usize) std.Build // glslc shaders/.glsl -fshader-stage= -o .spv, // returned as a LazyPath for @embedFile. The stage is the name's own suffix. +/// The GLSL a shader is compiled from: shaders/.glsl, or for a bundled +/// post pass (post-.frag) shaders/post/.glsl behind the Shadertoy +/// prefix, the one the gui puts before a user's file at run time. +fn shaderSource(b: *std.Build, name: []const u8) std.Build.LazyPath { + if (!std.mem.startsWith(u8, name, "post-")) return b.path(b.fmt("shaders/{s}.glsl", .{name})); + const joined = b.addSystemCommand(&.{"cat"}); + joined.addFileArg(b.path("shaders/post/prefix.glsl")); + joined.addFileArg(b.path(b.fmt("shaders/post/{s}.glsl", .{name["post-".len .. name.len - ".frag".len]}))); + return joined.captureStdOut(.{}); +} + +/// The one glslc invocation every shader is compiled with, at build time here +/// and at run time for a user's post pass (src/gui/Post.zig). fn compileGlsl(b: *std.Build, name: []const u8) std.Build.LazyPath { const stage = if (std.mem.endsWith(u8, name, ".vert")) "vertex" else "fragment"; const cmd = b.addSystemCommand(&.{ "glslc", b.fmt("-fshader-stage={s}", .{stage}) }); - cmd.addFileArg(b.path(b.fmt("shaders/{s}.glsl", .{name}))); + cmd.addFileArg(shaderSource(b, name)); cmd.addArg("-o"); return cmd.addOutputFileArg(b.fmt("{s}.spv", .{name})); } diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index d25323b6..254187a3 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -491,21 +491,27 @@ Mirror ghostty 1.3.2 (`zig-pkg/ghostty-*/src/renderer/shadertoy.zig`, ≈3 cells, at the corners of a 4K screen, where the grips and rails are. `crt.zig` is deleted. If the user insists on barrel in the bundled CRT, keep `crt.zig`'s inverse keyed to "bundled crt active". -- **Compile**: GLSL → SPIR-V at runtime with glslang (ghostty's `pkg/glslang`, - the same vendored package in zig-pkg, GUI build only, behind `-Dshadertoy`). - Compiled on load and on file change (file_watch), never on the frame path; - the last good pipeline stays on error; the error is a pane message. glslang is - C++ with its own allocator: a recorded exception to the "Zig allocator hooks - in all C deps" policy. +- **Compile** (decision 3): a user's file is compiled by spawning `glslc` with + the build's own invocation (`-fshader-stage=fragment -o -`, SPIR-V on + stdout), on a thread of its own when the file joins the chain, never on the + frame path; the result is taken in at the next loop step. The prefix ends in + `#line 1`, so glslc's errors count the file's own lines, and they name the + file, not the temporary source. A failed compile keeps the file's last good + pipeline and says glslc's first line as a message; glslc missing is said + once, and only files stay off (the bundled passes are compiled with the + build, through the same prefix). No file watching: `Shader ` twice + (out, then in) compiles it again. - **Animation mode**: `ShaderAnimation off|on|always` (ghostty's `custom-shader-animation`): off = redraw only when content changes; on = continuous while the window is focused; always = continuous. Continuous means the shell redraws its retained Surface (3.1), never a core tick or render. - **Existing crt/ripple/glitch** become bundled Shadertoy files in - `shaders/post/` selected by the same builtins; `scene_effects` and - `crt.frag.glsl`/`crt.zig` are deleted. The bundled CRT is rewritten to - quality (6.1); ripple and glitch are dropped unless the feel review keeps a - rewritten version. + `shaders/post/` selected by the same builtins (decision 2: kept, polished); + `scene_effects` and `crt.frag.glsl`/`crt.zig` are deleted. The chain is + `Crt`, `Ripple`, `Glitch` and `Shader ` in the order they were put in + (`Shader off` empties it of files), `ShaderAnimation off|on|always` + (default on). The core keeps no clock for it: level A redraws are the + shell's. - macOS (out of scope) would need spirv-cross → MSL, as ghostty does. Region filters (e.g. "tint/blur one pane") are NOT built. If one is ever needed: @@ -811,7 +817,7 @@ stripped. No visual change until stage 9 unless stated. | 6 | body layer joined the same way (paint once, copy visible rows) | riskiest join; A/B the old double paint in a temporary test with tag_bottom on and off, then delete it | | 7 | Layer merge (TagLayer + BodyLayer), wire v8, web accessors | after the other agent lands; touches mouse hit paths; breaks the macOS shell's layer ABI (accepted: macOS build ignored for now); done: one `Layer` (src/Layer.zig) with `rows` (0 = no layer) and a `cursor{x, y}`; a tag of N rows is ONE layer of N grid rows (`tagHit` answers the row as `line`, `bodyHit` keeps its meaning), so the per-line layer bases are gone. Wire v8 ships rows, cursor y and the region list in the one bump; v7 and v9 peers are refused in both directions (tests). web: `tag_layer_value` 11 = rows, 12 = cursor y, and app.mjs lays every row. macOS: its Zig side compiles against `Layer`, but pardes.h still sees one row per tag layer (a taller tag shows its first row there) | | 8 | GUI draws from regions: role tiers with track groups in tiers 2-3, page cover, hard-edged snapped decor for rules/rails/grips (pane chrome in the pane's tier), per-instance clip; delete inference functions, `transient_on` and `mark_hover` (breaks macOS glass hover; accepted, macOS ignored for now) | stage-0 PPM goldens byte-identical (possible only because hard decor is snapped and not anti-aliased); pane chrome now also shows during transitions, which is the one allowed visible delta, listed Done: the frame is drawn in groups (makeGroups): tier 0, one group per track in paint order (tiers 2 and 3), tier 4 as two groups (the notices; then the guides and the debug box, which the core paints over them), tier 5 (bar cursors), each drawing cells, images, decor. Decor is every rule, rail, thumb, grip mark, spine, the workspace and column rules, the bottom band, notice rules and bar cursors: whole-pixel rects drawn by `decor.frag` over `ui.vert` as cell instances (colour in the ground, coverage in fg.r, blended like the overlay), so each carries its track's transition and clip; a closing pane's comes from the last frame's regions (`Surface.previous_regions`). Only `solid` exists yet: `decor.vert` and the other kinds (§5.1) come with the first effect that needs them. Deleted: taglineBaseRgb, topbarPaneBorderHeight, bottomTaglinePresent, frameChromeBg, cellBackgroundIs and the rail inference, paneGripCell's scan, transient_on, PaintPlan; one cover map (coverFrame: layer, grip and offset, focus, anchor, floating) is marked from layers and regions once a frame. New regions: `column` (spines, anchors, the focused column's tint), `guide`, `debug`; `Surface.chrome` is the palette, on the wire in v8 (not yet shipped, so no bump), and an attached GUI draws the same chrome (test). Per-instance clip (`CellInstance.clip_*`, 104 → 120 bytes an instance, ~15% more upload a frame) replaces the vertical transition's scissor. While any pane moves, opens or closes, notices stay in their own panes' groups, under whatever slides over them; tier 4 holds them only when nothing moves. Goldens: 01-12 byte-identical; 13-17 differ only in pixels past the grid (with a picture on screen the image pass left the scissor at the grid's size, cutting every rule end, rail foot and band that runs into the leftover pixels; they now run to the edge as in every scene without one); 16-debug shows the debug box (it was drawn from the grid under the source's context-row layer, so the GUI never showed it there); 18-mid-transition is new (virtual clock, PanelSlide Newcol with the picture, frame 6 of 12: chrome moves with its pane). Other deltas, outside the goldens: a guide over a tag or a context-row body now shows; with WindowOpacity < 100 a layer's bar cursor is ink like the grid's; an attached GUI gains the focus tint, notice rules, spines and the theme's page and caret colours. Deferrals fixed: the GUI sleeps when idle (SDL and queue events wake it; a smooth scroll, a gamepad, the test feed, a shell's kill deadline, a present to retry still poll), and a minimized or occluded window sleeps through animation. Tracy, `gui frame build`, 200x60, terminal output plus scrolling, ReleaseFast, two interleaved runs of ~180 frames: before 992/1015 µs median, after 989/987 µs | -| 9 | post chain: glslang, Shadertoy prefix, ping-pong, ShaderAnimation, redraw levels A/B (§5.5); bundled CRT without barrel; delete scene_effects/crt.zig/crt.frag | first visual change (the CRT look) | +| 9 | post chain: glslang, Shadertoy prefix, ping-pong, ShaderAnimation, redraw levels A/B (§5.5); bundled CRT without barrel; delete scene_effects/crt.zig/crt.frag | first visual change (the CRT look) Done: src/gui/Post.zig, shaders/post/ (prefix, crt, ripple, glitch), post.vert. glslc (decision 3) with compileGlsl's flags, off the frame path; ghostty's Uniforms matched offset for offset by a test against a copy, and the prefix's block checked name by name; ghostty's test_shadertoy_crt and _focus compile, _invalid fails with glslc's words. Level A measured (Tracy, 200x60, Crt, idle 10 s): 215 chain-only redraws at 34 µs median CPU each, 6 core frames (957 µs) in the same time; the core's clock stays idle. Input is identity (test: the corner click with Crt on). The three bundled passes were rewritten: Crt without barrel or tube edge, scanlines and mask that average to one, dithered vignette; Ripple as rings in pixels, eased in, lit on their slopes in linear light, dithered; Glitch as short eased bursts of torn bands with an RGB split, keyed to iTime. Before/after stills for the user's judgement. | | 10+ | `fx` bookmark: G1–G3 → feel review → G4 bundled → G5 lapis theme → P2s; tty T1–T2 → feel review → removals of audited effects (after user decision) → T3–T5 | each effect its own change, default off | Tests to add: fixed-clock animation tests (Tween/Spring closed form, retarget diff --git a/shaders/crt.frag.glsl b/shaders/crt.frag.glsl deleted file mode 100644 index b0b0b93b..00000000 --- a/shaders/crt.frag.glsl +++ /dev/null @@ -1,175 +0,0 @@ -#version 450 - -// The Crt builtin's post-process. Screen point cc shows the scene at -// uv' = cc*(1 + BARREL*r2), and src/gui/crt.zig maps mouse input through that -// same transform — keep them equal or clicks drift near the edges. - -layout(set = 2, binding = 0) uniform sampler2D u_scene; -layout(std140, set = 3, binding = 0) uniform CrtUniforms { - float time_seconds; - float frame; - vec2 viewport; - // x = CRT, y = ripple, z = glitch. One scene pass can combine them. - vec4 effects; -} u_crt; - -layout(location = 0) out vec4 o_col; - -const float TAU = 6.28318530718; -const float BARREL = 0.016; // MUST match src/gui/crt.zig barrel -const float EDGE_SOFTNESS_PX = 3.5; -const float CHROMA_PX = 0.28; // radial R/B separation in physical pixels at r2=1 -const float BLOOM_RADIUS_PX = 1.75; -const float BLOOM_THRESHOLD = 0.60; // linear-light channel peak -const float BLOOM_SOFTNESS = 0.25; -const float BLOOM_STRENGTH = 0.045; -const float SCAN_PERIOD_PX = 3.0; -const float SCAN_STRENGTH = 0.055; -const float MASK_PERIOD_PX = 3.0; -const float MASK_STRENGTH = 0.015; -const float VIGNETTE_STRENGTH = 0.11; // 11% only at the extreme corners -const float HUM_STRENGTH = 0.0025; -const float NOISE_STRENGTH = 0.0020; -const float EXPOSURE = 1.012; - -vec3 srgbToLinear(vec3 c) { - vec3 low = c / 12.92; - vec3 high = pow((c + 0.055) / 1.055, vec3(2.4)); - return mix(high, low, lessThanEqual(c, vec3(0.04045))); -} - -vec3 linearToSrgb(vec3 c) { - c = max(c, vec3(0.0)); - vec3 low = c * 12.92; - vec3 high = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055; - return mix(high, low, lessThanEqual(c, vec3(0.0031308))); -} - -vec3 sceneLinear(vec2 uv) { - return srgbToLinear(texture(u_scene, uv).rgb); -} - -vec3 brightPass(vec2 uv) { - vec3 c = sceneLinear(uv); - float peak = max(c.r, max(c.g, c.b)); - return c * smoothstep(BLOOM_THRESHOLD, BLOOM_THRESHOLD + BLOOM_SOFTNESS, peak); -} - -float noiseAt(vec2 p) { - vec3 q = fract(vec3(p.xyx) * 0.1031); - q += dot(q, q.yzx + 33.33); - return fract((q.x + q.y) * q.z); -} - -// The non-CRT scene effects deliberately distort the source coordinates, -// rather than remapping the final colors. Themes retain their palette and the -// effects remain composable with the linear-light CRT treatment below. -vec2 sceneUv(vec2 uv) { - if (u_crt.effects.y > 0.5) { - vec2 centered = uv - 0.5; - float radius = length(centered); - float envelope = 1.0 - smoothstep(0.04, 0.72, radius); - float wave = sin(radius * 42.0 - u_crt.time_seconds * 5.2); - uv += centered / max(radius, 0.00001) * wave * envelope * 0.0032; - } - if (u_crt.effects.z > 0.5) { - float band = floor(uv.y * 96.0); - float seed = noiseAt(vec2(band, floor(u_crt.time_seconds * 13.0))); - float tear = smoothstep(0.955, 1.0, seed); - uv.x += (noiseAt(vec2(seed, band)) - 0.5) * tear * 0.020; - } - return uv; -} - -// The scene, ALPHA INCLUDED: with a see-through ground the grid arrives -// premultiplied and partly empty, and a pass that dropped .a would turn the -// desktop behind a `*_transparent` theme into opaque black the moment Crt, -// Ripple or Glitch came on. -vec4 sceneSrgb(vec2 uv) { - vec2 warped = sceneUv(uv); - vec4 center = texture(u_scene, warped); - if (u_crt.effects.z <= 0.5) return center; - - float band = floor(warped.y * 96.0); - float seed = noiseAt(vec2(band, floor(u_crt.time_seconds * 13.0))); - float tear = smoothstep(0.955, 1.0, seed); - vec2 chroma = vec2((0.35 + 1.25 * tear) / u_crt.viewport.x, 0.0); - return vec4( - texture(u_scene, warped + chroma).r, - center.g, - texture(u_scene, warped - chroma).b, - center.a - ); -} - -vec3 effectSceneLinear(vec2 uv) { - return srgbToLinear(sceneSrgb(uv).rgb); -} - -void main() { - vec2 size = u_crt.viewport; - vec2 texel = 1.0 / size; - vec2 cc = gl_FragCoord.xy / size * 2.0 - 1.0; - float r2 = dot(cc, cc); - vec2 uv = cc * (1.0 + BARREL * r2 * u_crt.effects.x) * 0.5 + 0.5; - - // Ripple and glitch are useful without the CRT. They only move samples; - // skip every tube-specific light/mask/exposure adjustment. - if (u_crt.effects.x <= 0.5) { - // The scene texture is already sRGB. Avoid a needless transfer-curve - // round trip so a coordinate-only effect preserves theme bytes. - vec4 s = sceneSrgb(uv); - o_col = vec4(clamp(s.rgb, 0.0, 1.0), clamp(s.a, 0.0, 1.0)); - return; - } - - vec3 col = vec3(0.0); - // Outside the tube nothing is drawn, so nothing is covered either: the - // old opaque black there would have punched a rectangle out of the - // compositor's backdrop. - float alpha = 0.0; - float edge = min(min(uv.x, uv.y), min(1.0 - uv.x, 1.0 - uv.y)); - if (edge > 0.0) { - vec3 center = effectSceneLinear(uv); - vec2 chroma = cc * (CHROMA_PX * r2) * texel; - // Each radial sample follows the same ripple/glitch path from its own - // pre-warp coordinate. This is also how the Metal kernel composes the - // effects; sampling a once-warped center here made combined effects - // diverge between the two GUI backends. - col = vec3( - effectSceneLinear(uv + chroma).r, - center.g, - effectSceneLinear(uv - chroma).b - ); - - vec2 bloom_px = texel * BLOOM_RADIUS_PX; - vec3 bloom = brightPass(uv + vec2(bloom_px.x, 0.0)) + - brightPass(uv - vec2(bloom_px.x, 0.0)) + - brightPass(uv + vec2(0.0, bloom_px.y)) + - brightPass(uv - vec2(0.0, bloom_px.y)); - col += bloom * (0.25 * BLOOM_STRENGTH); - - // The mask has unit mean and the scan loss stays small; neither needs - // the old global lift that remapped every theme toward white. - float scan_wave = 0.5 + 0.5 * cos(gl_FragCoord.y * TAU / SCAN_PERIOD_PX); - col *= 1.0 - SCAN_STRENGTH * scan_wave * scan_wave; - vec3 mask = 1.0 + MASK_STRENGTH * cos( - gl_FragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0) - ); - col *= mask; - - vec2 edge_px = min(uv, 1.0 - uv) * size; - float tube = smoothstep(0.0, EDGE_SOFTNESS_PX, min(edge_px.x, edge_px.y)); - float vignette = 1.0 - VIGNETTE_STRENGTH * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6); - float hum = 1.0 + HUM_STRENGTH * sin((uv.y * 1.35 - u_crt.time_seconds * 0.11) * TAU); - col *= tube * vignette * hum * EXPOSURE; - // The tube's edge falloff dims coverage as well as colour; everything - // else here is a light adjustment and leaves it alone. - alpha = sceneSrgb(uv).a * tube; - - float luma = dot(col, vec3(0.2126, 0.7152, 0.0722)); - vec2 noise_seed = gl_FragCoord.xy + vec2(u_crt.frame * 17.0, u_crt.frame * 59.0); - col += (noiseAt(noise_seed) - 0.5) * NOISE_STRENGTH * (0.15 + 0.85 * clamp(luma, 0.0, 1.0)); - } - o_col = vec4(linearToSrgb(clamp(col, 0.0, 1.0)), clamp(alpha, 0.0, 1.0)); -} diff --git a/shaders/crt.vert.glsl b/shaders/crt.vert.glsl deleted file mode 100644 index 4e15fcdf..00000000 --- a/shaders/crt.vert.glsl +++ /dev/null @@ -1,9 +0,0 @@ -#version 450 - -// CRT post-process pass: one fullscreen triangle from gl_VertexIndex, no -// vertex buffer. The fragment shader derives uv from gl_FragCoord. - -void main() { - vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); - gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); -} diff --git a/shaders/post.vert.glsl b/shaders/post.vert.glsl new file mode 100644 index 00000000..86739a65 --- /dev/null +++ b/shaders/post.vert.glsl @@ -0,0 +1,10 @@ +#version 450 + +// A post pass (docs/render-pipeline.md §6): one fullscreen triangle from +// gl_VertexIndex, no vertex buffer. The Shadertoy fragment shader works from +// gl_FragCoord, y down, as its fragCoord. + +void main() { + vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); +} diff --git a/shaders/post/crt.glsl b/shaders/post/crt.glsl new file mode 100644 index 00000000..16a5cdd9 --- /dev/null +++ b/shaders/post/crt.glsl @@ -0,0 +1,79 @@ +// Crt: a slot-mask tube's light, with nothing that moves a pixel. There is +// no barrel: every click lands where it is drawn, rails and grips at the +// window's edges included. What is left is light: a beam profile for the +// scanlines, an aperture mask, a soft bloom of the brightest ink, a faint +// vignette and the hum of a live tube. Everything is worked out in linear +// light and dithered back into 8 bits, so the vignette never bands. It is a +// Shadertoy file: pardes puts its prefix before it (shaders/post/prefix.glsl). + +const float TAU = 6.28318530718; +const float CHROMA_PX = 0.35; // red and blue apart, at the corners +const float BLOOM_RADIUS_PX = 1.75; +const float BLOOM_THRESHOLD = 0.60; // linear-light channel peak +const float BLOOM_SOFTNESS = 0.25; +const float BLOOM_STRENGTH = 0.05; +const float SCAN_PERIOD_PX = 3.0; +const float SCAN_STRENGTH = 0.07; +const float MASK_PERIOD_PX = 3.0; +const float MASK_STRENGTH = 0.018; +const float VIGNETTE_STRENGTH = 0.12; // at the far corners only +const float HUM_STRENGTH = 0.004; +const float HUM_HZ = 0.11; +const float EXPOSURE = 1.015; + +vec3 toLinear(vec3 c) { + return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); +} + +vec3 toSrgb(vec3 c) { + c = max(c, vec3(0.0)); + return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); +} + +vec3 scene(vec2 uv) { + return toLinear(texture(iChannel0, uv).rgb); +} + +vec3 brightPass(vec2 uv) { + vec3 c = scene(uv); + float peak = max(c.r, max(c.g, c.b)); + return c * smoothstep(BLOOM_THRESHOLD, BLOOM_THRESHOLD + BLOOM_SOFTNESS, peak); +} + +// Interleaved gradient noise: a dither with no visible pattern. +float ign(vec2 p) { + return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 size = iResolution.xy; + vec2 texel = 1.0 / size; + vec2 uv = fragCoord / size; + vec2 cc = uv * 2.0 - 1.0; + float r2 = dot(cc, cc); + + // The beam converges least at the corners: red and blue drift apart + // there, by a third of a pixel. + vec2 chroma = cc * (CHROMA_PX * r2 * 0.5) * texel; + vec3 col = vec3(scene(uv + chroma).r, scene(uv).g, scene(uv - chroma).b); + + vec2 b = texel * BLOOM_RADIUS_PX; + vec3 bloom = brightPass(uv + vec2(b.x, 0.0)) + brightPass(uv - vec2(b.x, 0.0)) + + brightPass(uv + vec2(0.0, b.y)) + brightPass(uv - vec2(0.0, b.y)); + col += bloom * (0.25 * BLOOM_STRENGTH); + + // A beam is brightest on its line and falls off between lines; the + // mask is three phosphor stripes a triad. Both average to one, so the + // theme keeps its brightness. + float line = 0.5 + 0.5 * cos(fragCoord.y * TAU / SCAN_PERIOD_PX); + col *= 1.0 - SCAN_STRENGTH * (line * line - 0.375) / 0.625; + col *= 1.0 + MASK_STRENGTH * cos(fragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0)); + + float vignette = 1.0 - VIGNETTE_STRENGTH * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6); + float hum = 1.0 + HUM_STRENGTH * sin((uv.y * 1.35 - iTime * HUM_HZ) * TAU); + col *= vignette * hum * EXPOSURE; + + vec3 srgb = toSrgb(col); + srgb += (ign(fragCoord + float(iFrame % 64) * vec2(5.588238, 5.588238)) - 0.5) / 255.0; + fragColor = vec4(clamp(srgb, 0.0, 1.0), texture(iChannel0, uv).a); +} diff --git a/shaders/post/glitch.glsl b/shaders/post/glitch.glsl new file mode 100644 index 00000000..ff9ebcb8 --- /dev/null +++ b/shaders/post/glitch.glsl @@ -0,0 +1,60 @@ +// Glitch: now and then the signal slips. Every couple of seconds a short +// burst tears a few horizontal bands sideways and pulls their red and blue +// apart; the bands are sized in pixels and the burst eases in and out over +// a fraction of a second, so it reads as one event rather than a flicker, +// and it looks the same at 60 or 144 Hz because every step is keyed to +// iTime, not to frames. Between bursts the page is left exactly as it is. +// A torn band's brightness dips in linear light. It is a Shadertoy file: +// pardes puts its prefix before it. + +const float PERIOD = 2.4; // seconds between bursts, on average +const float BURST = 0.28; // seconds a burst lasts +const float STEP_HZ = 24.0; // how often a burst re-tears +const float SHIFT_PX = 14.0; +const float SPLIT_PX = 2.5; +const float DIP = 0.12; + +float hash(vec2 p) { + vec3 q = fract(vec3(p.xyx) * 0.1031); + q += dot(q, q.yzx + 33.33); + return fract((q.x + q.y) * q.z); +} + +vec3 toLinear(vec3 c) { + return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); +} + +vec3 toSrgb(vec3 c) { + c = max(c, vec3(0.0)); + return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 size = iResolution.xy; + vec2 uv = fragCoord / size; + // Which burst window this is, when in it the burst starts, and how far + // into the burst we are: 0 outside one. + float window = floor(iTime / PERIOD); + float start = window * PERIOD + hash(vec2(window, 7.0)) * (PERIOD - BURST); + float t = (iTime - start) / BURST; + float live = step(0.0, t) * step(t, 1.0); + float envelope = live * sin(3.14159265 * clamp(t, 0.0, 1.0)); + if (envelope <= 0.0) { + fragColor = texture(iChannel0, uv); + return; + } + // Bands of 6 to 40 px, re-cut at STEP_HZ during the burst; about one + // band in four tears. + float tick = floor(iTime * STEP_HZ); + float band_px = mix(6.0, 40.0, hash(vec2(tick, 3.0))); + float band = floor(fragCoord.y / band_px); + float roll = hash(vec2(band, tick)); + float tear = smoothstep(0.72, 0.9, roll) * envelope; + float shift = (hash(vec2(tick, band + 11.0)) - 0.5) * 2.0 * SHIFT_PX * tear; + vec2 at = vec2(fragCoord.x + shift, fragCoord.y); + vec2 split = vec2(SPLIT_PX * tear, 0.0); + vec4 center = texture(iChannel0, at / size); + vec3 col = vec3(texture(iChannel0, (at + split) / size).r, center.g, texture(iChannel0, (at - split) / size).b); + col = toSrgb(toLinear(col) * (1.0 - DIP * tear)); + fragColor = vec4(col, center.a); +} diff --git a/shaders/post/prefix.glsl b/shaders/post/prefix.glsl new file mode 100644 index 00000000..efa49483 --- /dev/null +++ b/shaders/post/prefix.glsl @@ -0,0 +1,67 @@ +#version 450 +// pardes's Shadertoy prefix (docs/render-pipeline.md §6), put before every +// post pass: a bundled one in shaders/post/ at build time, a user's file at +// run time. The uniform names, types and std140 order are ghostty 1.3.2's +// (src/renderer/shaders/shadertoy_prefix.glsl), so a shader written for +// ghostty runs here; only the bindings are SDL GPU's (a fragment shader's +// textures in set 2, its uniform blocks in set 3). Y is down everywhere, as +// on ghostty's Metal renderer: fragCoord, iChannel0's uv and the cursor +// rectangles share one top-left space, and iCurrentCursor is (left x, +// BOTTOM-edge y, width, height) in framebuffer pixels. + +layout(set = 3, binding = 0, std140) uniform Globals { + uniform vec3 iResolution; + uniform float iTime; + uniform float iTimeDelta; + uniform float iFrameRate; + uniform int iFrame; + uniform float iChannelTime[4]; + uniform vec3 iChannelResolution[4]; + uniform vec4 iMouse; + uniform vec4 iDate; + uniform float iSampleRate; + uniform vec4 iCurrentCursor; + uniform vec4 iPreviousCursor; + uniform vec4 iCurrentCursorColor; + uniform vec4 iPreviousCursorColor; + uniform int iCurrentCursorStyle; + uniform int iPreviousCursorStyle; + uniform int iCursorVisible; + uniform float iTimeCursorChange; + uniform float iTimeFocus; + uniform int iFocus; + uniform vec3 iPalette[256]; + uniform vec3 iBackgroundColor; + uniform vec3 iForegroundColor; + uniform vec3 iCursorColor; + uniform vec3 iCursorText; + uniform vec3 iSelectionForegroundColor; + uniform vec3 iSelectionBackgroundColor; +}; + +// pardes's own: 1 when the window is opaque, so a pass cannot punch holes +// in it through its alpha. +layout(set = 3, binding = 1, std140) uniform PardesPost { + float pardesOpaque; +}; + +#define CURSORSTYLE_BLOCK 0 +#define CURSORSTYLE_BLOCK_HOLLOW 1 +#define CURSORSTYLE_BAR 2 +#define CURSORSTYLE_UNDERLINE 3 +#define CURSORSTYLE_LOCK 4 + +layout(set = 2, binding = 0) uniform sampler2D iChannel0; + +layout(location = 0) out vec4 _fragColor; + +#define texture2D texture + +void mainImage(out vec4 fragColor, in vec2 fragCoord); +void main() { + mainImage(_fragColor, gl_FragCoord.xy); + if (pardesOpaque > 0.5) _fragColor.a = 1.0; +} + +// The shader's own lines count from 1, so glslc's errors name them. +#line 1 diff --git a/shaders/post/ripple.glsl b/shaders/post/ripple.glsl new file mode 100644 index 00000000..ffecbf70 --- /dev/null +++ b/shaders/post/ripple.glsl @@ -0,0 +1,49 @@ +// Ripple: the page as a still pool a slow swell crosses. Rings travel out +// from the middle of the window at a fixed speed in pixels, so they look +// the same at any size and frame rate; the displacement is at most a couple +// of pixels, so text stays readable, and it eases in over its first half +// second instead of snapping on. Each ring catches the light a little on its +// rising side, in linear light, and the result is dithered so the shading +// never bands. It is a Shadertoy file: pardes puts its prefix before it. + +const float TAU = 6.28318530718; +const float WAVELENGTH_PX = 150.0; +const float SPEED_PX = 55.0; // outward, per second +const float AMPLITUDE_PX = 2.2; +const float SHADE = 0.035; // light on a slope, linear +const float EASE_IN = 0.5; // seconds + +vec3 toLinear(vec3 c) { + return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); +} + +vec3 toSrgb(vec3 c) { + c = max(c, vec3(0.0)); + return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); +} + +float ign(vec2 p) { + return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 size = iResolution.xy; + vec2 center = 0.5 * size; + vec2 offset = fragCoord - center; + float radius = length(offset); + float reach = 0.5 * length(size); + // Strongest a third of the way out, fading to nothing at the middle + // and at the edges, so the window's frame never wobbles. + float envelope = smoothstep(0.0, 0.18, radius / reach) * (1.0 - smoothstep(0.35, 0.95, radius / reach)); + float ease = smoothstep(0.0, EASE_IN, iTime); + float phase = (radius - iTime * SPEED_PX) * TAU / WAVELENGTH_PX; + float height = sin(phase); + float slope = cos(phase); + vec2 dir = offset / max(radius, 1e-3); + vec2 moved = fragCoord + dir * height * AMPLITUDE_PX * envelope * ease; + + vec4 s = texture(iChannel0, moved / size); + vec3 col = toLinear(s.rgb) * (1.0 + SHADE * slope * envelope * ease); + vec3 srgb = toSrgb(col) + (ign(fragCoord + float(iFrame % 64) * 5.588238) - 0.5) / 255.0; + fragColor = vec4(clamp(srgb, 0.0, 1.0), s.a); +} diff --git a/shaders/prebuilt/crt.frag.glsl b/shaders/prebuilt/crt.frag.glsl deleted file mode 100644 index b0b0b93b..00000000 --- a/shaders/prebuilt/crt.frag.glsl +++ /dev/null @@ -1,175 +0,0 @@ -#version 450 - -// The Crt builtin's post-process. Screen point cc shows the scene at -// uv' = cc*(1 + BARREL*r2), and src/gui/crt.zig maps mouse input through that -// same transform — keep them equal or clicks drift near the edges. - -layout(set = 2, binding = 0) uniform sampler2D u_scene; -layout(std140, set = 3, binding = 0) uniform CrtUniforms { - float time_seconds; - float frame; - vec2 viewport; - // x = CRT, y = ripple, z = glitch. One scene pass can combine them. - vec4 effects; -} u_crt; - -layout(location = 0) out vec4 o_col; - -const float TAU = 6.28318530718; -const float BARREL = 0.016; // MUST match src/gui/crt.zig barrel -const float EDGE_SOFTNESS_PX = 3.5; -const float CHROMA_PX = 0.28; // radial R/B separation in physical pixels at r2=1 -const float BLOOM_RADIUS_PX = 1.75; -const float BLOOM_THRESHOLD = 0.60; // linear-light channel peak -const float BLOOM_SOFTNESS = 0.25; -const float BLOOM_STRENGTH = 0.045; -const float SCAN_PERIOD_PX = 3.0; -const float SCAN_STRENGTH = 0.055; -const float MASK_PERIOD_PX = 3.0; -const float MASK_STRENGTH = 0.015; -const float VIGNETTE_STRENGTH = 0.11; // 11% only at the extreme corners -const float HUM_STRENGTH = 0.0025; -const float NOISE_STRENGTH = 0.0020; -const float EXPOSURE = 1.012; - -vec3 srgbToLinear(vec3 c) { - vec3 low = c / 12.92; - vec3 high = pow((c + 0.055) / 1.055, vec3(2.4)); - return mix(high, low, lessThanEqual(c, vec3(0.04045))); -} - -vec3 linearToSrgb(vec3 c) { - c = max(c, vec3(0.0)); - vec3 low = c * 12.92; - vec3 high = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055; - return mix(high, low, lessThanEqual(c, vec3(0.0031308))); -} - -vec3 sceneLinear(vec2 uv) { - return srgbToLinear(texture(u_scene, uv).rgb); -} - -vec3 brightPass(vec2 uv) { - vec3 c = sceneLinear(uv); - float peak = max(c.r, max(c.g, c.b)); - return c * smoothstep(BLOOM_THRESHOLD, BLOOM_THRESHOLD + BLOOM_SOFTNESS, peak); -} - -float noiseAt(vec2 p) { - vec3 q = fract(vec3(p.xyx) * 0.1031); - q += dot(q, q.yzx + 33.33); - return fract((q.x + q.y) * q.z); -} - -// The non-CRT scene effects deliberately distort the source coordinates, -// rather than remapping the final colors. Themes retain their palette and the -// effects remain composable with the linear-light CRT treatment below. -vec2 sceneUv(vec2 uv) { - if (u_crt.effects.y > 0.5) { - vec2 centered = uv - 0.5; - float radius = length(centered); - float envelope = 1.0 - smoothstep(0.04, 0.72, radius); - float wave = sin(radius * 42.0 - u_crt.time_seconds * 5.2); - uv += centered / max(radius, 0.00001) * wave * envelope * 0.0032; - } - if (u_crt.effects.z > 0.5) { - float band = floor(uv.y * 96.0); - float seed = noiseAt(vec2(band, floor(u_crt.time_seconds * 13.0))); - float tear = smoothstep(0.955, 1.0, seed); - uv.x += (noiseAt(vec2(seed, band)) - 0.5) * tear * 0.020; - } - return uv; -} - -// The scene, ALPHA INCLUDED: with a see-through ground the grid arrives -// premultiplied and partly empty, and a pass that dropped .a would turn the -// desktop behind a `*_transparent` theme into opaque black the moment Crt, -// Ripple or Glitch came on. -vec4 sceneSrgb(vec2 uv) { - vec2 warped = sceneUv(uv); - vec4 center = texture(u_scene, warped); - if (u_crt.effects.z <= 0.5) return center; - - float band = floor(warped.y * 96.0); - float seed = noiseAt(vec2(band, floor(u_crt.time_seconds * 13.0))); - float tear = smoothstep(0.955, 1.0, seed); - vec2 chroma = vec2((0.35 + 1.25 * tear) / u_crt.viewport.x, 0.0); - return vec4( - texture(u_scene, warped + chroma).r, - center.g, - texture(u_scene, warped - chroma).b, - center.a - ); -} - -vec3 effectSceneLinear(vec2 uv) { - return srgbToLinear(sceneSrgb(uv).rgb); -} - -void main() { - vec2 size = u_crt.viewport; - vec2 texel = 1.0 / size; - vec2 cc = gl_FragCoord.xy / size * 2.0 - 1.0; - float r2 = dot(cc, cc); - vec2 uv = cc * (1.0 + BARREL * r2 * u_crt.effects.x) * 0.5 + 0.5; - - // Ripple and glitch are useful without the CRT. They only move samples; - // skip every tube-specific light/mask/exposure adjustment. - if (u_crt.effects.x <= 0.5) { - // The scene texture is already sRGB. Avoid a needless transfer-curve - // round trip so a coordinate-only effect preserves theme bytes. - vec4 s = sceneSrgb(uv); - o_col = vec4(clamp(s.rgb, 0.0, 1.0), clamp(s.a, 0.0, 1.0)); - return; - } - - vec3 col = vec3(0.0); - // Outside the tube nothing is drawn, so nothing is covered either: the - // old opaque black there would have punched a rectangle out of the - // compositor's backdrop. - float alpha = 0.0; - float edge = min(min(uv.x, uv.y), min(1.0 - uv.x, 1.0 - uv.y)); - if (edge > 0.0) { - vec3 center = effectSceneLinear(uv); - vec2 chroma = cc * (CHROMA_PX * r2) * texel; - // Each radial sample follows the same ripple/glitch path from its own - // pre-warp coordinate. This is also how the Metal kernel composes the - // effects; sampling a once-warped center here made combined effects - // diverge between the two GUI backends. - col = vec3( - effectSceneLinear(uv + chroma).r, - center.g, - effectSceneLinear(uv - chroma).b - ); - - vec2 bloom_px = texel * BLOOM_RADIUS_PX; - vec3 bloom = brightPass(uv + vec2(bloom_px.x, 0.0)) + - brightPass(uv - vec2(bloom_px.x, 0.0)) + - brightPass(uv + vec2(0.0, bloom_px.y)) + - brightPass(uv - vec2(0.0, bloom_px.y)); - col += bloom * (0.25 * BLOOM_STRENGTH); - - // The mask has unit mean and the scan loss stays small; neither needs - // the old global lift that remapped every theme toward white. - float scan_wave = 0.5 + 0.5 * cos(gl_FragCoord.y * TAU / SCAN_PERIOD_PX); - col *= 1.0 - SCAN_STRENGTH * scan_wave * scan_wave; - vec3 mask = 1.0 + MASK_STRENGTH * cos( - gl_FragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0) - ); - col *= mask; - - vec2 edge_px = min(uv, 1.0 - uv) * size; - float tube = smoothstep(0.0, EDGE_SOFTNESS_PX, min(edge_px.x, edge_px.y)); - float vignette = 1.0 - VIGNETTE_STRENGTH * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6); - float hum = 1.0 + HUM_STRENGTH * sin((uv.y * 1.35 - u_crt.time_seconds * 0.11) * TAU); - col *= tube * vignette * hum * EXPOSURE; - // The tube's edge falloff dims coverage as well as colour; everything - // else here is a light adjustment and leaves it alone. - alpha = sceneSrgb(uv).a * tube; - - float luma = dot(col, vec3(0.2126, 0.7152, 0.0722)); - vec2 noise_seed = gl_FragCoord.xy + vec2(u_crt.frame * 17.0, u_crt.frame * 59.0); - col += (noiseAt(noise_seed) - 0.5) * NOISE_STRENGTH * (0.15 + 0.85 * clamp(luma, 0.0, 1.0)); - } - o_col = vec4(linearToSrgb(clamp(col, 0.0, 1.0)), clamp(alpha, 0.0, 1.0)); -} diff --git a/shaders/prebuilt/crt.frag.spv b/shaders/prebuilt/crt.frag.spv deleted file mode 100644 index f0e14627..00000000 Binary files a/shaders/prebuilt/crt.frag.spv and /dev/null differ diff --git a/shaders/prebuilt/crt.vert.glsl b/shaders/prebuilt/crt.vert.glsl deleted file mode 100644 index 4e15fcdf..00000000 --- a/shaders/prebuilt/crt.vert.glsl +++ /dev/null @@ -1,9 +0,0 @@ -#version 450 - -// CRT post-process pass: one fullscreen triangle from gl_VertexIndex, no -// vertex buffer. The fragment shader derives uv from gl_FragCoord. - -void main() { - vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); - gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); -} diff --git a/shaders/prebuilt/crt.vert.spv b/shaders/prebuilt/crt.vert.spv deleted file mode 100644 index 8a75ebb3..00000000 Binary files a/shaders/prebuilt/crt.vert.spv and /dev/null differ diff --git a/shaders/prebuilt/post-crt.frag.glsl b/shaders/prebuilt/post-crt.frag.glsl new file mode 100644 index 00000000..11b95272 --- /dev/null +++ b/shaders/prebuilt/post-crt.frag.glsl @@ -0,0 +1,146 @@ +#version 450 +// pardes's Shadertoy prefix (docs/render-pipeline.md §6), put before every +// post pass: a bundled one in shaders/post/ at build time, a user's file at +// run time. The uniform names, types and std140 order are ghostty 1.3.2's +// (src/renderer/shaders/shadertoy_prefix.glsl), so a shader written for +// ghostty runs here; only the bindings are SDL GPU's (a fragment shader's +// textures in set 2, its uniform blocks in set 3). Y is down everywhere, as +// on ghostty's Metal renderer: fragCoord, iChannel0's uv and the cursor +// rectangles share one top-left space, and iCurrentCursor is (left x, +// BOTTOM-edge y, width, height) in framebuffer pixels. + +layout(set = 3, binding = 0, std140) uniform Globals { + uniform vec3 iResolution; + uniform float iTime; + uniform float iTimeDelta; + uniform float iFrameRate; + uniform int iFrame; + uniform float iChannelTime[4]; + uniform vec3 iChannelResolution[4]; + uniform vec4 iMouse; + uniform vec4 iDate; + uniform float iSampleRate; + uniform vec4 iCurrentCursor; + uniform vec4 iPreviousCursor; + uniform vec4 iCurrentCursorColor; + uniform vec4 iPreviousCursorColor; + uniform int iCurrentCursorStyle; + uniform int iPreviousCursorStyle; + uniform int iCursorVisible; + uniform float iTimeCursorChange; + uniform float iTimeFocus; + uniform int iFocus; + uniform vec3 iPalette[256]; + uniform vec3 iBackgroundColor; + uniform vec3 iForegroundColor; + uniform vec3 iCursorColor; + uniform vec3 iCursorText; + uniform vec3 iSelectionForegroundColor; + uniform vec3 iSelectionBackgroundColor; +}; + +// pardes's own: 1 when the window is opaque, so a pass cannot punch holes +// in it through its alpha. +layout(set = 3, binding = 1, std140) uniform PardesPost { + float pardesOpaque; +}; + +#define CURSORSTYLE_BLOCK 0 +#define CURSORSTYLE_BLOCK_HOLLOW 1 +#define CURSORSTYLE_BAR 2 +#define CURSORSTYLE_UNDERLINE 3 +#define CURSORSTYLE_LOCK 4 + +layout(set = 2, binding = 0) uniform sampler2D iChannel0; + +layout(location = 0) out vec4 _fragColor; + +#define texture2D texture + +void mainImage(out vec4 fragColor, in vec2 fragCoord); +void main() { + mainImage(_fragColor, gl_FragCoord.xy); + if (pardesOpaque > 0.5) _fragColor.a = 1.0; +} + +// The shader's own lines count from 1, so glslc's errors name them. +#line 1 +// Crt: a slot-mask tube's light, with nothing that moves a pixel. There is +// no barrel: every click lands where it is drawn, rails and grips at the +// window's edges included. What is left is light: a beam profile for the +// scanlines, an aperture mask, a soft bloom of the brightest ink, a faint +// vignette and the hum of a live tube. Everything is worked out in linear +// light and dithered back into 8 bits, so the vignette never bands. It is a +// Shadertoy file: pardes puts its prefix before it (shaders/post/prefix.glsl). + +const float TAU = 6.28318530718; +const float CHROMA_PX = 0.35; // red and blue apart, at the corners +const float BLOOM_RADIUS_PX = 1.75; +const float BLOOM_THRESHOLD = 0.60; // linear-light channel peak +const float BLOOM_SOFTNESS = 0.25; +const float BLOOM_STRENGTH = 0.05; +const float SCAN_PERIOD_PX = 3.0; +const float SCAN_STRENGTH = 0.07; +const float MASK_PERIOD_PX = 3.0; +const float MASK_STRENGTH = 0.018; +const float VIGNETTE_STRENGTH = 0.12; // at the far corners only +const float HUM_STRENGTH = 0.004; +const float HUM_HZ = 0.11; +const float EXPOSURE = 1.015; + +vec3 toLinear(vec3 c) { + return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); +} + +vec3 toSrgb(vec3 c) { + c = max(c, vec3(0.0)); + return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); +} + +vec3 scene(vec2 uv) { + return toLinear(texture(iChannel0, uv).rgb); +} + +vec3 brightPass(vec2 uv) { + vec3 c = scene(uv); + float peak = max(c.r, max(c.g, c.b)); + return c * smoothstep(BLOOM_THRESHOLD, BLOOM_THRESHOLD + BLOOM_SOFTNESS, peak); +} + +// Interleaved gradient noise: a dither with no visible pattern. +float ign(vec2 p) { + return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 size = iResolution.xy; + vec2 texel = 1.0 / size; + vec2 uv = fragCoord / size; + vec2 cc = uv * 2.0 - 1.0; + float r2 = dot(cc, cc); + + // The beam converges least at the corners: red and blue drift apart + // there, by a third of a pixel. + vec2 chroma = cc * (CHROMA_PX * r2 * 0.5) * texel; + vec3 col = vec3(scene(uv + chroma).r, scene(uv).g, scene(uv - chroma).b); + + vec2 b = texel * BLOOM_RADIUS_PX; + vec3 bloom = brightPass(uv + vec2(b.x, 0.0)) + brightPass(uv - vec2(b.x, 0.0)) + + brightPass(uv + vec2(0.0, b.y)) + brightPass(uv - vec2(0.0, b.y)); + col += bloom * (0.25 * BLOOM_STRENGTH); + + // A beam is brightest on its line and falls off between lines; the + // mask is three phosphor stripes a triad. Both average to one, so the + // theme keeps its brightness. + float line = 0.5 + 0.5 * cos(fragCoord.y * TAU / SCAN_PERIOD_PX); + col *= 1.0 - SCAN_STRENGTH * (line * line - 0.375) / 0.625; + col *= 1.0 + MASK_STRENGTH * cos(fragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0)); + + float vignette = 1.0 - VIGNETTE_STRENGTH * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6); + float hum = 1.0 + HUM_STRENGTH * sin((uv.y * 1.35 - iTime * HUM_HZ) * TAU); + col *= vignette * hum * EXPOSURE; + + vec3 srgb = toSrgb(col); + srgb += (ign(fragCoord + float(iFrame % 64) * vec2(5.588238, 5.588238)) - 0.5) / 255.0; + fragColor = vec4(clamp(srgb, 0.0, 1.0), texture(iChannel0, uv).a); +} diff --git a/shaders/prebuilt/post-crt.frag.spv b/shaders/prebuilt/post-crt.frag.spv new file mode 100644 index 00000000..e4d98138 Binary files /dev/null and b/shaders/prebuilt/post-crt.frag.spv differ diff --git a/shaders/prebuilt/post-glitch.frag.glsl b/shaders/prebuilt/post-glitch.frag.glsl new file mode 100644 index 00000000..46538452 --- /dev/null +++ b/shaders/prebuilt/post-glitch.frag.glsl @@ -0,0 +1,127 @@ +#version 450 +// pardes's Shadertoy prefix (docs/render-pipeline.md §6), put before every +// post pass: a bundled one in shaders/post/ at build time, a user's file at +// run time. The uniform names, types and std140 order are ghostty 1.3.2's +// (src/renderer/shaders/shadertoy_prefix.glsl), so a shader written for +// ghostty runs here; only the bindings are SDL GPU's (a fragment shader's +// textures in set 2, its uniform blocks in set 3). Y is down everywhere, as +// on ghostty's Metal renderer: fragCoord, iChannel0's uv and the cursor +// rectangles share one top-left space, and iCurrentCursor is (left x, +// BOTTOM-edge y, width, height) in framebuffer pixels. + +layout(set = 3, binding = 0, std140) uniform Globals { + uniform vec3 iResolution; + uniform float iTime; + uniform float iTimeDelta; + uniform float iFrameRate; + uniform int iFrame; + uniform float iChannelTime[4]; + uniform vec3 iChannelResolution[4]; + uniform vec4 iMouse; + uniform vec4 iDate; + uniform float iSampleRate; + uniform vec4 iCurrentCursor; + uniform vec4 iPreviousCursor; + uniform vec4 iCurrentCursorColor; + uniform vec4 iPreviousCursorColor; + uniform int iCurrentCursorStyle; + uniform int iPreviousCursorStyle; + uniform int iCursorVisible; + uniform float iTimeCursorChange; + uniform float iTimeFocus; + uniform int iFocus; + uniform vec3 iPalette[256]; + uniform vec3 iBackgroundColor; + uniform vec3 iForegroundColor; + uniform vec3 iCursorColor; + uniform vec3 iCursorText; + uniform vec3 iSelectionForegroundColor; + uniform vec3 iSelectionBackgroundColor; +}; + +// pardes's own: 1 when the window is opaque, so a pass cannot punch holes +// in it through its alpha. +layout(set = 3, binding = 1, std140) uniform PardesPost { + float pardesOpaque; +}; + +#define CURSORSTYLE_BLOCK 0 +#define CURSORSTYLE_BLOCK_HOLLOW 1 +#define CURSORSTYLE_BAR 2 +#define CURSORSTYLE_UNDERLINE 3 +#define CURSORSTYLE_LOCK 4 + +layout(set = 2, binding = 0) uniform sampler2D iChannel0; + +layout(location = 0) out vec4 _fragColor; + +#define texture2D texture + +void mainImage(out vec4 fragColor, in vec2 fragCoord); +void main() { + mainImage(_fragColor, gl_FragCoord.xy); + if (pardesOpaque > 0.5) _fragColor.a = 1.0; +} + +// The shader's own lines count from 1, so glslc's errors name them. +#line 1 +// Glitch: now and then the signal slips. Every couple of seconds a short +// burst tears a few horizontal bands sideways and pulls their red and blue +// apart; the bands are sized in pixels and the burst eases in and out over +// a fraction of a second, so it reads as one event rather than a flicker, +// and it looks the same at 60 or 144 Hz because every step is keyed to +// iTime, not to frames. Between bursts the page is left exactly as it is. +// A torn band's brightness dips in linear light. It is a Shadertoy file: +// pardes puts its prefix before it. + +const float PERIOD = 2.4; // seconds between bursts, on average +const float BURST = 0.28; // seconds a burst lasts +const float STEP_HZ = 24.0; // how often a burst re-tears +const float SHIFT_PX = 14.0; +const float SPLIT_PX = 2.5; +const float DIP = 0.12; + +float hash(vec2 p) { + vec3 q = fract(vec3(p.xyx) * 0.1031); + q += dot(q, q.yzx + 33.33); + return fract((q.x + q.y) * q.z); +} + +vec3 toLinear(vec3 c) { + return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); +} + +vec3 toSrgb(vec3 c) { + c = max(c, vec3(0.0)); + return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 size = iResolution.xy; + vec2 uv = fragCoord / size; + // Which burst window this is, when in it the burst starts, and how far + // into the burst we are: 0 outside one. + float window = floor(iTime / PERIOD); + float start = window * PERIOD + hash(vec2(window, 7.0)) * (PERIOD - BURST); + float t = (iTime - start) / BURST; + float live = step(0.0, t) * step(t, 1.0); + float envelope = live * sin(3.14159265 * clamp(t, 0.0, 1.0)); + if (envelope <= 0.0) { + fragColor = texture(iChannel0, uv); + return; + } + // Bands of 6 to 40 px, re-cut at STEP_HZ during the burst; about one + // band in four tears. + float tick = floor(iTime * STEP_HZ); + float band_px = mix(6.0, 40.0, hash(vec2(tick, 3.0))); + float band = floor(fragCoord.y / band_px); + float roll = hash(vec2(band, tick)); + float tear = smoothstep(0.72, 0.9, roll) * envelope; + float shift = (hash(vec2(tick, band + 11.0)) - 0.5) * 2.0 * SHIFT_PX * tear; + vec2 at = vec2(fragCoord.x + shift, fragCoord.y); + vec2 split = vec2(SPLIT_PX * tear, 0.0); + vec4 center = texture(iChannel0, at / size); + vec3 col = vec3(texture(iChannel0, (at + split) / size).r, center.g, texture(iChannel0, (at - split) / size).b); + col = toSrgb(toLinear(col) * (1.0 - DIP * tear)); + fragColor = vec4(col, center.a); +} diff --git a/shaders/prebuilt/post-glitch.frag.spv b/shaders/prebuilt/post-glitch.frag.spv new file mode 100644 index 00000000..8a224958 Binary files /dev/null and b/shaders/prebuilt/post-glitch.frag.spv differ diff --git a/shaders/prebuilt/post-ripple.frag.glsl b/shaders/prebuilt/post-ripple.frag.glsl new file mode 100644 index 00000000..28059def --- /dev/null +++ b/shaders/prebuilt/post-ripple.frag.glsl @@ -0,0 +1,116 @@ +#version 450 +// pardes's Shadertoy prefix (docs/render-pipeline.md §6), put before every +// post pass: a bundled one in shaders/post/ at build time, a user's file at +// run time. The uniform names, types and std140 order are ghostty 1.3.2's +// (src/renderer/shaders/shadertoy_prefix.glsl), so a shader written for +// ghostty runs here; only the bindings are SDL GPU's (a fragment shader's +// textures in set 2, its uniform blocks in set 3). Y is down everywhere, as +// on ghostty's Metal renderer: fragCoord, iChannel0's uv and the cursor +// rectangles share one top-left space, and iCurrentCursor is (left x, +// BOTTOM-edge y, width, height) in framebuffer pixels. + +layout(set = 3, binding = 0, std140) uniform Globals { + uniform vec3 iResolution; + uniform float iTime; + uniform float iTimeDelta; + uniform float iFrameRate; + uniform int iFrame; + uniform float iChannelTime[4]; + uniform vec3 iChannelResolution[4]; + uniform vec4 iMouse; + uniform vec4 iDate; + uniform float iSampleRate; + uniform vec4 iCurrentCursor; + uniform vec4 iPreviousCursor; + uniform vec4 iCurrentCursorColor; + uniform vec4 iPreviousCursorColor; + uniform int iCurrentCursorStyle; + uniform int iPreviousCursorStyle; + uniform int iCursorVisible; + uniform float iTimeCursorChange; + uniform float iTimeFocus; + uniform int iFocus; + uniform vec3 iPalette[256]; + uniform vec3 iBackgroundColor; + uniform vec3 iForegroundColor; + uniform vec3 iCursorColor; + uniform vec3 iCursorText; + uniform vec3 iSelectionForegroundColor; + uniform vec3 iSelectionBackgroundColor; +}; + +// pardes's own: 1 when the window is opaque, so a pass cannot punch holes +// in it through its alpha. +layout(set = 3, binding = 1, std140) uniform PardesPost { + float pardesOpaque; +}; + +#define CURSORSTYLE_BLOCK 0 +#define CURSORSTYLE_BLOCK_HOLLOW 1 +#define CURSORSTYLE_BAR 2 +#define CURSORSTYLE_UNDERLINE 3 +#define CURSORSTYLE_LOCK 4 + +layout(set = 2, binding = 0) uniform sampler2D iChannel0; + +layout(location = 0) out vec4 _fragColor; + +#define texture2D texture + +void mainImage(out vec4 fragColor, in vec2 fragCoord); +void main() { + mainImage(_fragColor, gl_FragCoord.xy); + if (pardesOpaque > 0.5) _fragColor.a = 1.0; +} + +// The shader's own lines count from 1, so glslc's errors name them. +#line 1 +// Ripple: the page as a still pool a slow swell crosses. Rings travel out +// from the middle of the window at a fixed speed in pixels, so they look +// the same at any size and frame rate; the displacement is at most a couple +// of pixels, so text stays readable, and it eases in over its first half +// second instead of snapping on. Each ring catches the light a little on its +// rising side, in linear light, and the result is dithered so the shading +// never bands. It is a Shadertoy file: pardes puts its prefix before it. + +const float TAU = 6.28318530718; +const float WAVELENGTH_PX = 150.0; +const float SPEED_PX = 55.0; // outward, per second +const float AMPLITUDE_PX = 2.2; +const float SHADE = 0.035; // light on a slope, linear +const float EASE_IN = 0.5; // seconds + +vec3 toLinear(vec3 c) { + return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); +} + +vec3 toSrgb(vec3 c) { + c = max(c, vec3(0.0)); + return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); +} + +float ign(vec2 p) { + return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 size = iResolution.xy; + vec2 center = 0.5 * size; + vec2 offset = fragCoord - center; + float radius = length(offset); + float reach = 0.5 * length(size); + // Strongest a third of the way out, fading to nothing at the middle + // and at the edges, so the window's frame never wobbles. + float envelope = smoothstep(0.0, 0.18, radius / reach) * (1.0 - smoothstep(0.35, 0.95, radius / reach)); + float ease = smoothstep(0.0, EASE_IN, iTime); + float phase = (radius - iTime * SPEED_PX) * TAU / WAVELENGTH_PX; + float height = sin(phase); + float slope = cos(phase); + vec2 dir = offset / max(radius, 1e-3); + vec2 moved = fragCoord + dir * height * AMPLITUDE_PX * envelope * ease; + + vec4 s = texture(iChannel0, moved / size); + vec3 col = toLinear(s.rgb) * (1.0 + SHADE * slope * envelope * ease); + vec3 srgb = toSrgb(col) + (ign(fragCoord + float(iFrame % 64) * 5.588238) - 0.5) / 255.0; + fragColor = vec4(clamp(srgb, 0.0, 1.0), s.a); +} diff --git a/shaders/prebuilt/post-ripple.frag.spv b/shaders/prebuilt/post-ripple.frag.spv new file mode 100644 index 00000000..1a105a33 Binary files /dev/null and b/shaders/prebuilt/post-ripple.frag.spv differ diff --git a/shaders/prebuilt/post.vert.glsl b/shaders/prebuilt/post.vert.glsl new file mode 100644 index 00000000..86739a65 --- /dev/null +++ b/shaders/prebuilt/post.vert.glsl @@ -0,0 +1,10 @@ +#version 450 + +// A post pass (docs/render-pipeline.md §6): one fullscreen triangle from +// gl_VertexIndex, no vertex buffer. The Shadertoy fragment shader works from +// gl_FragCoord, y down, as its fragCoord. + +void main() { + vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); +} diff --git a/shaders/prebuilt/post.vert.spv b/shaders/prebuilt/post.vert.spv new file mode 100644 index 00000000..8a75ebb3 Binary files /dev/null and b/shaders/prebuilt/post.vert.spv differ diff --git a/src/animation.zig b/src/animation.zig index d6ef1ec3..948a6679 100644 --- a/src/animation.zig +++ b/src/animation.zig @@ -77,12 +77,6 @@ pub const Transition = enum(u8) { } }; -pub const SceneEffect = struct { - crt: bool = false, - ripple: bool = false, - glitch: bool = false, -}; - pub const Phase = enum(u8) { opening = 0, moving = 1, diff --git a/src/builtins.zig b/src/builtins.zig index 6bfc9b26..1864086f 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -156,7 +156,7 @@ pub const registry = struct { if (@intFromEnum(b) == i) return switch (setting.action) { // a switch flips bare, and DumpDir bare is the default .toggle, .transition, .scene, .dump_dir => false, - .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms => true, + .shell, .theme, .font, .tagline_size, .window_opacity, .window_blur, .message_ms, .shader, .shader_animation => true, }; unreachable; } diff --git a/src/config.zig b/src/config.zig index 122c0e75..1d4043ab 100644 --- a/src/config.zig +++ b/src/config.zig @@ -131,6 +131,8 @@ pub const leader_path = paths: { table.set(.Crt, "tr"); table.set(.Ripple, "tR"); table.set(.Glitch, "tg"); + table.set(.Shader, null); + table.set(.ShaderAnimation, null); } if (builtins.EffectCode.enabled) table.set(.EffectCode, null); if (builtins.TreeContext.enabled) table.set(.TreeContext, null); @@ -611,7 +613,64 @@ pub const Runtime = struct { } = .{}, panel_transition: animation.Transition = .off, - scene_effects: animation.SceneEffect = .{}, + /// The post chain (docs/render-pipeline.md §6), in the order it runs. + post: Post = .{}, + /// When the chain redraws on its own, past the frames it post-processes: + /// never, while the window has the focus, or always (ghostty's + /// custom-shader-animation). + shader_animation: ShaderAnimation = .on, + + pub const ShaderAnimation = enum { off, on, always }; + + /// Each pass a bundled effect (Crt, Ripple, Glitch) or a Shadertoy file. + pub const Post = struct { + pub const max = 8; + pub const Entry = struct { + scene: ?Scene = null, + path: Text(limits.host_path_cap) = .{}, + }; + entries: [max]Entry = @splat(.{}), + len: u8 = 0, + /// Bumped on every change: a shell compiles the chain again when it + /// moves. + generation: u32 = 0, + + pub fn list(post: *const Post) []const Entry { + return post.entries[0..post.len]; + } + + pub fn has(post: *const Post, scene: Scene) bool { + for (post.list()) |entry| if (entry.scene == scene) return true; + return false; + } + + /// Puts the pass in the chain, at its end, or takes it out; `want` + /// null flips it. False when the chain is full or the path too long. + pub fn set(post: *Post, scene: ?Scene, path: []const u8, want: ?bool) bool { + const at: ?usize = for (post.list(), 0..) |entry, i| { + if (entry.scene == scene and std.mem.eql(u8, entry.path.get(), path)) break i; + } else null; + const on = want orelse (at == null); + if (on == (at != null)) return true; + post.generation +%= 1; + if (at) |i| { + std.mem.copyForwards(Entry, post.entries[i .. post.len - 1], post.entries[i + 1 .. post.len]); + post.len -= 1; + return true; + } + if (post.len == max) return false; + post.entries[post.len] = .{ .scene = scene }; + if (!post.entries[post.len].path.set(path)) return false; + post.len += 1; + return true; + } + + pub fn clear(post: *Post) void { + if (post.len == 0) return; + post.len = 0; + post.generation +%= 1; + } + }; pub fn toggleTransition(state: *Runtime, effect: animation.Transition) void { std.debug.assert(effect != .off); @@ -690,7 +749,8 @@ pub const Runtime = struct { pub const MessageTime = enum { linger, fall, dissolve }; pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose, message_animation, ligatures }; - pub const Scene = std.meta.FieldEnum(animation.SceneEffect); + /// The bundled post passes, each a Shadertoy file in shaders/post/. + pub const Scene = enum { crt, ripple, glitch }; pub const Action = union(enum) { toggle: Toggle, @@ -704,6 +764,10 @@ pub const Runtime = struct { dump_dir, transition: animation.Transition, scene: Scene, + /// A Shadertoy file into the post chain or out of it; `off` empties + /// it of files. + shader, + shader_animation, }; pub const Setting = struct { @@ -756,6 +820,8 @@ pub const Runtime = struct { .{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders }, .{ .word = "Ripple", .action = .{ .scene = .ripple }, .availability = .scene_shaders }, .{ .word = "Glitch", .action = .{ .scene = .glitch }, .availability = .scene_shaders }, + .{ .word = "Shader", .action = .shader, .availability = .scene_shaders }, + .{ .word = "ShaderAnimation", .action = .shader_animation, .availability = .scene_shaders }, }; pub fn find(name: []const u8) ?Setting { @@ -789,7 +855,7 @@ pub const Runtime = struct { @compileError("window blur must use the window-blur capability"), .transition => if (setting.availability != .panel_transitions) @compileError("panel effects must use the panel-transition capability"), - .scene => if (setting.availability != .scene_shaders) + .scene, .shader, .shader_animation => if (setting.availability != .scene_shaders) @compileError("scene effects must use the scene-shader capability"), // A toggle is plain state any shell can hold, except one for // what only some shells draw: that one exists where they do. @@ -815,9 +881,9 @@ pub const Runtime = struct { for (std.enums.values(Scene)) |effect| { if (actionCount(.{ .scene = effect }) != 1) @compileError("scene effect must occur exactly once: " ++ @tagName(effect)); - if (@FieldType(animation.SceneEffect, @tagName(effect)) != bool) - @compileError("scene effect fields must be booleans: " ++ @tagName(effect)); } + if (actionCount(.shader) != 1 or actionCount(.shader_animation) != 1) + @compileError("Shader and ShaderAnimation must each occur exactly once"); } pub fn apply(state: *Runtime, setting: Setting, argument: ?[]const u8) bool { @@ -884,12 +950,24 @@ pub const Runtime = struct { }, .scene => |effect| { const want = switchedTo(argument) catch return false; - switch (effect) { - inline else => |field| { - const value = &@field(state.scene_effects, @tagName(field)); - value.* = want orelse !value.*; - }, + return state.post.set(effect, "", want); + }, + .shader => { + const path = std.mem.trim(u8, argument orelse return false, " \t\r\n"); + if (path.len == 0) return false; + if (std.mem.eql(u8, path, "off")) { + var i: usize = 0; + while (i < state.post.len) { + const entry = state.post.entries[i]; + if (entry.scene == null) _ = state.post.set(null, entry.path.get(), false) else i += 1; + } + return true; } + return state.post.set(null, path, null); + }, + .shader_animation => { + const text = std.mem.trim(u8, argument orelse return false, " \t\r\n"); + state.shader_animation = std.meta.stringToEnum(ShaderAnimation, text) orelse return false; }, .theme, .font => return false, } @@ -1041,11 +1119,24 @@ pub const Runtime = struct { try out.print("{s} unsupported\n", .{setting.word}); continue; } - const enabled = switch (effect) { - inline else => |field| @field(state.scene_effects, @tagName(field)), + try out.print("{s} {s}\n", .{ setting.word, onOff(state.post.has(effect)) }); + }, + .shader => { + if (!setting.enabled(context.capabilities)) { + try out.print("{s} unsupported\n", .{setting.word}); + continue; + } + var files: usize = 0; + for (state.post.list()) |entry| if (entry.scene == null) { + try out.print("{s} {s}\n", .{ setting.word, entry.path.get() }); + files += 1; }; - try out.print("{s} {s}\n", .{ setting.word, onOff(enabled) }); + if (files == 0) try out.print("{s} off\n", .{setting.word}); }, + .shader_animation => if (setting.enabled(context.capabilities)) + try out.print("{s} {s}\n", .{ setting.word, @tagName(state.shader_animation) }) + else + try out.print("{s} unsupported\n", .{setting.word}), }; if (context.startup_config_path) |path| @@ -1092,9 +1183,7 @@ pub const Runtime = struct { const scene = findAction(.{ .scene = std.enums.values(Scene)[0] }).?; try std.testing.expect(apply(&state, scene, "on")); try std.testing.expect(apply(&state, scene, "on")); - switch (std.enums.values(Scene)[0]) { - inline else => |f| try std.testing.expect(@field(state.scene_effects, @tagName(f))), - } + try std.testing.expect(state.post.has(std.enums.values(Scene)[0])); } test "a toggle flips when bare and sets when told on or off" { @@ -1131,7 +1220,30 @@ pub const Runtime = struct { try std.testing.expect(apply(&state, find("PanelAscii").?, null)); try std.testing.expectEqual(animation.Transition.off, state.panel_transition); try std.testing.expect(apply(&state, find("Crt").?, null)); - try std.testing.expect(state.scene_effects.crt); + try std.testing.expect(state.post.has(.crt)); + } + + test "the post chain runs in the order its passes were put in, and Shader off empties it of files" { + var state: Runtime = .{}; + try std.testing.expect(apply(&state, find("Shader").?, " ~/a.glsl\n")); + try std.testing.expect(apply(&state, find("Glitch").?, "on")); + try std.testing.expect(apply(&state, find("Shader").?, "/b.glsl")); + const chain = state.post.list(); + try std.testing.expectEqual(@as(usize, 3), chain.len); + try std.testing.expectEqualStrings("~/a.glsl", chain[0].path.get()); + try std.testing.expectEqual(Scene.glitch, chain[1].scene.?); + try std.testing.expectEqualStrings("/b.glsl", chain[2].path.get()); + const moved = state.post.generation; + // A file named again leaves; the others keep their order. + try std.testing.expect(apply(&state, find("Shader").?, "~/a.glsl")); + try std.testing.expect(state.post.generation != moved); + try std.testing.expectEqual(Scene.glitch, state.post.list()[0].scene.?); + try std.testing.expect(apply(&state, find("Shader").?, "off")); + try std.testing.expectEqual(@as(usize, 1), state.post.len); + try std.testing.expect(!apply(&state, find("Shader").?, null)); + try std.testing.expect(apply(&state, find("ShaderAnimation").?, "always")); + try std.testing.expectEqual(ShaderAnimation.always, state.shader_animation); + try std.testing.expect(!apply(&state, find("ShaderAnimation").?, "sometimes")); } test "tagline size validates before mutating live state" { @@ -1252,6 +1364,8 @@ pub const Runtime = struct { .window_blur => "40", .message_ms => "1500", .dump_dir => "~/dumps", + .shader => "~/crt.glsl", + .shader_animation => "always", else => null, }; try std.testing.expect(apply(&state, setting, argument)); @@ -1302,6 +1416,8 @@ pub const Runtime = struct { .ripple => "Ripple on\n", .glitch => "Glitch on\n", }, + .shader => "Shader ~/crt.glsl\n", + .shader_animation => "ShaderAnimation always\n", .theme, .font => unreachable, }; try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); diff --git a/src/effect_sources.zig b/src/effect_sources.zig index 08680860..6ce11160 100644 --- a/src/effect_sources.zig +++ b/src/effect_sources.zig @@ -11,8 +11,11 @@ const ui_vertex_path = shader_dir ++ "ui.vert.glsl"; const ui_fragment_path = shader_dir ++ "ui.frag.glsl"; const image_vertex_path = shader_dir ++ "image.vert.glsl"; const image_fragment_path = shader_dir ++ "image.frag.glsl"; -const scene_vertex_path = shader_dir ++ "crt.vert.glsl"; -const scene_fragment_path = shader_dir ++ "crt.frag.glsl"; +const post_vertex_path = shader_dir ++ "post.vert.glsl"; +// The bundled post passes as compiled: the prefix and the pass together. +const post_crt_path = shader_dir ++ "post-crt.frag.glsl"; +const post_ripple_path = shader_dir ++ "post-ripple.frag.glsl"; +const post_glitch_path = shader_dir ++ "post-glitch.frag.glsl"; pub const Backend = @TypeOf(build_config.platform); pub const backend: Backend = build_config.platform; @@ -45,13 +48,15 @@ pub const files = switch (backend) { }, .gui => [_]filesystem.Source{ .{ .path = "src/gui/gui.zig", .contents = @embedFile("gui/gui.zig") }, - .{ .path = "src/gui/crt.zig", .contents = @embedFile("gui/crt.zig") }, + .{ .path = "src/gui/Post.zig", .contents = @embedFile("gui/Post.zig") }, .{ .path = ui_vertex_path, .contents = @embedFile("effect-source-ui.vert.glsl") }, .{ .path = ui_fragment_path, .contents = @embedFile("effect-source-ui.frag.glsl") }, .{ .path = image_vertex_path, .contents = @embedFile("effect-source-image.vert.glsl") }, .{ .path = image_fragment_path, .contents = @embedFile("effect-source-image.frag.glsl") }, - .{ .path = scene_vertex_path, .contents = @embedFile("effect-source-crt.vert.glsl") }, - .{ .path = scene_fragment_path, .contents = @embedFile("effect-source-crt.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_ripple_path, .contents = @embedFile("effect-source-post-ripple.frag.glsl") }, + .{ .path = post_glitch_path, .contents = @embedFile("effect-source-post-glitch.frag.glsl") }, }, .macos => [_]filesystem.Source{ .{ .path = "src/macos/Sources/PardesView.swift", .contents = @embedFile("macos/Sources/PardesView.swift") }, @@ -69,8 +74,12 @@ pub fn forSetting(setting: config.Runtime.Setting) ?[]const []const u8 { .macos => &.{ "src/layout.zig", "src/pardes.zig", "src/macos/Sources/PardesView.swift", "src/macos/Sources/ScenePostprocessor.swift", "shaders/crt.ci.metal" }, .web, .esp32p4 => null, }, - .scene => switch (backend) { - .gui => &.{ "src/gui/crt.zig", "src/gui/gui.zig", scene_vertex_path, scene_fragment_path }, + .scene => |effect| switch (backend) { + .gui => switch (effect) { + .crt => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_crt_path }, + .ripple => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_ripple_path }, + .glitch => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_glitch_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 new file mode 100644 index 00000000..d92623d7 --- /dev/null +++ b/src/gui/Post.zig @@ -0,0 +1,559 @@ +//! The post chain (docs/render-pipeline.md §6): Shadertoy passes over the +//! finished frame, each reading the one before it (the frame first) and the +//! last one writing the window. A pass is bundled (Crt, Ripple, Glitch: +//! shaders/post/, compiled with the build) or a user's file (Shader ), +//! compiled when it joins the chain by the build's own glslc invocation on a +//! thread of its own, never on the frame path. A file whose compile fails +//! keeps its last good pipeline, and glslc missing is said once. +//! +//! The uniforms are ghostty's (Uniforms), so a shader written for ghostty runs +//! here. Their clock is the shell's and moves only when the chain draws: +//! with a frame of the core's, or on its own while ShaderAnimation asks for +//! it (redraw level A, §5.5: the chain alone, over the retained frame). +const std = @import("std"); +const libc = std.c; +const ghostty_vt = @import("ghostty-vt"); +const pardes = @import("../pardes.zig"); +const filesystem = @import("../fs.zig"); +const host_io = @import("../host_io.zig"); +const gui = @import("gui.zig"); +const c = gui.c; + +const Post = @This(); +const Scene = pardes.config.Runtime.Scene; +const Chain = pardes.config.Runtime.Post; +const Pipeline = c.SDL_GPUGraphicsPipeline; +pub const max = Chain.max; + +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"), + .ripple = @embedFile("post-ripple.frag.spv"), + .glitch = @embedFile("post-glitch.frag.spv"), +}); + +/// 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 +/// names: ghostty's own Zig mirror calls the second-to-last slot +/// selection_background, so there the two selection colours arrive swapped +/// (docs §6, open point 1); here each is what its GLSL name says. +pub const Uniforms = extern struct { + resolution: [3]f32 align(16) = .{ 0, 0, 1 }, + time: f32 align(4) = 0, + time_delta: f32 align(4) = 0, + frame_rate: f32 align(4) = 60, + frame: i32 align(4) = 0, + channel_time: [4][4]f32 align(16) = @splat(@splat(0)), + channel_resolution: [4][4]f32 align(16) = @splat(@splat(0)), + mouse: [4]f32 align(16) = @splat(0), + date: [4]f32 align(16) = @splat(0), + sample_rate: f32 align(4) = 44100, + current_cursor: [4]f32 align(16) = @splat(0), + previous_cursor: [4]f32 align(16) = @splat(0), + current_cursor_color: [4]f32 align(16) = @splat(0), + previous_cursor_color: [4]f32 align(16) = @splat(0), + current_cursor_style: i32 align(4) = 0, + previous_cursor_style: i32 align(4) = 0, + cursor_visible: i32 align(4) = 0, + cursor_change_time: f32 align(4) = 0, + time_focus: f32 align(4) = 0, + focus: i32 align(4) = 1, + palette: [256][4]f32 align(16) = @splat(@splat(0)), + background_color: [4]f32 align(16) = @splat(0), + foreground_color: [4]f32 align(16) = @splat(0), + cursor_color: [4]f32 align(16) = @splat(0), + cursor_text: [4]f32 align(16) = @splat(0), + selection_foreground_color: [4]f32 align(16) = @splat(0), + selection_background_color: [4]f32 align(16) = @splat(0), +}; + +pub const Pass = struct { + scene: ?Scene = null, + /// A user's file, owned; empty for a bundled pass. + path: []u8 = &.{}, + pipeline: ?*Pipeline = null, +}; + +/// One compile of a chain's new files, on its own thread. +const Job = struct { + const Result = union(enum) { none, spirv: []u8, failed: []u8, missing }; + io: std.Io, + gpa: std.mem.Allocator, + paths: [max][]u8 = @splat(&.{}), + results: [max]Result = @splat(.none), + len: usize = 0, + done: std.atomic.Value(bool) = .init(false), + thread: std.Thread = undefined, +}; + +passes: [max]Pass = @splat(.{}), +len: usize = 0, +/// The chain generation `passes` follow; null before the first. +generation: ?u32 = null, +bundled: std.EnumArray(Scene, ?*Pipeline) = .initFill(null), +job: ?*Job = null, +/// Said once: without glslc, files never compile. +missing_said: bool = false, +ping: [2]?*c.SDL_GPUTexture = .{ null, null }, +ping_w: u32 = 0, +ping_h: u32 = 0, +uniforms: Uniforms = .{}, +started_ns: ?u64 = null, +last_ns: u64 = 0, + +/// Brings the passes to the chain the core holds, and takes in a finished +/// compile. Called each loop step, off the frame path. +pub fn sync(post: *Post, gpa: std.mem.Allocator, io: std.Io, device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, core: *pardes.Pardes) void { + if (post.job) |job| { + if (!job.done.load(.acquire)) return; + job.thread.join(); + post.job = null; + post.collect(gpa, device, format, core, job); + } + const chain = &core.settings.post; + if (post.generation == chain.generation) return; + post.generation = chain.generation; + 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); + next[i] = .{ .scene = scene, .pipeline = post.bundled.get(scene) }; + continue; + } + // A file already in the chain keeps its pipeline and its path. + for (post.passes[0..post.len]) |*pass| { + if (pass.scene != null or !std.mem.eql(u8, pass.path, entry.path.get())) continue; + next[i] = pass.*; + pass.* = .{}; + break; + } else next[i] = .{ .path = gpa.dupe(u8, entry.path.get()) catch &.{} }; + } + for (post.passes[0..post.len]) |pass| { + if (pass.scene == null) if (pass.pipeline) |pipeline| c.SDL_ReleaseGPUGraphicsPipeline(device, pipeline); + gpa.free(pass.path); + } + post.passes = next; + post.len = chain.len; + // An emptied chain's clock starts again with the next pass put in it. + if (post.len == 0) { + post.started_ns = null; + post.last_ns = 0; + post.uniforms.frame = 0; + } + // The new files compile together, on a thread of their own. + const job = gpa.create(Job) catch return; + job.* = .{ .io = io, .gpa = gpa }; + for (post.passes[0..post.len]) |pass| { + if (pass.scene != null or pass.pipeline != null or pass.path.len == 0) continue; + job.paths[job.len] = gpa.dupe(u8, pass.path) catch continue; + job.len += 1; + } + if (job.len == 0) return gpa.destroy(job); + job.thread = std.Thread.spawn(.{}, compile, .{job}) catch { + for (job.paths[0..job.len]) |path| gpa.free(path); + return gpa.destroy(job); + }; + post.job = job; +} + +fn collect(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, core: *pardes.Pardes, job: *Job) void { + defer gpa.destroy(job); + for (job.paths[0..job.len], job.results[0..job.len]) |path, result| { + defer gpa.free(path); + const pass = for (post.passes[0..post.len]) |*pass| { + if (pass.scene == null and std.mem.eql(u8, pass.path, path)) break pass; + } else null; + var buf: [512]u8 = undefined; + switch (result) { + .none => {}, + .missing => if (!post.missing_said) { + post.missing_said = true; + core.setMessage(core.active, "Shader: glslc is not on PATH, so Shadertoy files do not run (Crt, Ripple and Glitch do)"); + }, + .failed => |text| { + defer gpa.free(text); + // glslc's first line says where: in the file's own lines. + const first = std.mem.sliceTo(text, '\n'); + core.setMessage(core.active, std.fmt.bufPrint(&buf, "Shader {s}: {s}", .{ path, first[0..@min(first.len, 400)] }) catch "Shader: compile failed"); + }, + .spirv => |spirv| { + defer gpa.free(spirv); + const target = pass orelse continue; + const made = makePipeline(device, format, spirv) catch { + core.setMessage(core.active, std.fmt.bufPrint(&buf, "Shader {s}: the GPU refused it", .{path}) catch "Shader: the GPU refused it"); + continue; + }; + if (target.pipeline) |old| c.SDL_ReleaseGPUGraphicsPipeline(device, old); + target.pipeline = made; + }, + } + } +} + +/// The compile thread: each file behind the prefix, through glslc exactly as +/// the build calls it (build.zig compileGlsl), its SPIR-V on stdout. +fn compile(job: *Job) void { + defer job.done.store(true, .release); + defer { + var event = std.mem.zeroes(c.SDL_Event); + event.type = c.SDL_EVENT_USER; + _ = c.SDL_PushEvent(&event); + } + for (job.paths[0..job.len], 0..) |path, i| job.results[i] = compileOne(job.gpa, job.io, path, i); +} + +fn compileOne(gpa: std.mem.Allocator, io: std.Io, path: []const u8, index: usize) Job.Result { + var home_buf: [4096]u8 = undefined; + const expanded = if (std.mem.startsWith(u8, path, "~/")) if (libc.getenv("HOME")) |home| + std.fmt.bufPrint(&home_buf, "{s}/{s}", .{ std.mem.span(home), path[2..] }) catch path + else + path else path; + const body = filesystem.readFile(gpa, expanded) catch |err| + return .{ .failed = std.fmt.allocPrint(gpa, "cannot read it ({t})", .{err}) catch return .none }; + defer gpa.free(body); + const dir = if (libc.getenv("XDG_RUNTIME_DIR")) |d| std.mem.span(d) else "/tmp"; + var source_buf: [4096]u8 = undefined; + const source = std.fmt.bufPrintSentinel(&source_buf, "{s}/pardes-post-{d}-{d}.frag.glsl", .{ dir, libc.getpid(), index }, 0) catch return .none; + defer _ = libc.unlink(source); + { + const fd = libc.open(source, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o600)); + if (fd < 0) return .{ .failed = gpa.dupe(u8, "cannot write its source for glslc") catch return .none }; + defer _ = libc.close(fd); + if (!host_io.writeFd(fd, prefix) or !host_io.writeFd(fd, body)) + return .{ .failed = gpa.dupe(u8, "cannot write its source for glslc") catch return .none }; + } + const run = std.process.run(gpa, io, .{ + .argv = &.{ "glslc", "-fshader-stage=fragment", source, "-o", "-" }, + .stdout_limit = .limited(16 << 20), + .stderr_limit = .limited(64 << 10), + }) catch |err| return switch (err) { + error.FileNotFound => .missing, + else => .{ .failed = std.fmt.allocPrint(gpa, "glslc did not run ({t})", .{err}) catch return .none }, + }; + const clean = switch (run.term) { + .exited => |code| code == 0, + else => false, + }; + if (clean) { + gpa.free(run.stderr); + return .{ .spirv = run.stdout }; + } + gpa.free(run.stdout); + // glslc names the temporary source; the file is what a person knows. + defer gpa.free(run.stderr); + const named = std.mem.replaceOwned(u8, gpa, run.stderr, source, path) catch return .{ .failed = gpa.dupe(u8, "glslc failed") catch return .none }; + return .{ .failed = named }; +} + +fn makePipeline(device: *c.SDL_GPUDevice, format: c.SDL_GPUTextureFormat, frag: []const u8) !*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); + 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); + info.vertex_shader = vs; + info.fragment_shader = fs; + info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; + info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL; + info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE; + info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1; + info.target_info = .{ .color_target_descriptions = &target, .num_color_targets = 1 }; + return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; +} + +/// A pass is ready to draw. +pub fn ready(post: *const Post) bool { + for (post.passes[0..post.len]) |pass| if (pass.pipeline != null) return true; + return false; +} + +/// The chain redraws on its own (level A) as ShaderAnimation says. +pub fn animating(post: *const Post, mode: pardes.config.Runtime.ShaderAnimation) bool { + return post.ready() and switch (mode) { + .off => false, + .on => post.uniforms.focus != 0, + .always => true, + }; +} + +/// What a frame of the core's tells the passes: where its cursor is drawn +/// and in what, and the theme's colours. +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, + background: [3]u8, + foreground: [3]u8, + selection_foreground: [3]u8, + selection_background: [3]u8, +}; + +fn rgba(rgb: [3]u8) [4]f32 { + return .{ @as(f32, @floatFromInt(rgb[0])) / 255, @as(f32, @floatFromInt(rgb[1])) / 255, @as(f32, @floatFromInt(rgb[2])) / 255, 1 }; +} + +/// Takes in a frame of the core's. The cursor's rectangle is (left, BOTTOM +/// edge, width, height) in framebuffer pixels, y down; a move or a new +/// colour makes the old one the previous and stamps the change. +pub fn describe(post: *Post, frame: Frame) void { + const u = &post.uniforms; + u.background_color = rgba(frame.background); + u.foreground_color = rgba(frame.foreground); + u.selection_foreground_color = rgba(frame.selection_foreground); + u.selection_background_color = rgba(frame.selection_background); + if (u.palette[1][3] == 0) for (&u.palette, 0..) |*slot, i| { + const color = ghostty_vt.color.default[i]; + slot.* = rgba(.{ color.r, color.g, color.b }); + }; + u.cursor_visible = @intFromBool(frame.cursor != null); + const cursor = frame.cursor orelse return; + const rect: [4]f32 = .{ cursor.x0, cursor.y1, cursor.x1 - cursor.x0, cursor.y1 - cursor.y0 }; + const style: i32 = if (cursor.bar) 2 else 0; + const color = rgba(cursor.rgb); + u.cursor_color = color; + u.cursor_text = rgba(cursor.text); + if (std.meta.eql(rect, u.current_cursor) and std.meta.eql(color, u.current_cursor_color) and style == u.current_cursor_style) return; + u.previous_cursor = u.current_cursor; + u.previous_cursor_color = u.current_cursor_color; + u.previous_cursor_style = u.current_cursor_style; + u.current_cursor = rect; + u.current_cursor_color = color; + u.current_cursor_style = style; + u.cursor_change_time = u.time; +} + +/// 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; + post.uniforms.focus = @intFromBool(focused); +} + +/// The pointer, in framebuffer pixels: Shadertoy's iMouse, xy where it is, +/// zw where the last press was, negative once released. +pub fn mouse(post: *Post, x: f32, y: f32, press: ?bool) void { + const m = &post.uniforms.mouse; + m[0] = x; + m[1] = y; + if (press) |down| { + if (down) { + m[2] = x; + m[3] = y; + } else { + m[2] = -@abs(m[2]); + m[3] = -@abs(m[3]); + } + } +} + +/// Runs the passes over `source`, the finished frame, into `target`: each +/// reads the one before, the ones between write two ping-pong textures that +/// start cleared, and iTime and iFrame move by this one draw. +pub fn draw(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer, sampler: *c.SDL_GPUSampler, format: c.SDL_GPUTextureFormat, source: *c.SDL_GPUTexture, target: *c.SDL_GPUTexture, w: u32, h: u32, now_ns: u64, opaque_window: bool) !void { + var live: usize = 0; + for (post.passes[0..post.len]) |pass| live += @intFromBool(pass.pipeline != null); + if (live == 0) return; + if (live > 1 and (post.ping[0] == null or post.ping_w != w or post.ping_h != h)) { + for (&post.ping) |*texture| { + 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 = format; + info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | c.SDL_GPU_TEXTUREUSAGE_SAMPLER; + info.width = w; + info.height = h; + 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.ping_w = w; + post.ping_h = h; + } + const u = &post.uniforms; + const started = post.started_ns orelse now_ns; + post.started_ns = started; + const elapsed = @as(f64, @floatFromInt(now_ns -| started)) / std.time.ns_per_s; + const delta: f32 = if (post.last_ns == 0) 0 else @floatCast(@as(f64, @floatFromInt(now_ns -| post.last_ns)) / std.time.ns_per_s); + post.last_ns = now_ns; + u.time = @floatCast(elapsed); + u.time_delta = delta; + u.frame_rate = if (delta > 0) 1 / delta else 60; + u.resolution = .{ @floatFromInt(w), @floatFromInt(h), 1 }; + u.channel_resolution[0] = .{ @floatFromInt(w), @floatFromInt(h), 1, 0 }; + // UTC: the year, the month from 0, the day, the second of the day. + var wall: libc.timespec = undefined; + if (libc.clock_gettime(.REALTIME, &wall) != 0) wall.sec = 0; + const epoch: std.time.epoch.EpochSeconds = .{ .secs = @intCast(@max(0, wall.sec)) }; + const day = epoch.getEpochDay().calculateYearDay(); + const month = day.calculateMonthDay(); + u.date = .{ @floatFromInt(day.year), @floatFromInt(month.month.numeric() - 1), @floatFromInt(month.day_index + 1), @floatFromInt(epoch.getDaySeconds().secs) }; + const own: [4]f32 = .{ @floatFromInt(@intFromBool(opaque_window)), 0, 0, 0 }; + + var input = source; + var index: usize = 0; + for (post.passes[0..post.len]) |pass| { + const pipeline = pass.pipeline orelse continue; + index += 1; + const last = index == live; + const output = if (last) target else post.ping[index % 2].?; + var info = std.mem.zeroes(c.SDL_GPUColorTargetInfo); + 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; + 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)); + c.SDL_PushGPUFragmentUniformData(cmd, 1, &own, @sizeOf(@TypeOf(own))); + c.SDL_DrawGPUPrimitives(pass_rp, 3, 1, 0, 0); + c.SDL_EndGPURenderPass(pass_rp); + input = output; + } + u.frame +%= 1; +} + +pub fn deinit(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice) void { + if (post.job) |job| { + job.thread.join(); + for (job.paths[0..job.len], job.results[0..job.len]) |path, result| { + gpa.free(path); + switch (result) { + .spirv, .failed => |bytes| gpa.free(bytes), + .none, .missing => {}, + } + } + gpa.destroy(job); + } + for (post.passes[0..post.len]) |pass| { + if (pass.scene == null) if (pass.pipeline) |pipeline| c.SDL_ReleaseGPUGraphicsPipeline(device, pipeline); + gpa.free(pass.path); + } + for (post.bundled.values) |pipeline| if (pipeline) |p| c.SDL_ReleaseGPUGraphicsPipeline(device, p); + for (post.ping) |texture| if (texture) |t| c.SDL_ReleaseGPUTexture(device, t); +} + +/// ghostty 1.3.2's Uniforms (src/renderer/shadertoy.zig), copied: the offsets +/// ours must match. Its last two names are swapped against its own GLSL. +const GhosttyUniforms = extern struct { + resolution: [3]f32 align(16), + time: f32 align(4), + time_delta: f32 align(4), + frame_rate: f32 align(4), + frame: i32 align(4), + channel_time: [4][4]f32 align(16), + channel_resolution: [4][4]f32 align(16), + mouse: [4]f32 align(16), + date: [4]f32 align(16), + sample_rate: f32 align(4), + current_cursor: [4]f32 align(16), + previous_cursor: [4]f32 align(16), + current_cursor_color: [4]f32 align(16), + previous_cursor_color: [4]f32 align(16), + current_cursor_style: i32 align(4), + previous_cursor_style: i32 align(4), + cursor_visible: i32 align(4), + cursor_change_time: f32 align(4), + time_focus: f32 align(4), + focus: i32 align(4), + palette: [256][4]f32 align(16), + background_color: [4]f32 align(16), + foreground_color: [4]f32 align(16), + cursor_color: [4]f32 align(16), + cursor_text: [4]f32 align(16), + selection_background_color: [4]f32 align(16), + selection_foreground_color: [4]f32 align(16), +}; + +test "the uniform block is ghostty's, offset for offset, and the prefix names it in that order" { + const ours = @typeInfo(Uniforms).@"struct".fields; + const theirs = @typeInfo(GhosttyUniforms).@"struct".fields; + try std.testing.expectEqual(theirs.len, ours.len); + try std.testing.expectEqual(@sizeOf(GhosttyUniforms), @sizeOf(Uniforms)); + inline for (ours, theirs) |a, b| { + try std.testing.expectEqual(@offsetOf(GhosttyUniforms, b.name), @offsetOf(Uniforms, a.name)); + try std.testing.expectEqual(b.type, a.type); + } + // By NAME: iSelectionForegroundColor sits where ghostty's Zig says + // selection_background, which is where its GLSL puts the foreground. + try std.testing.expectEqual(@offsetOf(GhosttyUniforms, "selection_background_color"), @offsetOf(Uniforms, "selection_foreground_color")); + // The prefix's block, member by member, in ghostty's GLSL names and types. + const glsl = [_]struct { []const u8, []const u8 }{ + .{ "vec3", "iResolution" }, .{ "float", "iTime" }, .{ "float", "iTimeDelta" }, + .{ "float", "iFrameRate" }, .{ "int", "iFrame" }, .{ "float", "iChannelTime[4]" }, + .{ "vec3", "iChannelResolution[4]" }, .{ "vec4", "iMouse" }, .{ "vec4", "iDate" }, + .{ "float", "iSampleRate" }, .{ "vec4", "iCurrentCursor" }, .{ "vec4", "iPreviousCursor" }, + .{ "vec4", "iCurrentCursorColor" }, .{ "vec4", "iPreviousCursorColor" }, .{ "int", "iCurrentCursorStyle" }, + .{ "int", "iPreviousCursorStyle" }, .{ "int", "iCursorVisible" }, .{ "float", "iTimeCursorChange" }, + .{ "float", "iTimeFocus" }, .{ "int", "iFocus" }, .{ "vec3", "iPalette[256]" }, + .{ "vec3", "iBackgroundColor" }, .{ "vec3", "iForegroundColor" }, .{ "vec3", "iCursorColor" }, + .{ "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().?, " ;"); + 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.expect(lines.next() == null); +} + +test "a moved cursor becomes the previous one, stamped with the time of the move" { + var post: Post = .{}; + const frame: Frame = .{ .cursor = .{ .x0 = 10, .y0 = 20, .x1 = 11, .y1 = 40, .rgb = .{ 255, 0, 0 }, .text = .{ 0, 0, 0 }, .bar = true }, .background = .{ 0, 0, 0 }, .foreground = .{ 255, 255, 255 }, .selection_foreground = .{ 1, 2, 3 }, .selection_background = .{ 4, 5, 6 } }; + post.uniforms.time = 1.5; + post.describe(frame); + // Left, BOTTOM edge, width, height, in framebuffer pixels, y down. + try std.testing.expectEqual([4]f32{ 10, 40, 1, 20 }, post.uniforms.current_cursor); + try std.testing.expectEqual(@as(i32, 2), post.uniforms.current_cursor_style); + try std.testing.expectEqual(@as(f32, 1.5), post.uniforms.cursor_change_time); + post.uniforms.time = 2; + post.describe(frame); + try std.testing.expectEqual(@as(f32, 1.5), post.uniforms.cursor_change_time); + var moved = frame; + moved.cursor.?.x0 = 20; + moved.cursor.?.x1 = 21; + post.describe(moved); + try std.testing.expectEqual([4]f32{ 10, 40, 1, 20 }, post.uniforms.previous_cursor); + try std.testing.expectEqual(@as(f32, 2), post.uniforms.cursor_change_time); + try std.testing.expectEqual([4]f32{ 1.0 / 255.0, 2.0 / 255.0, 3.0 / 255.0, 1 }, post.uniforms.selection_foreground_color); +} + +test "ghostty's test shaders compile behind the prefix, and its invalid one fails with glslc's words" { + const gpa = std.testing.allocator; + const io = std.testing.io; + // ghostty's own test shaders, from its package in zig-pkg. + var pkg = std.Io.Dir.cwd().openDir(io, "zig-pkg", .{ .iterate = true }) catch return error.SkipZigTest; + defer pkg.close(io); + var it = pkg.iterate(); + const ghostty = while (it.next(io) catch null) |entry| { + if (std.mem.startsWith(u8, entry.name, "ghostty-")) break entry.name; + } else return error.SkipZigTest; + var buf: [512]u8 = undefined; + for ([_][]const u8{ "crt", "focus", "invalid" }, 0..) |name, i| { + const path = try std.fmt.bufPrint(&buf, "zig-pkg/{s}/src/renderer/shaders/test_shadertoy_{s}.glsl", .{ ghostty, name }); + const result = compileOne(gpa, io, path, i); + switch (result) { + .missing => return error.SkipZigTest, // no glslc here + .spirv => |spirv| { + defer gpa.free(spirv); + try std.testing.expect(!std.mem.eql(u8, name, "invalid")); + // SPIR-V's magic number, little-endian. + try std.testing.expectEqual(@as(u32, 0x07230203), std.mem.readInt(u32, spirv[0..4], .little)); + }, + .failed => |text| { + defer gpa.free(text); + try std.testing.expectEqualStrings("invalid", name); + // Named by the file, not by the temporary source glslc read. + try std.testing.expect(std.mem.indexOf(u8, text, path) != null); + try std.testing.expect(std.mem.indexOf(u8, text, "pardes-post-") == null); + }, + .none => return error.TestUnexpectedResult, + } + } +} diff --git a/src/gui/crt.zig b/src/gui/crt.zig deleted file mode 100644 index 2a5ea7e4..00000000 --- a/src/gui/crt.zig +++ /dev/null @@ -1,101 +0,0 @@ -//! The Crt builtin's screen geometry, shared between the gui shell's input -//! mapping and its shader: crt.frag.glsl shows the scene point -//! uv' = cc*(1 + barrel*r2) at screen point cc, so a physical mouse position -//! must pass through the SAME forward transform before it becomes a cell. -//! Pure std, so the unit-test step can pin it against the shader formula. - -const std = @import("std"); - -/// MUST match BARREL in shaders/crt.frag.glsl. -pub const barrel: f32 = 0.016; - -pub const Point = struct { x: f32, y: f32 }; -pub const Effects = struct { crt: bool = false, ripple: bool = false, glitch: bool = false }; -pub const Frame = struct { effects: Effects = .{}, time_seconds: f32 = 0 }; - -fn fract(x: f32) f32 { - return x - @floor(x); -} - -fn smoothstep(lo: f32, hi: f32, value: f32) f32 { - const t = std.math.clamp((value - lo) / (hi - lo), 0, 1); - return t * t * (3 - 2 * t); -} - -/// Match `sceneUv` in crt.frag.glsl. This is a forward screen→sample mapping: -/// a physical pointer is sent to the logical scene pixel displayed under it. -pub fn mapScene(x: f32, y: f32, w: f32, h: f32, effects: Effects, time_seconds: f32) ?Point { - const safe_w = @max(1, w); - const safe_h = @max(1, h); - const ccx = x / safe_w * 2.0 - 1.0; - const ccy = y / safe_h * 2.0 - 1.0; - const s = 1.0 + barrel * (ccx * ccx + ccy * ccy) * @as(f32, @floatFromInt(@intFromBool(effects.crt))); - var u = ccx * s * 0.5 + 0.5; - var v = ccy * s * 0.5 + 0.5; - // The CRT shader paints no scene when this barrel-mapped coordinate lies - // on or beyond the tube edge. There is no logical edge cell under those - // black pixels, so input must be inert there rather than clamped. - if (effects.crt and (u <= 0 or u >= 1 or v <= 0 or v >= 1)) return null; - if (effects.ripple) { - const cx = u - 0.5; - const cy = v - 0.5; - const radius = @sqrt(cx * cx + cy * cy); - const envelope = 1.0 - smoothstep(0.04, 0.72, radius); - const wave = @sin(radius * 42.0 - time_seconds * 5.2); - const len = @max(radius, 0.00001); - u += cx / len * wave * envelope * 0.0032; - v += cy / len * wave * envelope * 0.0032; - } - if (effects.glitch) { - const band = @floor(v * 96.0); - const tick = @floor(time_seconds * 13.0); - const seed = noiseAt(band, tick); - const tear = smoothstep(0.955, 1.0, seed); - u += (noiseAt(seed, band) - 0.5) * tear * 0.020; - } - return .{ - .x = std.math.clamp(u * safe_w, 0, safe_w - 1), - .y = std.math.clamp(v * safe_h, 0, safe_h - 1), - }; -} - -fn noiseAt(x: f32, y: f32) f32 { - var qx = fract(x * 0.1031); - var qy = fract(y * 0.1031); - var qz = fract(x * 0.1031); - const d = qx * (qy + 33.33) + qy * (qz + 33.33) + qz * (qx + 33.33); - qx += d; - qy += d; - qz += d; - return fract((qx + qy) * qz); -} - -test "map matches the shader's barrel formula" { - // center is a fixed point - const center = mapScene(500, 250, 1000, 500, .{ .crt = true }, 0).?; - try std.testing.expectApproxEqAbs(@as(f32, 500), center.x, 0.001); - try std.testing.expectApproxEqAbs(@as(f32, 250), center.y, 0.001); - // Corners barrel beyond the tube and are black/inert in the shader. - try std.testing.expect(mapScene(0, 0, 1000, 500, .{ .crt = true }, 0) == null); - try std.testing.expect(mapScene(1000, 500, 1000, 500, .{ .crt = true }, 0) == null); - // (100,100) of 1000x500: cc=(-0.8,-0.6), r2=1.0, s=1.016 -> - // cc'=(-0.8128,-0.6096) -> (93.6, 97.6) - const near_edge = mapScene(100, 100, 1000, 500, .{ .crt = true }, 0).?; - try std.testing.expectApproxEqAbs(@as(f32, 93.6), near_edge.x, 0.01); - try std.testing.expectApproxEqAbs(@as(f32, 97.6), near_edge.y, 0.01); - // (900,250): cc=(0.8,0), r2=0.64, s=1.01024 -> x'=904.096 - const side = mapScene(900, 250, 1000, 500, .{ .crt = true }, 0).?; - try std.testing.expectApproxEqAbs(@as(f32, 904.096), side.x, 0.01); -} - -test "scene mapping composes ripple and deterministic glitch" { - const plain = mapScene(300, 200, 800, 600, .{}, 2.5).?; - try std.testing.expectApproxEqAbs(@as(f32, 300), plain.x, 0.001); - try std.testing.expectApproxEqAbs(@as(f32, 200), plain.y, 0.001); - const ripple = mapScene(300, 200, 800, 600, .{ .ripple = true }, 2.5).?; - try std.testing.expect(ripple.x != plain.x or ripple.y != plain.y); - try std.testing.expectEqual( - mapScene(300, 200, 800, 600, .{ .glitch = true }, 2.5).?, - mapScene(300, 200, 800, 600, .{ .glitch = true }, 2.5).?, - ); -} diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 2cde5edf..ab1875b7 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -12,7 +12,7 @@ const look = @import("../look.zig"); const message = pardes.Messages.Message; const file_watch = @import("../file_watch.zig"); const deck = @import("deck.zig"); -const crt = @import("crt.zig"); +const Post = @import("Post.zig"); const pet = @import("pet.zig"); const fonts = @import("../fonts.zig"); // the Font builtin's half of the seam const selection_pipe = @import("../selection_pipe.zig"); @@ -43,8 +43,6 @@ const overlay_vert_spv = @embedFile("overlay.vert.spv"); const overlay_frag_spv = @embedFile("overlay.frag.spv"); const image_vert_spv = @embedFile("image.vert.spv"); const image_frag_spv = @embedFile("image.frag.spv"); -const crt_vert_spv = @embedFile("crt.vert.spv"); -const crt_frag_spv = @embedFile("crt.frag.spv"); const bg_default = [3]u8{ 18, 18, 18 }; const fg_default = [3]u8{ 204, 204, 204 }; @@ -202,18 +200,17 @@ const touch_click_flash_max_frames: u8 = 14; const touch_click_flash_vertices: usize = 1400; const touch_scroll_tick: f32 = 0.02; -test "a persistent scene presents a new panel's frame zero before advancing it" { +test "a new panel's frame zero is presented before it advances" { const core = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer core.deinit(); var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena.deinit(); const frame = pardes.animation.frame_ns; - core.settings.scene_effects.crt = true; core.advance(100); - const scene_frame = try core.render(arena.allocator()); - core.acknowledgePanelPresentation(scene_frame.panelTracks()); - try std.testing.expectEqual(@as(?u64, 100 + frame), core.nextWake()); + const idle = try core.render(arena.allocator()); + core.acknowledgePanelPresentation(idle.panelTracks()); + try std.testing.expectEqual(@as(?u64, null), core.nextWake()); core.settings.panel_transition = .slide; core.update(.{ .command = "Newcol" }); @@ -235,10 +232,9 @@ test "a persistent scene presents a new panel's frame zero before advancing it" for (second.panelTracks()) |track| try std.testing.expectEqual(@as(u16, 1), track.frame); } -// Per pane: background, rule, two grip strokes, two square rails, and the -// rule under each of its notice chips. -const pane_chrome_vertices: usize = 6 * (6 + pardes.Pane.Notices.max); -const max_overlay_vertices: usize = 24 + pet.max_vertices + pardes.MAX_COLS * 6 + pardes.MAX_PANES * pane_chrome_vertices + touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices); +// The triangle overlay holds only the pet and the touch view: the chrome is +// decor. +const max_overlay_vertices: usize = pet.max_vertices + touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices); const font_px_step: f32 = 2.0; const font_px_min: f32 = 8.0; @@ -1765,7 +1761,6 @@ const Gui = struct { decor_pipeline: *c.SDL_GPUGraphicsPipeline, overlay_pipeline: *c.SDL_GPUGraphicsPipeline, image_pipeline: *c.SDL_GPUGraphicsPipeline, - crt_pipeline: *c.SDL_GPUGraphicsPipeline, atlas_tex: *c.SDL_GPUTexture, atlas_sampler: *c.SDL_GPUSampler, linear_sampler: *c.SDL_GPUSampler, @@ -1838,7 +1833,8 @@ const Gui = struct { scene_tex_h: u32 = 0, scene_failures: u8 = 0, scene_target_failed: bool = false, - presented_scene: crt.Frame = .{}, + /// The post chain, run over the frame in scene_tex. + post: Post = .{}, pointer_present: bool = false, arrow_cursor: ?*c.SDL_Cursor = null, link_cursor: ?*c.SDL_Cursor = null, @@ -1977,7 +1973,9 @@ fn windowCells(g: *const Gui) GridCells { }; } -fn windowPointToPixels(geometry: WindowGeometry, x: f32, y: f32) crt.Point { +const Point = struct { x: f32, y: f32 }; + +fn windowPointToPixels(geometry: WindowGeometry, x: f32, y: f32) Point { return .{ .x = x * geometry.pixel_w / geometry.window_w, .y = y * geometry.pixel_h / geometry.window_h, @@ -1991,7 +1989,7 @@ test "high-density window coordinates map once to render pixels" { .pixel_w = 1120, .pixel_h = 720, }; - try std.testing.expectEqual(crt.Point{ .x = 700, .y = 240 }, windowPointToPixels(geometry, 350, 120)); + try std.testing.expectEqual(Point{ .x = 700, .y = 240 }, windowPointToPixels(geometry, 350, 120)); } fn fixedCellLayout(g: *const Gui) CellLayout { @@ -2397,7 +2395,6 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u 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); - const crt_pipeline = try makeCrtPipeline(device, swapchain_format); const overlay_buffer_size: u32 = @intCast(max_overlay_vertices * @sizeOf(OverlayVertex)); var ovb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = overlay_buffer_size, .props = 0 }; @@ -2427,7 +2424,6 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u .decor_pipeline = decor_pipeline, .overlay_pipeline = overlay_pipeline, .image_pipeline = image_pipeline, - .crt_pipeline = crt_pipeline, .atlas_tex = atlas_tex, .atlas_sampler = atlas_sampler, .linear_sampler = linear_sampler, @@ -2464,7 +2460,6 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u c.SDL_ReleaseGPUGraphicsPipeline(device, g.pipeline); c.SDL_ReleaseGPUGraphicsPipeline(device, g.decor_pipeline); c.SDL_ReleaseGPUGraphicsPipeline(device, g.overlay_pipeline); - c.SDL_ReleaseGPUGraphicsPipeline(device, g.crt_pipeline); c.SDL_ReleaseGPUTexture(device, g.atlas_tex); c.SDL_ReleaseGPUSampler(device, g.atlas_sampler); c.SDL_ReleaseGPUSampler(device, g.linear_sampler); @@ -2490,6 +2485,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options, attach: ?[]const u g.prepared_images.deinit(gpa); g.cover.deinit(gpa); g.decor.deinit(gpa); + g.post.deinit(gpa, device); } defer c.SDL_ReleaseGPUTransferBuffer(device, g.image_vxfer); defer c.SDL_ReleaseGPUBuffer(device, g.image_vbuf); @@ -2796,7 +2792,6 @@ fn paintAttached(g: *Gui, gpa: std.mem.Allocator, client: *detached_client.Clien gpa, null, &surface, - .{}, false, ) catch |err| blk: { log.err("render: {t}", .{err}); @@ -3065,23 +3060,14 @@ const StdinFeed = struct { f.fill = len - seq_start; break; }; - if (parseFinger(body[0..end])) |finger_in| { - var finger = finger_in; + if (parseFinger(body[0..end])) |finger| { if (g) |gp| { var wpw: c_int = 0; var wph: c_int = 0; _ = c.SDL_GetWindowSizeInPixels(gp.window, &wpw, &wph); const win_w: f32 = @floatFromInt(@max(wpw, 1)); const win_h: f32 = @floatFromInt(@max(wph, 1)); - if (mapScenePoint(gp, finger.x * win_w, finger.y * win_h, win_w, win_h)) |m| { - finger.x = m.x / win_w; - finger.y = m.y / win_h; - handleFinger(&gp.touch, in, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h), @floatFromInt(gp.tagline_width)); - } else { - finger.kind = .cancel; - handleFinger(&gp.touch, in, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h), @floatFromInt(gp.tagline_width)); - in.post(.pointer_leave); - } + handleFinger(&gp.touch, in, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h), @floatFromInt(gp.tagline_width)); } else if (in.core) |core| { handleFinger(&grid_touch, in, finger, @floatFromInt(core.screen_w), @floatFromInt(core.screen_h), 1, 1, 1); } @@ -3288,6 +3274,20 @@ const Input = struct { }; fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { + // What the post chain sees of the pointer and the focus. + switch (sev.type) { + c.SDL_EVENT_MOUSE_MOTION => { + const at = windowPointToPixels(windowGeometry(g.window), sev.motion.x, sev.motion.y); + g.post.mouse(at.x, at.y, null); + }, + c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP => { + 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_FOCUS_GAINED => g.post.focus(true), + c.SDL_EVENT_WINDOW_FOCUS_LOST => g.post.focus(false), + else => {}, + } switch (sev.type) { c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => in.close(), c.SDL_EVENT_WINDOW_MOUSE_LEAVE => { @@ -3460,7 +3460,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { }, c.SDL_EVENT_FINGER_DOWN, c.SDL_EVENT_FINGER_MOTION, c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => { const f = sev.tfinger; - var finger: Finger = .{ + const finger: Finger = .{ .kind = switch (sev.type) { c.SDL_EVENT_FINGER_DOWN => .down, c.SDL_EVENT_FINGER_MOTION => .motion, @@ -3479,15 +3479,7 @@ fn dispatch(g: *Gui, in: *Input, sev: *const c.SDL_Event) void { const win_h: f32 = @floatFromInt(@max(ph, 1)); const fcw: f32 = @floatFromInt(g.cell_w); const fch: f32 = @floatFromInt(g.cell_h); - if (mapScenePoint(g, finger.x * win_w, finger.y * win_h, win_w, win_h)) |m| { - finger.x = m.x / win_w; - finger.y = m.y / win_h; - handleFinger(&g.touch, in, finger, win_w, win_h, fcw, fch, @floatFromInt(g.tagline_width)); - } else { - finger.kind = .cancel; - handleFinger(&g.touch, in, finger, win_w, win_h, fcw, fch, @floatFromInt(g.tagline_width)); - in.post(.pointer_leave); - } + handleFinger(&g.touch, in, finger, win_w, win_h, fcw, fch, @floatFromInt(g.tagline_width)); }, c.SDL_EVENT_PINCH_BEGIN, c.SDL_EVENT_PINCH_UPDATE => in.post(.{ .pinch = sev.pinch.scale }), c.SDL_EVENT_GAMEPAD_ADDED => { @@ -3747,14 +3739,30 @@ test "tagline pointer mapping retains physical grips and carries logical text co try std.testing.expectEqual(pardes.TagKind.workspace, topbar.tag_hit.?.kind); } -fn mapScenePoint(g: *const Gui, x: f32, y: f32, w: f32, h: f32) ?crt.Point { - return crt.mapScene(x, y, w, h, g.presented_scene.effects, g.presented_scene.time_seconds); -} - +test "a click at the window's corner lands in the corner cell with a post chain on" { + const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + try std.testing.expect(core.settings.post.set(.crt, "", true)); + var g: Gui = undefined; + g.cell_w = 10; + g.cell_h = 20; + g.tagline_width = 6; + g.tagline_height = 12; + const geometry: WindowGeometry = .{ .window_w = 400, .window_h = 240, .pixel_w = 800, .pixel_h = 480 }; + const last = mouseCellWithGeometry(&g, core, 399.9, 239.9, geometry).?; + try std.testing.expectEqual(@as(u16, 79), last.col); + try std.testing.expectEqual(@as(u16, 23), last.row); + const first = mouseCellWithGeometry(&g, core, 0, 0, geometry).?; + try std.testing.expectEqual(@as(u16, 0), first.col); + try std.testing.expectEqual(@as(u16, 0), first.row); +} + +/// Where a window point lands: the cell drawn under it. Input is identity: +/// no post pass moves a pixel (the bundled Crt has no barrel), so a click +/// is exactly where it looks. fn mouseCellWithGeometry(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32, geometry: WindowGeometry) ?MouseCell { const physical = windowPointToPixels(geometry, x, y); - const mapped = mapScenePoint(g, physical.x, physical.y, geometry.pixel_w, geometry.pixel_h) orelse return null; - return gridCellAtPixels(g, core, mapped.x, mapped.y); + return gridCellAtPixels(g, core, physical.x, physical.y); } fn mouseCell(g: *const Gui, core: ?*const pardes.Pardes, x: f32, y: f32) ?MouseCell { @@ -4064,6 +4072,7 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { // shell's queue raises for a terminal's output, a completion, a file // system request. What the shell moves or times itself polls every // frame: a smooth scroll, a gamepad's sticks, the test stdin feed, + // a post chain that moves on its own (ShaderAnimation), // a retired shell waiting to be killed, a present to retry. A // minimized window sleeps through the core's animation too: nothing // shows it, and it is caught up at once when the window is back. @@ -4072,7 +4081,8 @@ 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; + 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 + 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: // waiting here too would make every tick cost two frames. @@ -4157,6 +4167,10 @@ fn pollFrame(ctx: ?*anyopaque) void { const geom = windowCells(g); if (updateCoreResize(core, geom.cols, geom.rows, g.cell_w, g.cell_h, g.tagline_width, g.tagline_height)) resetScroll(g); stepScroll(g, core, s.gpa); + g.post.sync(s.gpa, s.io, g.device, g.swapchain_format, core); + // Between the core's frames, a chain that moves on its own redraws alone. + if (g.post.animating(core.settings.shader_animation) and !core.needs_frame and s.presented and + c.SDL_GetTicksNS() -| g.post.last_ns >= pardes.animation.frame_ns) redrawPost(g, s.gpa); } fn finishWindowOpacity(core: *pardes.Pardes, applied: *u8, failure: ?[]const u8) void { @@ -4216,15 +4230,13 @@ fn present(ctx: ?*anyopaque, surface: *const pardes.Surface) void { frame_started_ns = monoNs(); const frame = @constCast(surface); s.surface = frame; - const scene_requested = core.settings.scene_effects.crt or - core.settings.scene_effects.ripple or core.settings.scene_effects.glitch; + const scene_requested = g.post.ready(); if (!scene_requested) g.scene_failures = 0; s.presented = renderFrame( g, s.gpa, core, frame, - core.settings.scene_effects, core.settings.debug, ) catch |err| blk: { log.err("render: {t}", .{err}); @@ -5190,7 +5202,6 @@ fn renderFrame( gpa: std.mem.Allocator, core: ?*const pardes.Pardes, surface: *pardes.Surface, - scene_effects: pardes.animation.SceneEffect, debug_on: bool, ) !bool { if (surface.pointer_shape != g.pointer_shape) { @@ -5205,48 +5216,19 @@ fn renderFrame( defer if (!command_consumed) { _ = c.SDL_SubmitGPUCommandBuffer(cmd); }; - var sw: u32 = 0; - var sh: u32 = 0; - var target: *c.SDL_GPUTexture = undefined; - if (g.capture) { - sw = @as(u32, surface.cols) * g.cell_w + g.capture_pad; - sh = @as(u32, surface.rows) * g.cell_h + g.capture_pad; - if (sw == 0 or sh == 0) { - command_consumed = true; - _ = c.SDL_SubmitGPUCommandBuffer(cmd); - return false; - } - try ensureCaptureTexture(g, sw, sh); - target = g.capture_tex.?; - } else if (g.soft_present) { - var pw: c_int = 0; - var ph: c_int = 0; - if (!c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph) or pw <= 0 or ph <= 0) { - command_consumed = true; - _ = c.SDL_SubmitGPUCommandBuffer(cmd); - return false; - } - sw = @intCast(pw); - sh = @intCast(ph); - try ensureCaptureTexture(g, sw, sh); - target = g.capture_tex.?; - } else { - var swap_tex: ?*c.SDL_GPUTexture = null; - if (!c.SDL_AcquireGPUSwapchainTexture(cmd, g.window, &swap_tex, &sw, &sh)) { - command_consumed = true; - _ = c.SDL_SubmitGPUCommandBuffer(cmd); - return false; - } - target = swap_tex orelse { // swapchain busy: skip the frame - command_consumed = true; - _ = c.SDL_SubmitGPUCommandBuffer(cmd); - return false; - }; - } + const frame_target = try acquireTarget(g, cmd, @as(u32, surface.cols) * g.cell_w + g.capture_pad, @as(u32, surface.rows) * g.cell_h + g.capture_pad) orelse { + command_consumed = true; + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return false; + }; + const target = frame_target.texture; + const sw = frame_target.w; + const sh = frame_target.h; const win_w: f32 = @floatFromInt(sw); const win_h: f32 = @floatFromInt(sh); - var scene_on = scene_effects.crt or scene_effects.ripple or scene_effects.glitch; + // The post chain runs over a local frame, from the scene texture. + var scene_on = core != null and g.post.ready(); if (scene_on) ensureSceneTexture(g, sw, sh) catch { g.scene_target_failed = true; scene_on = false; @@ -5559,64 +5541,87 @@ fn renderFrame( drawOverlayGpu(g, cmd, rp, overlay_count); c.SDL_EndGPURenderPass(rp); - var rendered_scene: crt.Frame = .{}; if (scene_on) { - var crt_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo); - crt_target.texture = target; - crt_target.load_op = c.SDL_GPU_LOADOP_DONT_CARE; - crt_target.store_op = c.SDL_GPU_STOREOP_STORE; - const crt_pass = c.SDL_BeginGPURenderPass(cmd, &crt_target, 1, null); - c.SDL_BindGPUGraphicsPipeline(crt_pass, g.crt_pipeline); - const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.scene_tex.?, .sampler = g.linear_sampler }; - c.SDL_BindGPUFragmentSamplers(crt_pass, 0, &samp_binding, 1); - const now_ns = c.SDL_GetTicksNS(); - const time_wrap_ns: u64 = 4096 * std.time.ns_per_s; - const time_seconds: f32 = @floatCast( - @as(f64, @floatFromInt(now_ns % time_wrap_ns)) / @as(f64, std.time.ns_per_s), - ); - rendered_scene = .{ - .effects = .{ - .crt = scene_effects.crt, - .ripple = scene_effects.ripple, - .glitch = scene_effects.glitch, - }, - .time_seconds = time_seconds, - }; - const crt_uniforms = [8]f32{ - time_seconds, - @floatFromInt((now_ns / (std.time.ns_per_s / 60)) % 65_536), - @floatFromInt(sw), - @floatFromInt(sh), - @floatFromInt(@intFromBool(scene_effects.crt)), - @floatFromInt(@intFromBool(scene_effects.ripple)), - @floatFromInt(@intFromBool(scene_effects.glitch)), - 0, + if (core) |p| { + var boxes: [max_cursors]CursorBox = undefined; + const cursors = if (cursors_shown) cursorBoxes(g, surface, layout, win_h, &boxes) else 0; + g.post.describe(.{ + .cursor = if (cursors > 0) boxes[0] else null, + .background = chrome.page orelse bg_default, + .foreground = chrome.fg orelse fg_default, + .selection_foreground = p.theme().sel_fg, + .selection_background = p.theme().sel_bg, + }); + } + const chain_zone = tracy.zone(@src(), "post chain"); + defer chain_zone.end(); + 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; }; - c.SDL_PushGPUFragmentUniformData(cmd, 0, &crt_uniforms, @sizeOf(@TypeOf(crt_uniforms))); - c.SDL_DrawGPUPrimitives(crt_pass, 3, 1, 0, 0); - c.SDL_EndGPURenderPass(crt_pass); } + command_consumed = true; + const shown = try finishFrame(g, gpa, cmd, frame_target, if (groups.tracks != 0) groups.items[1].track.?.frame else null); + if (shown and groups.tracks == 0) rememberPresentedImages(g, gpa, surface); + return shown; +} + +const Target = struct { texture: *c.SDL_GPUTexture, w: u32, h: u32 }; + +/// What this frame draws into: the capture texture in test mode (the grid's +/// size plus PARDES_TEST_PAD) or with a soft presenter, else the swapchain's +/// image; null when there is none to be had this time. +fn acquireTarget(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, capture_w: u32, capture_h: u32) !?Target { if (g.capture) { - command_consumed = true; - try captureFrame(g, gpa, cmd, target, sw, sh, if (groups.tracks != 0) groups.items[1].track.?.frame else null); - g.presented_scene = rendered_scene; - if (groups.tracks == 0) rememberPresentedImages(g, gpa, surface); + if (capture_w == 0 or capture_h == 0) return null; + try ensureCaptureTexture(g, capture_w, capture_h); + return .{ .texture = g.capture_tex.?, .w = capture_w, .h = capture_h }; + } + if (g.soft_present) { + var pw: c_int = 0; + var ph: c_int = 0; + if (!c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph) or pw <= 0 or ph <= 0) return null; + try ensureCaptureTexture(g, @intCast(pw), @intCast(ph)); + return .{ .texture = g.capture_tex.?, .w = @intCast(pw), .h = @intCast(ph) }; + } + var swap_tex: ?*c.SDL_GPUTexture = null; + var sw: u32 = 0; + var sh: u32 = 0; + if (!c.SDL_AcquireGPUSwapchainTexture(cmd, g.window, &swap_tex, &sw, &sh)) return null; + // A busy swapchain skips the frame. + return .{ .texture = swap_tex orelse return null, .w = sw, .h = sh }; +} + +/// Submits the frame: captured, handed to the soft presenter, or presented. +fn finishFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: Target, transition: ?u16) !bool { + if (g.capture) { + try captureFrame(g, gpa, cmd, target.texture, target.w, target.h, transition); return true; } if (g.soft_present) { - command_consumed = true; - try softPresentFrame(g, cmd, target, sw, sh); - g.presented_scene = rendered_scene; - if (groups.tracks == 0) rememberPresentedImages(g, gpa, surface); + try softPresentFrame(g, cmd, target.texture, target.w, target.h); return true; } - command_consumed = true; - const submitted = c.SDL_SubmitGPUCommandBuffer(cmd); - if (submitted) { - g.presented_scene = rendered_scene; - if (groups.tracks == 0) rememberPresentedImages(g, gpa, surface); + return c.SDL_SubmitGPUCommandBuffer(cmd); +} + +/// Redraw level A (docs §5.5): the post chain alone, over the frame the core +/// last drew, for a pass that moves on its own. No core render, no cell. +fn redrawPost(g: *Gui, gpa: std.mem.Allocator) void { + const scene = g.scene_tex orelse return; + const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return; + const target = (acquireTarget(g, cmd, g.scene_tex_w, g.scene_tex_h) catch null) orelse { + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return; + }; + // A window the retained frame no longer fits waits for the core's. + if (target.w != g.scene_tex_w or target.h != g.scene_tex_h) { + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return; } - return submitted; + const zone = tracy.zone(@src(), "post chain redraw"); + g.post.draw(g.device, cmd, g.linear_sampler, g.swapchain_format, scene, target.texture, target.w, target.h, c.SDL_GetTicksNS(), backgroundOpacity(g.applied_window_opacity) >= 1) catch {}; + zone.end(); + _ = finishFrame(g, gpa, cmd, target, null) catch {}; } const ResolvedCell = struct { @@ -7016,25 +7021,7 @@ fn makeImagePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFor return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; } -fn makeCrtPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { - const vs = try makeShader(device, crt_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0, 0); - defer c.SDL_ReleaseGPUShader(device, vs); - const fs = try makeShader(device, crt_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1, 1); - defer c.SDL_ReleaseGPUShader(device, fs); - - var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) }; - var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); - info.vertex_shader = vs; - info.fragment_shader = fs; - info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; - info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL; - info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE; - info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1; - info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 }; - return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; -} - -fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShaderStage, n_samplers: u32, n_uniform_buffers: u32) !*c.SDL_GPUShader { +pub fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShaderStage, n_samplers: u32, n_uniform_buffers: u32) !*c.SDL_GPUShader { var info = std.mem.zeroes(c.SDL_GPUShaderCreateInfo); info.code = code.ptr; info.code_size = code.len; @@ -7519,25 +7506,32 @@ 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); } -/// The bar cursors: the grid's where no layer has the cell, and each layer's -/// at its own pitch. -fn cursorDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32) !void { +const CursorBox = Post.Frame.Cursor; +const max_cursors = 1 + pardes.MAX_PANES + pardes.MAX_TAG_LAYERS; + +/// Every cursor on the frame where it is drawn, in window pixels: the grid's +/// where no layer has its cell, and each layer's at that layer's pitch. A bar +/// is an eighth of its cell wide, a block the whole cell. +fn cursorBoxes(g: *const Gui, surface: *const pardes.Surface, layout: CellLayout, win_h: f32, out: *[max_cursors]CursorBox) usize { const chrome = &surface.chrome; + const page = chrome.page orelse bg_default; + var n: usize = 0; if (surface.cursor) |cursor| { - if (cursor.bar and cursor.x < surface.cols and cursor.y < surface.rows and bodyLayerAt(surface, cursor.x, cursor.y) == null and tagLayerIn(surface.tagLayers(), cursor.x, cursor.y) == null) { + if (cursor.x < surface.cols and cursor.y < surface.rows and bodyLayerAt(surface, cursor.x, cursor.y) == null and tagLayerIn(surface.tagLayers(), cursor.x, cursor.y) == null) { const cell = &surface.cells[@as(usize, cursor.y) * surface.cols + cursor.x]; const role = cellFontRole(cell); const width: f32 = if (role == .tagline) @floatFromInt(g.tagline_width) else layout.w; 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)))); - try addDecor(g, gpa, x0, y0, x0 + @max(1.0, width / 8.0), y0 + height, cursorColor(chrome, cell, role), 1, null, win_w, win_h); + 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 }; + n += 1; } } for (surface.bodyLayers()) |*layer| { if (layer.rows == 0) continue; const cursor = layer.cursor orelse continue; - if (!cursor.bar or cursor.y >= layer.rows or cursor.x >= layer.cols) continue; + if (cursor.y >= layer.rows or cursor.x >= layer.cols) continue; const left = @as(f32, @floatFromInt(layer.viewport.x)) * layout.w; const bottom = @as(f32, @floatFromInt(layer.viewport.y + layer.viewport.h)) * layout.h; const top = layer.rowTop(cursor.y, layout.h, @floatFromInt(g.tagline_height)); @@ -7547,12 +7541,13 @@ fn cursorDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, 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]; - try addDecor(g, gpa, x, top, x + @max(1, width / 8), @min(bottom, top + height), cursorColor(chrome, cell, role), 1, null, win_w, win_h); + 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 }; + n += 1; } for (surface.tagLayers()) |*layer| { if (layer.rows == 0) continue; const cursor = layer.cursor orelse continue; - if (!cursor.bar or cursor.x >= layer.cols or cursor.y >= layer.rows) continue; + if (cursor.x >= layer.cols or cursor.y >= layer.rows) continue; 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; @@ -7560,8 +7555,17 @@ fn cursorDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, if (x >= right) continue; 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))); - try addDecor(g, gpa, x, top, @min(right, x + @max(1, width / 8)), top + @as(f32, @floatFromInt(g.tagline_height)), cursorColor(chrome, &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x], .tagline), 1, null, win_w, win_h); + 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 }; + n += 1; } + return n; +} + +/// The bar cursors as decor; a block cursor is its cell's own colours. +fn cursorDecor(g: *Gui, gpa: std.mem.Allocator, surface: *const pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32) !void { + var boxes: [max_cursors]CursorBox = undefined; + for (boxes[0..cursorBoxes(g, surface, layout, win_h, &boxes)]) |box| + if (box.bar) try addDecor(g, gpa, box.x0, box.y0, box.x1, box.y1, box.rgb, 1, null, win_w, win_h); } /// The triangle overlay: the workspace pet and the touch debug view, over diff --git a/src/macos.zig b/src/macos.zig index 4d8f2883..0bad86ee 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -850,16 +850,18 @@ export fn pardes_should_quit() bool { return st.core.quit; } -fn encodeSceneEffects(effects: animation.SceneEffect) u32 { +// The Metal shell keeps its own CRT, ripple and glitch kernel: the bundled +// passes of the GUI's post chain, as flags. +fn encodeSceneEffects(post: *const pardes.config.Runtime.Post) u32 { var flags: u32 = 0; - if (effects.crt) flags |= scene_flag_crt; - if (effects.ripple) flags |= scene_flag_ripple; - if (effects.glitch) flags |= scene_flag_glitch; + if (post.has(.crt)) flags |= scene_flag_crt; + if (post.has(.ripple)) flags |= scene_flag_ripple; + if (post.has(.glitch)) flags |= scene_flag_glitch; return flags; } fn currentSceneFlags(st: *const State) u32 { - return encodeSceneEffects(st.core.settings.scene_effects); + return encodeSceneEffects(&st.core.settings.post); } fn advanceSceneClock(st: *State, elapsed_ns: u64) void { @@ -2912,10 +2914,13 @@ test "pardes.h matches the Zig boundary" { test "scene effect flags and display clock are compact and independent" { const expectEqual = std.testing.expectEqual; - try expectEqual(@as(u32, 0), encodeSceneEffects(.{})); - try expectEqual(scene_flag_crt, encodeSceneEffects(.{ .crt = true })); - try expectEqual(scene_flag_ripple, encodeSceneEffects(.{ .ripple = true })); - try expectEqual(scene_flag_glitch, encodeSceneEffects(.{ .glitch = true })); + var post: pardes.config.Runtime.Post = .{}; + try expectEqual(@as(u32, 0), encodeSceneEffects(&post)); + _ = post.set(.crt, "", true); + try expectEqual(scene_flag_crt, encodeSceneEffects(&post)); + _ = post.set(.ripple, "", true); + _ = post.set(.glitch, "", true); + try expectEqual(scene_flag_crt | scene_flag_ripple | scene_flag_glitch, encodeSceneEffects(&post)); try expectEqual( scene_flag_crt | scene_flag_ripple | scene_flag_glitch, encodeSceneEffects(.{ .crt = true, .ripple = true, .glitch = true }), diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index c1f78bab..ed10dd70 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -263,7 +263,9 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void { // Bare, it names the default directory. .dump_dir => if (s.dump_dir.len > 0) try w.print("{s} {s}\n", .{ word, s.dump_dir.get() }) else try w.print("{s}\n", .{word}), .transition => |effect| try w.print("{s} {s}\n", .{ word, if (s.panel_transition == effect) "on" else "off" }), - .scene => |effect| try w.print("{s} {s}\n", .{ word, if (@field(s.scene_effects, @tagName(effect))) "on" else "off" }), + .scene => |effect| try w.print("{s} {s}\n", .{ word, if (s.post.has(effect)) "on" else "off" }), + .shader => for (s.post.list()) |entry| if (entry.scene == null) try w.print("{s} {s}\n", .{ word, entry.path.get() }), + .shader_animation => try w.print("{s} {s}\n", .{ word, @tagName(s.shader_animation) }), } } try w.writeAll("LocationsConfig "); diff --git a/src/pardes.zig b/src/pardes.zig index 616747fa..5fa3307b 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -584,18 +584,10 @@ test "startup config runs builtin lines in order and isolates bad lines" { }; } -test "scene effects keep the idle frontend animation clock active" { +test "a post chain keeps no core clock running: the shell animates it" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); - try std.testing.expect(!p.animationActive()); - - p.settings.scene_effects.crt = true; - try std.testing.expect(p.animationActive()); - p.settings.scene_effects = .{ .ripple = true }; - try std.testing.expect(p.animationActive()); - p.settings.scene_effects = .{ .glitch = true }; - try std.testing.expect(p.animationActive()); - p.settings.scene_effects = .{}; + try std.testing.expect(p.settings.post.set(.crt, "", true)); try std.testing.expect(!p.animationActive()); } @@ -5443,10 +5435,10 @@ pub const Pardes = struct { look.cancelLookHover(p); } - /// A native scene pass proved unavailable. Config reads these exact bits, - /// and the idle-clock predicate stops immediately once they are clear. + /// A native post pass proved unavailable: the chain is emptied, which + /// Config then reports. pub fn disableSceneEffects(p: *Pardes) void { - p.settings.scene_effects = .{}; + p.settings.post.clear(); } // ---- doc panes ---- @@ -5744,8 +5736,6 @@ pub const Pardes = struct { pub fn nextWake(p: *const Pardes) ?u64 { const next = p.stepped_ns + animation.frame_ns; if (p.chrome_animation.isActive() or p.presentation.animating()) return next; - const scene = p.settings.scene_effects; - if (scene.crt or scene.ripple or scene.glitch) return next; var frames: ?u64 = null; if (p.look_hover_wait) |waiting| { const delay = config.look_preview_delay_frames orelse return next; @@ -6953,8 +6943,8 @@ test "core animation keeps wall time at 60, 120 and 144 Hz and across a stall" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); const frame = animation.frame_ns; - // Something that animates for as long as it is on. - p.settings.scene_effects.crt = true; + // Something that animates for as long as the test wants: the theme + // fade, held at its start before every step. for ([_]u64{ 60, 120, 144 }) |hz| { const start: u64 = 5 * std.time.ns_per_s; p.clock_started = false; @@ -6963,6 +6953,7 @@ test "core animation keeps wall time at 60, 120 and 144 Hz and across a stall" { var at: u64 = 1; while (at <= hz) : (at += 1) { const before = p.stepped_ns; + p.chrome_animation.step = 0; p.advance(start + at * std.time.ns_per_s / hz); const taken = (p.stepped_ns - before + frame / 2) / frame; // Never two steps in one display frame: that is a visible hitch. @@ -6979,9 +6970,10 @@ test "core animation keeps wall time at 60, 120 and 144 Hz and across a stall" { } // A stall of minutes is not minutes of frames. const before = p.stepped_ns; + p.chrome_animation.step = 0; p.advance(before + 600 * std.time.ns_per_s); try std.testing.expectEqual(before + 600 * std.time.ns_per_s, p.stepped_ns); - p.settings.scene_effects.crt = false; + p.chrome_animation.step = animation.transition_steps; p.advance(p.stepped_ns + frame); try std.testing.expectEqual(@as(?u64, null), p.nextWake()); } -- cgit v1.3 From 4242c2cdf6593b3d7cd6dfb0b7a2a4b38ecdc971 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:47:01 -0300 Subject: The keys doc's skipped list says what is still open and why The two rows left for design, the tree-sitter textobject jumps and the git change jumps, now say what each is waiting on. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index fedf578d..53615eed 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -357,8 +357,8 @@ text. | Picker table (all rows), Prompt table (all rows) | picker / prompt internals | pickers — pardes' tag line is its own one-line editor | | `gn` `gp` `ga` `gm` | next/prev/alternate buffer | buffer nav — pardes panes aren't a buffer list | | Window mode table: `Ctrl-w` + `w v s t f F h j k l q o H J K L ns nv` (+ Ctrl variants) | splits/window management | window mode — pardes has its own Ctrl-w focus + Alt-n/Alt-c + mouse layout drags | -| `]f [f ]t [t ]a [a ]c [c ]e [e ]T [T ]x [x` | TS unimpaired jumps | tree-sitter | -| `]g [g ]G [G` | git change jumps | needs VCS diff state | +| `]f [f ]t [t ]a [a ]c [c ]e [e ]T [T ]x [x` (and `mif`/`maf`…) | TS unimpaired jumps | open question: they read a grammar's `textobjects.scm`, and of pardes's 29 grammars only Ada ships one; the rest need queries vendored from helix (MPL-2.0) or nvim-treesitter-textobjects (Apache-2.0). The node walks they build on are in A | +| `]g [g ]G [G` | git change jumps | open question: needs a diff base per file (git or jj), which pardes does not keep; see the design in the helix-parity report | | insert `Ctrl-x` | completion menu | completion exists, but not as a popup: insert-mode Tab straight after a `.` opens a buffer of candidate DECLARATIONS (section A). helix's menu itself is skipped | | `Ctrl-s` (normal) | save jumplist position | jumplist itself is implemented (`Ctrl-o`/`Ctrl-i`, section A); only the explicit save point is skipped | | `:` | command mode | side-effects/file-ops — pardes builtins live in the tag, and `:` is bound to focusing it (section A) | -- cgit v1.3 From be09d1ec7abd71f4e52f05942a941d76b6d420f2 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:26:08 -0300 Subject: Post chain review fixes: glslc on stdin, level A at the display's refresh Stage 9 review: a user's Shadertoy file goes to glslc on its stdin, so no source file is written anywhere (the old predictable name under XDG_RUNTIME_DIR or /tmp was a symlink-clobber hazard). std's spawn does all its allocation before fork and searches PATH on the stack after it, said at the call. Level A redraws are paced by the display's refresh rate (SDL_GetCurrentDisplayMode), not a fixed 16 ms. Shared files touched: gui.zig. --- docs/render-pipeline.md | 6 ++-- src/gui/Post.zig | 83 +++++++++++++++++++++++++++++-------------------- src/gui/gui.zig | 21 +++++++++++-- 3 files changed, 71 insertions(+), 39 deletions(-) diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index 254187a3..d991a1ce 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -492,11 +492,11 @@ Mirror ghostty 1.3.2 (`zig-pkg/ghostty-*/src/renderer/shadertoy.zig`, `crt.zig` is deleted. If the user insists on barrel in the bundled CRT, keep `crt.zig`'s inverse keyed to "bundled crt active". - **Compile** (decision 3): a user's file is compiled by spawning `glslc` with - the build's own invocation (`-fshader-stage=fragment -o -`, SPIR-V on - stdout), on a thread of its own when the file joins the chain, never on the + the build's own flags, prefix and file on its stdin, SPIR-V on its stdout + (no file of ours anywhere; std's spawn allocates before fork), on a thread of its own when the file joins the chain, never on the frame path; the result is taken in at the next loop step. The prefix ends in `#line 1`, so glslc's errors count the file's own lines, and they name the - file, not the temporary source. A failed compile keeps the file's last good + file, not ``. Level A redraws once a refresh of the window's display. A failed compile keeps the file's last good pipeline and says glslc's first line as a message; glslc missing is said once, and only files stay off (the bundled passes are compiled with the build, through the same prefix). No file watching: `Shader ` twice diff --git a/src/gui/Post.zig b/src/gui/Post.zig index d92623d7..c529efc0 100644 --- a/src/gui/Post.zig +++ b/src/gui/Post.zig @@ -15,7 +15,6 @@ const libc = std.c; const ghostty_vt = @import("ghostty-vt"); const pardes = @import("../pardes.zig"); const filesystem = @import("../fs.zig"); -const host_io = @import("../host_io.zig"); const gui = @import("gui.zig"); const c = gui.c; @@ -191,8 +190,9 @@ fn collect(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, format } } -/// The compile thread: each file behind the prefix, through glslc exactly as -/// the build calls it (build.zig compileGlsl), its SPIR-V on stdout. +/// The compile thread: each file behind the prefix, through glslc with the +/// build's own flags (build.zig compileGlsl), source on stdin, SPIR-V on +/// stdout. fn compile(job: *Job) void { defer job.done.store(true, .release); defer { @@ -200,10 +200,10 @@ fn compile(job: *Job) void { event.type = c.SDL_EVENT_USER; _ = c.SDL_PushEvent(&event); } - for (job.paths[0..job.len], 0..) |path, i| job.results[i] = compileOne(job.gpa, job.io, path, i); + for (job.paths[0..job.len], 0..) |path, i| job.results[i] = compileOne(job.gpa, job.io, path); } -fn compileOne(gpa: std.mem.Allocator, io: std.Io, path: []const u8, index: usize) Job.Result { +fn compileOne(gpa: std.mem.Allocator, io: std.Io, path: []const u8) Job.Result { var home_buf: [4096]u8 = undefined; const expanded = if (std.mem.startsWith(u8, path, "~/")) if (libc.getenv("HOME")) |home| std.fmt.bufPrint(&home_buf, "{s}/{s}", .{ std.mem.span(home), path[2..] }) catch path @@ -212,37 +212,54 @@ fn compileOne(gpa: std.mem.Allocator, io: std.Io, path: []const u8, index: usize const body = filesystem.readFile(gpa, expanded) catch |err| return .{ .failed = std.fmt.allocPrint(gpa, "cannot read it ({t})", .{err}) catch return .none }; defer gpa.free(body); - const dir = if (libc.getenv("XDG_RUNTIME_DIR")) |d| std.mem.span(d) else "/tmp"; - var source_buf: [4096]u8 = undefined; - const source = std.fmt.bufPrintSentinel(&source_buf, "{s}/pardes-post-{d}-{d}.frag.glsl", .{ dir, libc.getpid(), index }, 0) catch return .none; - defer _ = libc.unlink(source); - { - const fd = libc.open(source, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o600)); - if (fd < 0) return .{ .failed = gpa.dupe(u8, "cannot write its source for glslc") catch return .none }; - defer _ = libc.close(fd); - if (!host_io.writeFd(fd, prefix) or !host_io.writeFd(fd, body)) - return .{ .failed = gpa.dupe(u8, "cannot write its source for glslc") catch return .none }; - } - const run = std.process.run(gpa, io, .{ - .argv = &.{ "glslc", "-fshader-stage=fragment", source, "-o", "-" }, - .stdout_limit = .limited(16 << 20), - .stderr_limit = .limited(64 << 10), + // The prefix and the file go to glslc on its stdin: no file of ours is + // written anywhere. std's spawn does every allocation before fork and + // searches PATH on the stack after it (Io/Threaded.zig spawnPosix, + // posixExecv), so a child forked from this thread cannot wedge on a + // lock another thread held (the forkShell hazard). + var child = std.process.spawn(io, .{ + .argv = &.{ "glslc", "-fshader-stage=fragment", "-o", "-", "-" }, + .stdin = .pipe, + .stdout = .pipe, + .stderr = .pipe, }) catch |err| return switch (err) { error.FileNotFound => .missing, else => .{ .failed = std.fmt.allocPrint(gpa, "glslc did not run ({t})", .{err}) catch return .none }, }; - const clean = switch (run.term) { + defer child.kill(io); + // glslc reads all of its input before it writes a byte, so writing it + // first cannot deadlock against a full output pipe. + { + var stdin = child.stdin.?; + child.stdin = null; + defer stdin.close(io); + var buf: [4096]u8 = undefined; + var writer = stdin.writer(io, &buf); + writer.interface.writeAll(prefix) catch {}; + writer.interface.writeAll(body) catch {}; + writer.interface.flush() catch {}; + } + var streams: std.Io.File.MultiReader.Buffer(2) = undefined; + var reader: std.Io.File.MultiReader = undefined; + reader.init(gpa, io, streams.toStreams(), &.{ child.stdout.?, child.stderr.? }); + defer reader.deinit(); + while (reader.fill(64, .none)) |_| { + if (reader.reader(0).buffered().len > 16 << 20 or reader.reader(1).buffered().len > 64 << 10) + return .{ .failed = gpa.dupe(u8, "glslc said too much") catch return .none }; + } else |err| switch (err) { + error.EndOfStream => {}, + else => return .{ .failed = gpa.dupe(u8, "reading glslc failed") catch return .none }, + } + const term = child.wait(io) catch return .{ .failed = gpa.dupe(u8, "glslc did not finish") catch return .none }; + const clean = switch (term) { .exited => |code| code == 0, else => false, }; - if (clean) { - gpa.free(run.stderr); - return .{ .spirv = run.stdout }; - } - gpa.free(run.stdout); - // glslc names the temporary source; the file is what a person knows. - defer gpa.free(run.stderr); - const named = std.mem.replaceOwned(u8, gpa, run.stderr, source, path) catch return .{ .failed = gpa.dupe(u8, "glslc failed") catch return .none }; + if (clean) return .{ .spirv = reader.toOwnedSlice(0) catch return .none }; + const said = reader.toOwnedSlice(1) catch return .none; + // glslc calls its input ; the file is what a person knows. + defer gpa.free(said); + const named = std.mem.replaceOwned(u8, gpa, said, "", path) catch return .{ .failed = gpa.dupe(u8, "glslc failed") catch return .none }; return .{ .failed = named }; } @@ -535,9 +552,9 @@ test "ghostty's test shaders compile behind the prefix, and its invalid one fail if (std.mem.startsWith(u8, entry.name, "ghostty-")) break entry.name; } else return error.SkipZigTest; var buf: [512]u8 = undefined; - for ([_][]const u8{ "crt", "focus", "invalid" }, 0..) |name, i| { + for ([_][]const u8{ "crt", "focus", "invalid" }) |name| { const path = try std.fmt.bufPrint(&buf, "zig-pkg/{s}/src/renderer/shaders/test_shadertoy_{s}.glsl", .{ ghostty, name }); - const result = compileOne(gpa, io, path, i); + const result = compileOne(gpa, io, path); switch (result) { .missing => return error.SkipZigTest, // no glslc here .spirv => |spirv| { @@ -549,9 +566,9 @@ test "ghostty's test shaders compile behind the prefix, and its invalid one fail .failed => |text| { defer gpa.free(text); try std.testing.expectEqualStrings("invalid", name); - // Named by the file, not by the temporary source glslc read. + // Named by the file, not by glslc's . try std.testing.expect(std.mem.indexOf(u8, text, path) != null); - try std.testing.expect(std.mem.indexOf(u8, text, "pardes-post-") == null); + try std.testing.expect(std.mem.indexOf(u8, text, "") == null); }, .none => return error.TestUnexpectedResult, } diff --git a/src/gui/gui.zig b/src/gui/gui.zig index ab1875b7..aa39cf32 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -4086,7 +4086,12 @@ fn waitInput(ctx: ?*anyopaque, timeout_ms: u32) void { 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: // waiting here too would make every tick cost two frames. - const ms: c_int = if (latency_fd >= 0) 0 else if (minimized) -1 else if (timeout_ms != 0) @intCast(timeout_ms) else if (polls) 16 else -1; + // A chain that moves on its own wakes once a display refresh. + const poll_ms: c_int = if (g.post.animating(core.settings.shader_animation)) + @intCast(@max(1, refreshNs(g) / std.time.ns_per_ms)) + else + 16; + const ms: c_int = if (latency_fd >= 0) 0 else if (minimized) -1 else if (timeout_ms != 0) @min(@as(c_int, @intCast(@min(timeout_ms, std.math.maxInt(c_int)))), if (polls) poll_ms else std.math.maxInt(c_int)) else if (polls) poll_ms else -1; // The wait is the 9P connections' turn with the core. pardes.turn.rest(); const got = c.SDL_WaitEventTimeout(&sev, ms); @@ -4168,9 +4173,19 @@ fn pollFrame(ctx: ?*anyopaque) void { if (updateCoreResize(core, geom.cols, geom.rows, g.cell_w, g.cell_h, g.tagline_width, g.tagline_height)) resetScroll(g); stepScroll(g, core, s.gpa); g.post.sync(s.gpa, s.io, g.device, g.swapchain_format, core); - // Between the core's frames, a chain that moves on its own redraws alone. + // Between the core's frames, a chain that moves on its own redraws + // alone, once a refresh of the display the window is on (a millisecond + // early is on time: the wait that paces it is in whole milliseconds). if (g.post.animating(core.settings.shader_animation) and !core.needs_frame and s.presented and - c.SDL_GetTicksNS() -| g.post.last_ns >= pardes.animation.frame_ns) redrawPost(g, s.gpa); + c.SDL_GetTicksNS() -| g.post.last_ns + std.time.ns_per_ms >= refreshNs(g)) redrawPost(g, s.gpa); +} + +/// One refresh of the display the window is on, in ns; a frame of the +/// core's where the display does not say. +fn refreshNs(g: *const Gui) u64 { + const mode = c.SDL_GetCurrentDisplayMode(c.SDL_GetDisplayForWindow(g.window)) orelse return pardes.animation.frame_ns; + if (!(mode.*.refresh_rate > 1)) return pardes.animation.frame_ns; + return @intFromFloat(@as(f64, std.time.ns_per_s) / mode.*.refresh_rate); } fn finishWindowOpacity(core: *pardes.Pardes, applied: *u8, failure: ?[]const u8) void { -- cgit v1.3 From cd563b1768c688444cc6d172b3679d95b495207c Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:53:18 -0300 Subject: The git change jumps are marked deferred ]g/[g/]G/[G need a diff base per file that pardes does not keep; the design is agreed to wait, and section C says so. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 53615eed..88aca887 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -358,7 +358,7 @@ text. | `gn` `gp` `ga` `gm` | next/prev/alternate buffer | buffer nav — pardes panes aren't a buffer list | | Window mode table: `Ctrl-w` + `w v s t f F h j k l q o H J K L ns nv` (+ Ctrl variants) | splits/window management | window mode — pardes has its own Ctrl-w focus + Alt-n/Alt-c + mouse layout drags | | `]f [f ]t [t ]a [a ]c [c ]e [e ]T [T ]x [x` (and `mif`/`maf`…) | TS unimpaired jumps | open question: they read a grammar's `textobjects.scm`, and of pardes's 29 grammars only Ada ships one; the rest need queries vendored from helix (MPL-2.0) or nvim-treesitter-textobjects (Apache-2.0). The node walks they build on are in A | -| `]g [g ]G [G` | git change jumps | open question: needs a diff base per file (git or jj), which pardes does not keep; see the design in the helix-parity report | +| `]g [g ]G [G` | git change jumps | **deferred by decision**: needs a diff base per file (jj `@-` or git HEAD, fetched by the host on open and save) and line hunks from diffz; the design is written up, the work waits | | insert `Ctrl-x` | completion menu | completion exists, but not as a popup: insert-mode Tab straight after a `.` opens a buffer of candidate DECLARATIONS (section A). helix's menu itself is skipped | | `Ctrl-s` (normal) | save jumplist position | jumplist itself is implemented (`Ctrl-o`/`Ctrl-i`, section A); only the explicit save point is skipped | | `:` | command mode | side-effects/file-ops — pardes builtins live in the tag, and `:` is bound to focusing it (section A) | -- cgit v1.3 From 1ed34e1538f45daecc2c8a551dc0fb7aba5f3f54 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:30:57 -0300 Subject: Crt, Ripple and Glitch take a level, 0 to 3, tunable at run time Coordinator's request: `Crt 0..3` (likewise Ripple, Glitch), 0 off, `on` the default level 2 (the polished rewrite), `off` and a bare word as before. The level is one uniform (pardesLevel, beside pardesOpaque in the prefix's own block); each pass picks its constants by it. Level 3 is at least as strong as the old effects, measured as pixels changed by more than 8 in a channel against a plain frame: Crt 18.3% (old 17.4%), Ripple 11.1% (9.5%), Glitch 9.3% a frame (8.1%); Glitch's level raises how often it bursts as well as how hard. Config and ctl report `Crt 2` / `Crt off`. Shared files touched: ninep/ctl.zig (the report line). Not touched: pardes.zig, Messages.zig, mouse.zig, gui.zig, detached/*. --- docs/render-pipeline.md | 8 +++- shaders/post/crt.glsl | 40 ++++++++++-------- shaders/post/glitch.glsl | 36 +++++++++------- shaders/post/prefix.glsl | 11 ++++- shaders/post/ripple.glsl | 23 +++++----- shaders/prebuilt/post-crt.frag.glsl | 51 +++++++++++++--------- shaders/prebuilt/post-crt.frag.spv | Bin 9480 -> 12424 bytes shaders/prebuilt/post-glitch.frag.glsl | 47 +++++++++++++-------- shaders/prebuilt/post-glitch.frag.spv | Bin 8308 -> 10764 bytes shaders/prebuilt/post-ripple.frag.glsl | 34 +++++++++------ shaders/prebuilt/post-ripple.frag.spv | Bin 7180 -> 9096 bytes src/config.zig | 75 +++++++++++++++++++++++++++++---- src/gui/Post.zig | 6 ++- src/ninep/ctl.zig | 2 +- test/output.zig | 3 +- 15 files changed, 227 insertions(+), 109 deletions(-) diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index d991a1ce..2fd6045b 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -507,7 +507,13 @@ Mirror ghostty 1.3.2 (`zig-pkg/ghostty-*/src/renderer/shadertoy.zig`, the shell redraws its retained Surface (3.1), never a core tick or render. - **Existing crt/ripple/glitch** become bundled Shadertoy files in `shaders/post/` selected by the same builtins (decision 2: kept, polished); - `scene_effects` and `crt.frag.glsl`/`crt.zig` are deleted. The chain is + `scene_effects` and `crt.frag.glsl`/`crt.zig` are deleted. Each bundled pass + takes a level, `Crt 0..3` (0 off, `on` the default 2, `off` and bare still + work), passed as `pardesLevel`: 2 is the polished rewrite, 3 at least as + strong as the old effects (pixels changed by more than 8 in a channel, + against a plain frame: Crt 18.3% vs 17.4% old, Ripple 11.1% vs 9.5%, Glitch + 9.3% a frame vs 8.1%); a Glitch level raises how often it slips as well as + how hard. The chain is `Crt`, `Ripple`, `Glitch` and `Shader ` in the order they were put in (`Shader off` empties it of files), `ShaderAnimation off|on|always` (default on). The core keeps no clock for it: level A redraws are the diff --git a/shaders/post/crt.glsl b/shaders/post/crt.glsl index 16a5cdd9..768157ef 100644 --- a/shaders/post/crt.glsl +++ b/shaders/post/crt.glsl @@ -7,19 +7,23 @@ // Shadertoy file: pardes puts its prefix before it (shaders/post/prefix.glsl). const float TAU = 6.28318530718; -const float CHROMA_PX = 0.35; // red and blue apart, at the corners -const float BLOOM_RADIUS_PX = 1.75; -const float BLOOM_THRESHOLD = 0.60; // linear-light channel peak +// Level 3 glows wider and from dimmer ink. +float bloomRadiusPx() { return pardesByLevel(1.75, 1.75, 3.0); } +float bloomThreshold() { return pardesByLevel(0.6, 0.6, 0.3); } // linear-light channel peak const float BLOOM_SOFTNESS = 0.25; -const float BLOOM_STRENGTH = 0.05; const float SCAN_PERIOD_PX = 3.0; -const float SCAN_STRENGTH = 0.07; const float MASK_PERIOD_PX = 3.0; -const float MASK_STRENGTH = 0.018; -const float VIGNETTE_STRENGTH = 0.12; // at the far corners only -const float HUM_STRENGTH = 0.004; const float HUM_HZ = 0.11; -const float EXPOSURE = 1.015; + +// Each strength at levels 1, 2 and 3 (`Crt 1`..`Crt 3`): 2 is the default, +// 3 as strong as the old barrel CRT looked. +float chromaPx() { return pardesByLevel(0.2, 0.35, 2.2); } // red and blue apart, at the corners +float bloomStrength() { return pardesByLevel(0.03, 0.05, 0.3); } +float scanStrength() { return pardesByLevel(0.035, 0.07, 0.42); } +float maskStrength() { return pardesByLevel(0.01, 0.018, 0.12); } +float vignetteStrength() { return pardesByLevel(0.06, 0.12, 0.42); } // at the far corners only +float humStrength() { return pardesByLevel(0.002, 0.004, 0.012); } +float exposure() { return pardesByLevel(1.008, 1.015, 1.1); } vec3 toLinear(vec3 c) { return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); @@ -37,7 +41,7 @@ vec3 scene(vec2 uv) { vec3 brightPass(vec2 uv) { vec3 c = scene(uv); float peak = max(c.r, max(c.g, c.b)); - return c * smoothstep(BLOOM_THRESHOLD, BLOOM_THRESHOLD + BLOOM_SOFTNESS, peak); + return c * smoothstep(bloomThreshold(), bloomThreshold() + BLOOM_SOFTNESS, peak); } // Interleaved gradient noise: a dither with no visible pattern. @@ -54,24 +58,24 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) { // The beam converges least at the corners: red and blue drift apart // there, by a third of a pixel. - vec2 chroma = cc * (CHROMA_PX * r2 * 0.5) * texel; + vec2 chroma = cc * (chromaPx() * r2 * 0.5) * texel; vec3 col = vec3(scene(uv + chroma).r, scene(uv).g, scene(uv - chroma).b); - vec2 b = texel * BLOOM_RADIUS_PX; + vec2 b = texel * bloomRadiusPx(); vec3 bloom = brightPass(uv + vec2(b.x, 0.0)) + brightPass(uv - vec2(b.x, 0.0)) + brightPass(uv + vec2(0.0, b.y)) + brightPass(uv - vec2(0.0, b.y)); - col += bloom * (0.25 * BLOOM_STRENGTH); + col += bloom * (0.25 * bloomStrength()); // A beam is brightest on its line and falls off between lines; the // mask is three phosphor stripes a triad. Both average to one, so the // theme keeps its brightness. float line = 0.5 + 0.5 * cos(fragCoord.y * TAU / SCAN_PERIOD_PX); - col *= 1.0 - SCAN_STRENGTH * (line * line - 0.375) / 0.625; - col *= 1.0 + MASK_STRENGTH * cos(fragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0)); + col *= 1.0 - scanStrength() * (line * line - 0.375) / 0.625; + col *= 1.0 + maskStrength() * cos(fragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0)); - float vignette = 1.0 - VIGNETTE_STRENGTH * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6); - float hum = 1.0 + HUM_STRENGTH * sin((uv.y * 1.35 - iTime * HUM_HZ) * TAU); - col *= vignette * hum * EXPOSURE; + float vignette = 1.0 - vignetteStrength() * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6); + float hum = 1.0 + humStrength() * sin((uv.y * 1.35 - iTime * HUM_HZ) * TAU); + col *= vignette * hum * exposure(); vec3 srgb = toSrgb(col); srgb += (ign(fragCoord + float(iFrame % 64) * vec2(5.588238, 5.588238)) - 0.5) / 255.0; diff --git a/shaders/post/glitch.glsl b/shaders/post/glitch.glsl index ff9ebcb8..8f420d9c 100644 --- a/shaders/post/glitch.glsl +++ b/shaders/post/glitch.glsl @@ -1,4 +1,5 @@ -// Glitch: now and then the signal slips. Every couple of seconds a short +// Glitch: now and then the signal slips. At the default level, every couple +// of seconds a short // burst tears a few horizontal bands sideways and pulls their red and blue // apart; the bands are sized in pixels and the burst eases in and out over // a fraction of a second, so it reads as one event rather than a flicker, @@ -7,12 +8,15 @@ // A torn band's brightness dips in linear light. It is a Shadertoy file: // pardes puts its prefix before it. -const float PERIOD = 2.4; // seconds between bursts, on average -const float BURST = 0.28; // seconds a burst lasts -const float STEP_HZ = 24.0; // how often a burst re-tears -const float SHIFT_PX = 14.0; -const float SPLIT_PX = 2.5; -const float DIP = 0.12; +// Each at levels 1, 2 and 3 (`Glitch 1`..`Glitch 3`); 2 is the default. A +// higher level slips more often and harder. +float period() { return pardesByLevel(4.5, 2.4, 0.55); } // seconds between bursts, on average +float burst() { return pardesByLevel(0.2, 0.28, 0.5); } // seconds a burst lasts +float stepHz() { return pardesByLevel(20.0, 24.0, 30.0); } // how often a burst re-tears +float shiftPx() { return pardesByLevel(8.0, 14.0, 34.0); } +float splitPx() { return pardesByLevel(1.5, 2.5, 6.0); } +float dip() { return pardesByLevel(0.08, 0.12, 0.22); } +float tearFrom() { return pardesByLevel(0.8, 0.72, 0.35); } // how few bands tear float hash(vec2 p) { vec3 q = fract(vec3(p.xyx) * 0.1031); @@ -34,27 +38,27 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 uv = fragCoord / size; // Which burst window this is, when in it the burst starts, and how far // into the burst we are: 0 outside one. - float window = floor(iTime / PERIOD); - float start = window * PERIOD + hash(vec2(window, 7.0)) * (PERIOD - BURST); - float t = (iTime - start) / BURST; + float window = floor(iTime / period()); + float start = window * period() + hash(vec2(window, 7.0)) * (period() - burst()); + float t = (iTime - start) / burst(); float live = step(0.0, t) * step(t, 1.0); float envelope = live * sin(3.14159265 * clamp(t, 0.0, 1.0)); if (envelope <= 0.0) { fragColor = texture(iChannel0, uv); return; } - // Bands of 6 to 40 px, re-cut at STEP_HZ during the burst; about one + // Bands of 6 to 40 px, re-cut at stepHz() during the burst; about one // band in four tears. - float tick = floor(iTime * STEP_HZ); + float tick = floor(iTime * stepHz()); float band_px = mix(6.0, 40.0, hash(vec2(tick, 3.0))); float band = floor(fragCoord.y / band_px); float roll = hash(vec2(band, tick)); - float tear = smoothstep(0.72, 0.9, roll) * envelope; - float shift = (hash(vec2(tick, band + 11.0)) - 0.5) * 2.0 * SHIFT_PX * tear; + float tear = smoothstep(tearFrom(), tearFrom() + 0.18, roll) * envelope; + float shift = (hash(vec2(tick, band + 11.0)) - 0.5) * 2.0 * shiftPx() * tear; vec2 at = vec2(fragCoord.x + shift, fragCoord.y); - vec2 split = vec2(SPLIT_PX * tear, 0.0); + vec2 split = vec2(splitPx() * tear, 0.0); vec4 center = texture(iChannel0, at / size); vec3 col = vec3(texture(iChannel0, (at + split) / size).r, center.g, texture(iChannel0, (at - split) / size).b); - col = toSrgb(toLinear(col) * (1.0 - DIP * tear)); + col = toSrgb(toLinear(col) * (1.0 - dip() * tear)); fragColor = vec4(col, center.a); } diff --git a/shaders/post/prefix.glsl b/shaders/post/prefix.glsl index efa49483..e718a98f 100644 --- a/shaders/post/prefix.glsl +++ b/shaders/post/prefix.glsl @@ -39,12 +39,19 @@ layout(set = 3, binding = 0, std140) uniform Globals { uniform vec3 iSelectionBackgroundColor; }; -// pardes's own: 1 when the window is opaque, so a pass cannot punch holes -// in it through its alpha. +// pardes's own: pardesOpaque is 1 when the window is opaque, so a pass +// cannot punch holes in it through its alpha; pardesLevel is a bundled +// pass's strength, 1 to 3 (`Crt 3`), and 0 for a user's file. layout(set = 3, binding = 1, std140) uniform PardesPost { float pardesOpaque; + float pardesLevel; }; +// A bundled pass's constant at levels 1, 2 and 3. +float pardesByLevel(float one, float two, float three) { + return pardesLevel < 1.5 ? one : (pardesLevel < 2.5 ? two : three); +} + #define CURSORSTYLE_BLOCK 0 #define CURSORSTYLE_BLOCK_HOLLOW 1 #define CURSORSTYLE_BAR 2 diff --git a/shaders/post/ripple.glsl b/shaders/post/ripple.glsl index ffecbf70..3236d339 100644 --- a/shaders/post/ripple.glsl +++ b/shaders/post/ripple.glsl @@ -1,18 +1,21 @@ // Ripple: the page as a still pool a slow swell crosses. Rings travel out // from the middle of the window at a fixed speed in pixels, so they look -// the same at any size and frame rate; the displacement is at most a couple -// of pixels, so text stays readable, and it eases in over its first half +// the same at any size and frame rate; at the default level the displacement +// is at most a couple of pixels, so text stays readable, and it eases in over its first half // second instead of snapping on. Each ring catches the light a little on its // rising side, in linear light, and the result is dithered so the shading // never bands. It is a Shadertoy file: pardes puts its prefix before it. const float TAU = 6.28318530718; -const float WAVELENGTH_PX = 150.0; -const float SPEED_PX = 55.0; // outward, per second -const float AMPLITUDE_PX = 2.2; -const float SHADE = 0.035; // light on a slope, linear const float EASE_IN = 0.5; // seconds +// Each at levels 1, 2 and 3 (`Ripple 1`..`Ripple 3`); 2 is the default. +float wavelengthPx() { return pardesByLevel(170.0, 150.0, 120.0); } +float speedPx() { return pardesByLevel(40.0, 55.0, 80.0); } // outward, per second +float amplitudePx() { return pardesByLevel(1.2, 2.2, 5.0); } +float shade() { return pardesByLevel(0.02, 0.035, 0.09); } // light on a slope, linear +float reachEnd() { return pardesByLevel(0.85, 0.95, 1.3); } // how far out it dies away + vec3 toLinear(vec3 c) { return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); } @@ -34,16 +37,16 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) { float reach = 0.5 * length(size); // Strongest a third of the way out, fading to nothing at the middle // and at the edges, so the window's frame never wobbles. - float envelope = smoothstep(0.0, 0.18, radius / reach) * (1.0 - smoothstep(0.35, 0.95, radius / reach)); + float envelope = smoothstep(0.0, 0.18, radius / reach) * (1.0 - smoothstep(0.35, reachEnd(), radius / reach)); float ease = smoothstep(0.0, EASE_IN, iTime); - float phase = (radius - iTime * SPEED_PX) * TAU / WAVELENGTH_PX; + float phase = (radius - iTime * speedPx()) * TAU / wavelengthPx(); float height = sin(phase); float slope = cos(phase); vec2 dir = offset / max(radius, 1e-3); - vec2 moved = fragCoord + dir * height * AMPLITUDE_PX * envelope * ease; + vec2 moved = fragCoord + dir * height * amplitudePx() * envelope * ease; vec4 s = texture(iChannel0, moved / size); - vec3 col = toLinear(s.rgb) * (1.0 + SHADE * slope * envelope * ease); + vec3 col = toLinear(s.rgb) * (1.0 + shade() * slope * envelope * ease); vec3 srgb = toSrgb(col) + (ign(fragCoord + float(iFrame % 64) * 5.588238) - 0.5) / 255.0; fragColor = vec4(clamp(srgb, 0.0, 1.0), s.a); } diff --git a/shaders/prebuilt/post-crt.frag.glsl b/shaders/prebuilt/post-crt.frag.glsl index 11b95272..c2cadfa2 100644 --- a/shaders/prebuilt/post-crt.frag.glsl +++ b/shaders/prebuilt/post-crt.frag.glsl @@ -39,12 +39,19 @@ layout(set = 3, binding = 0, std140) uniform Globals { uniform vec3 iSelectionBackgroundColor; }; -// pardes's own: 1 when the window is opaque, so a pass cannot punch holes -// in it through its alpha. +// pardes's own: pardesOpaque is 1 when the window is opaque, so a pass +// cannot punch holes in it through its alpha; pardesLevel is a bundled +// pass's strength, 1 to 3 (`Crt 3`), and 0 for a user's file. layout(set = 3, binding = 1, std140) uniform PardesPost { float pardesOpaque; + float pardesLevel; }; +// A bundled pass's constant at levels 1, 2 and 3. +float pardesByLevel(float one, float two, float three) { + return pardesLevel < 1.5 ? one : (pardesLevel < 2.5 ? two : three); +} + #define CURSORSTYLE_BLOCK 0 #define CURSORSTYLE_BLOCK_HOLLOW 1 #define CURSORSTYLE_BAR 2 @@ -74,19 +81,23 @@ void main() { // Shadertoy file: pardes puts its prefix before it (shaders/post/prefix.glsl). const float TAU = 6.28318530718; -const float CHROMA_PX = 0.35; // red and blue apart, at the corners -const float BLOOM_RADIUS_PX = 1.75; -const float BLOOM_THRESHOLD = 0.60; // linear-light channel peak +// Level 3 glows wider and from dimmer ink. +float bloomRadiusPx() { return pardesByLevel(1.75, 1.75, 3.0); } +float bloomThreshold() { return pardesByLevel(0.6, 0.6, 0.3); } // linear-light channel peak const float BLOOM_SOFTNESS = 0.25; -const float BLOOM_STRENGTH = 0.05; const float SCAN_PERIOD_PX = 3.0; -const float SCAN_STRENGTH = 0.07; const float MASK_PERIOD_PX = 3.0; -const float MASK_STRENGTH = 0.018; -const float VIGNETTE_STRENGTH = 0.12; // at the far corners only -const float HUM_STRENGTH = 0.004; const float HUM_HZ = 0.11; -const float EXPOSURE = 1.015; + +// Each strength at levels 1, 2 and 3 (`Crt 1`..`Crt 3`): 2 is the default, +// 3 as strong as the old barrel CRT looked. +float chromaPx() { return pardesByLevel(0.2, 0.35, 2.2); } // red and blue apart, at the corners +float bloomStrength() { return pardesByLevel(0.03, 0.05, 0.3); } +float scanStrength() { return pardesByLevel(0.035, 0.07, 0.42); } +float maskStrength() { return pardesByLevel(0.01, 0.018, 0.12); } +float vignetteStrength() { return pardesByLevel(0.06, 0.12, 0.42); } // at the far corners only +float humStrength() { return pardesByLevel(0.002, 0.004, 0.012); } +float exposure() { return pardesByLevel(1.008, 1.015, 1.1); } vec3 toLinear(vec3 c) { return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); @@ -104,7 +115,7 @@ vec3 scene(vec2 uv) { vec3 brightPass(vec2 uv) { vec3 c = scene(uv); float peak = max(c.r, max(c.g, c.b)); - return c * smoothstep(BLOOM_THRESHOLD, BLOOM_THRESHOLD + BLOOM_SOFTNESS, peak); + return c * smoothstep(bloomThreshold(), bloomThreshold() + BLOOM_SOFTNESS, peak); } // Interleaved gradient noise: a dither with no visible pattern. @@ -121,24 +132,24 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) { // The beam converges least at the corners: red and blue drift apart // there, by a third of a pixel. - vec2 chroma = cc * (CHROMA_PX * r2 * 0.5) * texel; + vec2 chroma = cc * (chromaPx() * r2 * 0.5) * texel; vec3 col = vec3(scene(uv + chroma).r, scene(uv).g, scene(uv - chroma).b); - vec2 b = texel * BLOOM_RADIUS_PX; + vec2 b = texel * bloomRadiusPx(); vec3 bloom = brightPass(uv + vec2(b.x, 0.0)) + brightPass(uv - vec2(b.x, 0.0)) + brightPass(uv + vec2(0.0, b.y)) + brightPass(uv - vec2(0.0, b.y)); - col += bloom * (0.25 * BLOOM_STRENGTH); + col += bloom * (0.25 * bloomStrength()); // A beam is brightest on its line and falls off between lines; the // mask is three phosphor stripes a triad. Both average to one, so the // theme keeps its brightness. float line = 0.5 + 0.5 * cos(fragCoord.y * TAU / SCAN_PERIOD_PX); - col *= 1.0 - SCAN_STRENGTH * (line * line - 0.375) / 0.625; - col *= 1.0 + MASK_STRENGTH * cos(fragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0)); + col *= 1.0 - scanStrength() * (line * line - 0.375) / 0.625; + col *= 1.0 + maskStrength() * cos(fragCoord.x * TAU / MASK_PERIOD_PX + vec3(0.0, TAU / 3.0, 2.0 * TAU / 3.0)); - float vignette = 1.0 - VIGNETTE_STRENGTH * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6); - float hum = 1.0 + HUM_STRENGTH * sin((uv.y * 1.35 - iTime * HUM_HZ) * TAU); - col *= vignette * hum * EXPOSURE; + float vignette = 1.0 - vignetteStrength() * pow(clamp(r2 * 0.5, 0.0, 1.0), 1.6); + float hum = 1.0 + humStrength() * sin((uv.y * 1.35 - iTime * HUM_HZ) * TAU); + col *= vignette * hum * exposure(); vec3 srgb = toSrgb(col); srgb += (ign(fragCoord + float(iFrame % 64) * vec2(5.588238, 5.588238)) - 0.5) / 255.0; diff --git a/shaders/prebuilt/post-crt.frag.spv b/shaders/prebuilt/post-crt.frag.spv index e4d98138..6599abd9 100644 Binary files a/shaders/prebuilt/post-crt.frag.spv and b/shaders/prebuilt/post-crt.frag.spv differ diff --git a/shaders/prebuilt/post-glitch.frag.glsl b/shaders/prebuilt/post-glitch.frag.glsl index 46538452..7b288237 100644 --- a/shaders/prebuilt/post-glitch.frag.glsl +++ b/shaders/prebuilt/post-glitch.frag.glsl @@ -39,12 +39,19 @@ layout(set = 3, binding = 0, std140) uniform Globals { uniform vec3 iSelectionBackgroundColor; }; -// pardes's own: 1 when the window is opaque, so a pass cannot punch holes -// in it through its alpha. +// pardes's own: pardesOpaque is 1 when the window is opaque, so a pass +// cannot punch holes in it through its alpha; pardesLevel is a bundled +// pass's strength, 1 to 3 (`Crt 3`), and 0 for a user's file. layout(set = 3, binding = 1, std140) uniform PardesPost { float pardesOpaque; + float pardesLevel; }; +// A bundled pass's constant at levels 1, 2 and 3. +float pardesByLevel(float one, float two, float three) { + return pardesLevel < 1.5 ? one : (pardesLevel < 2.5 ? two : three); +} + #define CURSORSTYLE_BLOCK 0 #define CURSORSTYLE_BLOCK_HOLLOW 1 #define CURSORSTYLE_BAR 2 @@ -65,7 +72,8 @@ void main() { // The shader's own lines count from 1, so glslc's errors name them. #line 1 -// Glitch: now and then the signal slips. Every couple of seconds a short +// Glitch: now and then the signal slips. At the default level, every couple +// of seconds a short // burst tears a few horizontal bands sideways and pulls their red and blue // apart; the bands are sized in pixels and the burst eases in and out over // a fraction of a second, so it reads as one event rather than a flicker, @@ -74,12 +82,15 @@ void main() { // A torn band's brightness dips in linear light. It is a Shadertoy file: // pardes puts its prefix before it. -const float PERIOD = 2.4; // seconds between bursts, on average -const float BURST = 0.28; // seconds a burst lasts -const float STEP_HZ = 24.0; // how often a burst re-tears -const float SHIFT_PX = 14.0; -const float SPLIT_PX = 2.5; -const float DIP = 0.12; +// Each at levels 1, 2 and 3 (`Glitch 1`..`Glitch 3`); 2 is the default. A +// higher level slips more often and harder. +float period() { return pardesByLevel(4.5, 2.4, 0.55); } // seconds between bursts, on average +float burst() { return pardesByLevel(0.2, 0.28, 0.5); } // seconds a burst lasts +float stepHz() { return pardesByLevel(20.0, 24.0, 30.0); } // how often a burst re-tears +float shiftPx() { return pardesByLevel(8.0, 14.0, 34.0); } +float splitPx() { return pardesByLevel(1.5, 2.5, 6.0); } +float dip() { return pardesByLevel(0.08, 0.12, 0.22); } +float tearFrom() { return pardesByLevel(0.8, 0.72, 0.35); } // how few bands tear float hash(vec2 p) { vec3 q = fract(vec3(p.xyx) * 0.1031); @@ -101,27 +112,27 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 uv = fragCoord / size; // Which burst window this is, when in it the burst starts, and how far // into the burst we are: 0 outside one. - float window = floor(iTime / PERIOD); - float start = window * PERIOD + hash(vec2(window, 7.0)) * (PERIOD - BURST); - float t = (iTime - start) / BURST; + float window = floor(iTime / period()); + float start = window * period() + hash(vec2(window, 7.0)) * (period() - burst()); + float t = (iTime - start) / burst(); float live = step(0.0, t) * step(t, 1.0); float envelope = live * sin(3.14159265 * clamp(t, 0.0, 1.0)); if (envelope <= 0.0) { fragColor = texture(iChannel0, uv); return; } - // Bands of 6 to 40 px, re-cut at STEP_HZ during the burst; about one + // Bands of 6 to 40 px, re-cut at stepHz() during the burst; about one // band in four tears. - float tick = floor(iTime * STEP_HZ); + float tick = floor(iTime * stepHz()); float band_px = mix(6.0, 40.0, hash(vec2(tick, 3.0))); float band = floor(fragCoord.y / band_px); float roll = hash(vec2(band, tick)); - float tear = smoothstep(0.72, 0.9, roll) * envelope; - float shift = (hash(vec2(tick, band + 11.0)) - 0.5) * 2.0 * SHIFT_PX * tear; + float tear = smoothstep(tearFrom(), tearFrom() + 0.18, roll) * envelope; + float shift = (hash(vec2(tick, band + 11.0)) - 0.5) * 2.0 * shiftPx() * tear; vec2 at = vec2(fragCoord.x + shift, fragCoord.y); - vec2 split = vec2(SPLIT_PX * tear, 0.0); + vec2 split = vec2(splitPx() * tear, 0.0); vec4 center = texture(iChannel0, at / size); vec3 col = vec3(texture(iChannel0, (at + split) / size).r, center.g, texture(iChannel0, (at - split) / size).b); - col = toSrgb(toLinear(col) * (1.0 - DIP * tear)); + col = toSrgb(toLinear(col) * (1.0 - dip() * tear)); fragColor = vec4(col, center.a); } diff --git a/shaders/prebuilt/post-glitch.frag.spv b/shaders/prebuilt/post-glitch.frag.spv index 8a224958..619616a8 100644 Binary files a/shaders/prebuilt/post-glitch.frag.spv and b/shaders/prebuilt/post-glitch.frag.spv differ diff --git a/shaders/prebuilt/post-ripple.frag.glsl b/shaders/prebuilt/post-ripple.frag.glsl index 28059def..d29a4c22 100644 --- a/shaders/prebuilt/post-ripple.frag.glsl +++ b/shaders/prebuilt/post-ripple.frag.glsl @@ -39,12 +39,19 @@ layout(set = 3, binding = 0, std140) uniform Globals { uniform vec3 iSelectionBackgroundColor; }; -// pardes's own: 1 when the window is opaque, so a pass cannot punch holes -// in it through its alpha. +// pardes's own: pardesOpaque is 1 when the window is opaque, so a pass +// cannot punch holes in it through its alpha; pardesLevel is a bundled +// pass's strength, 1 to 3 (`Crt 3`), and 0 for a user's file. layout(set = 3, binding = 1, std140) uniform PardesPost { float pardesOpaque; + float pardesLevel; }; +// A bundled pass's constant at levels 1, 2 and 3. +float pardesByLevel(float one, float two, float three) { + return pardesLevel < 1.5 ? one : (pardesLevel < 2.5 ? two : three); +} + #define CURSORSTYLE_BLOCK 0 #define CURSORSTYLE_BLOCK_HOLLOW 1 #define CURSORSTYLE_BAR 2 @@ -67,19 +74,22 @@ void main() { #line 1 // Ripple: the page as a still pool a slow swell crosses. Rings travel out // from the middle of the window at a fixed speed in pixels, so they look -// the same at any size and frame rate; the displacement is at most a couple -// of pixels, so text stays readable, and it eases in over its first half +// the same at any size and frame rate; at the default level the displacement +// is at most a couple of pixels, so text stays readable, and it eases in over its first half // second instead of snapping on. Each ring catches the light a little on its // rising side, in linear light, and the result is dithered so the shading // never bands. It is a Shadertoy file: pardes puts its prefix before it. const float TAU = 6.28318530718; -const float WAVELENGTH_PX = 150.0; -const float SPEED_PX = 55.0; // outward, per second -const float AMPLITUDE_PX = 2.2; -const float SHADE = 0.035; // light on a slope, linear const float EASE_IN = 0.5; // seconds +// Each at levels 1, 2 and 3 (`Ripple 1`..`Ripple 3`); 2 is the default. +float wavelengthPx() { return pardesByLevel(170.0, 150.0, 120.0); } +float speedPx() { return pardesByLevel(40.0, 55.0, 80.0); } // outward, per second +float amplitudePx() { return pardesByLevel(1.2, 2.2, 5.0); } +float shade() { return pardesByLevel(0.02, 0.035, 0.09); } // light on a slope, linear +float reachEnd() { return pardesByLevel(0.85, 0.95, 1.3); } // how far out it dies away + vec3 toLinear(vec3 c) { return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); } @@ -101,16 +111,16 @@ void mainImage(out vec4 fragColor, in vec2 fragCoord) { float reach = 0.5 * length(size); // Strongest a third of the way out, fading to nothing at the middle // and at the edges, so the window's frame never wobbles. - float envelope = smoothstep(0.0, 0.18, radius / reach) * (1.0 - smoothstep(0.35, 0.95, radius / reach)); + float envelope = smoothstep(0.0, 0.18, radius / reach) * (1.0 - smoothstep(0.35, reachEnd(), radius / reach)); float ease = smoothstep(0.0, EASE_IN, iTime); - float phase = (radius - iTime * SPEED_PX) * TAU / WAVELENGTH_PX; + float phase = (radius - iTime * speedPx()) * TAU / wavelengthPx(); float height = sin(phase); float slope = cos(phase); vec2 dir = offset / max(radius, 1e-3); - vec2 moved = fragCoord + dir * height * AMPLITUDE_PX * envelope * ease; + vec2 moved = fragCoord + dir * height * amplitudePx() * envelope * ease; vec4 s = texture(iChannel0, moved / size); - vec3 col = toLinear(s.rgb) * (1.0 + SHADE * slope * envelope * ease); + vec3 col = toLinear(s.rgb) * (1.0 + shade() * slope * envelope * ease); vec3 srgb = toSrgb(col) + (ign(fragCoord + float(iFrame % 64) * 5.588238) - 0.5) / 255.0; fragColor = vec4(clamp(srgb, 0.0, 1.0), s.a); } diff --git a/shaders/prebuilt/post-ripple.frag.spv b/shaders/prebuilt/post-ripple.frag.spv index 1a105a33..5f05f9f3 100644 Binary files a/shaders/prebuilt/post-ripple.frag.spv and b/shaders/prebuilt/post-ripple.frag.spv differ diff --git a/src/config.zig b/src/config.zig index 1d4043ab..e8c5a705 100644 --- a/src/config.zig +++ b/src/config.zig @@ -628,7 +628,12 @@ pub const Runtime = struct { pub const Entry = struct { scene: ?Scene = null, path: Text(limits.host_path_cap) = .{}, + /// A bundled pass's strength, 1 to `max_level`; 0 for a file. + level: u8 = 0, }; + pub const max_level = 3; + /// What `on` and a bare word turn a bundled pass on at. + pub const default_level = 2; entries: [max]Entry = @splat(.{}), len: u8 = 0, /// Bumped on every change: a shell compiles the chain again when it @@ -640,8 +645,28 @@ pub const Runtime = struct { } pub fn has(post: *const Post, scene: Scene) bool { - for (post.list()) |entry| if (entry.scene == scene) return true; - return false; + return post.level(scene) != 0; + } + + /// A bundled pass's level; 0 when it is not in the chain. + pub fn level(post: *const Post, scene: Scene) u8 { + for (post.list()) |entry| if (entry.scene == scene) return entry.level; + return 0; + } + + /// Sets a bundled pass's level: 0 takes it out, anything else puts it + /// in at the end or changes it where it is. + pub fn setLevel(post: *Post, scene: Scene, to: u8) bool { + std.debug.assert(to <= max_level); + if (to == 0) return post.set(scene, "", false); + for (post.entries[0..post.len]) |*entry| if (entry.scene == scene) { + if (entry.level != to) post.generation +%= 1; + entry.level = to; + return true; + }; + if (!post.set(scene, "", true)) return false; + post.entries[post.len - 1].level = to; + return true; } /// Puts the pass in the chain, at its end, or takes it out; `want` @@ -949,8 +974,19 @@ pub const Runtime = struct { } }, .scene => |effect| { - const want = switchedTo(argument) catch return false; - return state.post.set(effect, "", want); + // A level, 0 (off) to 3; `on` is the default level, `off` 0, + // and bare flips between the two. + const text = std.mem.trim(u8, argument orelse "", " \t\r\n"); + const to: u8 = if (text.len == 0) + (if (state.post.has(effect)) 0 else Post.default_level) + else if (std.mem.eql(u8, text, "on")) + Post.default_level + else if (std.mem.eql(u8, text, "off")) + 0 + else + std.fmt.parseInt(u8, text, 10) catch return false; + if (to > Post.max_level) return false; + return state.post.setLevel(effect, to); }, .shader => { const path = std.mem.trim(u8, argument orelse return false, " \t\r\n"); @@ -1119,7 +1155,8 @@ pub const Runtime = struct { try out.print("{s} unsupported\n", .{setting.word}); continue; } - try out.print("{s} {s}\n", .{ setting.word, onOff(state.post.has(effect)) }); + const level = state.post.level(effect); + if (level == 0) try out.print("{s} off\n", .{setting.word}) else try out.print("{s} {d}\n", .{ setting.word, level }); }, .shader => { if (!setting.enabled(context.capabilities)) { @@ -1246,6 +1283,28 @@ pub const Runtime = struct { try std.testing.expect(!apply(&state, find("ShaderAnimation").?, "sometimes")); } + test "a bundled pass takes a level: on is the default, 0 and off take it out, and the old words still flip" { + var state: Runtime = .{}; + const crt = find("Crt").?; + try std.testing.expect(apply(&state, crt, "on")); + try std.testing.expectEqual(@as(u8, Post.default_level), state.post.level(.crt)); + try std.testing.expect(apply(&state, find("Glitch").?, "1")); + const moved = state.post.generation; + try std.testing.expect(apply(&state, crt, "3")); + try std.testing.expectEqual(@as(u8, 3), state.post.level(.crt)); + // A new level keeps its place in the chain. + try std.testing.expectEqual(Scene.crt, state.post.list()[0].scene.?); + try std.testing.expect(state.post.generation != moved); + try std.testing.expect(!apply(&state, crt, "4")); + try std.testing.expect(!apply(&state, crt, "loud")); + try std.testing.expect(apply(&state, crt, "0")); + try std.testing.expect(!state.post.has(.crt)); + try std.testing.expect(apply(&state, crt, null)); + try std.testing.expectEqual(@as(u8, Post.default_level), state.post.level(.crt)); + try std.testing.expect(apply(&state, crt, "off")); + try std.testing.expectEqual(@as(u8, 0), state.post.level(.crt)); + } + test "tagline size validates before mutating live state" { const setting = find("TaglineSize").?; var state: Runtime = .{}; @@ -1412,9 +1471,9 @@ pub const Runtime = struct { .typewriter => "Panel transition PanelType\n", }, .scene => |effect| switch (effect) { - .crt => "Crt on\n", - .ripple => "Ripple on\n", - .glitch => "Glitch on\n", + .crt => "Crt 2\n", + .ripple => "Ripple 2\n", + .glitch => "Glitch 2\n", }, .shader => "Shader ~/crt.glsl\n", .shader_animation => "ShaderAnimation always\n", diff --git a/src/gui/Post.zig b/src/gui/Post.zig index c529efc0..d9d3d53e 100644 --- a/src/gui/Post.zig +++ b/src/gui/Post.zig @@ -69,6 +69,8 @@ pub const Uniforms = extern struct { pub const Pass = struct { scene: ?Scene = null, + /// A bundled pass's strength (pardesLevel); 0 for a file. + level: u8 = 0, /// A user's file, owned; empty for a bundled pass. path: []u8 = &.{}, pipeline: ?*Pipeline = null, @@ -117,7 +119,7 @@ pub fn sync(post: *Post, gpa: std.mem.Allocator, io: std.Io, device: *c.SDL_GPUD 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); - next[i] = .{ .scene = scene, .pipeline = post.bundled.get(scene) }; + next[i] = .{ .scene = scene, .level = entry.level, .pipeline = post.bundled.get(scene) }; continue; } // A file already in the chain keeps its pipeline and its path. @@ -405,7 +407,6 @@ pub fn draw(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer, const day = epoch.getEpochDay().calculateYearDay(); const month = day.calculateMonthDay(); u.date = .{ @floatFromInt(day.year), @floatFromInt(month.month.numeric() - 1), @floatFromInt(month.day_index + 1), @floatFromInt(epoch.getDaySeconds().secs) }; - const own: [4]f32 = .{ @floatFromInt(@intFromBool(opaque_window)), 0, 0, 0 }; var input = source; var index: usize = 0; @@ -423,6 +424,7 @@ pub fn draw(post: *Post, device: *c.SDL_GPUDevice, cmd: *c.SDL_GPUCommandBuffer, 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))); c.SDL_DrawGPUPrimitives(pass_rp, 3, 1, 0, 0); c.SDL_EndGPURenderPass(pass_rp); diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index ed10dd70..cd327d03 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -263,7 +263,7 @@ fn writeSettings(p: *Pardes, w: *std.Io.Writer) !void { // Bare, it names the default directory. .dump_dir => if (s.dump_dir.len > 0) try w.print("{s} {s}\n", .{ word, s.dump_dir.get() }) else try w.print("{s}\n", .{word}), .transition => |effect| try w.print("{s} {s}\n", .{ word, if (s.panel_transition == effect) "on" else "off" }), - .scene => |effect| try w.print("{s} {s}\n", .{ word, if (s.post.has(effect)) "on" else "off" }), + .scene => |effect| if (s.post.level(effect) == 0) try w.print("{s} off\n", .{word}) else try w.print("{s} {d}\n", .{ word, s.post.level(effect) }), .shader => for (s.post.list()) |entry| if (entry.scene == null) try w.print("{s} {s}\n", .{ word, entry.path.get() }), .shader_animation => try w.print("{s} {s}\n", .{ word, @tagName(s.shader_animation) }), } diff --git a/test/output.zig b/test/output.zig index 400e9508..0fdd9f4d 100644 --- a/test/output.zig +++ b/test/output.zig @@ -667,7 +667,8 @@ test "every enabled setting builtin mutates the State Config reports" { else "Panel transition unsupported\n"; try std.testing.expect(std.mem.indexOf(u8, report, transition) != null); - const scene_status = if (builtins.capabilities.scene_shaders) "on" else "unsupported"; + // A bare word turns a bundled pass on at its default level. + const scene_status = if (builtins.capabilities.scene_shaders) "2" else "unsupported"; for ([_][]const u8{ "Crt", "Ripple", "Glitch" }) |name| { const line = try std.fmt.bufPrint(&buf, "{s} {s}\n", .{ name, scene_status }); try std.testing.expect(std.mem.indexOf(u8, report, line) != null); -- cgit v1.3 From 68e30f560e46bb9a30433eb23b4edb1b93d69f46 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:57:01 -0300 Subject: A selection's other ranges live out of line, made at the second range and freed at one The ranges beside the primary were an inline array in every Text, which at 1024 grew each pane by about 60 KiB. They are now allocated when a second range appears (Text.reserveSels) and given back when the selection is one range again (Text.dropSels); a column's or the workspace's tag allocates from Text.gpa, any other text from its pane's. A refused tag edit restores them by copy. The Pane size budget is back to 128 KiB, and a test holds that a single cursor allocates nothing for them. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 2 +- docs/selections.md | 5 ++++- src/Terminal.zig | 5 +---- src/Text.zig | 42 +++++++++++++++++++++++++++++++++++++++--- src/edit.zig | 10 ++++++++-- src/layout.zig | 6 +++--- src/normal.zig | 28 ++++++++++++++++++++++++---- src/pardes.zig | 10 ++++++++-- test/panes.zig | 1 + 9 files changed, 89 insertions(+), 20 deletions(-) diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 88aca887..1fb1c67a 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -282,7 +282,7 @@ keeps the matches it found so far. | an all-lowercase pattern | matches case-blind | helix's smart-case. mvzr has no such flag, so the surface is lowercased instead (ASCII folding is byte-for-byte, so the offsets are identical) | helix-verified | | `^` and `$` | hold at every line's start and end | helix compiles with `multi_line(true)`; pardes gets the same by searching each line as its own haystack (`src/regexp.zig`) | helix-verified | | `.` | never matches a newline | as the Rust regex crate: a pattern without `\n` searches line by line, and one that names `\n` has its `.`s made `[^\n]` (`src/regexp.zig`) | helix-verified | -| many matches | up to `memory.limits.selections` ranges: 1024 on the desktop, 64 on the board; matches past it are dropped | helix has no limit. The ranges are held inline in every `Text` (about 20 KiB each on the desktop) | helix-verified up to the limit | +| many matches | up to `memory.limits.selections` ranges: 1024 on the desktop, 64 on the board; matches past it are dropped | helix has no limit. The room for them is allocated when a second range appears and given back at one range, so a single cursor costs nothing | helix-verified up to the limit | | `K` / `Alt-K` Enter | keep only the ranges a match starts inside / only those none does; primary 0, and keeping none leaves the selection alone | helix `keep_selections` / `remove_selections`, on the same prompt as `s`/`S` (markers `Keep /`, `Remove /`). The range is searched as `s` searches it, line by line with its lines' context, where helix matches the range's text alone: a `^` right at a range that starts mid-line matches in helix and not here | helix-verified | ### `Ctrl-c` — toggle comments diff --git a/docs/selections.md b/docs/selections.md index 203cf1de..5cc1df1a 100644 --- a/docs/selections.md +++ b/docs/selections.md @@ -17,7 +17,10 @@ desktop, 64 on the board). head, `vsel.row`/`vsel.col` the anchor while `vsel.active`, and a bare cursor (inactive `vsel`) is the one-character range under it. `msel` is the older whole-line form (`r0`..`r1`), kept for search-result highlights. -- The others are `sels[0..nsel]`, each a `SelRange` of head and anchor plus +- The others are `sels[0..nsel]`, in room allocated when a second range + appears and given back when the selection is one range again (a column's + or the workspace's tag allocates from `Text.gpa`, any other text from its + pane's), each a `SelRange` of head and anchor plus its own `j`/`k` goal column, in document order. The primary is not in the list; `Text.ranges` slots it in and returns its index. - Ends are CELLS, the characters a block cursor sits on, where helix's are diff --git a/src/Terminal.zig b/src/Terminal.zig index 4c29e151..ba156ada 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -545,10 +545,7 @@ test "replay ownership is terminal-only and construction rolls back on allocatio try std.testing.expectEqual(terminal and enabled, pane.terminal != null); } }; - // Most of it is the three texts' selections, limits.selections ranges - // each held inline (about 20 KiB a text on the desktop), and the regex - // prompt's snapshot of them. - try std.testing.expect(@sizeOf(Pane) < 160 * 1024); + try std.testing.expect(@sizeOf(Pane) < 128 * 1024); try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{false}); try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{true}); } diff --git a/src/Text.zig b/src/Text.zig index 70ec5369..a44241ca 100644 --- a/src/Text.zig +++ b/src/Text.zig @@ -60,8 +60,14 @@ cur_row: i32 = 0, cur_col: i32 = 0, msel: LineSel = .{}, vsel: CharSel = .{}, -sels: [max_selections - 1]SelRange = undefined, +/// The ranges beside the primary, `nsel` of them: room for max_selections - 1 +/// made when a second range appears and given back when the selection is one +/// range again, so a single cursor holds nothing here. +sels: []SelRange = &.{}, nsel: u16 = 0, +/// What a column's or the workspace's tag allocates its ranges from; a text +/// with a pane uses the pane's. +gpa: ?std.mem.Allocator = null, select: bool = false, /// sticky goal column for j/k runs (helix old_visual_position): any /// non-vertical range write resets it to -1. @@ -101,10 +107,39 @@ pub fn pane(t: *Text) ?*Pane { /// Free what a text owns: its characters and the buffers its undo keeps. pub fn deinit(t: *Text, gpa: std.mem.Allocator) void { + if (t.sels.len > 0) gpa.free(t.sels); if (t.own) |own| gpa.free(own); for (t.ed_undo[0..t.ed_undo_len]) |item| if (item.ovl) |overlay| gpa.free(overlay.text); for (t.ed_redo[0..t.ed_redo_len]) |item| if (item.ovl) |overlay| gpa.free(overlay.text); - t.* = .{ .what = t.what }; + t.* = .{ .what = t.what, .gpa = t.gpa }; +} + +fn allocator(t: *Text) ?std.mem.Allocator { + return if (t.pane()) |w| w.gpa else t.gpa; +} + +/// Room for the ranges beside the primary; false when there is none to be had. +pub fn reserveSels(t: *Text) bool { + if (t.sels.len > 0) return true; + const gpa = t.allocator() orelse return false; + t.sels = gpa.alloc(SelRange, max_selections - 1) catch return false; + return true; +} + +/// The ranges beside the primary as they were (a refused edit's undoing). +pub fn restoreSels(t: *Text, kept: []const SelRange) void { + if (kept.len == 0) return t.dropSels(); + if (!t.reserveSels()) return; + @memcpy(t.sels[0..kept.len], kept); + t.nsel = @intCast(kept.len); +} + +/// Back to one range: the room for the others goes. +pub fn dropSels(t: *Text) void { + t.nsel = 0; + if (t.sels.len == 0) return; + if (t.allocator()) |gpa| gpa.free(t.sels); + t.sels = &.{}; } /// Bring the cursor into view (acme's textshow). Only a body scrolls to it; @@ -185,7 +220,7 @@ pub fn setRange(t: *Text, text: []const u8, row0: i32, r0: modal.Selection, expl .explicit = explicit or t.select, }; t.msel.active = false; - t.nsel = 0; // writing ONE range means the selection IS that range + t.dropSels(); // writing ONE range means the selection IS that range t.cur_pinned = true; t.sticky_col = -1; t.normal.clear(); @@ -262,6 +297,7 @@ pub fn setRanges(t: *Text, text: []const u8, in: []const modal.Selection, sticky } n = k + 1; setRange(t, text, 0, r[pri], explicit); + if (n > 1 and !t.reserveSels()) return; // no room: the primary alone t.sticky_col = st[pri]; var w: usize = 0; for (r[0..n], 0..) |x, idx| { diff --git a/src/edit.zig b/src/edit.zig index 01b34237..5437a67d 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -167,10 +167,16 @@ fn pasteInto(p: *Pardes, t: *Text, bytes: []const u8) void { if (bytes.len == 0) return; const typed: pardes.Key = .{ .cp = bytes[0], .text = bytes }; if (t.what == .tag and t.mode == .insert) if (t.pane()) |pane| if (tagline.typedIntoName(p, pane, typed)) return; - const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel }; + const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel }; + var kept_sels: [Text.max_selections - 1]Text.SelRange = undefined; + const kept_n = t.nsel; + @memcpy(kept_sels[0..kept_n], t.sels[0..kept_n]); t.refused = false; if (t.mode == .insert) insertKey(p, t, typed) else pasteText(p, t, bytes, false, 1); - if (t.refused) t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel = kept; + if (t.refused) { + t.cur_row, t.cur_col, t.vsel, t.msel = kept; + t.restoreSels(kept_sels[0..kept_n]); + } } pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void { diff --git a/src/layout.zig b/src/layout.zig index 8e44f1d9..be8bad67 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -947,7 +947,7 @@ pub fn removePane(p: *Pardes, id: usize) void { p.col_serial[j] = p.col_serial[j + 1]; } p.ncol -= 1; - p.col_tags[p.ncol] = .{ .what = .column }; + p.col_tags[p.ncol] = .{ .what = .column, .gpa = p.gpa }; p.col_serial[p.ncol] = 0; } } @@ -971,7 +971,7 @@ pub fn joinCol(p: *Pardes) void { p.col_serial[j] = p.col_serial[j + 1]; } p.ncol -= 1; - p.col_tags[p.ncol] = .{ .what = .column }; + p.col_tags[p.ncol] = .{ .what = .column, .gpa = p.gpa }; p.col_serial[p.ncol] = 0; } @@ -1021,7 +1021,7 @@ pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool { p.col_serial[j] = p.col_serial[j - 1]; } p.col_weight[c] = new_weight; - p.col_tags[c] = .{ .what = .column }; + p.col_tags[c] = .{ .what = .column, .gpa = p.gpa }; p.col_serial[c] = 0; p.col_panes[c][0] = id; p.col_n[c] = 1; diff --git a/src/normal.zig b/src/normal.zig index 4459ab53..2aea2e54 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -686,10 +686,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void .all => return t.setRange(text, 0, .{ .anchor = 0, .head = text.len }, false), }, .multi => |multi| { - if (multi.kind == .keep_primary) { - t.nsel = 0; - return; - } + if (multi.kind == .keep_primary) return t.dropSels(); return t.multiSelAction(text, multi.kind, multi.count); }, // s, S and | answer for the text they are pressed in; `/` searches @@ -922,6 +919,7 @@ test "flat text movement and selection replay need no scratch rows" { pane.body.cur_row = 1024; pane.body.cur_col = 0; pane.body.nsel = 1; + _ = pane.body.reserveSels(); pane.body.sels[0] = .{ .row = 3072, .col = 0, .arow = 3072, .acol = 0 }; handleNormal(p, &pane.body, .{ .cp = 'l' }); handleNormal(p, &pane.body, .{ .cp = 'j' }); @@ -1021,3 +1019,25 @@ test "tree-sitter selection keys do in a JSON file what hx 25.07.1 does" { }; } } + +test "one cursor holds no room for other ranges; a second range makes it and one again gives it back" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 12 }); + defer p.deinit(); + const pane = try p.setTestFile("alpha beta\nalpha beta\n"); + for ("wxe%;gg") |cp| handleNormal(p, &pane.body, .{ .cp = cp }); + try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len); + handleNormal(p, &pane.body, .{ .cp = 'C' }); + try std.testing.expectEqual(@as(u16, 1), pane.body.nsel); + try std.testing.expect(pane.body.sels.len > 0); + handleNormal(p, &pane.body, .{ .cp = 'l' }); // replayed at both, still two + try std.testing.expectEqual(@as(u16, 1), pane.body.nsel); + handleNormal(p, &pane.body, .{ .cp = ',' }); + try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len); + handleNormal(p, &pane.body, .{ .cp = 'C' }); + handleNormal(p, &pane.body, .{ .cp = ';' }); + handleNormal(p, &pane.body, .{ .cp = 'x' }); // x at both: still two lines + handleNormal(p, &pane.body, .{ .cp = '_', .alt = true }); // merged to one + try std.testing.expectEqual(@as(u16, 0), pane.body.nsel); + try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len); +} diff --git a/src/pardes.zig b/src/pardes.zig index 6b397017..187f3ed3 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4146,6 +4146,8 @@ pub const Pardes = struct { .pdf_gpa = pdf_gpa, .tree_sitter_gpa = tree_sitter_gpa, .opts = opts, + .col_tags = @splat(.{ .what = .column, .gpa = gpa }), + .global_tag = .{ .what = .workspace, .gpa = gpa }, .fs = .{}, .screen_w = opts.cols, .screen_h = opts.rows, @@ -5335,10 +5337,14 @@ pub const Pardes = struct { if (t.what == .tag and t.mode == .insert and tagline.typedIntoName(p, pane, key)) return; // A tag's prefix is read-only: a key whose edit reached into it // changes nothing, not even where the cursor is. - const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel }; + const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel }; + var kept_sels: [Text.max_selections - 1]Text.SelRange = undefined; + const kept_n = t.nsel; + @memcpy(kept_sels[0..kept_n], t.sels[0..kept_n]); t.refused = false; defer if (t.refused) { - t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel = kept; + t.cur_row, t.cur_col, t.vsel, t.msel = kept; + t.restoreSels(kept_sels[0..kept_n]); }; const was = t.mode; switch (t.mode) { diff --git a/test/panes.zig b/test/panes.zig index a56c07bf..181317ca 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -4560,6 +4560,7 @@ const LookResetTests = struct { pane.body.cur_col = 3; pane.body.vsel = .{ .active = true, .explicit = true, .row = 0, .col = 0 }; pane.body.msel = .{ .active = true, .r0 = 0, .r1 = 1 }; + _ = pane.body.reserveSels(); pane.body.sels[0] = .{ .row = 1, .col = 2, .arow = 0, .acol = 1 }; pane.body.nsel = 1; pane.body.select = true; -- cgit v1.3 From a007457cb007b03f310bca55c339e8cd5d9baafc Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:44:51 -0300 Subject: Ripple and Glitch are gone; Crt stays The user tried them live and wants them removed entirely: the builtins, their levels, config keys and leader paths (tR, tg), their slots in the built-in chain, their shaders (source and prebuilt), EffectCode paths, and on macOS their scene flags (pardes.h), the Metal kernel's coordinate warps and the Swift pointer mapping that mirrored them. Docs say so (decision 8 in docs/render-pipeline.md); config.md documents Crt's levels, Shader and ShaderAnimation instead. Shared files touched: macos.zig (flags). Not touched: pardes.zig, Messages.zig, mouse.zig, gui.zig, detached/*. --- build.zig | 5 +- docs/config.md | 15 ++-- docs/design.typ | 5 +- docs/macos.md | 21 ++--- docs/render-pipeline.md | 19 ++-- docs/web.md | 2 +- shaders/crt.ci.metal | 75 ++-------------- shaders/post/glitch.glsl | 64 ------------- shaders/post/ripple.glsl | 52 ----------- shaders/prebuilt/post-glitch.frag.glsl | 138 ----------------------------- shaders/prebuilt/post-glitch.frag.spv | Bin 10764 -> 0 bytes shaders/prebuilt/post-ripple.frag.glsl | 126 -------------------------- shaders/prebuilt/post-ripple.frag.spv | Bin 9096 -> 0 bytes src/config.zig | 22 ++--- src/effect_sources.zig | 8 +- src/gui/Post.zig | 8 +- src/macos.zig | 17 +--- src/macos/Sources/PardesView.swift | 2 +- src/macos/Sources/ScenePostprocessor.swift | 48 +--------- src/macos/pardes.h | 5 +- test/output.zig | 2 +- 21 files changed, 61 insertions(+), 573 deletions(-) delete mode 100644 shaders/post/glitch.glsl delete mode 100644 shaders/post/ripple.glsl delete mode 100644 shaders/prebuilt/post-glitch.frag.glsl delete mode 100644 shaders/prebuilt/post-glitch.frag.spv delete mode 100644 shaders/prebuilt/post-ripple.frag.glsl delete mode 100644 shaders/prebuilt/post-ripple.frag.spv diff --git a/build.zig b/build.zig index c1117593..97d51a51 100644 --- a/build.zig +++ b/build.zig @@ -51,10 +51,9 @@ const gui_shaders = [_][]const u8{ "overlay.vert", "overlay.frag", "image.vert", "image.frag", "post.vert", - // The bundled post passes: shaders/post/.glsl behind the Shadertoy + // The bundled post pass: shaders/post/crt.glsl behind the Shadertoy // prefix (shaderSource). - "post-crt.frag", "post-ripple.frag", - "post-glitch.frag", + "post-crt.frag", }; pub fn build(b: *std.Build) void { diff --git a/docs/config.md b/docs/config.md index 9c51f329..16fd59e5 100644 --- a/docs/config.md +++ b/docs/config.md @@ -513,18 +513,19 @@ immediately and remains the one authoritative geometry. DOM web intentionally does not expose these builtins: its renderer is selectable HTML/CSS and has no canvas or shader stage. -The scene effects are independent switches and can be combined: +The scene effect is `Crt`, at a level from 0 (off) to 3; `on` is 2: ```text Crt -Ripple -Glitch +Crt 3 ``` -They share one full-scene shader pass in SDL and macOS. With all three off the -pass is bypassed. CRT works in linear light with restrained scanlines, mask, -bloom, curvature, and noise rather than remapping the theme to a strong fixed -palette; Ripple and Glitch primarily perturb sample coordinates. +The SDL GUI runs it as the bundled pass of its post chain, which also takes +Shadertoy files written for ghostty (`Shader ~/crt.glsl`, `Shader off`), and +`ShaderAnimation off|on|always` says when the chain animates on its own. With +the chain empty the pass is bypassed. CRT works in linear light with restrained +scanlines, mask, bloom and vignette, and no curvature, so clicks land where +they are drawn. `EffectCode PanelAscii` or `EffectCode Crt` lists the current backend's build-embedded source paths under `/virtual`. Look opens each full file; diff --git a/docs/design.typ b/docs/design.typ index 234c3692..caa35b37 100644 --- a/docs/design.typ +++ b/docs/design.typ @@ -913,8 +913,9 @@ while its geometry moves and placed again on the last active sample. SDL supplies old/new glyph data, diff flags, final/presented boxes, and effect parameters to the glyph and native-image shaders. Pixel attachments bypass ASCII because they have no character byte. macOS passes the same records across its -plain C ABI and composites old/new panel images in Metal and Core Image. Scene -`Crt`, `Ripple` and `Glitch` bits share one full-window pass in each native GUI. +plain C ABI and composites old/new panel images in Metal and Core Image. The +scene `Crt` is one full-window pass in each native GUI; the SDL GUI's post +chain also runs Shadertoy files (`Shader`). DOM web is a separate platform, not a shader GUI: retaining selectable HTML and CSS is more important than duplicating the renderer in canvas, so it exposes diff --git a/docs/macos.md b/docs/macos.md index e104d3a6..5166e2d5 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -102,8 +102,8 @@ asking for a thin insert caret instead of a block. `Surface.images` crosses separately as `pardes_frame_images` / `pardes_frame_image_list` — see "Pixel attachments" below. -`pardes_scene` is the matching full-window snapshot: a bit each for CRT, -ripple, and glitch, plus the 60 Hz time/frame that animates them. It is returned +`pardes_scene` is the matching full-window snapshot: a bit for CRT, plus the +60 Hz time/frame that animates it. It is returned by value, so the renderer never holds a pointer into live core state. A zero flags word is also the fast-path contract: draw the CoreText frame directly. @@ -662,8 +662,7 @@ longer leaves a dark titlebar over a cream grid. ## Scene effects -`Crt`, `Ripple`, and `Glitch` are three switches over one postprocess, not three -stacked filters. Their canonical macOS source is `shaders/crt.ci.metal`, which +`Crt` is one full-window postprocess. Its canonical macOS source is `shaders/crt.ci.metal`, which the build installs as `pardes.app/Contents/Resources/crt.ci.metal`. The Swift shell loads that exact asset with `CIKernel.kernels(withMetalString:)` and runs it through a `CIContext` created from the system Metal device. The source is @@ -673,14 +672,12 @@ The ordinary CoreText/attachment/cursor pass is one function. With any scene bit or panel track it targets a retained, backing-scale bitmap; kernels then sample the complete frame into the flipped view. PDFs, image panes, taglines, rules, and the caret therefore receive the same effect. With every bit off and -no panel track the bitmap and Core Image context are bypassed entirely. Ripple -and glitch alter sample coordinates only, preserving theme colors; CRT works +no panel track the bitmap and Core Image context are bypassed entirely. CRT works in linear light with restrained bloom, scan/mask, vignette, hum, and noise instead of applying a broad color remap. The scene kernel alone receives a `clampedToExtent` image and the final output -is cropped back to the original finite extent. Coordinate tears and chroma -samples therefore clamp to the edge exactly like SDL's scene sampler instead +is cropped back to the original finite extent. Chroma samples therefore clamp to the edge exactly like SDL's scene sampler instead of acquiring transparent-black seams from Core Image's finite source image. The Zig side owns the clock. `pardes_animation_tick` increments its wrapped @@ -689,8 +686,8 @@ from that integer. Input bursts cannot accelerate the shader. Pointer input follows the same destination-to-source transform as the last presented scene frame before it is divided by the cell metrics. The view keeps -that exact `pardes_scene_s` snapshot and mirrors the Metal barrel, ripple and -glitch sampling arithmetic in `ScenePostprocessor.sourcePoint`; pixels outside +that exact `pardes_scene_s` snapshot and mirrors the Metal barrel arithmetic +in `ScenePostprocessor.sourcePoint`; pixels outside the CRT tube have no cell. The resulting displayed-grid cell then reaches the core, whose panel-track mapping resolves it to canonical pane content. @@ -716,8 +713,8 @@ while `pardesComposedInCore` effects only clip the already-composed core cells; pixel attachments have no character value and pass through unchanged. Because the input is the finished bitmap rather than a glyph-only batch, backgrounds, glyphs, taglines, rules, the caret, PDF pages, and image -panes move and dissolve together. Scene CRT/ripple/glitch runs once after the -panel composition. +panes move and dissolve together. The scene CRT runs once after the panel +composition. With no scene bit and no panel track the retained bitmap, Core Image context, and Metal passes are bypassed. `EffectCode Panel*` links to this Metal diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index 2fd6045b..7c94fbea 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -505,16 +505,14 @@ Mirror ghostty 1.3.2 (`zig-pkg/ghostty-*/src/renderer/shadertoy.zig`, `custom-shader-animation`): off = redraw only when content changes; on = continuous while the window is focused; always = continuous. Continuous means the shell redraws its retained Surface (3.1), never a core tick or render. -- **Existing crt/ripple/glitch** become bundled Shadertoy files in - `shaders/post/` selected by the same builtins (decision 2: kept, polished); - `scene_effects` and `crt.frag.glsl`/`crt.zig` are deleted. Each bundled pass - takes a level, `Crt 0..3` (0 off, `on` the default 2, `off` and bare still - work), passed as `pardesLevel`: 2 is the polished rewrite, 3 at least as - strong as the old effects (pixels changed by more than 8 in a channel, - against a plain frame: Crt 18.3% vs 17.4% old, Ripple 11.1% vs 9.5%, Glitch - 9.3% a frame vs 8.1%); a Glitch level raises how often it slips as well as - how hard. The chain is - `Crt`, `Ripple`, `Glitch` and `Shader ` in the order they were put in +- **Existing crt** becomes a bundled Shadertoy file in `shaders/post/` under + the same builtin; `scene_effects` and `crt.frag.glsl`/`crt.zig` are deleted. + It takes a level, `Crt 0..3` (0 off, `on` the default 2, `off` and bare + still work), passed as `pardesLevel`: 2 is the polished rewrite, 3 at least + as strong as the old CRT (pixels changed by more than 8 in a channel against + a plain frame: 18.3% vs 17.4%). Ripple and Glitch were rewritten too, then + removed at the user's word after a live look (decision 8). The chain is + `Crt` and `Shader ` in the order they were put in (`Shader off` empties it of files), `ShaderAnimation off|on|always` (default on). The core keeps no clock for it: level A redraws are the shell's. @@ -905,3 +903,4 @@ goldens; ssh/tty byte budgets; wire version bump breaks mixed-version attach. 5. The animation pacing fix goes into the no-visual-change phase. 6. Cursor blink is on by default. 7. The bundled CRT has no barrel distortion, so clicks stay exact. +8. (After trying them live) Ripple and Glitch are removed entirely; Crt stays. diff --git a/docs/web.md b/docs/web.md index 3a68f958..bbbed8a3 100644 --- a/docs/web.md +++ b/docs/web.md @@ -42,7 +42,7 @@ shells, and two comptime capability flags say so once each. It exposes no `PanelAscii`, `PanelVertical`, `PanelEdges`, `PanelFall`, `PanelWave`, `PanelCurtain`, `PanelScramble`, `PanelType`) because `capabilities.panel_transitions` is `pardes.hosted`, and no -`Crt`/`Ripple`/`Glitch` because `capabilities.scene_shaders` is +`Crt` or `Shader` because `capabilities.scene_shaders` is `platform == .gui or platform == .macos` (`builtins.capabilities`; the words themselves carry those availabilities in `config.Runtime.settings`). Applying either faithfully would require a second canvas renderer and give up diff --git a/shaders/crt.ci.metal b/shaders/crt.ci.metal index c7e91a7e..9159af18 100644 --- a/shaders/crt.ci.metal +++ b/shaders/crt.ci.metal @@ -1,7 +1,6 @@ // Native macOS panel and scene postprocess. Panel kernels consume the core's -// plain tracks; the final full-window kernel ports crt.frag.glsl and combines -// CRT, ripple and glitch. Scene constants and sampling geometry stay in step -// with GLSL, while panel easing/noise stays in step with panel_animation.zig. +// plain tracks; the final full-window kernel is the CRT. Panel easing/noise +// stays in step with panel_animation.zig. // // The three entry points are `extern "C" [[stitchable]]`. Both halves are // load-bearing: this source is compiled at runtime by @@ -38,24 +37,6 @@ float pardesCellNoise(float2 serialParts, float colValue, float rowValue) { return float(x & 0xffffu) / 65535.0; } -float2 pardesSceneUV(float2 uv, float timeSeconds, float rippleOn, float glitchOn) { - if (rippleOn > 0.5) { - float2 centered = uv - 0.5; - float radius = length(centered); - // Reversed smoothstep edges are undefined in GLSL and Metal alike. - float envelope = 1.0 - smoothstep(0.04, 0.72, radius); - float wave = sin(radius * 42.0 - timeSeconds * 5.2); - uv += centered / max(radius, 0.00001) * wave * envelope * 0.0032; - } - if (glitchOn > 0.5) { - float band = floor(uv.y * 96.0); - float seed = pardesNoise(float2(band, floor(timeSeconds * 13.0))); - float tear = smoothstep(0.955, 1.0, seed); - uv.x += (pardesNoise(float2(seed, band)) - 0.5) * tear * 0.020; - } - return uv; -} - float4 pardesSceneSample( coreimage::sampler source, float2 origin, @@ -66,42 +47,13 @@ float4 pardesSceneSample( source.sample(source.transform(origin + uv * size))); } -// Keep this ordering identical to crt.frag.glsl's sceneSrgb: every caller -// supplies its own pre-warp coordinate, then ripple/glitch move that sample, -// then glitch splits its red/blue channels around the moved center. -float4 pardesSceneSrgb( - coreimage::sampler source, - float2 origin, - float2 size, - float2 uv, - float timeSeconds, - float rippleOn, - float glitchOn -) { - float2 warped = pardesSceneUV(uv, timeSeconds, rippleOn, glitchOn); - float4 center = pardesSceneSample(source, origin, size, warped); - if (glitchOn <= 0.5) return center; - - float band = floor(warped.y * 96.0); - float seed = pardesNoise(float2(band, floor(timeSeconds * 13.0))); - float tear = smoothstep(0.955, 1.0, seed); - float2 chroma = float2((0.35 + 1.25 * tear) / size.x, 0.0); - float4 red = pardesSceneSample(source, origin, size, warped + chroma); - float4 blue = pardesSceneSample(source, origin, size, warped - chroma); - return float4(red.r, center.g, blue.b, center.a); -} - float3 pardesSceneLinear( coreimage::sampler source, float2 origin, float2 size, - float2 uv, - float timeSeconds, - float rippleOn, - float glitchOn + float2 uv ) { - return coreimage::srgb_to_linear(pardesSceneSrgb( - source, origin, size, uv, timeSeconds, rippleOn, glitchOn).rgb); + return coreimage::srgb_to_linear(pardesSceneSample(source, origin, size, uv).rgb); } bool pardesInside(float2 p, float4 box) { @@ -242,8 +194,6 @@ extern "C" [[stitchable]] float4 pardesScene( float timeSeconds, float frame, float crtOn, - float rippleOn, - float glitchOn, float2 sceneSize, float2 sceneOrigin, coreimage::destination destination @@ -277,24 +227,18 @@ extern "C" [[stitchable]] float4 pardesScene( // transparent theme. if (crtOn > 0.5 && edge <= 0.0) return float4(0.0, 0.0, 0.0, 1.0); - float4 center = pardesSceneSrgb( - source, origin, size, uv, timeSeconds, rippleOn, glitchOn); + float4 center = pardesSceneSample(source, origin, size, uv); float3 scene = center.rgb; - // Ripple/glitch alone are coordinate effects. Returning the sampled sRGB - // values directly keeps exact theme colors exact. + // Without the CRT the scene passes through, exact. if (crtOn <= 0.5) { return coreimage::premultiply(float4(clamp(scene, 0.0, 1.0), center.a)); } float3 centerLinear = coreimage::srgb_to_linear(scene); float2 radialChroma = cc * (chromaPx * r2) * texel; - float3 redSample = pardesSceneLinear( - source, origin, size, uv + radialChroma, - timeSeconds, rippleOn, glitchOn); - float3 blueSample = pardesSceneLinear( - source, origin, size, uv - radialChroma, - timeSeconds, rippleOn, glitchOn); + float3 redSample = pardesSceneLinear(source, origin, size, uv + radialChroma); + float3 blueSample = pardesSceneLinear(source, origin, size, uv - radialChroma); float3 col = float3( redSample.r, centerLinear.g, @@ -308,8 +252,7 @@ extern "C" [[stitchable]] float4 pardesScene( float2(0.0, bloom.y), float2(0.0, -bloom.y) }; for (uint i = 0; i < 4; i++) { - // Deliberately raw, like GLSL brightPass: bloom belongs to the source - // image and does not recursively warp through the scene effects. + // Deliberately raw, like GLSL brightPass. float4 sampleValue = pardesSceneSample( source, origin, size, uv + bloomOffsets[i]); float3 linearValue = coreimage::srgb_to_linear(sampleValue.rgb); diff --git a/shaders/post/glitch.glsl b/shaders/post/glitch.glsl deleted file mode 100644 index 8f420d9c..00000000 --- a/shaders/post/glitch.glsl +++ /dev/null @@ -1,64 +0,0 @@ -// Glitch: now and then the signal slips. At the default level, every couple -// of seconds a short -// burst tears a few horizontal bands sideways and pulls their red and blue -// apart; the bands are sized in pixels and the burst eases in and out over -// a fraction of a second, so it reads as one event rather than a flicker, -// and it looks the same at 60 or 144 Hz because every step is keyed to -// iTime, not to frames. Between bursts the page is left exactly as it is. -// A torn band's brightness dips in linear light. It is a Shadertoy file: -// pardes puts its prefix before it. - -// Each at levels 1, 2 and 3 (`Glitch 1`..`Glitch 3`); 2 is the default. A -// higher level slips more often and harder. -float period() { return pardesByLevel(4.5, 2.4, 0.55); } // seconds between bursts, on average -float burst() { return pardesByLevel(0.2, 0.28, 0.5); } // seconds a burst lasts -float stepHz() { return pardesByLevel(20.0, 24.0, 30.0); } // how often a burst re-tears -float shiftPx() { return pardesByLevel(8.0, 14.0, 34.0); } -float splitPx() { return pardesByLevel(1.5, 2.5, 6.0); } -float dip() { return pardesByLevel(0.08, 0.12, 0.22); } -float tearFrom() { return pardesByLevel(0.8, 0.72, 0.35); } // how few bands tear - -float hash(vec2 p) { - vec3 q = fract(vec3(p.xyx) * 0.1031); - q += dot(q, q.yzx + 33.33); - return fract((q.x + q.y) * q.z); -} - -vec3 toLinear(vec3 c) { - return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); -} - -vec3 toSrgb(vec3 c) { - c = max(c, vec3(0.0)); - return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); -} - -void mainImage(out vec4 fragColor, in vec2 fragCoord) { - vec2 size = iResolution.xy; - vec2 uv = fragCoord / size; - // Which burst window this is, when in it the burst starts, and how far - // into the burst we are: 0 outside one. - float window = floor(iTime / period()); - float start = window * period() + hash(vec2(window, 7.0)) * (period() - burst()); - float t = (iTime - start) / burst(); - float live = step(0.0, t) * step(t, 1.0); - float envelope = live * sin(3.14159265 * clamp(t, 0.0, 1.0)); - if (envelope <= 0.0) { - fragColor = texture(iChannel0, uv); - return; - } - // Bands of 6 to 40 px, re-cut at stepHz() during the burst; about one - // band in four tears. - float tick = floor(iTime * stepHz()); - float band_px = mix(6.0, 40.0, hash(vec2(tick, 3.0))); - float band = floor(fragCoord.y / band_px); - float roll = hash(vec2(band, tick)); - float tear = smoothstep(tearFrom(), tearFrom() + 0.18, roll) * envelope; - float shift = (hash(vec2(tick, band + 11.0)) - 0.5) * 2.0 * shiftPx() * tear; - vec2 at = vec2(fragCoord.x + shift, fragCoord.y); - vec2 split = vec2(splitPx() * tear, 0.0); - vec4 center = texture(iChannel0, at / size); - vec3 col = vec3(texture(iChannel0, (at + split) / size).r, center.g, texture(iChannel0, (at - split) / size).b); - col = toSrgb(toLinear(col) * (1.0 - dip() * tear)); - fragColor = vec4(col, center.a); -} diff --git a/shaders/post/ripple.glsl b/shaders/post/ripple.glsl deleted file mode 100644 index 3236d339..00000000 --- a/shaders/post/ripple.glsl +++ /dev/null @@ -1,52 +0,0 @@ -// Ripple: the page as a still pool a slow swell crosses. Rings travel out -// from the middle of the window at a fixed speed in pixels, so they look -// the same at any size and frame rate; at the default level the displacement -// is at most a couple of pixels, so text stays readable, and it eases in over its first half -// second instead of snapping on. Each ring catches the light a little on its -// rising side, in linear light, and the result is dithered so the shading -// never bands. It is a Shadertoy file: pardes puts its prefix before it. - -const float TAU = 6.28318530718; -const float EASE_IN = 0.5; // seconds - -// Each at levels 1, 2 and 3 (`Ripple 1`..`Ripple 3`); 2 is the default. -float wavelengthPx() { return pardesByLevel(170.0, 150.0, 120.0); } -float speedPx() { return pardesByLevel(40.0, 55.0, 80.0); } // outward, per second -float amplitudePx() { return pardesByLevel(1.2, 2.2, 5.0); } -float shade() { return pardesByLevel(0.02, 0.035, 0.09); } // light on a slope, linear -float reachEnd() { return pardesByLevel(0.85, 0.95, 1.3); } // how far out it dies away - -vec3 toLinear(vec3 c) { - return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); -} - -vec3 toSrgb(vec3 c) { - c = max(c, vec3(0.0)); - return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); -} - -float ign(vec2 p) { - return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); -} - -void mainImage(out vec4 fragColor, in vec2 fragCoord) { - vec2 size = iResolution.xy; - vec2 center = 0.5 * size; - vec2 offset = fragCoord - center; - float radius = length(offset); - float reach = 0.5 * length(size); - // Strongest a third of the way out, fading to nothing at the middle - // and at the edges, so the window's frame never wobbles. - float envelope = smoothstep(0.0, 0.18, radius / reach) * (1.0 - smoothstep(0.35, reachEnd(), radius / reach)); - float ease = smoothstep(0.0, EASE_IN, iTime); - float phase = (radius - iTime * speedPx()) * TAU / wavelengthPx(); - float height = sin(phase); - float slope = cos(phase); - vec2 dir = offset / max(radius, 1e-3); - vec2 moved = fragCoord + dir * height * amplitudePx() * envelope * ease; - - vec4 s = texture(iChannel0, moved / size); - vec3 col = toLinear(s.rgb) * (1.0 + shade() * slope * envelope * ease); - vec3 srgb = toSrgb(col) + (ign(fragCoord + float(iFrame % 64) * 5.588238) - 0.5) / 255.0; - fragColor = vec4(clamp(srgb, 0.0, 1.0), s.a); -} diff --git a/shaders/prebuilt/post-glitch.frag.glsl b/shaders/prebuilt/post-glitch.frag.glsl deleted file mode 100644 index 7b288237..00000000 --- a/shaders/prebuilt/post-glitch.frag.glsl +++ /dev/null @@ -1,138 +0,0 @@ -#version 450 -// pardes's Shadertoy prefix (docs/render-pipeline.md §6), put before every -// post pass: a bundled one in shaders/post/ at build time, a user's file at -// run time. The uniform names, types and std140 order are ghostty 1.3.2's -// (src/renderer/shaders/shadertoy_prefix.glsl), so a shader written for -// ghostty runs here; only the bindings are SDL GPU's (a fragment shader's -// textures in set 2, its uniform blocks in set 3). Y is down everywhere, as -// on ghostty's Metal renderer: fragCoord, iChannel0's uv and the cursor -// rectangles share one top-left space, and iCurrentCursor is (left x, -// BOTTOM-edge y, width, height) in framebuffer pixels. - -layout(set = 3, binding = 0, std140) uniform Globals { - uniform vec3 iResolution; - uniform float iTime; - uniform float iTimeDelta; - uniform float iFrameRate; - uniform int iFrame; - uniform float iChannelTime[4]; - uniform vec3 iChannelResolution[4]; - uniform vec4 iMouse; - uniform vec4 iDate; - uniform float iSampleRate; - uniform vec4 iCurrentCursor; - uniform vec4 iPreviousCursor; - uniform vec4 iCurrentCursorColor; - uniform vec4 iPreviousCursorColor; - uniform int iCurrentCursorStyle; - uniform int iPreviousCursorStyle; - uniform int iCursorVisible; - uniform float iTimeCursorChange; - uniform float iTimeFocus; - uniform int iFocus; - uniform vec3 iPalette[256]; - uniform vec3 iBackgroundColor; - uniform vec3 iForegroundColor; - uniform vec3 iCursorColor; - uniform vec3 iCursorText; - uniform vec3 iSelectionForegroundColor; - uniform vec3 iSelectionBackgroundColor; -}; - -// pardes's own: pardesOpaque is 1 when the window is opaque, so a pass -// cannot punch holes in it through its alpha; pardesLevel is a bundled -// pass's strength, 1 to 3 (`Crt 3`), and 0 for a user's file. -layout(set = 3, binding = 1, std140) uniform PardesPost { - float pardesOpaque; - float pardesLevel; -}; - -// A bundled pass's constant at levels 1, 2 and 3. -float pardesByLevel(float one, float two, float three) { - return pardesLevel < 1.5 ? one : (pardesLevel < 2.5 ? two : three); -} - -#define CURSORSTYLE_BLOCK 0 -#define CURSORSTYLE_BLOCK_HOLLOW 1 -#define CURSORSTYLE_BAR 2 -#define CURSORSTYLE_UNDERLINE 3 -#define CURSORSTYLE_LOCK 4 - -layout(set = 2, binding = 0) uniform sampler2D iChannel0; - -layout(location = 0) out vec4 _fragColor; - -#define texture2D texture - -void mainImage(out vec4 fragColor, in vec2 fragCoord); -void main() { - mainImage(_fragColor, gl_FragCoord.xy); - if (pardesOpaque > 0.5) _fragColor.a = 1.0; -} - -// The shader's own lines count from 1, so glslc's errors name them. -#line 1 -// Glitch: now and then the signal slips. At the default level, every couple -// of seconds a short -// burst tears a few horizontal bands sideways and pulls their red and blue -// apart; the bands are sized in pixels and the burst eases in and out over -// a fraction of a second, so it reads as one event rather than a flicker, -// and it looks the same at 60 or 144 Hz because every step is keyed to -// iTime, not to frames. Between bursts the page is left exactly as it is. -// A torn band's brightness dips in linear light. It is a Shadertoy file: -// pardes puts its prefix before it. - -// Each at levels 1, 2 and 3 (`Glitch 1`..`Glitch 3`); 2 is the default. A -// higher level slips more often and harder. -float period() { return pardesByLevel(4.5, 2.4, 0.55); } // seconds between bursts, on average -float burst() { return pardesByLevel(0.2, 0.28, 0.5); } // seconds a burst lasts -float stepHz() { return pardesByLevel(20.0, 24.0, 30.0); } // how often a burst re-tears -float shiftPx() { return pardesByLevel(8.0, 14.0, 34.0); } -float splitPx() { return pardesByLevel(1.5, 2.5, 6.0); } -float dip() { return pardesByLevel(0.08, 0.12, 0.22); } -float tearFrom() { return pardesByLevel(0.8, 0.72, 0.35); } // how few bands tear - -float hash(vec2 p) { - vec3 q = fract(vec3(p.xyx) * 0.1031); - q += dot(q, q.yzx + 33.33); - return fract((q.x + q.y) * q.z); -} - -vec3 toLinear(vec3 c) { - return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); -} - -vec3 toSrgb(vec3 c) { - c = max(c, vec3(0.0)); - return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); -} - -void mainImage(out vec4 fragColor, in vec2 fragCoord) { - vec2 size = iResolution.xy; - vec2 uv = fragCoord / size; - // Which burst window this is, when in it the burst starts, and how far - // into the burst we are: 0 outside one. - float window = floor(iTime / period()); - float start = window * period() + hash(vec2(window, 7.0)) * (period() - burst()); - float t = (iTime - start) / burst(); - float live = step(0.0, t) * step(t, 1.0); - float envelope = live * sin(3.14159265 * clamp(t, 0.0, 1.0)); - if (envelope <= 0.0) { - fragColor = texture(iChannel0, uv); - return; - } - // Bands of 6 to 40 px, re-cut at stepHz() during the burst; about one - // band in four tears. - float tick = floor(iTime * stepHz()); - float band_px = mix(6.0, 40.0, hash(vec2(tick, 3.0))); - float band = floor(fragCoord.y / band_px); - float roll = hash(vec2(band, tick)); - float tear = smoothstep(tearFrom(), tearFrom() + 0.18, roll) * envelope; - float shift = (hash(vec2(tick, band + 11.0)) - 0.5) * 2.0 * shiftPx() * tear; - vec2 at = vec2(fragCoord.x + shift, fragCoord.y); - vec2 split = vec2(splitPx() * tear, 0.0); - vec4 center = texture(iChannel0, at / size); - vec3 col = vec3(texture(iChannel0, (at + split) / size).r, center.g, texture(iChannel0, (at - split) / size).b); - col = toSrgb(toLinear(col) * (1.0 - dip() * tear)); - fragColor = vec4(col, center.a); -} diff --git a/shaders/prebuilt/post-glitch.frag.spv b/shaders/prebuilt/post-glitch.frag.spv deleted file mode 100644 index 619616a8..00000000 Binary files a/shaders/prebuilt/post-glitch.frag.spv and /dev/null differ diff --git a/shaders/prebuilt/post-ripple.frag.glsl b/shaders/prebuilt/post-ripple.frag.glsl deleted file mode 100644 index d29a4c22..00000000 --- a/shaders/prebuilt/post-ripple.frag.glsl +++ /dev/null @@ -1,126 +0,0 @@ -#version 450 -// pardes's Shadertoy prefix (docs/render-pipeline.md §6), put before every -// post pass: a bundled one in shaders/post/ at build time, a user's file at -// run time. The uniform names, types and std140 order are ghostty 1.3.2's -// (src/renderer/shaders/shadertoy_prefix.glsl), so a shader written for -// ghostty runs here; only the bindings are SDL GPU's (a fragment shader's -// textures in set 2, its uniform blocks in set 3). Y is down everywhere, as -// on ghostty's Metal renderer: fragCoord, iChannel0's uv and the cursor -// rectangles share one top-left space, and iCurrentCursor is (left x, -// BOTTOM-edge y, width, height) in framebuffer pixels. - -layout(set = 3, binding = 0, std140) uniform Globals { - uniform vec3 iResolution; - uniform float iTime; - uniform float iTimeDelta; - uniform float iFrameRate; - uniform int iFrame; - uniform float iChannelTime[4]; - uniform vec3 iChannelResolution[4]; - uniform vec4 iMouse; - uniform vec4 iDate; - uniform float iSampleRate; - uniform vec4 iCurrentCursor; - uniform vec4 iPreviousCursor; - uniform vec4 iCurrentCursorColor; - uniform vec4 iPreviousCursorColor; - uniform int iCurrentCursorStyle; - uniform int iPreviousCursorStyle; - uniform int iCursorVisible; - uniform float iTimeCursorChange; - uniform float iTimeFocus; - uniform int iFocus; - uniform vec3 iPalette[256]; - uniform vec3 iBackgroundColor; - uniform vec3 iForegroundColor; - uniform vec3 iCursorColor; - uniform vec3 iCursorText; - uniform vec3 iSelectionForegroundColor; - uniform vec3 iSelectionBackgroundColor; -}; - -// pardes's own: pardesOpaque is 1 when the window is opaque, so a pass -// cannot punch holes in it through its alpha; pardesLevel is a bundled -// pass's strength, 1 to 3 (`Crt 3`), and 0 for a user's file. -layout(set = 3, binding = 1, std140) uniform PardesPost { - float pardesOpaque; - float pardesLevel; -}; - -// A bundled pass's constant at levels 1, 2 and 3. -float pardesByLevel(float one, float two, float three) { - return pardesLevel < 1.5 ? one : (pardesLevel < 2.5 ? two : three); -} - -#define CURSORSTYLE_BLOCK 0 -#define CURSORSTYLE_BLOCK_HOLLOW 1 -#define CURSORSTYLE_BAR 2 -#define CURSORSTYLE_UNDERLINE 3 -#define CURSORSTYLE_LOCK 4 - -layout(set = 2, binding = 0) uniform sampler2D iChannel0; - -layout(location = 0) out vec4 _fragColor; - -#define texture2D texture - -void mainImage(out vec4 fragColor, in vec2 fragCoord); -void main() { - mainImage(_fragColor, gl_FragCoord.xy); - if (pardesOpaque > 0.5) _fragColor.a = 1.0; -} - -// The shader's own lines count from 1, so glslc's errors name them. -#line 1 -// Ripple: the page as a still pool a slow swell crosses. Rings travel out -// from the middle of the window at a fixed speed in pixels, so they look -// the same at any size and frame rate; at the default level the displacement -// is at most a couple of pixels, so text stays readable, and it eases in over its first half -// second instead of snapping on. Each ring catches the light a little on its -// rising side, in linear light, and the result is dithered so the shading -// never bands. It is a Shadertoy file: pardes puts its prefix before it. - -const float TAU = 6.28318530718; -const float EASE_IN = 0.5; // seconds - -// Each at levels 1, 2 and 3 (`Ripple 1`..`Ripple 3`); 2 is the default. -float wavelengthPx() { return pardesByLevel(170.0, 150.0, 120.0); } -float speedPx() { return pardesByLevel(40.0, 55.0, 80.0); } // outward, per second -float amplitudePx() { return pardesByLevel(1.2, 2.2, 5.0); } -float shade() { return pardesByLevel(0.02, 0.035, 0.09); } // light on a slope, linear -float reachEnd() { return pardesByLevel(0.85, 0.95, 1.3); } // how far out it dies away - -vec3 toLinear(vec3 c) { - return mix(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, lessThanEqual(c, vec3(0.04045))); -} - -vec3 toSrgb(vec3 c) { - c = max(c, vec3(0.0)); - return mix(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, lessThanEqual(c, vec3(0.0031308))); -} - -float ign(vec2 p) { - return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); -} - -void mainImage(out vec4 fragColor, in vec2 fragCoord) { - vec2 size = iResolution.xy; - vec2 center = 0.5 * size; - vec2 offset = fragCoord - center; - float radius = length(offset); - float reach = 0.5 * length(size); - // Strongest a third of the way out, fading to nothing at the middle - // and at the edges, so the window's frame never wobbles. - float envelope = smoothstep(0.0, 0.18, radius / reach) * (1.0 - smoothstep(0.35, reachEnd(), radius / reach)); - float ease = smoothstep(0.0, EASE_IN, iTime); - float phase = (radius - iTime * speedPx()) * TAU / wavelengthPx(); - float height = sin(phase); - float slope = cos(phase); - vec2 dir = offset / max(radius, 1e-3); - vec2 moved = fragCoord + dir * height * amplitudePx() * envelope * ease; - - vec4 s = texture(iChannel0, moved / size); - vec3 col = toLinear(s.rgb) * (1.0 + shade() * slope * envelope * ease); - vec3 srgb = toSrgb(col) + (ign(fragCoord + float(iFrame % 64) * 5.588238) - 0.5) / 255.0; - fragColor = vec4(clamp(srgb, 0.0, 1.0), s.a); -} diff --git a/shaders/prebuilt/post-ripple.frag.spv b/shaders/prebuilt/post-ripple.frag.spv deleted file mode 100644 index 5f05f9f3..00000000 Binary files a/shaders/prebuilt/post-ripple.frag.spv and /dev/null differ diff --git a/src/config.zig b/src/config.zig index e8c5a705..d1e5f23d 100644 --- a/src/config.zig +++ b/src/config.zig @@ -129,8 +129,6 @@ pub const leader_path = paths: { } if (builtins.capabilities.scene_shaders) { table.set(.Crt, "tr"); - table.set(.Ripple, "tR"); - table.set(.Glitch, "tg"); table.set(.Shader, null); table.set(.ShaderAnimation, null); } @@ -622,7 +620,7 @@ pub const Runtime = struct { pub const ShaderAnimation = enum { off, on, always }; - /// Each pass a bundled effect (Crt, Ripple, Glitch) or a Shadertoy file. + /// Each pass the bundled Crt or a Shadertoy file. pub const Post = struct { pub const max = 8; pub const Entry = struct { @@ -774,8 +772,8 @@ pub const Runtime = struct { pub const MessageTime = enum { linger, fall, dissolve }; pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose, message_animation, ligatures }; - /// The bundled post passes, each a Shadertoy file in shaders/post/. - pub const Scene = enum { crt, ripple, glitch }; + /// The bundled post pass, a Shadertoy file in shaders/post/. + pub const Scene = enum { crt }; pub const Action = union(enum) { toggle: Toggle, @@ -843,8 +841,6 @@ pub const Runtime = struct { .{ .word = "PanelScramble", .action = .{ .transition = .scramble }, .availability = .panel_transitions }, .{ .word = "PanelType", .action = .{ .transition = .typewriter }, .availability = .panel_transitions }, .{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders }, - .{ .word = "Ripple", .action = .{ .scene = .ripple }, .availability = .scene_shaders }, - .{ .word = "Glitch", .action = .{ .scene = .glitch }, .availability = .scene_shaders }, .{ .word = "Shader", .action = .shader, .availability = .scene_shaders }, .{ .word = "ShaderAnimation", .action = .shader_animation, .availability = .scene_shaders }, }; @@ -1263,18 +1259,18 @@ pub const Runtime = struct { test "the post chain runs in the order its passes were put in, and Shader off empties it of files" { var state: Runtime = .{}; try std.testing.expect(apply(&state, find("Shader").?, " ~/a.glsl\n")); - try std.testing.expect(apply(&state, find("Glitch").?, "on")); + try std.testing.expect(apply(&state, find("Crt").?, "on")); try std.testing.expect(apply(&state, find("Shader").?, "/b.glsl")); const chain = state.post.list(); try std.testing.expectEqual(@as(usize, 3), chain.len); try std.testing.expectEqualStrings("~/a.glsl", chain[0].path.get()); - try std.testing.expectEqual(Scene.glitch, chain[1].scene.?); + try std.testing.expectEqual(Scene.crt, chain[1].scene.?); try std.testing.expectEqualStrings("/b.glsl", chain[2].path.get()); const moved = state.post.generation; // A file named again leaves; the others keep their order. try std.testing.expect(apply(&state, find("Shader").?, "~/a.glsl")); try std.testing.expect(state.post.generation != moved); - try std.testing.expectEqual(Scene.glitch, state.post.list()[0].scene.?); + try std.testing.expectEqual(Scene.crt, state.post.list()[0].scene.?); try std.testing.expect(apply(&state, find("Shader").?, "off")); try std.testing.expectEqual(@as(usize, 1), state.post.len); try std.testing.expect(!apply(&state, find("Shader").?, null)); @@ -1288,7 +1284,7 @@ pub const Runtime = struct { const crt = find("Crt").?; try std.testing.expect(apply(&state, crt, "on")); try std.testing.expectEqual(@as(u8, Post.default_level), state.post.level(.crt)); - try std.testing.expect(apply(&state, find("Glitch").?, "1")); + try std.testing.expect(apply(&state, find("Shader").?, "/after.glsl")); const moved = state.post.generation; try std.testing.expect(apply(&state, crt, "3")); try std.testing.expectEqual(@as(u8, 3), state.post.level(.crt)); @@ -1472,8 +1468,6 @@ pub const Runtime = struct { }, .scene => |effect| switch (effect) { .crt => "Crt 2\n", - .ripple => "Ripple 2\n", - .glitch => "Glitch 2\n", }, .shader => "Shader ~/crt.glsl\n", .shader_animation => "ShaderAnimation always\n", @@ -1544,8 +1538,6 @@ pub const Runtime = struct { try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font requested") == null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Panel transition off\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Crt unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ripple unsupported\n") != null); - try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Glitch unsupported\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "TaglineSize unsupported\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Look hover delay: off\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Native images: off\n") != null); diff --git a/src/effect_sources.zig b/src/effect_sources.zig index 6ce11160..83bd1313 100644 --- a/src/effect_sources.zig +++ b/src/effect_sources.zig @@ -12,10 +12,8 @@ const ui_fragment_path = shader_dir ++ "ui.frag.glsl"; const image_vertex_path = shader_dir ++ "image.vert.glsl"; const image_fragment_path = shader_dir ++ "image.frag.glsl"; const post_vertex_path = shader_dir ++ "post.vert.glsl"; -// The bundled post passes as compiled: the prefix and the pass together. +// The bundled post pass as compiled: the prefix and the pass together. const post_crt_path = shader_dir ++ "post-crt.frag.glsl"; -const post_ripple_path = shader_dir ++ "post-ripple.frag.glsl"; -const post_glitch_path = shader_dir ++ "post-glitch.frag.glsl"; pub const Backend = @TypeOf(build_config.platform); pub const backend: Backend = build_config.platform; @@ -55,8 +53,6 @@ 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_ripple_path, .contents = @embedFile("effect-source-post-ripple.frag.glsl") }, - .{ .path = post_glitch_path, .contents = @embedFile("effect-source-post-glitch.frag.glsl") }, }, .macos => [_]filesystem.Source{ .{ .path = "src/macos/Sources/PardesView.swift", .contents = @embedFile("macos/Sources/PardesView.swift") }, @@ -77,8 +73,6 @@ 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 }, - .ripple => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_ripple_path }, - .glitch => &.{ "src/gui/Post.zig", "src/gui/gui.zig", post_vertex_path, post_glitch_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 d9d3d53e..f715965f 100644 --- a/src/gui/Post.zig +++ b/src/gui/Post.zig @@ -1,7 +1,7 @@ //! The post chain (docs/render-pipeline.md §6): Shadertoy passes over the //! finished frame, each reading the one before it (the frame first) and the -//! last one writing the window. A pass is bundled (Crt, Ripple, Glitch: -//! shaders/post/, compiled with the build) or a user's file (Shader ), +//! last one writing the window. A pass is the bundled Crt (shaders/post/, +//! compiled with the build) or a user's file (Shader ), //! compiled when it joins the chain by the build's own glslc invocation on a //! thread of its own, never on the frame path. A file whose compile fails //! keeps its last good pipeline, and glslc missing is said once. @@ -28,8 +28,6 @@ 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"), - .ripple = @embedFile("post-ripple.frag.spv"), - .glitch = @embedFile("post-glitch.frag.spv"), }); /// ghostty's Shadertoy uniform block, member for member in the GLSL block's @@ -170,7 +168,7 @@ fn collect(post: *Post, gpa: std.mem.Allocator, device: *c.SDL_GPUDevice, format .none => {}, .missing => if (!post.missing_said) { post.missing_said = true; - core.setMessage(core.active, "Shader: glslc is not on PATH, so Shadertoy files do not run (Crt, Ripple and Glitch do)"); + core.setMessage(core.active, "Shader: glslc is not on PATH, so Shadertoy files do not run (Crt does)"); }, .failed => |text| { defer gpa.free(text); diff --git a/src/macos.zig b/src/macos.zig index 0bad86ee..6cd7838f 100644 --- a/src/macos.zig +++ b/src/macos.zig @@ -112,8 +112,6 @@ const cell_flag_default: u8 = 1; const cell_flag_tagline: u8 = 2; const cell_flag_hover: u8 = 4; const scene_flag_crt: u32 = 1 << 0; -const scene_flag_ripple: u32 = 1 << 1; -const scene_flag_glitch: u32 = 1 << 2; const scene_frame_hz: u32 = 60; const scene_wrap_ns: u64 = 4096 * std.time.ns_per_s; @@ -850,13 +848,11 @@ export fn pardes_should_quit() bool { return st.core.quit; } -// The Metal shell keeps its own CRT, ripple and glitch kernel: the bundled -// passes of the GUI's post chain, as flags. +// The Metal shell keeps its own CRT kernel: the GUI's bundled post pass, as +// a flag. fn encodeSceneEffects(post: *const pardes.config.Runtime.Post) u32 { var flags: u32 = 0; if (post.has(.crt)) flags |= scene_flag_crt; - if (post.has(.ripple)) flags |= scene_flag_ripple; - if (post.has(.glitch)) flags |= scene_flag_glitch; return flags; } @@ -2842,8 +2838,6 @@ test "pardes.h matches the Zig boundary" { try expectEqual(@as(u8, c.PARDES_CELL_HOVER), cell_flag_hover); try expectEqual(@as(u16, c.PARDES_TOPBAR_H), pardes.TOPBAR_H); try expectEqual(@as(u32, c.PARDES_SCENE_CRT), scene_flag_crt); - try expectEqual(@as(u32, c.PARDES_SCENE_RIPPLE), scene_flag_ripple); - try expectEqual(@as(u32, c.PARDES_SCENE_GLITCH), scene_flag_glitch); try expectEqual(@as(u8, c.PARDES_PANEL_OPENING), @intFromEnum(animation.Phase.opening)); try expectEqual(@as(u8, c.PARDES_PANEL_MOVING), @intFromEnum(animation.Phase.moving)); try expectEqual(@as(u8, c.PARDES_PANEL_CLOSING), @intFromEnum(animation.Phase.closing)); @@ -2918,13 +2912,6 @@ test "scene effect flags and display clock are compact and independent" { try expectEqual(@as(u32, 0), encodeSceneEffects(&post)); _ = post.set(.crt, "", true); try expectEqual(scene_flag_crt, encodeSceneEffects(&post)); - _ = post.set(.ripple, "", true); - _ = post.set(.glitch, "", true); - try expectEqual(scene_flag_crt | scene_flag_ripple | scene_flag_glitch, encodeSceneEffects(&post)); - try expectEqual( - scene_flag_crt | scene_flag_ripple | scene_flag_glitch, - encodeSceneEffects(.{ .crt = true, .ripple = true, .glitch = true }), - ); var st: State = undefined; st.scene_ns = scene_wrap_ns - (std.time.ns_per_ms * 5); diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift index 70f467e3..8ba9c84b 100644 --- a/src/macos/Sources/PardesView.swift +++ b/src/macos/Sources/PardesView.swift @@ -684,7 +684,7 @@ final class PardesView: NSView { private var latchedButton: pardes_mouse_button_e? private var latchedCell: GridPoint? /// Last physical point delivered by AppKit and its mapping through the - /// accepted scene. Ripple/glitch can move a source cell under a stationary + /// accepted scene. The CRT's barrel moves a source cell under a stationary /// hand, so successful scene presentations reconcile this point again. private var pointerPoint: CGPoint? private var pointerCell: GridPoint? diff --git a/src/macos/Sources/ScenePostprocessor.swift b/src/macos/Sources/ScenePostprocessor.swift index cf7892b3..41d54e34 100644 --- a/src/macos/Sources/ScenePostprocessor.swift +++ b/src/macos/Sources/ScenePostprocessor.swift @@ -205,11 +205,9 @@ final class ScenePostprocessor { if scene.flags != 0 { let crt: Float = scene.flags & UInt32(PARDES_SCENE_CRT) != 0 ? 1 : 0 - let ripple: Float = scene.flags & UInt32(PARDES_SCENE_RIPPLE) != 0 ? 1 : 0 - let glitch: Float = scene.flags & UInt32(PARDES_SCENE_GLITCH) != 0 ? 1 : 0 // SDL samples the scene with CLAMP_TO_EDGE. A finite Core Image // instead supplies transparent black outside its extent, which - // would make ripple/glitch tear dark seams at the window edge. + // would tear dark seams at the window edge. // Clamp the input only; the kernel still emits true black before // sampling when a destination lies outside the CRT tube. let sceneInput = filtered.clampedToExtent() @@ -218,7 +216,7 @@ final class ScenePostprocessor { roiCallback: { _, _ in extent }, arguments: [ sceneInput, scene.time_seconds, Float(scene.frame), - crt, ripple, glitch, + crt, CIVector(x: extent.width, y: extent.height), CIVector(x: extent.minX, y: extent.minY), ]) @@ -291,7 +289,7 @@ final class ScenePostprocessor { /// The scene kernel is a destination-to-source mapping. Pointer input must /// apply that same mapping before the grid and its panel tracks see a cell, - /// otherwise a visible word under ripple/glitch is not the word Look gets. + /// otherwise a visible word under the barrel is not the word Look gets. static func sourcePoint( _ point: CGPoint, size: CGSize, @@ -311,27 +309,8 @@ final class ScenePostprocessor { ccY *= barrel uvX = ccX * 0.5 + 0.5 uvY = ccY * 0.5 + 0.5 - // Only CRT has a black tube. Ripple/glitch without CRT sample through - // the texture's clamp-to-edge policy and remain clickable there. + // The CRT's black tube has no cell under it. if crt > 0.5 && min(min(uvX, uvY), min(1 - uvX, 1 - uvY)) <= 0 { return nil } - - let time = scene.time_seconds - if scene.flags & UInt32(PARDES_SCENE_RIPPLE) != 0 { - let centeredX = uvX - 0.5 - let centeredY = uvY - 0.5 - let radius = sqrtf(centeredX * centeredX + centeredY * centeredY) - let envelope = 1 - smoothstep(0.04, 0.72, radius) - let wave = sinf(radius * 42 - time * 5.2) - let travel = wave * envelope * 0.0032 / max(radius, 0.00001) - uvX += centeredX * travel - uvY += centeredY * travel - } - if scene.flags & UInt32(PARDES_SCENE_GLITCH) != 0 { - let band = floorf(uvY * 96) - let seed = noise(band, floorf(time * 13)) - let tear = smoothstep(0.955, 1, seed) - uvX += (noise(seed, band) - 0.5) * tear * 0.020 - } uvX = min(max(uvX, 0), max(0, 1 - 1 / Float(size.width))) uvY = min(max(uvY, 0), max(0, 1 - 1 / Float(size.height))) return CGPoint( @@ -339,23 +318,4 @@ final class ScenePostprocessor { y: CGFloat(1 - uvY) * size.height) } - private static func smoothstep(_ edge0: Float, _ edge1: Float, _ x: Float) -> Float { - let t = min(max((x - edge0) / (edge1 - edge0), 0), 1) - return t * t * (3 - 2 * t) - } - - /// Scalar spelling of pardesNoise in crt.ci.metal. Float arithmetic is - /// deliberate: the shader's band decision must not move at a Double round. - private static func noise(_ x: Float, _ y: Float) -> Float { - var qx = fract(x * 0.1031) - var qy = fract(y * 0.1031) - var qz = fract(x * 0.1031) - let d = qx * (qy + 33.33) + qy * (qz + 33.33) + qz * (qx + 33.33) - qx += d - qy += d - qz += d - return fract((qx + qy) * qz) - } - - private static func fract(_ x: Float) -> Float { x - floorf(x) } } diff --git a/src/macos/pardes.h b/src/macos/pardes.h index f74330e5..29f8a37d 100644 --- a/src/macos/pardes.h +++ b/src/macos/pardes.h @@ -82,8 +82,7 @@ typedef struct { // from the background colour, which a real selection shares. #define PARDES_CELL_HOVER 0x04u -// One coherent snapshot for the full-window shader pass. The bitmask lets the -// host run CRT, ripple and glitch in one kernel instead of stacking filters; +// One coherent snapshot for the full-window shader pass (CRT); // time/frame advance only on pardes_animation_tick's display clock. typedef struct { uint32_t flags; @@ -92,8 +91,6 @@ typedef struct { } pardes_scene_s; #define PARDES_SCENE_CRT 0x01u -#define PARDES_SCENE_RIPPLE 0x02u -#define PARDES_SCENE_GLITCH 0x04u // One backend-neutral pane transition. Boxes are logical cell rectangles; // the native shader converts them to backing pixels using the same measured diff --git a/test/output.zig b/test/output.zig index 0fdd9f4d..e49ec4ff 100644 --- a/test/output.zig +++ b/test/output.zig @@ -669,7 +669,7 @@ test "every enabled setting builtin mutates the State Config reports" { try std.testing.expect(std.mem.indexOf(u8, report, transition) != null); // A bare word turns a bundled pass on at its default level. const scene_status = if (builtins.capabilities.scene_shaders) "2" else "unsupported"; - for ([_][]const u8{ "Crt", "Ripple", "Glitch" }) |name| { + for ([_][]const u8{"Crt"}) |name| { const line = try std.fmt.bufPrint(&buf, "{s} {s}\n", .{ name, scene_status }); try std.testing.expect(std.mem.indexOf(u8, report, line) != null); } -- cgit v1.3 From df4d11bb95bc7ea9554b55d2211c4704a2594f08 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 17:04:53 -0300 Subject: ]f, mif and the other textobject keys use helix's own queries helix's textobjects.scm for pardes's grammars are vendored in vendor/queries under MPL-2.0, each with a header naming its source and what it inherits, inlined; a README says what is there and what is not. syntax.objectAt and objectNext run them over the file's kept parse, with their #eq? and #match? predicates, for ]f [f ]t [t ]a [a ]c [c ]T [T ]e [e ]x [x and mi/ma with f t a c T e x. erlang's and zig's queries do not compile against the grammar versions pardes builds and are left out; fortran, markdown and powershell have none in helix. A test replays sixteen Python and JSON cases whose results were taken from the installed hx, and another holds that every vendored query compiles. Co-Authored-By: Claude Opus 5.5 --- build.zig | 4 + docs/helix-keys.md | 2 +- src/config.zig | 7 + src/edit.zig | 19 ++ src/modal.zig | 12 + src/normal.zig | 86 ++++++- src/syntax.zig | 144 ++++++++++++ vendor/queries/README.md | 15 ++ vendor/queries/ada/textobjects.scm | 26 +++ vendor/queries/bash/textobjects.scm | 17 ++ vendor/queries/c/textobjects.scm | 32 +++ vendor/queries/c_sharp/textobjects.scm | 25 ++ vendor/queries/clojure/textobjects.scm | 76 ++++++ vendor/queries/cpp/textobjects.scm | 39 ++++ vendor/queries/css/textobjects.scm | 27 +++ vendor/queries/elixir/textobjects.scm | 40 ++++ vendor/queries/go/textobjects.scm | 39 ++++ vendor/queries/haskell/textobjects.scm | 29 +++ vendor/queries/helix.LICENSE | 373 ++++++++++++++++++++++++++++++ vendor/queries/html/textobjects.scm | 14 ++ vendor/queries/java/textobjects.scm | 49 ++++ vendor/queries/javascript/textobjects.scm | 8 + vendor/queries/json/textobjects.scm | 13 ++ vendor/queries/kotlin/textobjects.scm | 50 ++++ vendor/queries/ocaml/textobjects.scm | 37 +++ vendor/queries/pascal/textobjects.scm | 14 ++ vendor/queries/php/textobjects.scm | 63 +++++ vendor/queries/python/textobjects.scm | 40 ++++ vendor/queries/ruby/textobjects.scm | 61 +++++ vendor/queries/rust/textobjects.scm | 90 +++++++ vendor/queries/scala/textobjects.scm | 70 ++++++ vendor/queries/typst/textobjects.scm | 28 +++ 32 files changed, 1540 insertions(+), 9 deletions(-) create mode 100644 vendor/queries/README.md create mode 100644 vendor/queries/ada/textobjects.scm create mode 100644 vendor/queries/bash/textobjects.scm create mode 100644 vendor/queries/c/textobjects.scm create mode 100644 vendor/queries/c_sharp/textobjects.scm create mode 100644 vendor/queries/clojure/textobjects.scm create mode 100644 vendor/queries/cpp/textobjects.scm create mode 100644 vendor/queries/css/textobjects.scm create mode 100644 vendor/queries/elixir/textobjects.scm create mode 100644 vendor/queries/go/textobjects.scm create mode 100644 vendor/queries/haskell/textobjects.scm create mode 100644 vendor/queries/helix.LICENSE create mode 100644 vendor/queries/html/textobjects.scm create mode 100644 vendor/queries/java/textobjects.scm create mode 100644 vendor/queries/javascript/textobjects.scm create mode 100644 vendor/queries/json/textobjects.scm create mode 100644 vendor/queries/kotlin/textobjects.scm create mode 100644 vendor/queries/ocaml/textobjects.scm create mode 100644 vendor/queries/pascal/textobjects.scm create mode 100644 vendor/queries/php/textobjects.scm create mode 100644 vendor/queries/python/textobjects.scm create mode 100644 vendor/queries/ruby/textobjects.scm create mode 100644 vendor/queries/rust/textobjects.scm create mode 100644 vendor/queries/scala/textobjects.scm create mode 100644 vendor/queries/typst/textobjects.scm diff --git a/build.zig b/build.zig index f4a3b542..51c4f76d 100644 --- a/build.zig +++ b/build.zig @@ -384,6 +384,10 @@ pub fn build(b: *std.Build) void { const query_path = dep.path(g.query); const query = std.Io.Dir.cwd().readFileAlloc(io, query_path.getPath(b), b.allocator, .limited(0x100000)) catch @panic("read " ++ g.name ++ " highlights"); ts_queries.addOption([]const u8, g.name ++ "_highlights", query); + // helix's textobject queries, vendored (vendor/queries); a + // grammar without one gets none + const objects = std.Io.Dir.cwd().readFileAlloc(io, b.pathFromRoot("vendor/queries/" ++ g.name ++ "/textobjects.scm"), b.allocator, .limited(0x100000)) catch ""; + ts_queries.addOption([]const u8, g.name ++ "_textobjects", objects); const lib = b.addLibrary(.{ .name = "tree-sitter-" ++ g.name, diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 1fb1c67a..b6ac6475 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -218,6 +218,7 @@ found. Pure text math in `modal.zig`: `findChar`, `matchBracket`, | `Alt-;` | flip anchor and head | vsel: swap cursor ↔ anchor; msel: cursor to the other end (r0/r1 swapped to keep the cursor-at-r1 invariant) | helix-verified (phase 5) | | `Alt-:` | make every range point forward | helix `ensure_selections_forward` | helix-verified | | `Alt-o`/`Alt-up`, `Alt-i`/`Alt-down`, `Alt-p`/`Alt-left`, `Alt-right`, `Alt-a`, `Alt-I`, `Alt-e`, `Alt-b` | walk the file's syntax tree from every range: grow to the enclosing node (and remember what it grew from), shrink back to that or to the first child, the previous / next sibling (anonymous ones included, so often a comma), every named sibling or child, the enclosing named node's end / start (stretching to it in select mode) | helix `expand_selection` and kin, on a whole-file parse (`File.node_tree`) made on the first such key after an edit and reused until the next; a range the tree says nothing about, and every range of a file without a grammar, stays. Checked against the installed hx on JSON (`normal.zig` test): the harness runs without grammars, so it only proves the plain-text no-op | helix-verified (plain text), hx-checked (JSON) | +| `]f [f ]t [t ]a [a ]c [c ]T [T ]e [e ]x [x`, `mi`/`ma` + `f t a c T e x` | function, class, argument, comment, test, entry, element: jump to the next one starting after the cursor / the previous one ending before it (count times; select mode stretches to it), or select the smallest one around the cursor, inside or around | helix `goto_ts_object` / `textobject_treesitter`, from helix's own `textobjects.scm` vendored in `vendor/queries` (MPL-2.0), with its `#eq?`/`#match?` predicates; `syntax.objectAt`/`objectNext` on the file's kept parse. Grammars without a query (fortran, markdown, powershell) or whose query does not compile against pardes's grammar (erlang, zig) have none, and the keys do nothing there. Checked against the installed hx on Python and JSON (`normal.zig` test) | hx-checked | | `%` | select whole buffer | vsel anchor 0,0, cursor on the buffer's last char | helix-verified (phase 5) | | `X` | snap selection to line bounds | vsel → msel over its row span; bare cursor → 1-line msel; msel: already line-wise, no-op | helix-verified (phase 5) | | `Alt-x` | shrink selection to line bounds | vsel only: partial first/last lines drop out; nothing left collapses the selection; msel: no-op | helix-verified (phase 5) | @@ -357,7 +358,6 @@ text. | Picker table (all rows), Prompt table (all rows) | picker / prompt internals | pickers — pardes' tag line is its own one-line editor | | `gn` `gp` `ga` `gm` | next/prev/alternate buffer | buffer nav — pardes panes aren't a buffer list | | Window mode table: `Ctrl-w` + `w v s t f F h j k l q o H J K L ns nv` (+ Ctrl variants) | splits/window management | window mode — pardes has its own Ctrl-w focus + Alt-n/Alt-c + mouse layout drags | -| `]f [f ]t [t ]a [a ]c [c ]e [e ]T [T ]x [x` (and `mif`/`maf`…) | TS unimpaired jumps | open question: they read a grammar's `textobjects.scm`, and of pardes's 29 grammars only Ada ships one; the rest need queries vendored from helix (MPL-2.0) or nvim-treesitter-textobjects (Apache-2.0). The node walks they build on are in A | | `]g [g ]G [G` | git change jumps | **deferred by decision**: needs a diff base per file (jj `@-` or git HEAD, fetched by the host on open and save) and line hunks from diffz; the design is written up, the work waits | | insert `Ctrl-x` | completion menu | completion exists, but not as a popup: insert-mode Tab straight after a `.` opens a buffer of candidate DECLARATIONS (section A). helix's menu itself is skipped | | `Ctrl-s` (normal) | save jumplist position | jumplist itself is implemented (`Ctrl-o`/`Ctrl-i`, section A); only the explicit save point is skipped | diff --git a/src/config.zig b/src/config.zig index 3739d3f4..78787ad2 100644 --- a/src/config.zig +++ b/src/config.zig @@ -511,6 +511,13 @@ pub const goto_paragraph: []const Chord = &.{.{ .cp = 'p' }}; pub const add_newline: []const Chord = &.{.{ .cp = ' ' }}; pub const goto_diagnostic: []const Chord = &.{.{ .cp = 'd' }}; pub const goto_diagnostic_end: []const Chord = &.{.{ .cp = 'D' }}; +pub const goto_function: []const Chord = &.{.{ .cp = 'f' }}; +pub const goto_class: []const Chord = &.{.{ .cp = 't' }}; +pub const goto_parameter: []const Chord = &.{.{ .cp = 'a' }}; +pub const goto_comment: []const Chord = &.{.{ .cp = 'c' }}; +pub const goto_test: []const Chord = &.{.{ .cp = 'T' }}; +pub const goto_entry: []const Chord = &.{.{ .cp = 'e' }}; +pub const goto_xml_element: []const Chord = &.{.{ .cp = 'x' }}; pub const insert: []const Chord = &.{.{ .cp = 'i' }}; pub const append: []const Chord = &.{.{ .cp = 'a' }}; diff --git a/src/edit.zig b/src/edit.zig index 5437a67d..1b947749 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -1858,6 +1858,25 @@ fn pairFor(ch: u21) ?struct { o: u8, c: u8 } { pub fn textobjectSelect(p: *Pardes, t: *Text, text: []const u8, obj: u21, around: bool) void { const range = t.primaryRange(text, 0); + // helix's tree-sitter objects, from the grammar's textobjects query + const object: ?modal.Normal.Object = switch (obj) { + 'f' => .function, + 't' => .class, + 'a' => .parameter, + 'c' => .comment, + 'T' => .@"test", + 'e' => .entry, + 'x' => .xml_element, + else => null, + }; + if (object) |o| { + if (t.what != .body) return; + const pane = t.pane() orelse return; + const f = if (pane.file) |*file| file else return; + const tree = normal.fileTree(p, f) orelse return; + const s = syntax.objectAt(f.path, tree, text, o, around, modal.selectionCursor(text, range)) orelse return; + return t.setRange(text, 0, .{ .anchor = s.from, .head = s.to }, false); + } switch (obj) { 'w', 'W' => { const r = modal.selectWord(text, range, around, obj == 'W'); diff --git a/src/modal.zig b/src/modal.zig index 6371475c..cc7f1ebd 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -54,6 +54,13 @@ pub const Normal = struct { add_newline, goto_diagnostic, goto_diagnostic_end, + goto_function, + goto_class, + goto_parameter, + goto_comment, + goto_test, + goto_entry, + goto_xml_element, move_left, move_right, @@ -229,6 +236,8 @@ pub const Normal = struct { }; /// helix's tree-sitter selection keys: where a range goes in the tree + /// helix's tree-sitter textobjects, by what their queries call them + pub const Object = enum { function, class, parameter, comment, @"test", entry, xml_element }; pub const NodeWalk = enum { expand, shrink, next_sibling, prev_sibling, all_siblings, all_children, parent_end, parent_start }; /// what the s/S/K/Alt-K prompt does with its pattern's matches pub const RegexMode = enum { select, split, keep, remove }; @@ -351,6 +360,7 @@ pub const Normal = struct { macro_record, macro_replay: u32, node: NodeWalk, + object_jump: struct { object: Object, direction: Direction, count: u32 }, repeat_insert: u32, pub fn scope(value: Action) Scope { @@ -540,6 +550,8 @@ pub const Normal = struct { if (key.has(.add_newline)) return resultAction(.{ .add_newline = .{ .direction = direction, .count = count } }); if (key.has(.goto_diagnostic)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = false } }); if (key.has(.goto_diagnostic_end)) return resultAction(.{ .diagnostic = .{ .direction = direction, .endpoint = true } }); + inline for (.{ .{ .goto_function, .function }, .{ .goto_class, .class }, .{ .goto_parameter, .parameter }, .{ .goto_comment, .comment }, .{ .goto_test, .@"test" }, .{ .goto_entry, .entry }, .{ .goto_xml_element, .xml_element } }) |pair| + if (key.has(pair[0])) return resultAction(.{ .object_jump = .{ .object = pair[1], .direction = direction, .count = count } }); return .ignored; }, .register => { diff --git a/src/normal.zig b/src/normal.zig index 2aea2e54..b68f9ba6 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -168,13 +168,9 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, mode: m t.setRanges(text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO) } -/// helix's tree-sitter selection keys: every range walks the file's parse, -/// which is made on the first of these keys after an edit and kept, never -/// made again for a key that changed nothing. A range the tree has nothing -/// to say about stays as it was; a text without a grammar, all of them. -pub fn nodeSelect(p: *Pardes, pane: *Pane, t: *Text, walk: modal.Normal.NodeWalk) void { - if (t.what != .body) return; - const f = if (pane.file) |*file| file else return; +/// The file's parse, made on the first key that walks it after an edit and +/// kept until the next: never made again for a key that changed nothing. +pub fn fileTree(p: *Pardes, f: *panes.File.State) ?*anyopaque { if (f.node_tree == null or f.node_tree_revision != f.revision) { if (f.node_tree) |old| syntax.freeTree(old); f.node_tree = syntax.parseTree(f.path, f.content); @@ -182,7 +178,40 @@ pub fn nodeSelect(p: *Pardes, pane: *Pane, t: *Text, walk: modal.Normal.NodeWalk for (f.node_history.items) |step| p.gpa.free(step.ranges); f.node_history.clearRetainingCapacity(); } - const tree = f.node_tree orelse return; + return f.node_tree; +} + +/// `]f`/`[f` and kin (helix goto_ts_object): to the next object starting +/// after the cursor or the previous one ending before it, count times; select +/// mode stretches the range to it. +fn objectJump(p: *Pardes, pane: *Pane, t: *Text, text: []const u8, range: modal.Selection, object: modal.Normal.Object, forward: bool, count: u32) void { + if (t.what != .body) return; + const f = if (pane.file) |*file| file else return; + const tree = fileTree(p, f) orelse return; + var last = range; + for (0..count) |_| { + const s = syntax.objectNext(f.path, tree, text, object, forward, modal.selectionCursor(text, last)) orelse break; + const next: modal.Selection = .{ .anchor = s.from, .head = s.to }; + if (std.meta.eql(next, last)) break; + last = next; + } + if (std.meta.eql(last, range)) return; + if (t.select) { + const head = if (last.head < range.anchor) last.anchor else last.head; + return t.setRange(text, 0, .{ .anchor = range.anchor, .head = head }, false); + } + const out: modal.Selection = if (forward) last else .{ .anchor = last.head, .head = last.anchor }; + t.setRange(text, 0, out, false); +} + +/// helix's tree-sitter selection keys: every range walks the file's parse, +/// which is made on the first of these keys after an edit and kept, never +/// made again for a key that changed nothing. A range the tree has nothing +/// to say about stays as it was; a text without a grammar, all of them. +pub fn nodeSelect(p: *Pardes, pane: *Pane, t: *Text, walk: modal.Normal.NodeWalk) void { + if (t.what != .body) return; + const f = if (pane.file) |*file| file else return; + const tree = fileTree(p, f) orelse return; const text = f.content; const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; const arena = p.scratch.allocator(); @@ -742,6 +771,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void ), .node => |walk| return nodeSelect(p, pane, t, walk), .goto_word => return startJump(p, pane, t), + .object_jump => |jump| return objectJump(p, pane, t, text, range, jump.object, jump.direction == .forward, jump.count), .macro_record => { const m = &p.macro; if (m.recording == 0) { @@ -1041,3 +1071,43 @@ test "one cursor holds no room for other ranges; a second range makes it and one try std.testing.expectEqual(@as(u16, 0), pane.body.nsel); try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len); } + +test "tree-sitter textobject keys do in Python and JSON what hx 25.07.1 does" { + if (!syntax.supportsPath("a.py") or !syntax.supportsPath("a.json")) return; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + // each checked against the installed hx through a pty: the keys, then + // the text it saved + const Case = struct { path: []const u8, text: []const u8, keys: []const u8, want: []const u8 }; + for ([_]Case{ + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]fd", .want = "def alpha(a, b):\n return a + b\n\n\n\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]f]fd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\n\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "2]fd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\n\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "Gk[fd", .want = "ef alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "jmifd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "jmafd", .want = "\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "10lmiad", .want = "def alpha(, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "10lmaad", .want = "def alpha( b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]Td", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\n\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]cd", .want = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n \n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "v]fd", .want = "\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]td", .want = "ef alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.py", .text = "def alpha(a, b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n", .keys = "]ad", .want = "def alpha( b):\n return a + b\n\n\ndef beta(c):\n # note\n return c\n\n\ndef test_one():\n assert beta(1) == 1\n" }, + .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "]ed", .want = "{, \"b\": [1, 2]}\n" }, + .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "]e]ed", .want = "{\"a\": 1, }\n" }, + .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "8lmaed", .want = "{\"a\": 1,\"b\": [1, 2]}\n" }, + }) |c| { + const pane = try p.setTestFile(c.text); + p.gpa.free(pane.file.?.path); + pane.file.?.path = try p.gpa.dupe(u8, c.path); + for (c.keys, 0..) |ch, i| { + p.update(.{ .key = .{ .cp = ch, .text = c.keys[i..][0..1] } }); + while (p.nextEffect()) |_| {} + } + std.testing.expectEqualStrings(c.want, pane.file.?.content) catch |err| { + std.debug.print("keys {s} on {s}\n", .{ c.keys, c.path }); + return err; + }; + } +} diff --git a/src/syntax.zig b/src/syntax.zig index 89fc4198..3a83800b 100644 --- a/src/syntax.zig +++ b/src/syntax.zig @@ -3,6 +3,7 @@ const config = @import("pardes_config"); const tracy = @import("tracy.zig"); const grammar_manifest = @import("grammar_manifest.zig"); const look = @import("look.zig"); +const regexp = @import("regexp.zig"); const c_heap = @import("c_heap"); pub const enabled = config.syntax_highlighting; @@ -27,6 +28,10 @@ const Spec = struct { exts: []const []const u8, language: *const fn () callconv(.c) *const ts.Language, query_src: []const u8, + /// helix's textobjects.scm for the grammar (vendor/queries), "" for none + objects_src: []const u8 = "", + objects: ?*ts.Query = null, + objects_failed: bool = false, selected: ?Selected = null, capture_styles: [256]u8 = undefined, }; @@ -131,6 +136,7 @@ fn initSpecs() [specCount()]Spec { .exts = g.exts, .language = @extern(LanguageFn, .{ .name = "tree_sitter_" ++ g.name }), .query_src = querySrc(g), + .objects_src = @field(ts_queries, g.name ++ "_textobjects"), }; i += 1; } @@ -173,6 +179,9 @@ pub fn stop() void { selected.query.destroy(); } spec.selected = null; + if (spec.objects) |q| q.destroy(); + spec.objects = null; + spec.objects_failed = false; } ts_set_allocator(null, null, null, null); syntax_started = false; @@ -427,6 +436,141 @@ pub fn walkNodes(tree_ptr: *anyopaque, walk: NodeWalk, from: usize, to: usize, a } +/// helix's textobjects, which its `]f`, `mif` and kin select: the name a +/// query's captures begin with (`function.around`, `test.inside`). +pub const Object = @import("modal.zig").Normal.Object; + +fn objectName(o: Object) []const u8 { + return if (o == .xml_element) "xml-element" else @tagName(o); +} + +/// The grammar's textobject query for `path`, compiled the first time; null +/// when the grammar has none. +fn objectQuery(path: []const u8) ?*ts.Query { + const ext = std.fs.path.extension(path); + for (&specs) |*spec| for (spec.exts) |choice| { + if (!std.ascii.eqlIgnoreCase(ext, choice)) continue; + if (spec.objects) |q| return q; + if (spec.objects_src.len == 0 or spec.objects_failed) return null; + var error_offset: u32 = 0; + spec.objects = ts.Query.create(spec.language(), spec.objects_src, &error_offset) catch { + spec.objects_failed = true; + return null; + }; + return spec.objects; + }; + return null; +} + +fn captureId(q: *const ts.Query, name: []const u8) ?u32 { + for (0..q.captureCount()) |i| if (std.mem.eql(u8, q.captureNameForId(@intCast(i)) orelse "", name)) return @intCast(i); + return null; +} + +/// A match's `capture`, as one span over all its nodes (helix's grouped +/// capture), when the match's `#eq?` and `#match?` hold. +fn matchSpan(q: *const ts.Query, match: anytype, capture: u32, text: []const u8) ?Span { + var span: ?Span = null; + for (match.captures) |c| if (c.index == capture) { + const s: Span = .{ .from = c.node.startByte(), .to = c.node.endByte() }; + span = if (span) |old| .{ .from = @min(old.from, s.from), .to = @max(old.to, s.to) } else s; + }; + const found = span orelse return null; + const steps = q.predicatesForPattern(match.pattern_index); + var i: usize = 0; + while (i < steps.len) { + var end = i; + while (end < steps.len and steps[end].type != .done) end += 1; + const pred = steps[i..end]; + i = end + 1; + if (pred.len != 3 or pred[0].type != .string or pred[1].type != .capture) continue; + const op = q.stringValueForId(pred[0].value_id) orelse continue; + const subject = for (match.captures) |c| { + if (c.index == pred[1].value_id) break text[@min(c.node.startByte(), text.len)..@min(c.node.endByte(), text.len)]; + } else continue; + const want = if (pred[2].type == .string) q.stringValueForId(pred[2].value_id) orelse continue else for (match.captures) |c| { + if (c.index == pred[2].value_id) break text[@min(c.node.startByte(), text.len)..@min(c.node.endByte(), text.len)]; + } else continue; + if (std.mem.eql(u8, op, "eq?")) { + if (!std.mem.eql(u8, subject, want)) return null; + } else if (std.mem.eql(u8, op, "match?")) { + var re = regexp.Regex.compile(want) catch continue; + if ((re.find(subject, 0, subject.len, subject.len) catch null) == null) return null; + } + } + return found; +} + +/// `mi`/`ma`: the smallest `object.inside` (or `.around`) holding byte `at` +/// (helix textobject_treesitter). None without a query or such an object. +pub fn objectAt(path: []const u8, tree_ptr: *anyopaque, text: []const u8, object: Object, around: bool, at: usize) ?Span { + if (!enabled) return null; + const q = objectQuery(path) orelse return null; + var buf: [32]u8 = undefined; + const want = std.fmt.bufPrint(&buf, "{s}.{s}", .{ objectName(object), if (around) "around" else "inside" }) catch return null; + const capture = captureId(q, want) orelse return null; + const tree: *const ts.Tree = @ptrCast(@alignCast(tree_ptr)); + const cursor = ts.QueryCursor.create(); + defer cursor.destroy(); + cursor.exec(q, tree.rootNode()); + var best: ?Span = null; + while (cursor.nextMatch()) |match| { + const s = matchSpan(q, match, capture, text) orelse continue; + if (!(s.from <= at and at < s.to)) continue; + if (best == null or s.to - s.from < best.?.to - best.?.from) best = s; + } + const s = best orelse return null; + if (s.from >= text.len or s.to >= text.len) return null; + return s; +} + +/// `]f`/`[f` and kin: the next object starting after byte `at`, or the +/// previous one ending before it (helix goto_treesitter_object), from the +/// first of `.movement`, `.around`, `.inside` the query names. +pub fn objectNext(path: []const u8, tree_ptr: *anyopaque, text: []const u8, object: Object, forward: bool, at: usize) ?Span { + if (!enabled) return null; + const q = objectQuery(path) orelse return null; + var buf: [32]u8 = undefined; + const capture = for ([_][]const u8{ "movement", "around", "inside" }) |kind| { + const want = std.fmt.bufPrint(&buf, "{s}.{s}", .{ objectName(object), kind }) catch return null; + if (captureId(q, want)) |id| break id; + } else return null; + const tree: *const ts.Tree = @ptrCast(@alignCast(tree_ptr)); + const cursor = ts.QueryCursor.create(); + defer cursor.destroy(); + cursor.exec(q, tree.rootNode()); + var best: ?Span = null; + while (cursor.nextMatch()) |match| { + const s = matchSpan(q, match, capture, text) orelse continue; + if (forward) { + if (s.from <= at) continue; + if (best == null or s.from < best.?.from or (s.from == best.?.from and s.to > best.?.to)) best = s; + } else { + if (s.to >= at) continue; + if (best == null or s.to > best.?.to or (s.to == best.?.to and s.from < best.?.from)) best = s; + } + } + const s = best orelse return null; + if (s.from >= text.len or s.to >= text.len) return null; + return s; +} + +test "every vendored textobject query compiles against its grammar" { + if (!enabled) return; + var failed: usize = 0; + for (&specs) |*spec| { + if (spec.objects_src.len == 0) continue; + var error_offset: u32 = 0; + const q = ts.Query.create(spec.language(), spec.objects_src, &error_offset) catch { + std.debug.print("textobjects for {s} do not compile (offset {d})\n", .{ spec.name, error_offset }); + failed += 1; + continue; + }; + q.destroy(); + } + try std.testing.expectEqual(@as(usize, 0), failed); +} + /// Owned source analysis. Both slices use the allocator passed to analyzeSource. /// The parsed tree is released before returning; callers can cache this result. pub const SourceAnalysis = struct { diff --git a/vendor/queries/README.md b/vendor/queries/README.md new file mode 100644 index 00000000..abd37d00 --- /dev/null +++ b/vendor/queries/README.md @@ -0,0 +1,15 @@ +# Textobject queries + +`/textobjects.scm` is helix's own query for that grammar, vendored +from helix (https://github.com/helix-editor/helix) at 278b24389, the +reference build docs/helix-keys.md names. Each file says where it came from +and what it inherits, inlined. They are Mozilla Public License 2.0, whose text +is `helix.LICENSE` here. They give `]f`, `[f`, `mif` and the other +tree-sitter textobject keys what a function, class, argument, comment, test, +entry or element is. + +Not vendored: fortran, markdown and powershell (helix has no query for +them), and erlang and zig, whose helix queries do not compile against the +grammar versions pardes builds (a node name either side has renamed). Those +grammars have no textobjects, and the keys do nothing there, as in helix +without a query. diff --git a/vendor/queries/ada/textobjects.scm b/vendor/queries/ada/textobjects.scm new file mode 100644 index 00000000..a6f9c2ff --- /dev/null +++ b/vendor/queries/ada/textobjects.scm @@ -0,0 +1,26 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/ada/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +;; Support for high-level text objects selections. +;; For instance: +;; maf (v)isually select (a) (f)unction or subprogram +;; mif (v)isually select (i)nside a (f)unction or subprogram +;; mai (v)isually select (a) (i)f statement (or loop) +;; mii (v)isually select (i)nside an (i)f statement (or loop) +;; +;; For navigations using textobjects, check link below: +;; https://docs.helix-editor.com/master/usage.html#navigating-using-tree-sitter-textobjects +;; +;; For Textobject queries explanation, check out link below: +;; https://docs.helix-editor.com/master/guides/textobject.html + +(subprogram_body) @function.around +(subprogram_body (non_empty_declarative_part) @function.inside) +(subprogram_body (handled_sequence_of_statements) @function.inside) +(function_specification) @function.around +(procedure_specification) @function.around +(package_declaration) @function.around +(generic_package_declaration) @function.around +(package_body) @function.around diff --git a/vendor/queries/bash/textobjects.scm b/vendor/queries/bash/textobjects.scm new file mode 100644 index 00000000..8e7ad4f9 --- /dev/null +++ b/vendor/queries/bash/textobjects.scm @@ -0,0 +1,17 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/bash/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(function_definition + body: (_) @function.inside) @function.around + +(command + argument: (_) @parameter.inside) + +(comment) @comment.inside + +(comment)+ @comment.around + +(array + (_) @entry.around) diff --git a/vendor/queries/c/textobjects.scm b/vendor/queries/c/textobjects.scm new file mode 100644 index 00000000..34a5682f --- /dev/null +++ b/vendor/queries/c/textobjects.scm @@ -0,0 +1,32 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/c/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(function_definition + body: (_) @function.inside) @function.around + +(struct_specifier + body: (_) @class.inside) @class.around + +(enum_specifier + body: (_) @class.inside) @class.around + +(union_specifier + body: (_) @class.inside) @class.around + +(parameter_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(argument_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(comment) @comment.inside + +(comment)+ @comment.around + +(enumerator + (_) @entry.inside) @entry.around + +(initializer_list + (_) @entry.around) diff --git a/vendor/queries/c_sharp/textobjects.scm b/vendor/queries/c_sharp/textobjects.scm new file mode 100644 index 00000000..0b90f428 --- /dev/null +++ b/vendor/queries/c_sharp/textobjects.scm @@ -0,0 +1,25 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/c-sharp/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +[ + (class_declaration body: (_) @class.inside) + (struct_declaration body: (_) @class.inside) + (interface_declaration body: (_) @class.inside) + (enum_declaration body: (_) @class.inside) + (delegate_declaration) + (record_declaration body: (_) @class.inside) +] @class.around + +(constructor_declaration body: (_) @function.inside) @function.around + +(destructor_declaration body: (_) @function.inside) @function.around + +(method_declaration body: (_) @function.inside) @function.around + +(property_declaration (_) @function.inside) @function.around + +(parameter (_) @parameter.inside) @parameter.around + +(comment)+ @comment.around diff --git a/vendor/queries/clojure/textobjects.scm b/vendor/queries/clojure/textobjects.scm new file mode 100644 index 00000000..5121ff8c --- /dev/null +++ b/vendor/queries/clojure/textobjects.scm @@ -0,0 +1,76 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/clojure/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +; Function definitions (defn, defn-, defmacro, defmethod, etc.) +(list_lit + . + (sym_lit) @_keyword + . + (sym_lit) + (_)* @function.inside + (#match? @_keyword "^(defn|defn-|defmacro|defmethod|defmulti|definline)$")) @function.around + +; Anonymous functions (fn) +(list_lit + . + (sym_lit) @_fn + (_)* @function.inside + (#match? @_fn "^fn$")) @function.around + +; Anonymous function shorthand #() +(anon_fn_lit + (_)* @function.inside) @function.around + +; deftype, defrecord, defprotocol +(list_lit + . + (sym_lit) @_keyword + . + (sym_lit) + (_)* @class.inside + (#match? @_keyword "^(deftype|defrecord|defprotocol|definterface|defstruct)$")) @class.around + +; Test definitions (deftest) +(list_lit + . + (sym_lit) @_keyword + . + (sym_lit) + (_)* @test.inside + (#match? @_keyword "^deftest$")) @test.around + +; Function parameters in vectors +(vec_lit + (_)* @parameter.inside) @parameter.around + +; List entries +(list_lit + (_) @entry.inside @entry.around) + +; Vector entries +(vec_lit + (_) @entry.inside @entry.around) + +; Map entries +(map_lit + (_) @entry.inside @entry.around) + +; Set entries +(set_lit + (_) @entry.inside @entry.around) + +; Comments +(comment) @comment.inside +(comment)+ @comment.around + +; Discard expressions (also treated as comments) +(dis_expr) @comment.inside + +; Comment special form (comment ...) +(list_lit + . + (sym_lit) @_comment + (_)* @comment.inside + (#match? @_comment "^comment$")) @comment.around diff --git a/vendor/queries/cpp/textobjects.scm b/vendor/queries/cpp/textobjects.scm new file mode 100644 index 00000000..dba417a9 --- /dev/null +++ b/vendor/queries/cpp/textobjects.scm @@ -0,0 +1,39 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/cpp/textobjects.scm, with what it inherits from c inlined. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(function_definition + body: (_) @function.inside) @function.around + +(struct_specifier + body: (_) @class.inside) @class.around + +(enum_specifier + body: (_) @class.inside) @class.around + +(union_specifier + body: (_) @class.inside) @class.around + +(parameter_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(argument_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(comment) @comment.inside + +(comment)+ @comment.around + +(enumerator + (_) @entry.inside) @entry.around + +(initializer_list + (_) @entry.around) + + +(lambda_expression + body: (_) @function.inside) @function.around + +(class_specifier + body: (_) @class.inside) @class.around diff --git a/vendor/queries/css/textobjects.scm b/vendor/queries/css/textobjects.scm new file mode 100644 index 00000000..c6713c53 --- /dev/null +++ b/vendor/queries/css/textobjects.scm @@ -0,0 +1,27 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/css/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +; Rule sets and their declaration blocks +(rule_set) @function.around +(rule_set + (block) @function.inside) + +(keyframe_block_list) @function.around +(keyframe_block + (block) @function.inside) + +; At-rules with a body (e.g. @media, @supports) +(media_statement) @class.around +(media_statement + (block) @class.inside) + +; Declarations as key/value entries +(declaration) @entry.around +(declaration + ":" + (_) @entry.inside) + +(comment) @comment.inside +(comment)+ @comment.around diff --git a/vendor/queries/elixir/textobjects.scm b/vendor/queries/elixir/textobjects.scm new file mode 100644 index 00000000..ebe18d08 --- /dev/null +++ b/vendor/queries/elixir/textobjects.scm @@ -0,0 +1,40 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/elixir/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +; Function heads and guards have no body at all, so `keywords` and `do_block` nodes are both optional +((call + target: (identifier) @_keyword + (arguments + [ + (call + (arguments (_)? @parameter.inside)) + ; function has a guard + (binary_operator + left: + (call + (arguments (_)? @parameter.inside))) + ] + ; body is "do: body" instead of a do-block + (keywords + (pair + value: (_) @function.inside))?)? + (do_block (_)* @function.inside)?) + (#match? @_keyword "^(def|defdelegate|defguard|defguardp|defmacro|defmacrop|defn|defnp|defp)$")) @function.around + +(anonymous_function + (stab_clause right: (body) @function.inside)) @function.around + +((call + target: (identifier) @_keyword + (do_block (_)* @class.inside)) + (#match? @_keyword "^(defmodule|defprotocol|defimpl)$")) @class.around + +((call + target: (identifier) @_keyword + (arguments ((string) . (_)?)) + (do_block (_)* @test.inside)?) + (#match? @_keyword "^(test|describe)$")) @test.around + +(comment)+ @comment.around @comment.inside diff --git a/vendor/queries/go/textobjects.scm b/vendor/queries/go/textobjects.scm new file mode 100644 index 00000000..48529015 --- /dev/null +++ b/vendor/queries/go/textobjects.scm @@ -0,0 +1,39 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/go/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(function_declaration + body: (block)? @function.inside) @function.around + +(func_literal + body: (block)? @function.inside) @function.around + +(method_declaration + body: (block)? @function.inside) @function.around + +;; struct and interface declaration as class textobject? +(type_declaration + (type_spec (type_identifier) (struct_type (field_declaration_list (_)?) @class.inside))) @class.around + +(type_declaration + (type_spec (type_identifier) (interface_type (method_elem)+ @class.inside))) @class.around + +(type_parameter_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(parameter_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(argument_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(comment) @comment.inside + +(comment)+ @comment.around + +; Go test/benchmark/example/fuzz functions (all live in _test.go). +((function_declaration + name: (identifier) @_name + body: (block)? @test.inside) @test.around + (#match? @_name "^(Test|Benchmark|Example|Fuzz)")) diff --git a/vendor/queries/haskell/textobjects.scm b/vendor/queries/haskell/textobjects.scm new file mode 100644 index 00000000..2a085248 --- /dev/null +++ b/vendor/queries/haskell/textobjects.scm @@ -0,0 +1,29 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/haskell/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(comment) @comment.inside +(comment)+ @comment.around + +(newtype + (newtype_constructor + (_) @class.inside)) @class.around +(data_type + constructors: (_) @class.inside) @class.around +(decl/function + (match expression:(_) @function.inside)) @function.around +(lambda + expression:(_) @function.inside) @function.around + +(decl/function + patterns: (patterns + (_) @parameter.inside)) + +(expression/lambda + patterns: (patterns + (_) @parameter.inside)) + +(decl/function + (infix + (pattern) @parameter.inside)) diff --git a/vendor/queries/helix.LICENSE b/vendor/queries/helix.LICENSE new file mode 100644 index 00000000..a612ad98 --- /dev/null +++ b/vendor/queries/helix.LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/vendor/queries/html/textobjects.scm b/vendor/queries/html/textobjects.scm new file mode 100644 index 00000000..b7c4b83e --- /dev/null +++ b/vendor/queries/html/textobjects.scm @@ -0,0 +1,14 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/html/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(script_element (start_tag) (_) @xml-element.inside (end_tag)) @xml-element.around + +(style_element (start_tag) (_) @xml-element.inside (end_tag)) @xml-element.around + +(element (start_tag) (_)* @xml-element.inside (end_tag)) + +(element) @xml-element.around + +(comment) @comment.around diff --git a/vendor/queries/java/textobjects.scm b/vendor/queries/java/textobjects.scm new file mode 100644 index 00000000..306efb56 --- /dev/null +++ b/vendor/queries/java/textobjects.scm @@ -0,0 +1,49 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/java/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(method_declaration + body: (_)? @function.inside) @function.around + +(constructor_declaration + body: (_) @function.inside) @function.around + +(interface_declaration + body: (_) @class.inside) @class.around + +(class_declaration + body: (_) @class.inside) @class.around + +(record_declaration + body: (_) @class.inside) @class.around + +(enum_declaration + body: (_) @class.inside) @class.around + +(formal_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(type_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(type_arguments + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(argument_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +[ + (line_comment) + (block_comment) +] @comment.inside + +(line_comment)+ @comment.around + +(block_comment) @comment.around + +(array_initializer + (_) @entry.around) + +(enum_body + (enum_constant) @entry.around) diff --git a/vendor/queries/javascript/textobjects.scm b/vendor/queries/javascript/textobjects.scm new file mode 100644 index 00000000..50fe599c --- /dev/null +++ b/vendor/queries/javascript/textobjects.scm @@ -0,0 +1,8 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/javascript/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +; See runtime/queries/ecma/README.md for more info. + +; inherits: _javascript,ecma diff --git a/vendor/queries/json/textobjects.scm b/vendor/queries/json/textobjects.scm new file mode 100644 index 00000000..e0df6ecd --- /dev/null +++ b/vendor/queries/json/textobjects.scm @@ -0,0 +1,13 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/json/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(pair + (_) @entry.inside) @entry.around + +(array + (_) @entry.around) + +(comment) @comment.inside +(comment)+ @comment.around diff --git a/vendor/queries/kotlin/textobjects.scm b/vendor/queries/kotlin/textobjects.scm new file mode 100644 index 00000000..b02a8f7e --- /dev/null +++ b/vendor/queries/kotlin/textobjects.scm @@ -0,0 +1,50 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/kotlin/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(function_declaration + (function_body)? @function.inside) @function.around + +; Unlike function_body above, the constructor body is does not have its own +; symbol in the current grammar. +(secondary_constructor) @function.around + +(class_declaration + (class_body)? @class.inside) @class.around + +(class_declaration + (enum_class_body) @class.inside) @class.around + +[ + (line_comment) + (multiline_comment) +] @comment.inside + +(line_comment)+ @comment.around + +(multiline_comment) @comment.around + +(enum_entry) @entry.around +(lambda_literal) @entry.around +(property_declaration) @entry.around +(object_declaration) @entry.around +(assignment) @entry.around + +; TODO: This doesn't work with annotations yet, but fixing it without breaking +; the case of multiple parameters is non-trivial. +(function_value_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +; secondary constructor uses function_value_parameters above +(primary_constructor + ((_)@parameter.inside . ","? @parameter.around) @parameter.around) + +(function_type_parameters + ((_)@parameter.inside . ","? @parameter.around) @parameter.around) + +(value_arguments + ((_)@parameter.inside . ","? @parameter.around) @parameter.around) + +(lambda_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) diff --git a/vendor/queries/ocaml/textobjects.scm b/vendor/queries/ocaml/textobjects.scm new file mode 100644 index 00000000..9e6bb2cd --- /dev/null +++ b/vendor/queries/ocaml/textobjects.scm @@ -0,0 +1,37 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/ocaml/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +; Value/function definitions +(value_definition) @function.around +(value_definition + (let_binding + body: (_) @function.inside)) + +(fun_expression + body: (_) @function.inside) @function.around + +(function_expression) @function.around + +(method_definition + body: (_) @function.inside) @function.around + +; Types and modules +(type_definition) @class.around +(type_definition + (type_binding + body: (_) @class.inside)) + +(module_definition) @class.around +(module_definition + (module_binding + body: (_) @class.inside)) + +; Parameters +(parameter) @parameter.around +(parameter + pattern: (_) @parameter.inside) + +(comment) @comment.inside +(comment)+ @comment.around diff --git a/vendor/queries/pascal/textobjects.scm b/vendor/queries/pascal/textobjects.scm new file mode 100644 index 00000000..9abb7871 --- /dev/null +++ b/vendor/queries/pascal/textobjects.scm @@ -0,0 +1,14 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/pascal/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(declType (declClass (declSection) @class.inside)) @class.around + +(defProc body: (_) @function.inside) @function.around + +(declArgs (_) @parameter.inside) @parameter.around +(exprArgs (_) @parameter.inside) @parameter.around + +(comment) @comment.inside +(comment)+ @comment.around diff --git a/vendor/queries/php/textobjects.scm b/vendor/queries/php/textobjects.scm new file mode 100644 index 00000000..8bf7fab9 --- /dev/null +++ b/vendor/queries/php/textobjects.scm @@ -0,0 +1,63 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/php/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(class_declaration + body: (_) @class.inside) @class.around + +(interface_declaration + body: (_) @class.inside) @class.around + +(trait_declaration + body: (_) @class.inside) @class.around + +(enum_declaration + body: (_) @class.inside) @class.around + +(function_definition + body: (_) @function.inside) @function.around + +(method_declaration + body: (_) @function.inside) @function.around + +(arrow_function + body: (_) @function.inside) @function.around + +(anonymous_function + body: (_) @function.inside) @function.around + +(anonymous_class + body: (_) @class.inside) @class.around + +(property_hook + body: (_) @function.inside) @function.around + +(anonymous_function_use_clause + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(formal_parameters + ([ + (simple_parameter) + (variadic_parameter) + (property_promotion_parameter) + ] @parameter.inside . ","? @parameter.around) @parameter.around) + +(arguments + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(comment) @comment.inside + +(comment)+ @comment.around + +(array_creation_expression + (array_element_initializer + (_) @entry.inside + ) @entry.around @entry.movement) + +(list_literal + (_) @entry.inside @entry.around @entry.movement) + +[ + (enum_case) +] @entry.around @entry.movement diff --git a/vendor/queries/python/textobjects.scm b/vendor/queries/python/textobjects.scm new file mode 100644 index 00000000..c4dbcd0e --- /dev/null +++ b/vendor/queries/python/textobjects.scm @@ -0,0 +1,40 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/python/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(function_definition + body: (block)? @function.inside) @function.around + +(class_definition + body: (block)? @class.inside) @class.around + +(parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(lambda_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(argument_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(comment) @comment.inside + +(comment)+ @comment.around + +((function_definition + name: (identifier) @_name + body: (block)? @test.inside) @test.around + (#match? @_name "^test_")) + +(list + (_) @entry.around) + +(tuple + (_) @entry.around) + +(set + (_) @entry.around) + +(pair + (_) @entry.inside) @entry.around diff --git a/vendor/queries/ruby/textobjects.scm b/vendor/queries/ruby/textobjects.scm new file mode 100644 index 00000000..368d80b9 --- /dev/null +++ b/vendor/queries/ruby/textobjects.scm @@ -0,0 +1,61 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/ruby/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +; Class and Modules +(class + body: (_)? @class.inside) @class.around + +(singleton_class + value: (_) + (_)+ @class.inside) @class.around + +(call + receiver: (constant) @class_const + method: (identifier) @class_method + (#match? @class_const "Class") + (#match? @class_method "new") + (do_block (_)+ @class.inside)) @class.around + +(module + body: (_)? @class.inside) @class.around + +; Functions and Blocks +(singleton_method + body: (_)? @function.inside) @function.around + +(method + body: (_)? @function.inside) @function.around + +(do_block + body: (_)? @function.inside) @function.around + +(block + body: (_)? @function.inside) @function.around + +; Parameters +(method_parameters + (_) @parameter.inside) @parameter.around + +(block_parameters + (_) @parameter.inside) @parameter.around + +(lambda_parameters + (_) @parameter.inside) @parameter.around + +; Comments +(comment) @comment.inside +(comment)+ @comment.around + +(pair + (_) @entry.inside) @entry.around + +(array + (_) @entry.around) + +(string_array + (_) @entry.around) + +(symbol_array + (_) @entry.around) diff --git a/vendor/queries/rust/textobjects.scm b/vendor/queries/rust/textobjects.scm new file mode 100644 index 00000000..d888c762 --- /dev/null +++ b/vendor/queries/rust/textobjects.scm @@ -0,0 +1,90 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/rust/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(function_item + body: (_) @function.inside) @function.around + +(closure_expression + body: (_) @function.inside) @function.around + +(struct_item + body: (_) @class.inside) @class.around + +(enum_item + body: (_) @class.inside) @class.around + +(union_item + body: (_) @class.inside) @class.around + +(trait_item + body: (_) @class.inside) @class.around + +(impl_item + body: (_) @class.inside) @class.around + +(parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(type_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(type_arguments + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(closure_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(arguments + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(field_initializer_list + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +[ + (line_comment) + (block_comment) +] @comment.inside + +(line_comment)+ @comment.around + +(block_comment) @comment.around + +(; #[test] + (attribute_item + (attribute + (identifier) @_test_attribute)) + ; allow other attributes like #[should_panic] and comments + [ + (attribute_item) + (line_comment) + ]* + ; the test function + (function_item + body: (_) @test.inside) @test.around + (#eq? @_test_attribute "test")) + +(array_expression + (_) @entry.around) + +(tuple_expression + (_) @entry.around) + +(tuple_pattern + (_) @entry.around) + +; Commonly used vec macro initializer is special cased +(macro_invocation + (identifier) @_id (token_tree (_) @entry.around) + (#eq? @_id "vec")) + +(enum_variant) @entry.around + +(field_declaration + (_) @entry.inside) @entry.around + +(field_initializer + (_) @entry.inside) @entry.around + +(shorthand_field_initializer) @entry.around diff --git a/vendor/queries/scala/textobjects.scm b/vendor/queries/scala/textobjects.scm new file mode 100644 index 00000000..7ff60759 --- /dev/null +++ b/vendor/queries/scala/textobjects.scm @@ -0,0 +1,70 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/scala/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +; Function queries + +(function_definition + body: (_) @function.inside) @function.around ; Does not include end marker + +(lambda_expression + (_) @function.inside) @function.around + +; Scala 3 braceless lambda +(colon_argument + (_) @function.inside) @function.around + + +; Class queries + +(object_definition + body: (_)? @class.inside) @class.around + +(class_definition + body: (_)? @class.inside) @class.around + +(trait_definition + body: (_)? @class.inside) @class.around + +(type_definition) @class.around + +(enum_case_definitions) @class.around + +(enum_definition + body: (_)? @class.inside) @class.around + + +; Parameter queries + +(parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(class_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(parameter_types + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(bindings + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +; Does not match context bounds or higher-kinded types +(type_parameters + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(arguments + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +(type_arguments + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + + +; Comment queries + +[(comment) (block_comment)] @comment.inside +[(comment) (block_comment)] @comment.around ; Does not match consecutive block comments + + +; Test queries +; Not supported diff --git a/vendor/queries/typst/textobjects.scm b/vendor/queries/typst/textobjects.scm new file mode 100644 index 00000000..64c99275 --- /dev/null +++ b/vendor/queries/typst/textobjects.scm @@ -0,0 +1,28 @@ +; Vendored from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/typst/textobjects.scm. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(let + pattern: (call) + value: (_) @function.inside) @function.around + +(call + (group + ((_) @parameter.inside . ","? @parameter.around) @parameter.around)) + +(lambda + pattern: + (group + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + value: (_) @function.inside) @function.around + +(group + [ + (tagged (_) @entry.inside) + (_) + ] @entry.around) + +(comment) @comment.inside + +(comment)+ @comment.around -- cgit v1.3 From 2663a4d1b3170cb2a4dca5e6f1f3535f44b30e44 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:47:26 -0300 Subject: No panic from a notice wider than its band, or from the rest of its class The showcase GUI died in joinNotice on a long shader-error notice: with the tagline narrower than the body, the band hides more columns (skip) than a long message has padding (pad), and `skip..@min(pad, ..)` ran backwards. An audit of draw.zig, Layer.zig, Presentation.zig and animation.zig found the same class elsewhere, all fixed: - a multi-row pane tag or header scrolled for its caret's line sliced a shorter row past its end (paintPaneTag, renderHeaderLayer); - a zero-width column tag kept an earlier frame's viewport, and a column move's clip took it below zero (now saturating, and the early return resets the viewport and caret); - a tag line past 65535 columns overflowed tag_scroll's u16; - a hover left over from a bigger window dashed past the grid; - a border drag over a column that went away, a move preview past a shrunken grid, a column-move rail at zero columns; - Layer.pointAt cast a host's unchecked float metrics (web) to u16; - Text.insertVerticalCursor read past its lines for a cursor whose text changed under it. Regressions: a test of a notice wider than its band through Msg and through setMessage (the Post.zig compile-error path), which panics without the fix; and a monkey over the core at a gui's metrics (random resizes to a cell, long notices, prompts, multi-row tags, clicks, keys), 400 steps in the suite, PARDES_FUZZ_SEED/STEPS for longer runs; 60 seeds x 3000 steps clean. Shared files touched: none of the list (draw.zig, Layer.zig, Text.zig). --- src/Layer.zig | 9 ++-- src/Text.zig | 3 +- src/draw.zig | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 133 insertions(+), 16 deletions(-) diff --git a/src/Layer.zig b/src/Layer.zig index e979eab7..a9827304 100644 --- a/src/Layer.zig +++ b/src/Layer.zig @@ -106,9 +106,12 @@ const Point = struct { index: u16, col: u16, metrics: Metrics }; fn pointAt(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?Point { if (layer.rows == 0 or !std.math.isFinite(x) or !std.math.isFinite(y)) return null; // A band's hit is refused outright on metrics no host could have; a - // body's has always been floored to a pixel instead. - if (layer.kind != .body) for ([_]f32{ body_w, body_h, tagline_w, tagline_h }) |metric| - if (!std.math.isFinite(metric) or metric < 1 or metric > std.math.maxInt(u16)) return null; + // body's has always been floored to a pixel instead, and both refuse + // what is no number or past a u16 (a host's floats: web's exports). + for ([_]f32{ body_w, body_h, tagline_w, tagline_h }) |metric| { + if (!std.math.isFinite(metric) or metric > std.math.maxInt(u16)) return null; + if (layer.kind != .body and metric < 1) return null; + } const bw = @max(1, body_w); const bh = @max(1, body_h); const tw = std.math.clamp(tagline_w, 1, bw); diff --git a/src/Text.zig b/src/Text.zig index 43dd8a95..f6780935 100644 --- a/src/Text.zig +++ b/src/Text.zig @@ -118,7 +118,8 @@ pub fn fromModalCursor(t: *Text, c: modal.Cursor) void { pub fn insertVerticalCursor(lines: []const []const u8, c: modal.Cursor, down: bool) modal.Cursor { if (lines.len == 0) return c; - const row = if (down) @min(c.row + 1, lines.len - 1) else c.row -| 1; + // A cursor past the lines (its text changed under it) moves from the last. + const row = if (down) @min(c.row + 1, lines.len - 1) else @min(c.row, lines.len) -| 1; const target = lines[row]; if (target.len == 0) return .{ .row = row, .col = 0 }; const source = if (c.row < lines.len) lines[c.row] else ""; diff --git a/src/draw.zig b/src/draw.zig index 3798bf59..0dbf5bae 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -219,13 +219,16 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { if (col > first) s.addRegion(.{ .kind = .guide, .rect = .{ .x = first, .y = d.cur_y, .w = col - first, .h = 1 } }); }; }, - .border_h => |d| if (d.cur_y < s.rows) { + // A column that went away mid-drag has nothing to guide. + .border_h => |d| if (d.cur_y < s.rows and d.col < p.ncol and p.col_x[d.col] < s.cols) { + const end = @min(s.cols, p.col_x[d.col] + p.col_w[d.col]); var col = p.col_x[d.col]; - while (col < p.col_x[d.col] + p.col_w[d.col]) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); - s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[d.col], .y = d.cur_y, .w = p.col_w[d.col], .h = 1 } }); + while (col < end) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[d.col], .y = d.cur_y, .w = end - p.col_x[d.col], .h = 1 } }); }, .column_move => |d| rail: { const source = layout.columnBySerial(p, d.serial) orelse break :rail; + if (s.cols == 0) break :rail; // Where the column's left edge would land, reordered or not. const edge: u16 = switch (layout.columnDrop(p, source, d.cur_x, d.grab)) { .stay => break :rail, @@ -258,7 +261,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { if (row > run) s.addRegion(.{ .kind = .guide, .rect = .{ .x = x, .y = run, .w = 1, .h = row - run } }); }, .move => |d| if (d.cur_x < s.cols) { - if (layout.movePlacement(p, d.id, d.cur_x, d.cur_y)) |placement| { + if (layout.movePlacement(p, d.id, d.cur_x, d.cur_y)) |placement| if (placement.row < s.rows and p.col_x[placement.preview_col] + p.col_w[placement.preview_col] <= s.cols) { var col: u16 = p.col_x[placement.preview_col]; while (col < p.col_x[placement.preview_col] + p.col_w[placement.preview_col]) : (col += 1) { s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = chrome.lineno } }); @@ -266,9 +269,10 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = chrome.lineno } }); s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[placement.preview_col], .y = placement.row, .w = p.col_w[placement.preview_col], .h = 1 } }); s.addRegion(.{ .kind = .guide, .rect = .{ .x = d.cur_x, .y = placement.row, .w = 1, .h = 1 } }); - } + }; }, - .none, .select => if (p.pointer_inside) { + // A hover left over from a bigger window points past this one. + .none, .select => if (p.pointer_inside and p.hover_row < s.rows and p.hover_col < s.cols) { // hover hints on a pane's own trailing edge var c: usize = 0; while (c + 1 < p.ncol) : (c += 1) { @@ -554,7 +558,7 @@ pub fn paintHeaders(p: *Pardes, s: *Surface, arena: std.mem.Allocator) !void { try renderHeaderLayer(p, arena, layer, .column, @intCast(c), region.rect, tagline.columnTag(p, c), top, bg, fg); if (p.col_x[c] < s.cols) joinHeaderRows(s, layer, fg); // Laid out at its full width; only shown short of the rail. - layer.viewport.w -= region.clip; + layer.viewport.w -|= region.clip; }; } @@ -623,7 +627,10 @@ fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, layer: *pardes.Layer, kind: TagKind, id: u16, viewport: Rect, text: []const u8, top: usize, bg: [3]u8, fg: [3]u8) !void { _ = arena; if (viewport.w == 0 or viewport.h == 0) { + // No layer, and nothing of an earlier frame's left in it. layer.rows = 0; + layer.viewport = viewport; + layer.cursor = null; return; } var widest: usize = 0; @@ -656,7 +663,8 @@ fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, layer: *pardes.Layer, const shown = modal.lineSlice(text, index + top); var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells[index * columns ..][0..columns] }; surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); - _ = surface.print(0, 0, columns, shown[panes.File.rawAtDisplay(shown, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); + // The scroll is the caret's line's: a shorter line shows nothing. + _ = surface.print(0, 0, columns, shown[@min(shown.len, panes.File.rawAtDisplay(shown, scroll))..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); // The word under the pointer, a Look or Exec target: on a header even // while it is edited, on a notice, and on the grid's copy of either // (joined from this); not on a column a carried column's pointer is @@ -811,12 +819,13 @@ fn joinNotice(p: *Pardes, s: *Surface, arena: std.mem.Allocator, region: Region) joinCells(s, region.rect.x, region.rect.y, layer.cells[0..layer.cols], skip, chip, .{ .rgb = fg }); // The band's padding before the text is the chip's fill, which carries // no ink of its own (the layer prints it as spaces in the band's ink). - for (skip..@min(pad, skip + chip)) |col| s.at(region.rect.x + @as(u16, @intCast(col - skip)), region.rect.y).style.fg = .default; + // A notice wider than its band has less padding than the band hides. + for (skip..@max(skip, @min(pad, skip + chip))) |col| s.at(region.rect.x + @as(u16, @intCast(col - skip)), region.rect.y).style.fg = .default; if (layer.cursor) |caret| { if (caret.x >= skip) s.cursor = .{ .x = region.rect.x + caret.x - @as(u16, @intCast(skip)), .y = region.rect.y, .bar = caret.bar }; } // Laid out at its full width; only shown short of the rail. - layer.viewport.w -= region.clip; + layer.viewport.w -|= region.clip; } pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void { @@ -873,10 +882,12 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa raw = modal.nextGrapheme(line, raw); scroll = panes.File.rawDisplayCol(line, raw); } - pane.tag_scroll = @intCast(scroll); + // A line past a u16 of columns keeps its caret off the band. + pane.tag_scroll = @intCast(@min(scroll, std.math.maxInt(u16))); } else if (pane.focus != .tag) pane.tag_scroll = 0; for (lines[0..rows], 0..) |line, row| { - const start = panes.File.rawAtDisplay(line, pane.tag_scroll); + // The scroll is the caret's line's: a shorter row shows nothing. + const start = @min(line.len, panes.File.rawAtDisplay(line, pane.tag_scroll)); _ = s.print(tx, tag_y + @as(u16, @intCast(row)), tw, line[start..], tag_style); } const tag = lines[0]; @@ -1064,6 +1075,108 @@ test "the word under the pointer on a notice is lit in its layer and in the grid try std.testing.expect(lit_in_layer); } +test "a notice wider than its band, as a shader's compile error is, draws its end and nothing past it" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20 }); + defer p.deinit(); + // A gui-shaped core: its tagline is narrower than the body, so a notice's + // band holds more columns than its chip has cells. + p.update(.{ .resize = .{ .cols = 80, .rows = 20, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } }); + _ = try p.setTestFile("text\n"); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const long = "Shader /home/someone/shaders/test_shadertoy_invalid.glsl: error: Linking fragment stage: No function definition (body) found: " ** 3; + for ([_][]const u8{ "Msg " ++ long, "" }) |line| { + // The Msg builtin, and the path a failed compile takes (Post.zig). + if (line.len > 0) try std.testing.expect(p.executeBuiltinLine(p.active, line)) else p.setMessage(p.active, long); + p.sync(); + for (0..30) |_| p.update(.tick); + _ = arena.reset(.retain_capacity); + const s = try p.render(arena.allocator()); + const notice = for (s.regionList()) |region| { + if (region.kind == .notice) break region; + } else return error.TestExpectedNotice; + // Text up to the chip's blank last cell, and no padding ink. + const row = s.cells[@as(usize, notice.rect.y) * s.cols ..][notice.rect.x..][0..notice.rect.w]; + try std.testing.expectEqualStrings(" ", row[row.len - 1].grapheme()); + try std.testing.expect(!std.mem.eql(u8, " ", row[row.len - 2].grapheme())); + } +} + +test "a monkey over notices, prompts, tags and tiny panes at a gui's metrics never panics" { + // The snapshot harness runs the tty, whose tagline pitch is the body's, + // so it never reaches what a narrower tagline does to a notice's band: + // this drives the core at a gui's metrics instead. PARDES_FUZZ_STEPS + // runs it longer (the suite's run is short). + const steps: usize = if (std.c.getenv("PARDES_FUZZ_STEPS")) |v| std.fmt.parseInt(usize, std.mem.span(v), 10) catch 400 else 400; + const seed: u64 = if (std.c.getenv("PARDES_FUZZ_SEED")) |v| std.fmt.parseInt(u64, std.mem.span(v), 10) catch 7 else 7; + var prng = std.Random.DefaultPrng.init(seed); + const random = prng.random(); + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer p.deinit(); + _ = try p.setTestFile("fuzz\n\u{754c}\u{754c} wide\n" ** 20); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const words = [_][]const u8{ "error:", "\u{754c}", "x", "Shader /tmp/a.glsl:", "e\u{301}", "\t", "long-" ** 20, " " }; + const commands = [_][]const u8{ "New", "Newcol", "Collapse", "Tagbottom", "ColumnTags", "TreeContext", "Wrap", "Find", "Del", "FocusTint", "Debug" }; + var text: std.ArrayListUnmanaged(u8) = .empty; + defer text.deinit(std.testing.allocator); + for (0..steps) |_| { + switch (random.uintLessThan(u8, 8)) { + // A resize, down to a cell, at a gui's metrics: a tagline no + // wider and no taller than the body's cell, often narrower. + 0 => { + const body_w = random.intRangeAtMost(u16, 6, 12); + const body_h = random.intRangeAtMost(u16, 12, 24); + const cols = if (random.boolean()) random.intRangeAtMost(u16, 1, 12) else random.intRangeAtMost(u16, 13, 140); + const rows = if (random.boolean()) random.intRangeAtMost(u16, 1, 6) else random.intRangeAtMost(u16, 7, 50); + p.update(.{ .resize = .{ .cols = cols, .rows = rows, .row_metrics = .{ .body_w = body_w, .body_h = body_h, .tagline_w = random.intRangeAtMost(u16, 2, body_w), .tagline_h = random.intRangeAtMost(u16, 6, body_h) } } }); + }, + // A message, as long as a compile error or longer. + 1, 2 => { + text.clearRetainingCapacity(); + for (0..random.uintAtMost(usize, 60)) |_| try text.appendSlice(std.testing.allocator, words[random.uintLessThan(usize, words.len)]); + if (random.boolean()) p.setMessage(p.active, text.items) else { + const line = try std.fmt.allocPrint(arena.allocator(), "Msg {s}", .{text.items}); + _ = p.executeBuiltinLine(p.active, line); + } + }, + 3 => _ = p.executeBuiltinLine(p.active, commands[random.uintLessThan(usize, commands.len)]), + // Typing, escaping and entering, into a prompt when one is up. + 4 => p.update(.{ .key = .{ .cp = switch (random.uintLessThan(u8, 9)) { + 0 => pardes.Key.escape, + 1 => pardes.Key.enter, + 2 => 0x754c, + 3 => pardes.Key.end, + 4 => pardes.Key.home, + 5 => pardes.Key.up, + 6 => pardes.Key.down, + else => 'a' + random.uintLessThan(u21, 26), + } } }), + // A tag of several rows. + 5 => if (p.panes[p.active]) |pane| { + text.clearRetainingCapacity(); + for (0..random.uintAtMost(usize, 5)) |_| try text.appendSlice(std.testing.allocator, if (random.boolean()) " Look\nmore \u{754c}" else "\n" ++ "x" ** 150); + if (pane.tag.own) |own| std.testing.allocator.free(own); + pane.tag.own = try std.testing.allocator.dupe(u8, text.items); + }, + // Hover, and clicks: into a tag, a header, a body, a grip. + 6 => { + const col = random.uintAtMost(u16, p.screen_w); + const row = random.uintAtMost(u16, p.screen_h); + if (random.boolean()) p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = col, .row = row } }) else { + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = col, .row = row } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = col, .row = row } }); + } + }, + else => for (0..random.uintAtMost(usize, 20)) |_| p.update(.tick), + } + p.sync(); + _ = arena.reset(.retain_capacity); + const s = try p.render(arena.allocator()); + p.acknowledgePanelPresentation(s.panelTracks()); + } +} + test "a wide glyph cut by a tag's or a notice's edge is joined into the grid as blank, never half drawn" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 41, .rows = 12 }); defer p.deinit(); -- cgit v1.3 From c5d96bec3d5279904525292ef933a985a974b017 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 17:13:46 -0300 Subject: Zig has textobjects: helix's query, ported to the zig grammar pardes builds pardes builds maxxnino/tree-sitter-zig, whose node names are not those of the grammar helix's query targets, so the query is ported by hand (vendor/queries/zig, its MPL header saying it is modified and how): a function is a Decl holding an FnProto and a Block with pub beside it, struct/enum/union one ContainerDecl, parameters and arguments ParamDeclList and FnCallArguments, and comments come in three kinds. That grammar's comments take their line's newline, and a node ending in a newline is now taken without it, as helix's grammars have it. The replay test gains fifteen Zig cases from the installed hx; none walks call arguments, which the reference helix counts as parameters and 25.07.1's query does not. Co-Authored-By: Claude Opus 5.5 --- docs/helix-keys.md | 2 +- src/normal.zig | 22 +++++++++++++++++++-- src/syntax.zig | 5 ++++- vendor/queries/README.md | 7 +++++-- vendor/queries/zig/textobjects.scm | 40 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 vendor/queries/zig/textobjects.scm diff --git a/docs/helix-keys.md b/docs/helix-keys.md index b6ac6475..c703a3da 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -218,7 +218,7 @@ found. Pure text math in `modal.zig`: `findChar`, `matchBracket`, | `Alt-;` | flip anchor and head | vsel: swap cursor ↔ anchor; msel: cursor to the other end (r0/r1 swapped to keep the cursor-at-r1 invariant) | helix-verified (phase 5) | | `Alt-:` | make every range point forward | helix `ensure_selections_forward` | helix-verified | | `Alt-o`/`Alt-up`, `Alt-i`/`Alt-down`, `Alt-p`/`Alt-left`, `Alt-right`, `Alt-a`, `Alt-I`, `Alt-e`, `Alt-b` | walk the file's syntax tree from every range: grow to the enclosing node (and remember what it grew from), shrink back to that or to the first child, the previous / next sibling (anonymous ones included, so often a comma), every named sibling or child, the enclosing named node's end / start (stretching to it in select mode) | helix `expand_selection` and kin, on a whole-file parse (`File.node_tree`) made on the first such key after an edit and reused until the next; a range the tree says nothing about, and every range of a file without a grammar, stays. Checked against the installed hx on JSON (`normal.zig` test): the harness runs without grammars, so it only proves the plain-text no-op | helix-verified (plain text), hx-checked (JSON) | -| `]f [f ]t [t ]a [a ]c [c ]T [T ]e [e ]x [x`, `mi`/`ma` + `f t a c T e x` | function, class, argument, comment, test, entry, element: jump to the next one starting after the cursor / the previous one ending before it (count times; select mode stretches to it), or select the smallest one around the cursor, inside or around | helix `goto_ts_object` / `textobject_treesitter`, from helix's own `textobjects.scm` vendored in `vendor/queries` (MPL-2.0), with its `#eq?`/`#match?` predicates; `syntax.objectAt`/`objectNext` on the file's kept parse. Grammars without a query (fortran, markdown, powershell) or whose query does not compile against pardes's grammar (erlang, zig) have none, and the keys do nothing there. Checked against the installed hx on Python and JSON (`normal.zig` test) | hx-checked | +| `]f [f ]t [t ]a [a ]c [c ]T [T ]e [e ]x [x`, `mi`/`ma` + `f t a c T e x` | function, class, argument, comment, test, entry, element: jump to the next one starting after the cursor / the previous one ending before it (count times; select mode stretches to it), or select the smallest one around the cursor, inside or around | helix `goto_ts_object` / `textobject_treesitter`, from helix's own `textobjects.scm` vendored in `vendor/queries` (MPL-2.0), with its `#eq?`/`#match?` predicates; `syntax.objectAt`/`objectNext` on the file's kept parse. Zig's query is helix's ported by hand to the zig grammar pardes builds, and a node that ends in its line's newline (that grammar's comments) is taken without it, as helix's grammars have it. Grammars without a query (fortran, markdown, powershell) or whose query does not compile against pardes's grammar (erlang) have none, and the keys do nothing there. Checked against the installed hx on Python, JSON and Zig (`normal.zig` test) | hx-checked | | `%` | select whole buffer | vsel anchor 0,0, cursor on the buffer's last char | helix-verified (phase 5) | | `X` | snap selection to line bounds | vsel → msel over its row span; bare cursor → 1-line msel; msel: already line-wise, no-op | helix-verified (phase 5) | | `Alt-x` | shrink selection to line bounds | vsel only: partial first/last lines drop out; nothing left collapses the selection; msel: no-op | helix-verified (phase 5) | diff --git a/src/normal.zig b/src/normal.zig index b68f9ba6..17ebaac6 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -1072,8 +1072,8 @@ test "one cursor holds no room for other ranges; a second range makes it and one try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len); } -test "tree-sitter textobject keys do in Python and JSON what hx 25.07.1 does" { - if (!syntax.supportsPath("a.py") or !syntax.supportsPath("a.json")) return; +test "tree-sitter textobject keys do in Python, JSON and Zig what hx 25.07.1 does" { + if (!syntax.supportsPath("a.py") or !syntax.supportsPath("a.json") or !syntax.supportsPath("a.zig")) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); @@ -1097,6 +1097,24 @@ test "tree-sitter textobject keys do in Python and JSON what hx 25.07.1 does" { .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "]ed", .want = "{, \"b\": [1, 2]}\n" }, .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "]e]ed", .want = "{\"a\": 1, }\n" }, .{ .path = "/test.json", .text = "{\"a\": 1, \"b\": [1, 2]}\n", .keys = "8lmaed", .want = "{\"a\": 1,\"b\": [1, 2]}\n" }, + // Zig: the query is helix's, ported to pardes's zig grammar. No case + // walks call arguments: the reference helix counts them as + // parameters and the installed 25.07.1's query does not. + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "]fd", .want = "const std = @import(\"std\");\n\n/// adds\n\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "5jmifd", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 \n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "5jmafd", .want = "const std = @import(\"std\");\n\n/// adds\n\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "]td", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\n\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "9jmitd", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "9jmatd", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\n\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "]Td", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\n\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "16jmiTd", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" \n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "3j11lmiad", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "3j11lmaad", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add( b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "]cd", .want = "const std = @import(\"std\");\n\n\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "4jmacd", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "16jf2[ad", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, ) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "16j[fd", .want = "const std = @import(\"std\");\n\n/// adds\n\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, + .{ .path = "/test.zig", .text = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\nconst Color = enum { red, green };\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n", .keys = "]t]td", .want = "const std = @import(\"std\");\n\n/// adds\npub fn add(a: u32, b: u32) u32 {\n // sum\n return a + b;\n}\n\nconst Point = struct {\n x: i32,\n y: i32,\n};\n\n\n\ntest \"add works\" {\n try std.testing.expectEqual(@as(u32, 3), add(1, 2));\n}\n" }, }) |c| { const pane = try p.setTestFile(c.text); p.gpa.free(pane.file.?.path); diff --git a/src/syntax.zig b/src/syntax.zig index 3a83800b..4494945f 100644 --- a/src/syntax.zig +++ b/src/syntax.zig @@ -475,7 +475,10 @@ fn matchSpan(q: *const ts.Query, match: anytype, capture: u32, text: []const u8) const s: Span = .{ .from = c.node.startByte(), .to = c.node.endByte() }; span = if (span) |old| .{ .from = @min(old.from, s.from), .to = @max(old.to, s.to) } else s; }; - const found = span orelse return null; + var found = span orelse return null; + // a node that takes its line's newline (this zig grammar's comments) + // is the line without it, as helix's grammars have it + if (found.to > found.from + 1 and found.to <= text.len and text[found.to - 1] == '\n') found.to -= 1; const steps = q.predicatesForPattern(match.pattern_index); var i: usize = 0; while (i < steps.len) { diff --git a/vendor/queries/README.md b/vendor/queries/README.md index abd37d00..082f4e6b 100644 --- a/vendor/queries/README.md +++ b/vendor/queries/README.md @@ -8,8 +8,11 @@ is `helix.LICENSE` here. They give `]f`, `[f`, `mif` and the other tree-sitter textobject keys what a function, class, argument, comment, test, entry or element is. +`zig/textobjects.scm` is helix's query MODIFIED by hand for the zig grammar +pardes builds, which names its nodes differently; its header says how. + Not vendored: fortran, markdown and powershell (helix has no query for -them), and erlang and zig, whose helix queries do not compile against the -grammar versions pardes builds (a node name either side has renamed). Those +them), and erlang, whose helix query does not compile against the grammar +version pardes builds. Those grammars have no textobjects, and the keys do nothing there, as in helix without a query. diff --git a/vendor/queries/zig/textobjects.scm b/vendor/queries/zig/textobjects.scm new file mode 100644 index 00000000..1c40b137 --- /dev/null +++ b/vendor/queries/zig/textobjects.scm @@ -0,0 +1,40 @@ +; Adapted from helix (https://github.com/helix-editor/helix) at 278b24389: +; runtime/queries/zig/textobjects.scm, MODIFIED for the zig grammar pardes +; builds (maxxnino/tree-sitter-zig), whose node names differ from the +; tree-sitter-grammars one helix uses: function_declaration is a Decl holding +; an FnProto and a Block, with `pub` a sibling before it; variable_declaration +; is VarDecl; struct/enum/union are one ContainerDecl; parameters and +; arguments are ParamDeclList and FnCallArguments; comments come in three +; kinds. +; Licensed under the Mozilla Public License 2.0 (vendor/queries/helix.LICENSE, +; https://mozilla.org/MPL/2.0/). + +(_ + "pub"? @function.around + . + (Decl + (FnProto) + (Block) @function.inside) @function.around) + +(TestDecl (Block) @test.inside) @test.around + +; matches all of: struct, enum, union (one ContainerDecl in this grammar), +; `pub` with them where there is one +(_ + "pub"? @class.around + . + (Decl + (VarDecl + (ErrorUnionExpr + (SuffixExpr + (ContainerDecl + [(ContainerField) (Decl) (TestDecl) (ComptimeDecl)] @class.inside))))) @class.around) + +(ParamDeclList + ((ParamDecl) @parameter.inside . ","? @parameter.around) @parameter.around) + +(FnCallArguments + ((_) @parameter.inside . ","? @parameter.around) @parameter.around) + +[(line_comment) (doc_comment) (container_doc_comment)] @comment.inside +[(line_comment) (doc_comment) (container_doc_comment)]+ @comment.around -- cgit v1.3