diff options
132 files changed, 11436 insertions, 5929 deletions
@@ -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 @@ -228,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 | @@ -47,9 +47,13 @@ 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", + "post.vert", + // The bundled post pass: shaders/post/crt.glsl behind the Shadertoy + // prefix (shaderSource). + "post-crt.frag", }; pub fn build(b: *std.Build) void { @@ -160,7 +164,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}), ); } @@ -384,6 +388,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, @@ -690,7 +698,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"), @@ -755,6 +763,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})) @@ -1097,6 +1107,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] <cases.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", "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); + const lspbench = b.addExecutable(.{ .name = "pardes-lspbench", .root_module = b.createModule(.{ @@ -1443,7 +1460,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(.{ @@ -1625,10 +1641,23 @@ fn reindexed(b: *std.Build, archive: std.Build.LazyPath, index: usize) std.Build // glslc shaders/<name>.glsl -fshader-stage=<vertex|fragment> -o <name>.spv, // returned as a LazyPath for @embedFile. The stage is the name's own suffix. +/// The GLSL a shader is compiled from: shaders/<name>.glsl, or for a bundled +/// post pass (post-<name>.frag) shaders/post/<name>.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/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..a6bf56a3 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 @@ -1916,7 +1917,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 41f9b364..c703a3da 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 @@ -73,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 @@ -93,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**; `<n>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` / `<n>gg` | goto first line / line n | | helix-verified | | `ge` | goto last content line | matches `goto_last_line` (ignores the trailing empty line) | helix-verified | @@ -103,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; `<n>o` opens n | matches `open_below`/`open_above` | helix-verified | @@ -110,9 +119,9 @@ language-backend queries, and the shell pipe. | `x` / `<n>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 | -| `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 | +| `y` | yank each range's text into the register (`"<reg>` 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, `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 | | `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 | @@ -120,16 +129,16 @@ 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 `<prefix> ?` 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 | | 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 | @@ -139,6 +148,10 @@ 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 | +| `Q` / `"<reg>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=<ret>_<A-(>`), so `"@p` pastes it and a yanked text can be replayed | helix `record_macro` | helix-verified | +| `q` / `<n>q` / `"<reg>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 | +| `.` / `<n>.` | 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 @@ -173,6 +186,8 @@ 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|`, `<n>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) | @@ -182,11 +197,12 @@ found. Pure text math in `modal.zig`: `findChar`, `matchBracket`, | Key | Behavior | Notes | Status | | --- | --- | --- | --- | | `r<ch>` | replace selection/char with `<ch>`, 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) | | `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) | @@ -200,6 +216,9 @@ 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) | +| `]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) | @@ -223,10 +242,12 @@ 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 | | `<n>o` / `<n>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 | -| `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 | +| `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 | +| `"<reg>` | 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 <reg>` | 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 | +| 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-(` / `<n>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 @@ -260,10 +281,10 @@ keeps the matches it found so far. | `S<pat>` 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 | -| 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 | +| `^` 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 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<pat>` / `Alt-K<pat>` 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 @@ -283,7 +304,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<pair>` / `ma<pair>` | 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<ch>` | surround selection (or cursor char) with the `<ch>` 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<ch>` | surround selection (or cursor char) with the `<ch>` 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<from><to>` | replace enclosing `<from>` pair chars with `<to>`'s | `Pane.pending_ch` holds `<from>` while `<to>` pends; file panes only | helix-verified (phase 5) | | `md<ch>` | delete the enclosing `<ch>` pair chars | file panes only | helix-verified (phase 5) | @@ -301,7 +322,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 `<n>` blank lines below / above, cursor staying on its text line | file panes only (shell output immutable) | helix-verified (phase 5) | +| `]Space` / `[Space` | add `<n>` 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 @@ -324,6 +345,8 @@ 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 @@ -334,29 +357,13 @@ 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 | -| `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 | +| `]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 | -| `"` `<reg>`, 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 | -| `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 | | `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 | -| `.` | 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) @@ -389,21 +396,40 @@ 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: - `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). +- `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` — 13 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 - 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). + 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 + 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. + `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 + harness and absent in pardes) to close its `VALUES (`, and + `invert_dictionary_2` needs 25.07.1's `<count>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/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 2a679b8d..7c94fbea 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -491,21 +491,31 @@ 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 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 `<stdin>`. 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 <path>` 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. +- **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 <path>` 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: @@ -803,15 +813,15 @@ 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 | | 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) | -| 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) | +| 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) 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 @@ -842,6 +852,26 @@ 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. 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 + 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. 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 @@ -873,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/selections.md b/docs/selections.md new file mode 100644 index 00000000..5cc1df1a --- /dev/null +++ b/docs/selections.md @@ -0,0 +1,123 @@ +# 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. +"helix" here is the reference build docs/helix-keys.md names: `hx-harness` +at `694e7dfdd`, upstream `278b24389`. + +## 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 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 + 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]`, 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 + 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. `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 +primary through the merges, and keeps the first `max_selections`. `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), `<n>o`/`<n>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 + 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`). + +## 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 + 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. +- 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. `"<reg>` 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 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`. `.` 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 + +- 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 `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 + 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). 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/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/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/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..768157ef --- /dev/null +++ b/shaders/post/crt.glsl @@ -0,0 +1,83 @@ +// 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; +// 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 SCAN_PERIOD_PX = 3.0; +const float MASK_PERIOD_PX = 3.0; +const float HUM_HZ = 0.11; + +// 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))); +} + +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(bloomThreshold(), bloomThreshold() + 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 * (chromaPx() * r2 * 0.5) * texel; + vec3 col = vec3(scene(uv + chroma).r, scene(uv).g, scene(uv - chroma).b); + + 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 * 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 - 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 - 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; + fragColor = vec4(clamp(srgb, 0.0, 1.0), texture(iChannel0, uv).a); +} diff --git a/shaders/post/prefix.glsl b/shaders/post/prefix.glsl new file mode 100644 index 00000000..e718a98f --- /dev/null +++ b/shaders/post/prefix.glsl @@ -0,0 +1,74 @@ +#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 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 Binary files differdeleted file mode 100644 index f0e14627..00000000 --- a/shaders/prebuilt/crt.frag.spv +++ /dev/null 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/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 Binary files differnew file mode 100644 index 00000000..7e162f5a --- /dev/null +++ b/shaders/prebuilt/decor.frag.spv diff --git a/shaders/prebuilt/post-crt.frag.glsl b/shaders/prebuilt/post-crt.frag.glsl new file mode 100644 index 00000000..c2cadfa2 --- /dev/null +++ b/shaders/prebuilt/post-crt.frag.glsl @@ -0,0 +1,157 @@ +#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 +// 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; +// 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 SCAN_PERIOD_PX = 3.0; +const float MASK_PERIOD_PX = 3.0; +const float HUM_HZ = 0.11; + +// 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))); +} + +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(bloomThreshold(), bloomThreshold() + 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 * (chromaPx() * r2 * 0.5) * texel; + vec3 col = vec3(scene(uv + chroma).r, scene(uv).g, scene(uv - chroma).b); + + 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 * 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 - 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 - 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; + 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 Binary files differnew file mode 100644 index 00000000..6599abd9 --- /dev/null +++ b/shaders/prebuilt/post-crt.frag.spv 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/crt.vert.spv b/shaders/prebuilt/post.vert.spv Binary files differindex 8a75ebb3..8a75ebb3 100644 --- a/shaders/prebuilt/crt.vert.spv +++ b/shaders/prebuilt/post.vert.spv 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 Binary files differindex 120438e7..033c10ab 100644 --- a/shaders/prebuilt/ui.frag.spv +++ b/shaders/prebuilt/ui.frag.spv 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 Binary files differindex 659049dd..619a9690 100644 --- a/shaders/prebuilt/ui.vert.spv +++ b/shaders/prebuilt/ui.vert.spv 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/File.zig b/src/File.zig index 8ba58fbc..b8368482 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"); @@ -69,9 +70,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 +827,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); @@ -1124,8 +1138,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 +1189,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 +1267,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 +1281,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 +1290,7 @@ pub fn drawWrapMarkers( pub fn paintWordSelection( p: *Pardes, + s: *Surface, pane: *Pane, r: pardes.Rect, row: i32, @@ -1301,7 +1315,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/Layer.zig b/src/Layer.zig new file mode 100644 index 00000000..a9827304 --- /dev/null +++ b/src/Layer.zig @@ -0,0 +1,201 @@ +//! 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, 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); + 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/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=<ret>_<A-(>`), 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, `"<reg>`, 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 (`<C-r>`, `<A-(>`, `<ret>`). +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=<ret>_<A-(><C-r><lt><space>-<A-minus>λ<esc>", text); + const back = try decode(gpa, text); + defer gpa.free(back); + try std.testing.expectEqualSlices(Stored, &keys, back); +} diff --git a/src/Messages.zig b/src/Messages.zig index 66261eb4..ea62041f 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; @@ -302,11 +303,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 @@ -316,7 +341,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.frame_ms - 1) / animation.frame_ms)); } pub fn messageMotion(p: *const Pardes, life: Pane.MessageLife) MessageMotion { @@ -811,7 +836,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); @@ -858,7 +883,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. @@ -878,5 +903,50 @@ 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" { + 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/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/Pipe.zig b/src/Pipe.zig index 1ad7b826..48e98b7b 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, }; } @@ -54,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); @@ -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 }); @@ -394,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/Presentation.zig b/src/Presentation.zig new file mode 100644 index 00000000..3b5bb3e0 --- /dev/null +++ b/src/Presentation.zig @@ -0,0 +1,735 @@ +//! 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.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, +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; + // 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); + } + } +} + +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) { + 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.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; + } + @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; + 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.previous_regions = self.previous_regions[0..self.previous_nregions]; + 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(); + // 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; + 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.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 } }; + 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.bodyHit(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, .rows = 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/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/Terminal.zig b/src/Terminal.zig index e204b44c..91c23305 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"); @@ -717,7 +718,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.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 @@ -843,7 +844,7 @@ fn ingestWithPositions(pane: *Pane, bytes: []const u8) void { extra[nextra + 1] = &selection.arow; nextra += 2; } - if (pane.body.append_at) |*at| { + if (pane.body.last_edit) |*at| { extra[nextra] = &at.row; nextra += 1; } @@ -1124,7 +1125,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(); } @@ -1386,7 +1387,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; @@ -1763,11 +1764,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/Text.zig b/src/Text.zig index 43dd8a95..ab23bf8e 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, @@ -59,13 +60,26 @@ cur_row: i32 = 0, cur_col: i32 = 0, msel: LineSel = .{}, vsel: CharSel = .{}, -sels: [max_selections - 1]SelRange = undefined, -nsel: u8 = 0, +/// 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. 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, +/// 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 = .{}, /// last f/F/t/T motion, for Alt-. repeat find_op: u8 = 0, @@ -93,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; @@ -118,7 +161,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 ""; @@ -177,7 +221,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(); @@ -254,6 +298,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| { @@ -323,6 +368,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/animation.zig b/src/animation.zig new file mode 100644 index 00000000..948a6679 --- /dev/null +++ b/src/animation.zig @@ -0,0 +1,739 @@ +//! 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 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 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 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 = 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); + + // 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 = 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); + + 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 = 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/body_layer.zig b/src/body_layer.zig index 42b9c082..a0195fb8 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,112 +37,11 @@ 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 ---- -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 +74,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 +83,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 +105,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 +116,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, @@ -227,18 +124,19 @@ 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 { - 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)); 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. @@ -255,15 +153,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); @@ -272,27 +188,24 @@ 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 }; - 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, 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; @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; - layer.viewport = .{ .x = r.x + config.GUTTER, .y = p.bodyTop(pane, r), .w = width, .h = viewport_rows }; + 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); layer.context_rows = pane.context_rows; @@ -302,10 +215,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, 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, rail: ?pardes.Region, built: ?[]const u8) !void { const th = p.theme(); const chrome = p.chromeTheme(); const tx = r.x + config.GUTTER; @@ -325,8 +257,14 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id .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. @@ -343,32 +281,25 @@ 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(); - 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 - // 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, 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, pane.tag_rows, preview_bg, null); } } const modal_hover = if (p.look_hover_preview) |preview| @@ -381,7 +312,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, 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) { @@ -477,6 +408,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)) { @@ -507,34 +461,14 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id } } - 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 { @@ -555,453 +489,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()) { - // `tag` is the first row; a prefix longer than it ran to a second. - var words = std.mem.tokenizeAny(u8, tag[@min(prefix_len, tag.len)..], " \t\r\n"); - while (words.next()) |word| { - if (!std.mem.eql(u8, word, "Tty") and !std.mem.startsWith(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..@min(prefix_len, tag.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(if (kind == .message and pane.msg_sent) chrome.tag_name_fg else 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 or pane.prompt == .repl_choice) 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/builtins.zig b/src/builtins.zig index 2f34c647..23d853c8 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -170,7 +170,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/colors.zig b/src/colors.zig index 8e608590..aeff7b00 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.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps); return out; } }; pub const ChromeAnimation = if (theme_animation) - layout.Animation.Transition(ChromeTheme) + animation.Fade(ChromeTheme) else - layout.Animation.Immediate(ChromeTheme); + animation.Immediate(ChromeTheme); pub const initial_chrome = ChromeTheme.fromTheme(&themes[0]); @@ -426,7 +427,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.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 9ad9cc7e..186b29a1 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"); @@ -129,8 +130,8 @@ 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); } if (builtins.EffectCode.enabled) table.set(.EffectCode, null); if (builtins.TreeContext.enabled) table.set(.TreeContext, null); @@ -266,10 +267,15 @@ 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 = '$' }}; +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 = " !"; 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 @@ -409,6 +415,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"; @@ -433,6 +441,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 = ']'; @@ -454,7 +463,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' }}; @@ -466,6 +475,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 } }; @@ -480,6 +490,10 @@ 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_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' }}; @@ -492,11 +506,19 @@ 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 = ' ' }}; 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' }}; @@ -526,6 +548,19 @@ 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 }}; +// 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 }}; @@ -538,13 +573,19 @@ 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 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 = '=' }}; 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. @@ -553,6 +594,8 @@ 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 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 }}; @@ -611,10 +654,92 @@ pub const Runtime = struct { tagline_percent: u8 = 100, } = .{}, - panel_transition: layout.Transition = .off, - scene_effects: layout.SceneEffect = .{}, + panel_transition: animation.Transition = .off, + /// 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 fn toggleTransition(state: *Runtime, effect: layout.Transition) void { + pub const ShaderAnimation = enum { off, on, always }; + + /// Each pass the bundled Crt 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) = .{}, + /// 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 + /// 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 { + 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` + /// 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); state.panel_transition = if (state.panel_transition == effect) .off else effect; } @@ -691,7 +816,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(layout.SceneEffect); + /// The bundled post pass, a Shadertoy file in shaders/post/. + pub const Scene = enum { crt }; pub const Action = union(enum) { toggle: Toggle, @@ -703,8 +829,12 @@ pub const Runtime = struct { window_blur, message_ms: MessageTime, dump_dir, - transition: layout.Transition, + 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 { @@ -755,8 +885,8 @@ 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 }, }; pub fn find(name: []const u8) ?Setting { @@ -790,7 +920,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. @@ -808,7 +938,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)); @@ -816,9 +946,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(layout.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,13 +1014,36 @@ 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.*; - }, + // 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"); + 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, } @@ -931,7 +1084,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; } @@ -1042,11 +1195,25 @@ 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)), + 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)) { + 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| @@ -1082,20 +1249,18 @@ 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")); 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" { @@ -1128,11 +1293,56 @@ 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); + 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("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.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.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)); + 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 "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("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)); + // 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" { @@ -1253,6 +1463,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)); @@ -1299,10 +1511,10 @@ 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", }, + .shader => "Shader ~/crt.glsl\n", + .shader_animation => "ShaderAnimation always\n", .theme, .font => unreachable, }; try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); @@ -1370,8 +1582,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/detached/client.zig b/src/detached/client.zig index ba533751..8c9a2560 100644 --- a/src/detached/client.zig +++ b/src/detached/client.zig @@ -103,8 +103,12 @@ 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, + /// 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 @@ -182,6 +186,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 +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) 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)), }; @@ -996,6 +1001,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(); @@ -1007,7 +1018,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)); @@ -1025,6 +1036,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); @@ -1282,7 +1304,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 8fc7b96e..5374bf73 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -150,6 +150,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, @@ -355,8 +357,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, @@ -767,7 +775,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) &.{} @@ -787,6 +795,8 @@ pub const Session = struct { prev, &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; @@ -867,8 +877,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))); @@ -878,9 +889,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.takeExits(); s.expire(monotonicMs()); @@ -1212,6 +1224,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/detached/wire.zig b/src/detached/wire.zig index 0854ce3e..b1b81eda 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,9 @@ 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), chrome: *pardes.Chrome) !void { + regions.clearRetainingCapacity(); + chrome.* = .{}; for (layers) |*layer| { gpa.free(layer.cells); layer.* = .{}; @@ -185,19 +189,22 @@ pub const Frame = struct { gpa.free(layer.cells); layer.* = .{}; } + regions.clearRetainingCapacity(); + chrome.* = .{}; } if (f.layers.len == 0) return; var r: Reader = .init(f.layers); 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 +224,49 @@ 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); + } + 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(); } @@ -672,21 +709,26 @@ 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 + chrome_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; +/// 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, 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 @@ -708,7 +750,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 +759,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 +773,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 +788,33 @@ 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); + } + 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; @@ -1820,22 +1885,29 @@ 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 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.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); + 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)); @@ -1847,10 +1919,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 +1933,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, &chrome)); 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, &chrome); try std.testing.expectEqual(@as(u16, 0), decoded[0].rows); } diff --git a/src/draw.zig b/src/draw.zig new file mode 100644 index 00000000..2d757635 --- /dev/null +++ b/src/draw.zig @@ -0,0 +1,1264 @@ +//! 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 vaxis = @import("vaxis"); +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 FontRole = @import("surface.zig").FontRole; +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 Region = @import("surface.zig").Region; +const TagKind = @import("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; + +/// 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; + 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); + 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); + 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; + 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. + 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)); + } + 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. + 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; + 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(); + 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]); + 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) + 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: { + 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; + 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; + for (&s.body_layers) |*layer| layer.rows = 0; + for (&s.tag_layers) |*layer| layer.rows = 0; + s.nimages = 0; + s.npanel_tracks = 0; + 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; + 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; + pane.body_rows = 0; + pane.body_visible_rows = 0; + pane.context_row_limit = null; + s.body_layers[id].rows = 0; + try renderPane(p, s, arena, pane, p.rects[id], id, id == p.active); + } + // Each notice once, as a tag layer, and the grid's chip a copy of it. + for (s.regionList()) |region| { + if (region.kind == .notice) try joinNotice(p, s, arena, region); + } + + try paintHeaders(p, s, 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, "╎"); + 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) { + 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 } }); + }; + }, + // 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 < 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, + .reorder => |target| if (target < source) p.col_x[target] else p.col_x[target] + p.col_w[target], + .edge => |x| x, + }; + // 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. + // 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(); + // 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) { + 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| 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 } }); + } + 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 } }); + }; + }, + // 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) { + 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| { + 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, "╌"); + 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| { + 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, "╌"); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[cn + 1], .y = p.hover_row, .w = p.col_w[cn + 1], .h = 1 } }); + } + }, + } + + // 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) { + 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(), "â•®", .{}); + 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, 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 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 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. + 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, rail)) 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, 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 + 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.joinBody(p, s, arena, pane, r, id, active, rail.*); +} + +/// 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(); + // 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); + if (s.region(.workspace_tag, Region.none)) |region| { + const top = if (p.header_focus and p.header_column == null) p.header_top else 0; + 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| { + 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 }); + } + const top = if (p.header_focus and p.header_column == c) p.header_top else 0; + 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'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); + 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.x < v.w) s.cursor = .{ .x = v.x + caret.x, .y = v.y + caret.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()) { + // `tag` is the first row; a prefix longer than it ran to a second. + var words = std.mem.tokenizeAny(u8, tag[@min(prefix_len, tag.len)..], " \t\r\n"); + while (words.next()) |word| { + if (!std.mem.eql(u8, word, "Tty") and !std.mem.startsWith(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..@min(prefix_len, tag.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 }; +} + +/// 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; + 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; + 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.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; + // 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, .body => false, + }) tagline.focusedHeader(p) else null; + const scroll = if (editing != null) p.header_scroll else 0; + 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 }); + // 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 + // 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| + (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 }; + } + } +} + +/// 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. + 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 }; + // 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| { + @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); + } + 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 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); + // Where an exec was sent to a REPL reads in the tag's name tint, set + // apart from what the editor says. + const ink = if (kind == .message and pane.msg_sent) chrome.tag_name_fg else chrome.tag_fg; + const fg = Messages.blendRgb(ink, 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 or pane.prompt == .repl_choice) 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 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. + if (kind == .prompt and id == p.active and pane.prompt != .del_side and pane.prompt != .repl_choice) 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 = .{ .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). + 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). + // 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; +} + +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); + 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); + } + // 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| { + // The scroll is the caret's line's: a shorter row shows nothing. + const start = @min(line.len, panes.File.rawAtDisplay(line, pane.tag_scroll)); + _ = s.print(tx, tag_y + @as(u16, @intCast(row)), tw, line[start..], tag_style); + } + const tag = lines[0]; + if (top == 0) if (tagTintRange(pane, tag, prefix_len)) |name| { + const lo = @max(name.lo, panes.File.rawAtDisplay(tag, pane.tag_scroll)); + const hi = @min(name.hi, tag.len); + if (lo < hi) { + var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll; + const end = panes.File.rawDisplayCol(tag, hi) -| pane.tag_scroll; + const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg; + 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, 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 + // 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 }; + } + + // 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 (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" { + 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); + // 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); + 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); + } + } +} + +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 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(); + _ = 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); + } + } +} + +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/src/edit.zig b/src/edit.zig index 3cbbc28c..84bbb292 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -13,11 +13,12 @@ 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; 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; @@ -26,23 +27,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 (`"<reg>`), 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. @@ -96,7 +149,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); @@ -114,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 { @@ -125,6 +184,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 +196,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,30 +348,24 @@ 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| { - 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); + 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); } pub fn paneCursorLines(p: *Pardes, t: *Text) ![]const []const u8 { @@ -415,7 +471,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)) { @@ -432,11 +490,25 @@ 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); + // 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); } +/// 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. @@ -462,7 +534,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; @@ -488,12 +560,25 @@ 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 }; - 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.restore_cursor = true; + 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); - 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 => { @@ -565,6 +650,87 @@ 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 <reg>: 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; + } + // 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 + 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); @@ -679,12 +845,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); @@ -694,8 +877,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; @@ -742,6 +936,17 @@ pub fn insertKey(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 => {}, @@ -1121,31 +1326,45 @@ 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, 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(); @@ -1201,53 +1420,213 @@ 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); + // 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; + } + 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 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); + 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); +} + +/// `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)); } - t.msel.active = false; - t.cur_pinned = true; - t.sticky_col = -1; - t.show(); + 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); +} + +/// `&`: 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 { @@ -1439,9 +1818,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'); @@ -1449,10 +1831,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(); @@ -1472,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'); @@ -1512,16 +1917,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(); @@ -1760,16 +2171,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; @@ -1783,14 +2198,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/effect_sources.zig b/src/effect_sources.zig index 08680860..83bd1313 100644 --- a/src/effect_sources.zig +++ b/src/effect_sources.zig @@ -11,8 +11,9 @@ 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 pass as compiled: the prefix and the pass together. +const post_crt_path = shader_dir ++ "post-crt.frag.glsl"; pub const Backend = @TypeOf(build_config.platform); pub const backend: Backend = build_config.platform; @@ -45,13 +46,13 @@ 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") }, }, .macos => [_]filesystem.Source{ .{ .path = "src/macos/Sources/PardesView.swift", .contents = @embedFile("macos/Sources/PardesView.swift") }, @@ -69,8 +70,10 @@ 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 }, + }, .macos => &.{ "src/macos/Sources/PardesView.swift", "src/macos/Sources/ScenePostprocessor.swift", "shaders/crt.ci.metal" }, .tty, .web, .esp32p4 => null, }, diff --git a/src/esp32p4.zig b/src/esp32p4.zig index 45126a74..b7725b81 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: no pads" on the message diff --git a/src/exec.zig b/src/exec.zig index eb685c05..ee7c31e9 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -771,6 +771,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(); } @@ -835,7 +839,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 @@ -843,7 +847,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/gui/Post.zig b/src/gui/Post.zig new file mode 100644 index 00000000..f715965f --- /dev/null +++ b/src/gui/Post.zig @@ -0,0 +1,576 @@ +//! 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 the bundled Crt (shaders/post/, +//! compiled with the build) or a user's file (Shader <path>), +//! 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 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"), +}); + +/// 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 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, +}; + +/// 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, .level = entry.level, .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 does)"); + }, + .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 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 { + 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); +} + +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 + 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); + // 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 }, + }; + 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) return .{ .spirv = reader.toOwnedSlice(0) catch return .none }; + const said = reader.toOwnedSlice(1) catch return .none; + // glslc calls its input <stdin>; the file is what a person knows. + defer gpa.free(said); + const named = std.mem.replaceOwned(u8, gpa, said, "<stdin>", 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) }; + + 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)); + 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); + 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" }) |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); + 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 glslc's <stdin>. + try std.testing.expect(std.mem.indexOf(u8, text, path) != null); + try std.testing.expect(std.mem.indexOf(u8, text, "<stdin>") == 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 cf708a0e..5bc1ea6d 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"); @@ -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,12 +38,11 @@ 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"); 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 }; @@ -200,55 +200,17 @@ 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.layout.Animation.frame_ns; - return false; - } - if (now_ns < clock.next_ns) return false; - clock.next_ns = now_ns +| pardes.layout.Animation.frame_ns; - return true; - } -}; - -fn finishPresentedAnimationFrame( - clock: *AnimationClock, - core: *pardes.Pardes, - tracks: []const pardes.layout.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.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.expectEqual(@as(u64, 0), clock.next_ns); -} - -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(); - var clock: AnimationClock = .{}; + const frame = pardes.animation.frame_ns; - 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); + core.advance(100); + 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" }); @@ -258,22 +220,21 @@ 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.layout.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); 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; @@ -480,6 +441,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 { @@ -557,8 +523,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, }; @@ -582,7 +548,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")); @@ -592,43 +559,52 @@ 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 }, }; - 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" { - 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(@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" { - const closing: pardes.layout.Track = .{ + const closing: pardes.animation.Track = .{ .pane = 0, .phase = .closing, .effect = .vertical, .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.layout.Phase.closing, plan.batches[1].track.?.phase); + 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); } fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cell_h: u32, tag_w: u32, tag_h: u32) bool { @@ -1791,9 +1767,9 @@ 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, atlas_tex: *c.SDL_GPUTexture, atlas_sampler: *c.SDL_GPUSampler, linear_sampler: *c.SDL_GPUSampler, @@ -1810,9 +1786,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 = &.{}, @@ -1825,8 +1803,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, @@ -1864,7 +1842,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, @@ -2003,7 +1982,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, @@ -2017,7 +1998,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 { @@ -2040,64 +2021,152 @@ 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, +/// 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.layout.Track, has_diff: bool) PaintPlan { - var plan: PaintPlan = .{}; - for ([_]pardes.layout.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; } -fn panelCellCoord(track: pardes.layout.Track, col: u16, row: u16) u32 { +/// 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 { + if (groups.tracks > 0) 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) { + /// 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))); 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; @@ -2112,7 +2181,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, @@ -2147,6 +2216,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; @@ -2322,10 +2400,10 @@ 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); 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 }; @@ -2352,9 +2430,9 @@ 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, .atlas_tex = atlas_tex, .atlas_sampler = atlas_sampler, .linear_sampler = linear_sampler, @@ -2389,8 +2467,8 @@ 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); c.SDL_ReleaseGPUSampler(device, g.atlas_sampler); c.SDL_ReleaseGPUSampler(device, g.linear_sampler); @@ -2414,7 +2492,9 @@ 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); + g.post.deinit(gpa, device); } defer c.SDL_ReleaseGPUTransferBuffer(device, g.image_vxfer); defer c.SDL_ReleaseGPUBuffer(device, g.image_vbuf); @@ -2525,6 +2605,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(); @@ -2716,25 +2797,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: { log.err("render: {t}", .{err}); @@ -2742,17 +2810,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 { @@ -2955,23 +3073,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); } @@ -3178,6 +3287,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 => { @@ -3350,7 +3473,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, @@ -3369,15 +3492,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 => { @@ -3567,7 +3682,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; } @@ -3579,19 +3694,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; } @@ -3637,14 +3752,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 { @@ -3755,10 +3886,11 @@ 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(.{}), + /// PARDES_TEST_CLOCK's virtual time (host_io.testClock), in test mode. + test_clock: ?u64 = null, fn reap(s: *Shell) void { const now = shellClock(); @@ -3845,6 +3977,7 @@ const Shell = struct { const vtable: pardes.Host.VTable = .{ .wait_input = waitInput, + .now = guiNow, .present = present, .post_present = postPresent, .poll_frame = pollFrame, @@ -3870,6 +4003,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; @@ -3981,13 +4115,38 @@ 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 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. + 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 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. - const ms: c_int = if (latency_fd >= 0) 0 else if (timeout_ms != 0) @intCast(timeout_ms) else 16; + // 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); 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 @@ -4060,6 +4219,20 @@ 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, 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 + 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 { @@ -4119,18 +4292,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.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: { log.err("render: {t}", .{err}); @@ -4153,20 +4321,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(ctx: ?*anyopaque) u64 { + return shellOf(ctx).test_clock orelse c.SDL_GetTicksNS(); } fn gridPollFrame(ctx: ?*anyopaque) void { @@ -4176,8 +4340,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; } } @@ -4374,7 +4540,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; @@ -4685,7 +4851,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); @@ -4857,8 +5023,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; @@ -4933,7 +5099,7 @@ fn prepareNativeImages( gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, surface: *pardes.Surface, - plan: *PaintPlan, + groups: *Groups, sw: u32, sh: u32, ) bool { @@ -4959,9 +5125,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) { @@ -4981,7 +5147,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, @@ -4990,7 +5156,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| { @@ -4999,7 +5165,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; @@ -5047,7 +5213,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)))); @@ -5068,11 +5234,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; @@ -5091,12 +5256,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); } @@ -5105,10 +5266,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.layout.SceneEffect, debug_on: bool, ) !bool { if (surface.pointer_shape != g.pointer_shape) { @@ -5123,93 +5280,58 @@ 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; }; 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 .{ @@ -5226,28 +5348,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; @@ -5255,33 +5370,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; @@ -5290,29 +5401,29 @@ fn renderFrame( } } for (surface.tagLayers()) |*layer| { - if (layer.cols == 0) continue; - const batch = &paint_plan.batches[paintBatchAt(&paint_plan, layer.viewport.x, layer.viewport.y)]; - batch.cell_count += tagLayerCellCount(g, layer) + 1; + if (layer.rows == 0) continue; + 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 (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.cols == 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; + if (layer.rows == 0 or layer.kind != .pane or layer.serial != track.serial) continue; + 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; + 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; } - if (cell_total != 0) { - const capacity = std.math.add(u32, cell_total, cells) 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 { @@ -5326,45 +5437,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, @@ -5372,12 +5478,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, ); } @@ -5386,36 +5491,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.cols == 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); + 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) 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 (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.cols == 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); + if (layer.rows == 0 or layer.kind != .pane or layer.serial != track.serial) continue; + 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; @@ -5423,13 +5528,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, @@ -5437,10 +5541,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, @@ -5448,119 +5551,141 @@ 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); - 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); - g.presented_scene = rendered_scene; - if (paint_plan.len == 1) 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 (paint_plan.len == 1) 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 (paint_plan.len == 1) 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 { @@ -5574,24 +5699,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; @@ -5674,75 +5781,18 @@ 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.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; } -/// 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 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 { - @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.cols == 0 or r.y >= rows or r.x >= right) continue; - @memset(cover[@as(usize, r.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 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); }; } @@ -5756,7 +5806,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; @@ -5769,10 +5819,21 @@ 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()); - for (s.tagLayers()) |layer| notices = notices or (layer.cols != 0 and layer.kind == .notice); + // 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); @@ -5789,30 +5850,30 @@ 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 grid: [64 * 40]bool = undefined; + var body: [pardes.MAX_PANES]pardes.Layer = undefined; + var tag: [pardes.MAX_TAG_LAYERS]pardes.Layer = 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); } } -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.layout.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); @@ -5822,20 +5883,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; + } } } @@ -5847,11 +5913,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; @@ -5875,7 +5941,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.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); @@ -5925,42 +5991,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.TagLayer, 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; - 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, @@ -5968,20 +6015,22 @@ fn emitSurfaceCell( body_layout: CellLayout, win_w: f32, win_h: f32, - track: ?pardes.layout.Track, + 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. @@ -5989,27 +6038,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; } @@ -6021,8 +6075,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; @@ -6035,7 +6088,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); @@ -6048,12 +6101,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); } @@ -6067,7 +6120,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, @@ -6167,24 +6220,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 { @@ -6694,17 +6748,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.TagLayer{.{ .kind = .pane, .serial = 1, .viewport = .{ .x = pardes.TAG_TEXT_INSET, .y = 0, .w = 3, .h = 1 }, .cols = 3 }}; 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); @@ -6728,7 +6789,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); } @@ -6883,10 +6944,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{ @@ -6903,8 +6966,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; @@ -7021,25 +7085,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; @@ -7216,7 +7262,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-<frame>.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); @@ -7249,10 +7298,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; @@ -7266,8 +7317,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; @@ -7293,128 +7344,308 @@ 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); } -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 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. + 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; + 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); + } } - 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)); +} + +/// 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); + } + } +} + +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, - }); + 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); } - 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)); - } + 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); +} + +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.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)))); + 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; } } - if (transient_on) for (surface.bodyLayers()) |*layer| { + for (surface.bodyLayers()) |*layer| { if (layer.rows == 0) continue; + const cursor = layer.cursor orelse 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; - 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| { - if (layer.cols == 0) 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]; + 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.col >= layer.cols) 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; - 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))); - }; - if (transient_on) appendTouchOverlay(g, &builder, debug_on); + const y = layer.viewport.y + cursor.y; + const top = @as(f32, @floatFromInt(y)) * layout.h + @as(f32, @floatFromInt(taglineBandOffset(y, win_h, g.cell_h, g.tagline_height))); + out[n] = .{ .x0 = x, .y0 = top, .x1 = @min(right, x + if (cursor.bar) @max(1, width / 8) else width), .y1 = top + @as(f32, @floatFromInt(g.tagline_height)), .rgb = cursorColor(chrome, &layer.cells[@as(usize, cursor.y) * layer.cols + cursor.x], .tagline), .text = page, .bar = cursor.bar }; + 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 +/// 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); } @@ -7489,33 +7720,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) { +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, - else => p.chromeTheme().tag_fg, - } else p.theme().fg orelse fg_default; - return 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 { @@ -7527,181 +7736,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.cols == 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.cols == 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)); - } -} - -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 }; - surface.tag_layers[1] = .{ .kind = .pane, .viewport = .{ .x = 8, .y = 3, .w = 13, .h = 1 }, .cols = 20 }; - 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; @@ -8014,7 +8048,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, @@ -8054,7 +8088,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/host_io.zig b/src/host_io.zig index 340513df..a019e15d 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/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/layout.zig b/src/layout.zig index 855a3eb9..f0e6fd16 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; @@ -947,7 +297,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 +321,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 +371,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; @@ -1254,8 +604,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(); @@ -1461,789 +809,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 136a9ede..2e27a001 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"); @@ -20,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; @@ -502,7 +503,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.mode); + } 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 ..]); @@ -1012,7 +1017,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(.{}); @@ -1374,13 +1379,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 70469618..b472e6cc 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, @@ -111,13 +112,11 @@ 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; -const max_tick_catch_up_ns: u64 = 4 * pardes.layout.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; @@ -873,16 +872,16 @@ export fn pardes_should_quit() bool { return st.core.quit; } -fn encodeSceneEffects(effects: layout.SceneEffect) u32 { +// 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 (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; 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 { @@ -893,18 +892,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.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.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.layout.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); @@ -1904,25 +1903,22 @@ 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| { - // 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(.{}); } } 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; @@ -2164,7 +2160,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), @@ -2182,7 +2178,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); @@ -2619,7 +2615,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) @@ -2888,23 +2884,21 @@ 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(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); @@ -2960,14 +2954,10 @@ 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 })); - try expectEqual( - scene_flag_crt | scene_flag_ripple | scene_flag_glitch, - encodeSceneEffects(.{ .crt = true, .ripple = true, .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)); var st: State = undefined; st.scene_ns = scene_wrap_ns - (std.time.ns_per_ms * 5); @@ -3155,7 +3145,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)); @@ -3176,16 +3166,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/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/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/modal.zig b/src/modal.zig index 3827d11f..cc7f1ebd 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -15,6 +15,8 @@ pub const Normal = struct { prefix_replace, prefix_next, prefix_prev, + prefix_register, + prefix_view_sticky, goto_file_start, goto_last_line, @@ -27,6 +29,8 @@ pub const Normal = struct { goto_view_top, goto_view_center, goto_view_bottom, + goto_last_modification, + goto_word, goto_definition, goto_declaration, goto_type_definition, @@ -36,6 +40,7 @@ pub const Normal = struct { view_top, view_center, view_bottom, + view_middle, view_scroll_down, view_scroll_up, @@ -49,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, @@ -96,6 +108,17 @@ pub const Normal = struct { trim_sels, select_regex, split_regex, + 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, @@ -108,6 +131,10 @@ pub const Normal = struct { to_lowercase, to_uppercase, join_lines, + join_select_space, + rotate_contents_fwd, + rotate_contents_back, + align_selections, indent, unindent, format, @@ -123,6 +150,10 @@ pub const Normal = struct { pipe_selection_to, insert_output, append_output, + keep_pipe, + macro_record, + macro_replay, + repeat_insert, search, search_next, search_prev, @@ -156,6 +187,7 @@ pub const Normal = struct { replace, next, prev, + register, }; pub const MatchSub = enum(u8) { @@ -172,9 +204,14 @@ pub const Normal = struct { prefix: Prefix = .none, match_sub: MatchSub = .none, held_char: u21 = 0, + /// `"<reg>`: 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 .{}; } }; @@ -188,13 +225,22 @@ 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; } }; + /// 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 }; pub const Scope = enum { once, per_selection }; pub const Direction = enum { backward, forward }; pub const Motion = enum { @@ -221,8 +267,9 @@ 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 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 }; @@ -247,6 +294,7 @@ pub const Normal = struct { merge, merge_consecutive, trim, + ensure_forward, }; pub const Edit = enum { delete, @@ -260,6 +308,10 @@ pub const Normal = struct { lowercase, uppercase, join_lines, + join_select_space, + rotate_contents_forward, + rotate_contents_backward, + align_selections, indent, unindent, comment_toggle, @@ -295,7 +347,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, @@ -304,6 +356,12 @@ pub const Normal = struct { pipe_selection: PipeBehavior, search, search_step: Direction, + goto_word, + 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 { return switch (value) { @@ -315,9 +373,22 @@ pub const Normal = struct { .pipe_selection, .search, .search_step, + .macro_record, + .macro_replay, + .node, + .goto_word, + .repeat_insert, => .once, .edit => |edit| switch (edit.kind) { - .comment_toggle, .undo, .redo => .once, + .join_lines, + .join_select_space, + .rotate_contents_forward, + .rotate_contents_backward, + .align_selections, + .comment_toggle, + .undo, + .redo, + => .once, else => .per_selection, }, else => .per_selection, @@ -354,7 +425,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); } @@ -386,6 +460,8 @@ 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 }); if (key.has(.goto_type_definition)) return resultAction(.{ .lsp = .type_definition }); @@ -394,8 +470,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 }); @@ -473,14 +550,28 @@ 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 => { + 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 => {}, } // 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); @@ -489,6 +580,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 } }); @@ -535,8 +627,19 @@ 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(.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 } }); @@ -549,6 +652,10 @@ 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(.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 }); @@ -564,6 +671,10 @@ 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(.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 }); @@ -1287,6 +1398,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/mouse.zig b/src/mouse.zig index 1cfe6dbb..22eb3270 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"); @@ -745,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); @@ -857,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, ""); } @@ -961,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) { @@ -971,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)); @@ -1014,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); @@ -1037,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" { @@ -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/ctl.zig b/src/ninep/ctl.zig index 680a09b0..a64521e7 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -302,7 +302,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| 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) }), } } try w.writeAll("LocationsConfig "); 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/normal.zig b/src/normal.zig index da707e26..17ebaac6 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"); @@ -18,6 +19,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; @@ -27,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", @@ -35,6 +38,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), }; } @@ -67,27 +71,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; @@ -106,6 +121,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; @@ -113,7 +139,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; @@ -124,7 +150,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; } @@ -142,13 +168,158 @@ pub fn applySelRegex(p: *Pardes, pane: *Pane, t: *Text, pat: []const u8, split: t.setRanges(text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO) } +/// 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); + f.node_tree_revision = f.revision; + for (f.node_history.items) |step| p.gpa.free(step.ranges); + f.node_history.clearRetainingCapacity(); + } + 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(); + 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); +} + +/// `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, 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 { return .{ @@ -157,7 +328,8 @@ 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, }; } @@ -167,7 +339,8 @@ 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; } }; @@ -227,6 +400,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 +459,16 @@ pub fn replaySels(p: *Pardes, t: *Text, what: Replay) void { } pub fn handleNormal(p: *Pardes, t: *Text, key: Key) void { + // `"<reg>` 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 }); @@ -313,6 +493,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) { @@ -346,6 +528,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 => { @@ -360,6 +546,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), }, @@ -520,27 +715,28 @@ 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 // 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), .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), - .join_lines => return edit.normalJoin(p, t), + .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), + .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), @@ -556,7 +752,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; }, @@ -573,6 +769,46 @@ 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), + .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) { + m.recording = if (p.register != 0) p.register else '@'; + m.recorded.clearRetainingCapacity(); + return; + } + // the keys of this command (a count, `"<reg>`, 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 ..]); + }, } } @@ -713,10 +949,11 @@ 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' }); - 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); @@ -740,20 +977,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); @@ -763,3 +1000,132 @@ 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" }, + // 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); + 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 if (ch == '\t') { + p.update(.{ .key = .{ .cp = Key.tab } }); + } 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; + }; + } +} + +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); +} + +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(); + // 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" }, + // 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); + 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/panes.zig b/src/panes.zig index 29fe0cb0..8049da4e 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -157,9 +157,13 @@ 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: 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 91f0f9f4..f559b125 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,7 +18,10 @@ 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 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; @@ -35,6 +40,9 @@ 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 Chrome = @import("surface.zig").Chrome; +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"); @@ -243,21 +251,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; @@ -284,7 +288,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 +319,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 +333,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 +342,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); } @@ -453,11 +457,14 @@ test { _ = @import("regexp.zig"); _ = @import("edit.zig"); _ = @import("Messages.zig"); + _ = @import("Registers.zig"); + _ = @import("Macro.zig"); _ = @import("selection_pipe.zig"); _ = @import("Pipe.zig"); _ = @import("colors.zig"); _ = @import("surface.zig"); _ = @import("body_layer.zig"); + _ = @import("draw.zig"); _ = @import("tag_layer.zig"); } @@ -581,18 +588,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()); } @@ -784,7 +783,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); @@ -2463,7 +2462,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); @@ -2526,7 +2525,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, @@ -2632,7 +2631,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); @@ -3256,7 +3255,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" { @@ -3597,8 +3596,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()); @@ -3607,7 +3606,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); @@ -3646,9 +3645,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)); @@ -3669,14 +3668,14 @@ 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)); 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); @@ -3695,9 +3694,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)); @@ -3765,7 +3764,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); @@ -3819,11 +3818,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; @@ -3846,13 +3845,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)); } @@ -3890,7 +3889,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()); @@ -3925,7 +3924,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]; @@ -3933,8 +3932,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" { @@ -4088,7 +4087,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, @@ -4140,6 +4139,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 (`"<reg>`); 0 for the + /// default one. + register: u21 = 0, leader_on: bool = false, leader_keys: [4]u8 = undefined, leader_n: u8 = 0, @@ -4192,6 +4199,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. @@ -4220,7 +4234,10 @@ pub const Pardes = struct { in_head: usize = 0, in_len: usize = 0, - yank: ?[]u8 = null, + 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, /// the last serialized dump (gpa-owned), read by the write_dump effect dump_out: ?[]u8 = null, @@ -4266,6 +4283,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, @@ -4391,7 +4410,9 @@ 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); + p.macro.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); @@ -4513,6 +4534,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); @@ -4970,7 +4992,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, @@ -5011,7 +5033,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()) layout.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(); @@ -5021,7 +5053,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); @@ -5261,7 +5295,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| { @@ -5316,7 +5364,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); } @@ -5393,7 +5441,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(); @@ -5404,10 +5452,11 @@ 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 (hit(key, config.look_key) or hit(key, config.exec_key))) { + 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. const header_word = t.what == .column or t.what == .workspace; @@ -5454,19 +5503,45 @@ 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) { .normal => normal.handleNormal(p, t, key), - .insert => { - if (hit(key, config.escape)) 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 { @@ -5801,7 +5876,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| { @@ -5818,10 +5893,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 ---- @@ -6129,18 +6204,83 @@ 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; + 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; @@ -6157,15 +6297,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; } @@ -6183,446 +6322,21 @@ 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; } - // ---- render: build the canonical surface ---- - - 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; - // Where an exec was sent to a REPL reads in the tag's name - // tint, set apart from what the editor says. - const ink = if (kind == .message and pane.msg_sent) chrome.tag_name_fg else chrome.tag_fg; - if (kind == .message and pane.msg_sent) style.fg = .{ .rgb = ink }; - 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(ink, 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 or pane.prompt == .repl_choice) 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 }; - } - } + // ---- render: build the canonical surface (draw.zig) ---- - 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; @@ -6861,12 +6575,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. @@ -6912,11 +6626,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); @@ -7642,9 +7356,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(); @@ -7719,7 +7433,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); @@ -7755,3 +7469,42 @@ 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 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; + p.advance(start); + var steps: u64 = 0; + 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. + 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.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.chrome_animation.step = animation.transition_steps; + p.advance(p.stepped_ns + frame); + try std.testing.expectEqual(@as(?u64, null), p.nextWake()); +} diff --git a/src/pdf_view.zig b/src/pdf_view.zig index b2fd5866..13c02231 100644 --- a/src/pdf_view.zig +++ b/src/pdf_view.zig @@ -2542,11 +2542,13 @@ 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, 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; @@ -2616,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. - 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 }); - - 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), @@ -2639,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) - core.surface.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 a9e69cca..b6f41853 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"); @@ -10,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; @@ -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]; @@ -70,7 +64,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; @@ -323,40 +317,133 @@ 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, + /// 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); + + 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). 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`. + 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, +}; + +/// 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. 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]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, + 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, 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 = &.{}, - previous_tag_layers: []const TagLayer = &.{}, + 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 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; } - pub fn panelTracks(s: *const Surface) []const layout.Track { + pub fn regionList(s: *const Surface) []const Region { + 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; + return null; + } + + pub fn panelTracks(s: *const Surface) []const animation.Track { return s.panel_tracks[0..s.npanel_tracks]; } @@ -377,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 { diff --git a/src/syntax.zig b/src/syntax.zig index 9930db20..8ed00bfa 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; @@ -310,6 +319,234 @@ 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; +} + + +/// 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; + }; + 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) { + 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/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/tagline.zig b/src/tagline.zig index 6d5992f1..59c51b4d 100644 --- a/src/tagline.zig +++ b/src/tagline.zig @@ -226,7 +226,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(); @@ -332,7 +332,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/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 f8f8cdc5..822c2d7f 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 }, @@ -737,6 +738,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); host_io.setExitWake(loop, wakeForExit); @@ -786,7 +788,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()); @@ -911,7 +913,11 @@ 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 = &.{}, + /// 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.?)); @@ -923,12 +929,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, @@ -951,12 +959,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(); @@ -999,7 +1015,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; @@ -1565,21 +1583,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 tickWatch(io: std.Io, loop: *Loop) void { +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) { - 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 }; - _ = libc.nanosleep(&ts, null); - loop.postEvent(.tick) catch {}; + // 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 {}; } } +/// 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) { + 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 {}; @@ -1864,7 +1938,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/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/src/web.zig b/src/web.zig index b77d07a6..ac0ecbbf 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); @@ -221,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); @@ -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); @@ -349,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), @@ -358,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, }; } @@ -369,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); @@ -462,7 +458,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..03829178 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(); @@ -66,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; + } } } } @@ -120,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; @@ -173,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); } @@ -325,7 +335,6 @@ export class PardesRuntime { this.running = false; this.pointer = null; this.frameHandle = 0; - this.animationClock = { last: null, lag: 0 }; this.bindEvents(); } @@ -338,7 +347,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 +363,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 +563,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/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/gui-goldens.txt b/test/gui-goldens.txt new file mode 100644 index 00000000..38ca89e5 --- /dev/null +++ b/test/gui-goldens.txt @@ -0,0 +1,18 @@ +01-boot d4e3eb9dba35133df10d46b1d27ab7a90fa9bdefb64bcc304add221326ffdfc9 +02-second-document 235cdf75f3ce1d5800dbbdd7dcfde066af50cde01231a35d00c66c4310213a05 +03-notice 4d59205fe6b89a4589e94c68521257f21b40917c0198ea20248925687663d9f5 +04-context-rows-mid-thumb 7129ee318975f858c430b1e610b26ca756d5bb37553338c0dc6caab2290de2a4 +05-body-selection 39103f3747aae07a12b5151e7e377430b88b96e504670ac2f2d0272197eb827b +06-tag-focus-selection 6a7f50e292f4ede92df24a817218818b93a01fd15fcf39467cf06c3772077834 +07-prompt-notice d5cdacd74ae6d29834f0b09a607bfce5a2ca8a549c259be50b76a57a2d2d3271 +08-wide-glyph-edges 123a182b82a2a8c455291a5c89ccfa28e1f8ce9566172ca404c0b9c71dd33b48 +09-collapsed-focus-tint ae66076fdfb0cab9a906b052df257deee5ba91a6274712d52a76d8a7adc5b415 +10-tagbottom dd8ba0d3ecabf3420d000b0c9591a57278179cbd2a9060f849d283f3881d1847 +11-column-tags 6b1731d1611404597c96ad77f34c2d5cd5a81c1085ec68c6779388e07ce28614 +12-multiline-tag 1b9ccd331814dc76111470f18448b1ca192e1edc3d79ea6eee19a6a78a4b1831 +13-image a52e2cf9f238447d885692cba01e68ef7fdc6ae3fc7c8fe1e95c8463c85f519c +14-theme-ink cc2ea256ddda24e1c97871f75b71ddf62fa825bd02f5d1432592d05e478c707a +15-theme-acme-light 2bde8a2bdfada7f061217f52cb2272913fc0657dbfa1d7448e1852f321f8e3bf +16-debug 129ad6f5f85b0e60e81bc6e8e78434629ee751f49731cf2eb28fd0553efd914a +17-terminal 45a3d9f823ccf28a336ef6687c9c210f9fb05cc426c0fdee98bb0ebb2a957d42 +18-mid-transition 79f90fb247f75ab284fca147028ee18ea78e77a02a57203699fb423f4bcc02fb diff --git a/test/gui_golden.py b/test/gui_golden.py new file mode 100644 index 00000000..d97009c9 --- /dev/null +++ b/test/gui_golden.py @@ -0,0 +1,225 @@ +#!/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 <pardes-gui binary> compare +python3 -B test/gui_golden.py <pardes-gui binary> --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) + # 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' + 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 + # 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()), + '--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, 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 / frame, 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') + 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') + # 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) + 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 <scene>.ppm') + run(parser.parse_args()) diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index b0a3aad4..f36afe11 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": "]<space>"} {"name": "add-nl-above", "pane": "file", "text": "aa\nbb\n", "keys": "j[<space>"} {"name": "add-nl-count", "pane": "file", "text": "aa\nbb\n", "keys": "2]<space>"} +{"name": "add-nl-above-sel", "pane": "file", "text": "aa\nbb\ncc\n", "keys": "vj[<space>"} +{"name": "add-nl-below-sel", "pane": "file", "text": "aa\nbb\ncc\n", "keys": "jvk]<space>"} +{"name": "add-nl-above-lines", "pane": "file", "text": "aa\nbb\ncc\n", "keys": "jxx[<space>"} {"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"} @@ -287,6 +290,8 @@ {"name": "r-cancel-esc", "pane": "file", "text": "abc\n", "keys": "r<esc>l"} {"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`"} @@ -297,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\\w<ret>J"} +{"name": "alt-J-selects-space", "pane": "file", "text": "aa\n bb\ncc\n", "keys": "xx<A-J>"} +{"name": "alt-J-each-range", "pane": "file", "text": "a\nb\nc\nd\n", "keys": "%<A-s><A-J>"} +{"name": "alt-J-blank-no-space", "pane": "file", "text": "aa\n\nbb\n", "keys": "<A-J>"} +{"name": "msel-J-two-lines", "pane": "file", "text": "aa bb\ncc\ndd\nee\n", "keys": "Cs\\w<ret>J"} {"name": "indent-basic", "pane": "file", "text": "foo\nbar\n", "keys": "<gt>"} {"name": "indent-realign", "pane": "file", "text": " x\n", "keys": "gs<gt>"} {"name": "indent-cursor-maps", "pane": "file", "text": "foo\n", "keys": "ll<gt>"} @@ -316,6 +327,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\\d<ret>dhbms`"} {"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(["} @@ -330,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<esc>"} +{"name": "reg-selection-insert", "pane": "file", "text": "ab\n", "keys": "vli<C-r>.<esc>"} +{"name": "ins-sel-backspace", "pane": "file", "text": "alpha beta\n", "keys": "wi<backspace><esc>"} +{"name": "ins-append-enter", "pane": "file", "text": "ab cd\n", "keys": "wa<ret>x<esc>"} +{"name": "ins-append-backspace", "pane": "file", "text": "ab cd\n", "keys": "ea<backspace><backspace><esc>"} +{"name": "ins-sel-delete", "pane": "file", "text": "ab cd\n", "keys": "wi<del><esc>"} +{"name": "ins-sel-arrow-collapses", "pane": "file", "text": "ab cd\n", "keys": "wi<right><esc>"} +{"name": "msel-insert-keeps-ranges", "pane": "file", "text": "ab cd\nab cd\n", "keys": "wCiX<esc>"} +{"name": "msel-append-lines", "pane": "file", "text": "aa\nbb\n", "keys": "%<A-s>a!<esc>"} +{"name": "ins-append-bare", "pane": "file", "text": "ab\n", "keys": "axy<esc>"} +{"name": "ins-change-then-type", "pane": "file", "text": "ab cd\n", "keys": "wcX<esc>"} +{"name": "ins-open-below-sel", "pane": "file", "text": "ab\ncd\n", "keys": "xoX<esc>"} +{"name": "ins-enter-trims-blanks", "pane": "file", "text": "ab cd\n", "keys": "5li<ret><esc>"} +{"name": "ins-enter-in-indent", "pane": "file", "text": " ab\n", "keys": "2li<ret><esc>"} +{"name": "ins-enter-after-open", "pane": "file", "text": " ab\n", "keys": "o<ret>x<esc>"} +{"name": "msel-ins-enter", "pane": "file", "text": "ab cd\nab cd\n", "keys": "wCa<ret><esc>"} {"name": "a-esc-restore", "pane": "file", "text": "abc\n", "keys": "a<esc>"} {"name": "ax-restore", "pane": "file", "text": "abc\n", "keys": "ax<esc>"} {"name": "lax-restore", "pane": "file", "text": "abc\n", "keys": "lax<esc>"} @@ -341,6 +370,11 @@ {"name": "ins-enter-indent", "pane": "file", "text": " foo\n", "keys": "A<ret>x<esc>"} {"name": "ins-backspace", "pane": "file", "text": "abc\n", "keys": "lli<backspace><esc>"} {"name": "ins-backspace-join", "pane": "file", "text": "ab\ncd\n", "keys": "ji<backspace><esc>"} +{"name": "ins-backspace-indent-unit", "pane": "file", "text": " x\n", "keys": "8li<backspace>"} +{"name": "ins-backspace-indent-partial", "pane": "file", "text": " x\n", "keys": "6li<backspace>"} +{"name": "ins-backspace-indent-tab", "pane": "file", "text": "\t\tx\n", "keys": "2li<backspace>"} +{"name": "ins-backspace-open-indent", "pane": "file", "text": " ab\n", "keys": "O<backspace>"} +{"name": "ins-backspace-after-text", "pane": "file", "text": " a x\n", "keys": "7li<backspace>"} {"name": "ins-ctrl-w", "pane": "file", "text": "one two\n", "keys": "A<C-w><esc>"} {"name": "ins-ctrl-w-col0-join", "pane": "file", "text": "ab\ncd\n", "keys": "ji<C-w><esc>"} {"name": "ins-alt-bs", "pane": "file", "text": "one two\n", "keys": "A<A-backspace><esc>"} @@ -390,6 +424,56 @@ {"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": "x<A-x>C_"} +{"name": "msel-rotate-contents-fwd", "pane": "file", "text": "a b c\n", "keys": "%s\\w<ret><A-)>"} +{"name": "msel-rotate-contents-back", "pane": "file", "text": "a b c\n", "keys": "%s\\w<ret><A-(>"} +{"name": "msel-rotate-contents-count", "pane": "file", "text": "a b c d\n", "keys": "%s\\w<ret>2<A-)>"} +{"name": "msel-rotate-contents-lengths", "pane": "file", "text": "one to three\n", "keys": "%s\\w+<ret>)<A-(>"} +{"name": "msel-rotate-contents-backward", "pane": "file", "text": "ab cde\n", "keys": "%s\\w+<ret><A-;><A-)>"} +{"name": "msel-rotate-contents-lines", "pane": "file", "text": "aa\nbbb\n", "keys": "%<A-s><A-)>"} +{"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<ret>"} +{"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": "%sw<ret>cxy<esc>"} +{"name": "keep-regex", "pane": "file", "text": "foo bar baz\n", "keys": "%s\\w+<ret>Kba<ret>"} +{"name": "remove-regex", "pane": "file", "text": "foo bar baz\n", "keys": "%s\\w+<ret><A-K>ba<ret>"} +{"name": "keep-regex-none", "pane": "file", "text": "foo bar\n", "keys": "%s\\w+<ret>Kzz<ret>"} +{"name": "keep-regex-primary", "pane": "file", "text": "a1 b2 a3\n", "keys": "%s\\w+<ret>))Ka<ret>"} +{"name": "keep-regex-esc", "pane": "file", "text": "foo bar\n", "keys": "%s\\w+<ret>Kfo<esc>"} +{"name": "keep-regex-lines", "pane": "file", "text": "foo\nbar\nbaz\n", "keys": "%<A-s>K^b<ret>"} +{"name": "ensure-forward", "pane": "file", "text": "ab cd\n", "keys": "%s\\w+<ret><A-;><A-:>"} +{"name": "ensure-forward-one", "pane": "file", "text": "abc\n", "keys": "vll<A-;><A-:>"} +{"name": "node-keys-plain-text", "pane": "file", "text": "ab cd\n", "keys": "w<A-o><A-i><A-p><A-right><A-a><A-I><A-e><A-b>"} +{"name": "goto-last-mod", "pane": "file", "text": "ab\ncd\nef\n", "keys": "jliXY<esc>gg"} +{"name": "goto-last-mod-go", "pane": "file", "text": "ab\ncd\nef\n", "keys": "jliXY<esc>ggg."} +{"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": "jjiX<esc>ggvg."} +{"name": "alt-u-undo", "pane": "file", "text": "ab\n", "keys": "iX<esc><A-u>"} +{"name": "alt-U-redo", "pane": "file", "text": "ab\n", "keys": "iX<esc>u<A-U>"} +{"name": "ins-checkpoint-undo", "pane": "file", "text": "ab\n", "keys": "iX<C-s>Y<esc>u"} +{"name": "ins-checkpoint-undo-twice", "pane": "file", "text": "ab\n", "keys": "iX<C-s>Y<esc>uu"} +{"name": "ins-checkpoint-redo", "pane": "file", "text": "ab\n", "keys": "iX<C-s>Y<esc>uuU"} +{"name": "msel-ins-checkpoint", "pane": "file", "text": "ab\nab\n", "keys": "CiX<C-s>Y<esc>u"} +{"name": "ins-tab-after-text-plain", "pane": "file", "text": "ab cd\n", "keys": "2li<tab>X<esc>"} +{"name": "ins-shift-tab", "pane": "file", "text": "ab cd\n", "keys": "2li<S-tab>X<esc>"} +{"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<esc>"} +{"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": "Zjj<esc>j"} +{"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<esc>"} +{"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=<ret>&"} +{"name": "msel-align-columns", "pane": "file", "text": "a,b,c\nccc,d,e\n", "keys": "%s,<ret>&"} +{"name": "msel-align-uneven", "pane": "file", "text": "a,b\nccc,d,eee,f\n", "keys": "%s,<ret>&"} +{"name": "msel-align-words", "pane": "file", "text": "x = 1\nlonger = 22\n", "keys": "%s\\w+<ret>&"} +{"name": "msel-align-backward", "pane": "file", "text": "x = 11\nlonger = 222\n", "keys": "%s\\d+<ret><A-;>&"} +{"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=<ret>v&"} +{"name": "rotate-contents-one", "pane": "file", "text": "ab\n", "keys": "vl<A-)>"} {"name": "msel-insert", "pane": "file", "text": "aa\nbb\n", "keys": "Cix<esc>"} {"name": "msel-insert-tty", "pane": "tty", "text": "aa\nbb\n", "keys": "Cix<esc>"} {"name": "msel-append", "pane": "file", "text": "aa\nbb\n", "keys": "Caz<esc>"} @@ -409,6 +493,35 @@ {"name": "msel-collapse", "pane": "file", "text": "abcd\nabcd\n", "keys": "vllC;"} {"name": "msel-flip", "pane": "file", "text": "abcd\nabcd\n", "keys": "vllC<A-;>"} {"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": "Cy<A-(>Ry"} +{"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": "%<A-s>i<C-r>#<esc>"} +{"name": "reg-search-paste", "pane": "file", "text": "ab\n", "keys": "%sb<ret>\"/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": "\"ayli<C-r>a<esc>"} +{"name": "reg-ctrl-r-esc", "pane": "file", "text": "ab\n", "keys": "i<C-r><esc>x"} +{"name": "macro-record-replay", "pane": "file", "text": "a\nb\nc\n", "keys": "QA!<esc>jQq"} +{"name": "macro-count", "pane": "file", "text": "a\nb\nc\nd\n", "keys": "QA!<esc>jQ2q"} +{"name": "macro-named", "pane": "file", "text": "a\nb\nc\n", "keys": "\"aQA!<esc>jQ\"aq"} +{"name": "macro-register-text", "pane": "file", "text": "a\nb\n", "keys": "QA!<esc>jQ\"@P"} +{"name": "macro-with-prompt", "pane": "file", "text": "a,b\nc,d\n", "keys": "QxS,<ret>d<esc>jQq"} +{"name": "macro-esc-in-normal", "pane": "file", "text": "ab\ncd\n", "keys": "Qvl<esc>jQq"} +{"name": "macro-empty", "pane": "file", "text": "ab\n", "keys": "QQq"} +{"name": "dot-insert", "pane": "file", "text": "ab\ncd\n", "keys": "iX<esc>j.."} +{"name": "dot-append-count", "pane": "file", "text": "ab\ncd\n", "keys": "aYZ<esc>j3."} +{"name": "dot-change", "pane": "file", "text": "ab cd\nef gh\n", "keys": "wcX<esc>jb."} +{"name": "dot-open-below", "pane": "file", "text": "a\nb\n", "keys": "oX<esc>j."} +{"name": "dot-counted-command", "pane": "file", "text": "abcd\n", "keys": "2lixy<esc>."} +{"name": "dot-multi", "pane": "file", "text": "ab\nab\n", "keys": "CiX<esc>."} +{"name": "dot-nothing", "pane": "file", "text": "ab\n", "keys": ".x"} +{"name": "append-at-end-adds-newline", "pane": "file", "text": "ab\n", "keys": "jaX<esc>"} +{"name": "append-at-end-no-newline", "pane": "file", "text": "ab", "keys": "laX<esc>"} {"name": "msel-esc-keeps", "pane": "file", "text": "aa\nbb\n", "keys": "C<esc>"} {"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 eee7294b..2dab15c0 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}} @@ -287,6 +290,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}} @@ -297,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}} @@ -316,6 +327,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}} @@ -330,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}} @@ -341,6 +370,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}} @@ -390,6 +424,56 @@ {"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":"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":"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":"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":"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":"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} +{"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} {"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} @@ -409,6 +493,35 @@ {"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":"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!<esc>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/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: \"[email protected]\",\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: \"[email protected]\",\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: \"[email protected]\",\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: \"[email protected]\",\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: \"[email protected]\",\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: \"[email protected]\",\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-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}} 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": "%<A-s>"} +{"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": "%<A-s>\"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": "%<A-s>\"yys\\d<ret>"} +{"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": "%<A-s>\"yys\\d<ret>d"} +{"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": "%<A-s>\"yys\\d<ret>dhh"} +{"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": "%<A-s>\"yys\\d<ret>dhhb"} +{"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": "%<A-s>\"yys\\d<ret>dhhbms`"} +{"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": "%<A-s>\"yys\\d<ret>dhhbms``"} +{"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": "%<A-s>\"yys\\d<ret>dhhbms``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": "%<A-s>\"yys\\d<ret>dhhbms``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": "%<A-s>\"yys\\d<ret>dhhbms``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": "%<A-s>\"yys\\d<ret>dhhbms``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": "%<A-s>\"yys\\d<ret>dhhbms``x_ms(IINSERT INTO `database<esc>"} +{"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": "%<A-s>\"yys\\d<ret>dhhbms``x_ms(IINSERT INTO `database<esc>a.`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": "%<A-s>\"yys\\d<ret>dhhbms``x_ms(IINSERT INTO `database<esc>a.`table<esc>la"} +{"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": "%<A-s>\"yys\\d<ret>dhhbms``x_ms(IINSERT INTO `database<esc>a.`table<esc>la <esc>"} +{"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": "%<A-s>\"yys\\d<ret>dhhbms``x_ms(IINSERT INTO `database<esc>a.`table<esc>la <esc>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": "%<A-s>\"yys\\d<ret>dhhbms``x_ms(IINSERT INTO `database<esc>a.`table<esc>la <esc>AVALUES (<esc>"} +{"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": "%<A-s>\"yys\\d<ret>dhhbms``x_ms(IINSERT INTO `database<esc>a.`table<esc>la <esc>AVALUES (<esc>\"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": "%<A-s>\"yys\\d<ret>dhhbms``x_ms(IINSERT INTO `database<esc>a.`table<esc>la <esc>AVALUES (<esc>\"yPS,<ret>"} +{"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": "%<A-s>\"yys\\d<ret>dhhbms``x_ms(IINSERT INTO `database<esc>a.`table<esc>la <esc>AVALUES (<esc>\"yPS,<ret>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": "%<A-s>\"yys\\d<ret>dhhbms``x_ms(IINSERT INTO `database<esc>a.`table<esc>la <esc>AVALUES (<esc>\"yPS,<ret>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": "%<A-s>\"yys\\d<ret>dhhbms``x_ms(IINSERT INTO `database<esc>a.`table<esc>la <esc>AVALUES (<esc>\"yPS,<ret>ms'A;<esc>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": "%<A-s>\"yys\\d<ret>dhhbms``x_ms(IINSERT INTO `database<esc>a.`table<esc>la <esc>AVALUES (<esc>\"yPS,<ret>ms'A;<esc>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": "%<A-s>\"yys\\d<ret>dhhbms``x_ms(IINSERT INTO `database<esc>a.`table<esc>la <esc>AVALUES (<esc>\"yPS,<ret>ms'A;<esc>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\\{<ret>"} +{"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\\{<ret>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\\{<ret>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\\{<ret>a rank: <C-r>#"} +{"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\\{<ret>a rank: <C-r>#,"} +{"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\\{<ret>a rank: <C-r>#,<esc>"} +{"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\\{<ret>a rank: <C-r>#,<esc>%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\\{<ret>a rank: <C-r>#,<esc>%s |\\d+<ret>"} +{"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\\{<ret>a rank: <C-r>#,<esc>%s |\\d+<ret>&"} +{"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+<ret>"} +{"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+<ret>&"} +{"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[<space>"} +{"name": "golf-export-from-mod-04", "pane": "file", "text": "mod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\n", "keys": "%yp[<space><A-s>gse"} +{"name": "golf-export-from-mod-05", "pane": "file", "text": "mod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\n", "keys": "%yp[<space><A-s>gsecpub use<esc>"} +{"name": "golf-export-from-mod-06", "pane": "file", "text": "mod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\n", "keys": "%yp[<space><A-s>gsecpub use<esc>ley"} +{"name": "golf-export-from-mod-07", "pane": "file", "text": "mod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\n", "keys": "%yp[<space><A-s>gsecpub use<esc>leyp"} +{"name": "golf-export-from-mod", "pane": "file", "text": "mod generate_demos;\nmod mdbook_preprocessor;\nmod validate;\n", "keys": "%yp[<space><A-s>gsecpub use<esc>leypi::"} +{"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<ret>"} +{"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": "%scalculate<ret>cget"} +{"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": "%scalculate<ret>cget<esc>"} +{"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": "%scalculate<ret>cget<esc>O@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": "%scalculate<ret>cget<esc>O@staticmethod<esc>"} +{"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": "%scalculate<ret>cget<esc>O@staticmethod<esc>jj"} +{"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": "%scalculate<ret>cget<esc>O@staticmethod<esc>jjvgl"} +{"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": "%scalculate<ret>cget<esc>O@staticmethod<esc>jjvgly"} +{"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": "%scalculate<ret>cget<esc>O@staticmethod<esc>jjvglyx<A-d>"} +{"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": "%scalculate<ret>cget<esc>O@staticmethod<esc>jjvglyx<A-d>xb"} +{"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": "%scalculate<ret>cget<esc>O@staticmethod<esc>jjvglyx<A-d>xbR"} +{"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": "%scalculate<ret>cget<esc>O@staticmethod<esc>jjvglyx<A-d>xbRkxx"} +{"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": "%scalculate<ret>cget<esc>O@staticmethod<esc>jjvglyx<A-d>xbRkxxslength|width|height<ret>"} +{"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": "%scalculate<ret>cget<esc>O@staticmethod<esc>jjvglyx<A-d>xbRkxxslength|width|height<ret>b"} +{"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": "%scalculate<ret>cget<esc>O@staticmethod<esc>jjvglyx<A-d>xbRkxxslength|width|height<ret>blll"} +{"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": "%scalculate<ret>cget<esc>O@staticmethod<esc>jjvglyx<A-d>xbRkxxslength|width|height<ret>bllled"} +{"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": "%scalculate<ret>cget<esc>O@staticmethod<esc>jjvglyx<A-d>xbRkxxslength|width|height<ret>bllled%<gt>"} +{"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": "%scalculate<ret>cget<esc>O@staticmethod<esc>jjvglyx<A-d>xbRkxxslength|width|height<ret>bllled%<gt>O<backspace>"} +{"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": "%scalculate<ret>cget<esc>O@staticmethod<esc>jjvglyx<A-d>xbRkxxslength|width|height<ret>bllled%<gt>O<backspace>class 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": "%<gt>"} +{"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": "%<gt>O<backspace>"} +{"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": "%<gt>O<backspace>class 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_<ret>"} +{"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_<ret><A-(>"} +{"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_<ret><A-(>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_<ret><A-(>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_<ret><A-(>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_<ret><A-(>Qxt,S=<ret>"} +{"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_<ret><A-(>Qxt,S=<ret>_"} +{"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_<ret><A-(>Qxt,S=<ret>_<A-(>"} +{"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_<ret><A-(>Qxt,S=<ret>_<A-(>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_<ret><A-(>Qxt,S=<ret>_<A-(>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_<ret>"} +{"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_<ret><A-(>"} +{"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_<ret><A-(>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_<ret><A-(>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_<ret><A-(>xt}S,|=<ret>"} +{"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_<ret><A-(>xt}S,|=<ret>_"} +{"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_<ret><A-(>xt}S,|=<ret>_2<A-(>"} +{"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:<ret>"} +{"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:<ret>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:<ret>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:<ret>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:<ret>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:<ret>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<A-)>"} +{"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<A-)>%"} +{"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<A-)>%s`+j<ret>"} +{"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<A-)>%s`+j<ret>f;"} +{"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<A-)>%s`+j<ret>f;<A-(>"} +{"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: \"[email protected]\",\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: \"[email protected]\",\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_<ret>"} +{"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: \"[email protected]\",\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_<ret>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: \"[email protected]\",\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_<ret>5)<A-,>"} +{"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: \"[email protected]\",\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_<ret>5)<A-,>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: \"[email protected]\",\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_<ret>5)<A-,>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": "%<A-s>"} +{"name": "golf-text-into-array-03", "pane": "file", "text": "Hello\nThis\nIs\nHelix\n", "keys": "%<A-s>ms\""} +{"name": "golf-text-into-array-04", "pane": "file", "text": "Hello\nThis\nIs\nHelix\n", "keys": "%<A-s>ms\"<A-J>i,"} +{"name": "golf-text-into-array", "pane": "file", "text": "Hello\nThis\nIs\nHelix\n", "keys": "%<A-s>ms\"<A-J>i,<esc>xms["} diff --git a/test/hxcases/parity-waivers.jsonl b/test/hxcases/parity-waivers.jsonl index 39a6a7c9..e28f3ff5 100644 --- a/test/hxcases/parity-waivers.jsonl +++ b/test/hxcases/parity-waivers.jsonl @@ -11,3 +11,9 @@ {"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}}} +{"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}}} diff --git a/test/hxcases/waivers.jsonl b/test/hxcases/waivers.jsonl index 82da2a08..6abc7de2 100644 --- a/test/hxcases/waivers.jsonl +++ b/test/hxcases/waivers.jsonl @@ -1,6 +1,2 @@ -{"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}} -{"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}}} +{"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}} 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)) { 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/output.zig b/test/output.zig index 400e9508..e49ec4ff 100644 --- a/test/output.zig +++ b/test/output.zig @@ -667,8 +667,9 @@ 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"; - for ([_][]const u8{ "Crt", "Ripple", "Glitch" }) |name| { + // 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"}) |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); } diff --git a/test/panes.zig b/test/panes.zig index 662ea3a5..40b4b6ba 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; @@ -18,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" { @@ -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, @@ -199,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 = ':' } }); @@ -305,7 +308,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, '/'); @@ -314,6 +317,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(); @@ -325,7 +341,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); @@ -540,11 +556,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" { @@ -672,7 +688,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()); @@ -848,12 +864,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 +897,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 +913,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 +924,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 +956,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 +1017,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 +1049,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 ""); } } }; @@ -1941,7 +1957,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); @@ -1967,8 +1987,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); @@ -2744,7 +2764,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); @@ -4242,7 +4262,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' } }); @@ -4543,11 +4563,12 @@ 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; 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); @@ -4564,10 +4585,10 @@ 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.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/test/pdf.zig b/test/pdf.zig index 3a592451..47f8a9e8 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; @@ -859,7 +860,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' } }); @@ -1399,7 +1401,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.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/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 <path>` 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 diff --git a/test/snapshots/theme.golden b/test/snapshots/theme.golden index 176131e7..4986eaae 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-37 #a7d3c7,#39303a, 38-49 #d0c3d0,#39303a, 50-51 #ffffff,#705667, 52-52 d,#2b262f, 53-79 #c4b7c4,#2b262f, 80-87 #9cc7bb,#2b262f, 88-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-37 #9cc7bb,#2b262f, 38-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-37 #a1d5c4,#3b2f3b, 38-49 #d2c2d1,#3b2f3b, 50-51 #ffffff,#745469, 52-52 d,#2c2630, 53-79 #c6b6c5,#2c2630, 80-87 #96c9b8,#2c2630, 88-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-37 #96c9b8,#2c2630, 38-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-37 #f6e8b9,#253341, 38-49 #e0eaf5,#253341, 50-51 #ffffff,#61758c, 52-52 d,#17222c, 53-79 #d4dfed,#17222c, 80-87 #ecdead,#17222c, 88-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-37 #ecdead,#17222c, 38-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-37 #ffeab8,#223342, 38-49 #e1eef9,#223342, 50-51 #ffffff,#5f7990, 52-52 d,#15212c, 53-79 #d5e4f1,#15212c, 80-87 #f5e0ac,#15212c, 88-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-37 #f5e0ac,#15212c, 38-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-37 #785580,#c9d3cd, 38-49 #596963,#c9d3cd, 50-51 #000000,#8aa194, 52-52 d,#d3d6d1, 53-79 #5f6f69,#d3d6d1, 80-87 #7f5b87,#d3d6d1, 88-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-37 #7f5b87,#d3d6d1, 38-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-37 #69447a,#dce5dc, 38-49 #4a5a52,#dce5dc, 50-51 #000000,#8fa594, 52-52 d,#e8eae3, 53-79 #52625a,#e8eae3, 80-87 #724c83,#e8eae3, 88-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-37 #724c83,#e8eae3, 38-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-37 #542862,#dfeaf1, 38-49 #254156,#dfeaf1, 50-51 #000000,#7d9fb4, 52-52 d,#edf1f5, 53-79 #2d495e,#edf1f5, 80-87 #5c306a,#edf1f5, 88-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-37 #5c306a,#edf1f5, 38-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-37 #52255f,#dfeaf3, 38-49 #213e56,#dfeaf3, 50-51 #000000,#7b9eb7, 52-52 d,#edf2f7, 53-79 #29465e,#edf2f7, 80-87 #5a2d67,#edf2f7, 88-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-37 #5a2d67,#edf2f7, 38-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-37 #633c6a,#d4e7e1, 38-49 #33554e,#d4e7e1, 50-51 #000000,#92b7a4, 52-52 d,#e5f0ee, 53-79 #3b5d56,#e5f0ee, 80-87 #6c4473,#e5f0ee, 88-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-37 #6c4473,#e5f0ee, 38-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-37 #653f6b,#d3e7df, 38-49 #35584d,#d3e7df, 50-51 #000000,#95baa2, 52-52 d,#e4f0ed, 53-79 #3d6055,#e4f0ed, 80-87 #6e4774,#e4f0ed, 88-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-37 #6e4774,#e4f0ed, 38-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-37 #e9bda1,#3e4650, 38-49 #c1c9d0,#3e4650, 50-51 #ffffff,#5b6d7f, 52-52 d,#35393e, 53-79 #b7bfc6,#35393e, 80-87 #dfb397,#35393e, 88-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-37 #dfb397,#35393e, 38-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-37 #f8cba7,#2d3440, 38-49 #d1d5de,#2d3440, 50-51 #ffffff,#55647b, 52-52 d,#22252b, 53-79 #c5c9d2,#22252b, 80-87 #ecbf9b,#22252b, 88-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-37 #ecbf9b,#22252b, 38-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-37 #f1cef0,#33544c, 38-49 #c8e2d8,#33544c, 50-51 #ffffff,#548073, 52-52 d,#28413d, 53-79 #bcd6cc,#28413d, 80-87 #e5c2e4,#28413d, 88-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-37 #e5c2e4,#28413d, 38-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-37 #f0cef8,#34574d, 38-49 #c7e3d7,#34574d, 50-51 #ffffff,#548372, 52-52 d,#29443f, 53-79 #bbd7cb,#29443f, 80-87 #e4c2ec,#29443f, 88-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-37 #e4c2ec,#29443f, 38-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-37 #f5cded,#234f50, 38-49 #d2e0ca,#234f50, 50-51 #ffffff,#477f76, 52-52 d,#144043, 53-79 #c6d4be,#144043, 80-87 #e9c1e1,#144043, 88-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-37 #e9c1e1,#144043, 38-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-37 #f5cdec,#214e50, 38-49 #d3e0c9,#214e50, 50-51 #ffffff,#467f76, 52-52 d,#123f43, 53-79 #c7d4bd,#123f43, 80-87 #e9c1e0,#123f43, 88-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-37 #e9c1e0,#123f43, 38-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-37 #dae1fd,#494839, 38-49 #e7e4c7,#494839, 50-51 #ffffff,#8a8453, 52-52 d,#373932, 53-79 #dbd8bb,#373932, 80-87 #c9d5fc,#373932, 88-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-37 #c9d5fc,#373932, 38-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-37 #d7e3ff,#4d4736, 38-49 #e9e4c7,#4d4736, 50-51 #ffffff,#92844f, 52-52 d,#3b3830, 53-79 #ddd8bb,#3b3830, 80-87 #c5d7ff,#3b3830, 88-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-37 #c5d7ff,#3b3830, 38-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-37 #c7e7f8,#434c38, 38-49 #e2e4c4,#434c38, 50-51 #ffffff,#7d7f4e, 52-52 d,#353b2e, 53-79 #d6d8b8,#353b2e, 80-87 #bbdbed,#353b2e, 88-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-37 #bbdbed,#353b2e, 38-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-37 #c5e7f7,#424d38, 38-49 #e1e4c4,#424d38, 50-51 #ffffff,#7b7e4e, 52-52 d,#343b2e, 53-79 #d5d8b8,#343b2e, 80-87 #badbeb,#343b2e, 88-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-37 #badbeb,#343b2e, 38-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-37 #b7eced,#424f3f, 38-49 #e3e2c2,#424f3f, 50-51 #ffffff,#6a7c4d, 52-52 d,#343f35, 53-79 #d7d6b6,#343f35, 80-87 #ace0e1,#343f35, 88-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-37 #ace0e1,#343f35, 38-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-37 #b5ecec,#424f40, 38-49 #e3e2c2,#424f40, 50-51 #ffffff,#687c4d, 52-52 d,#343f36, 53-79 #d7d6b6,#343f36, 80-87 #aae0e0,#343f36, 88-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-37 #aae0e0,#343f36, 38-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-37 #f1ceae,#2f3740, 38-49 #d3d6db,#2f3740, 50-51 #ffffff,#576676, 52-52 d,#24282c, 53-79 #c7cacf,#24282c, 80-87 #e5c2a2,#24282c, 88-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-37 #e5c2a2,#24282c, 38-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-37 #f8cba7,#2d3440, 38-49 #d1d5de,#2d3440, 50-51 #ffffff,#55647b, 52-52 d,#22252b, 53-79 #c5c9d2,#22252b, 80-87 #ecbf9b,#22252b, 88-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-37 #ecbf9b,#22252b, 38-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-37 #f8cba7,#363d48, 38-49 #d1d5de,#363d48, 50-51 #ffffff,#55647b, 52-52 d,#292c33, 53-79 #c5c9d2,#292c33, 80-87 #ecbf9b,#292c33, 88-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-37 #f8cba7,#373e49, 38-49 #d1d5de,#373e49, 50-51 #ffffff,#55647b, 52-52 d,#2a2d34, 53-79 #c5c9d2,#2a2d34, 80-87 #ecbf9b,#2a2d34, 88-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-37 #ecbf9b,#292c33, 38-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-37 #ecbf9b,#2a2d34, 38-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-37 #e2bedf,#283730, 38-49 #c0d0c7,#283730, 50-51 #ffffff,#4a665a, 52-52 d,#1e2926, 53-79 #b4c4bb,#1e2926, 80-87 #d6b2d3,#1e2926, 88-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-37 #d6b2d3,#1e2926, 38-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-37 #dfbce5,#26362d, 38-49 #becfc4,#26362d, 50-51 #ffffff,#496656, 52-52 d,#1d2824, 53-79 #b2c3b8,#1d2824, 80-87 #d3b0d9,#1d2824, 88-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-37 #d3b0d9,#1d2824, 38-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-37 #9c989c,#141615, 38-49 #989a99,#141615, 50-51 #ffffff,#2a2c2b, 52-52 d,#131414, 53-79 #979998,#131414, 80-87 #9a979b,#131414, 88-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-37 #9a979b,#131414, 38-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"); 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 diff --git a/vendor/queries/README.md b/vendor/queries/README.md new file mode 100644 index 00000000..082f4e6b --- /dev/null +++ b/vendor/queries/README.md @@ -0,0 +1,18 @@ +# Textobject queries + +`<grammar>/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. + +`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, 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/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 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 |
