diff --git a/CLAUDE.md b/CLAUDE.md index ed655b2e..a3a779b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,8 @@ Plugins depend on the **`sdk/` package** (its own `build.zig` + `build.zig.zon`) Pattern: - **Plugins** (built-in + third-party): `.fizzy = .{ .path = ".../sdk" }` locally, or the `fizzy-sdk-v*` **release asset** URL from the matching `sdk-v*` tag (not the git archive — that is the monorepo root zon with Velopack). Call `fizzy.plugin.create` / `.install` as before; `b.dependency("fizzy", .{ .plugin_sdk = true })` still works (the option is accepted and ignored — `sdk/` always exports modules). Packing: `scripts/pack-sdk.sh` / `.github/workflows/sdk-tag.yml`. -- **App**: repo-root `zig build` as usual. Velopack stays `.lazy = true` in the root zon; never `@import("velopack_zig")` — the helper surface is vendored in `build/velopack.zig` and resolved only in `build/app.zig` via `lazyDependency`. +- **App**: repo-root `zig build` as usual. The app **consumes `sdk/` as a dependency** (`.fizzy_sdk = .{ .path = "sdk/" }`), so build scripts reach `plugin`/`core_module`/`sdk_version` through `@import("fizzy_sdk")` and never by relative path into `sdk/` — a file may belong to only one module, so a path import claims it for the root build module and breaks the dependency outright. The same applies in reverse: nothing under `src/` may relative-import an `sdk/` file. Velopack stays `.lazy = true` in the root zon; never `@import("velopack_zig")` — the helper surface is vendored in `build/velopack.zig` and resolved only in `build/app.zig` via `lazyDependency`. +- **dvui is pinned in exactly one place — `sdk/build.zig.zon` — and is deliberately absent from the root zon.** The app borrows it via `build/sdk.zig`'s `dvuiDependency` (which forwards backend/target/optimize normally), and build scripts get dvui's build API from `@import("fizzy_sdk").dvui`. Do **not** "fix" the missing root dep by re-adding `.dvui`: two pins that drift make `recorded_sdk_shape_fingerprint` unsatisfiable by *both* the app and plugin-SDK builds at once, and the resulting error tells you to bump `sdk_version`, which cannot help. Bump or swap to a local checkout in `sdk/build.zig.zon` only. - Shared `core` import wiring lives in `sdk/core_module.zig` and is called from the app build *and* `sdk/plugin_sdk.zig`'s `exportModules` so the import set can't drift. Note the `with_tui = false` on the zf dependency: without it, zf's standalone terminal binary drags `libvaxis` into every plugin build. Acceptance test after any build-graph change: diff --git a/build.zig b/build.zig index 9d9c3b9a..d4dc3ac3 100644 --- a/build.zig +++ b/build.zig @@ -2,7 +2,11 @@ const std = @import("std"); /// App-side re-export of the plugin build API (lives in `sdk/`). Plugins should depend on /// the `sdk/` package directly — see CLAUDE.md — not this root package. -pub const plugin = @import("sdk/plugin_sdk.zig"); +/// +/// Reached through the dependency rather than by path (`sdk/plugin_sdk.zig`): the app consumes +/// `sdk/` as a package so the two can share one dvui pin, and a file may belong to only one module, +/// so claiming these for the root's build module would make that impossible. +pub const plugin = @import("fizzy_sdk").plugin; pub fn build(b: *std.Build) !void { const windows_msvc_libc_opt = b.option([]const u8, "windows-msvc-libc", "zig libc manifest for *-windows-msvc when cross-compiling; forwarded by packageall for Windows children") orelse null; diff --git a/build.zig.zon b/build.zig.zon index 7ae71f4d..2166cf44 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -29,10 +29,8 @@ .hash = "icons-0.0.0-iJxA-VvGMwAgiKSXRe_Y0O7RpasdtEJhBfVx8IGGEBl_", .lazy = true, }, - .dvui = .{ - .url = "https://github.com/foxnne/dvui-dev/archive/ed2f1c67f0316184783c8dba7d79ed4c49d26f97.tar.gz", - .hash = "dvui-0.5.0-dev-AQFJmX1d_QA2wHjWCweU26ZxqIrA9LwWeysGFbfVMc7y", - //.path = "../dvui-dev", + .fizzy_sdk = .{ + .path = "sdk/", }, .assetpack = .{ .url = "https://github.com/foxnne/assetpack/archive/ac7592f3f5988857840d0df4610e1e1fad690e2e.tar.gz", diff --git a/build/app.zig b/build/app.zig index 8ca112c8..41c61041 100644 --- a/build/app.zig +++ b/build/app.zig @@ -1,8 +1,12 @@ const std = @import("std"); -const plugin = @import("../sdk/plugin_sdk.zig"); -const core_mod = @import("../sdk/core_module.zig"); -const dvui = @import("dvui"); +// Through the `sdk/` dependency, not by relative path — see `build/sdk.zig`'s `dvuiDependency` for +// why the app consumes the SDK as a package, and `sdk/build.zig` for what it exposes. dvui's build +// API arrives the same way because `sdk/` owns the repo's only dvui pin. +const fizzy_sdk = @import("fizzy_sdk"); +const plugin = fizzy_sdk.plugin; +const core_mod = fizzy_sdk.core_module; +const dvui = fizzy_sdk.dvui; const velopack = @import("velopack.zig"); pub const Options = struct { @@ -367,6 +371,10 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil // below never reaches it (nothing in the graph forces `sdk.manifest`), so it // needs its own root either way. .{ "fizzy-sdk-manifest-tests", "src/sdk/manifest.zig" }, + // The `[[wikilink]]` tokenizer. std-only on purpose: it's shared verbatim by the + // markdown renderer and by out-of-tree indexers, so it must not depend on dvui or + // anything else the SDK-rooted artifact drags in. + .{ "fizzy-sdk-wikilink-tests", "src/sdk/services/wikilink.zig" }, // The text plugin's headless editing model. Lives under src/plugins/ but is // deliberately dvui-free (see textcore.zig), so it tests as pure logic from the // app build. One root covers every file below it — they're relative imports. @@ -383,9 +391,16 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil // Sniffing image bytes stb can't decode (SVG badges), so the preview never re-enters // stbi for them every frame. std-only, same reasoning as the two above. .{ "fizzy-md-image-format-tests", "src/plugins/markdown/src/md/image_format.zig" }, + // The markdown preview's block height table — placement, height trust, and the + // never-blank visible-range guarantee. std-only by design (see block_heights.zig) so + // the rules the preview's scroll stability rests on are testable without a Window. + .{ "fizzy-md-block-heights-tests", "src/plugins/markdown/src/md/block_heights.zig" }, // Content-swap reveal phase machine. std-only by design (see reveal.zig) — the dvui // half is the thin wrapper in core/dvui.zig. .{ "fizzy-reveal-tests", "src/core/reveal.zig" }, + // Ring buffering and dot-segment filtering for the folder watcher. std-only so it can + // be tested here; FolderWatcher.zig itself needs a live editor. + .{ "fizzy-folder-events-tests", "src/editor/folder_events.zig" }, }) |entry| { try unit_test_artifacts.append(b.allocator, b.addTest(.{ .name = entry[0], @@ -448,7 +463,7 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil return; } - const dvui_testing_dep = b.dependency("dvui", .{ + const dvui_testing_dep = sdk.dvuiDependency(b, .{ .target = target, .optimize = optimize, .backend = .testing, @@ -481,7 +496,12 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil const icons_test = core_mod.addImports(b, core_module_test, dvui_testing_dep.module("dvui_testing"), target, optimize); fizzy_test_module.addImport("core", core_module_test); if (icons_test) |icons| fizzy_test_module.addImport("icons", icons); - if (b.lazyDependency("nightwatch", .{ .target = target, .optimize = optimize })) |dep| { + // See `exe.zig` for why macOS needs the FSEvents backend. + const nightwatch_test_dep = if (target.result.os.tag == .macos) + b.lazyDependency("nightwatch", .{ .target = target, .optimize = optimize, .macos_fsevents = true }) + else + b.lazyDependency("nightwatch", .{ .target = target, .optimize = optimize }); + if (nightwatch_test_dep) |dep| { fizzy_test_module.addImport("nightwatch", dep.module("nightwatch")); } @@ -499,7 +519,7 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil .sdk = sdk_module_test, .icons = icons_test, }, fizzy_test_module); - _ = plugins.markdown.addStaticModule(b, target, optimize, .{ + const markdown_module_test = plugins.markdown.addStaticModule(b, target, optimize, .{ .dvui = dvui_testing_dep.module("dvui_testing"), .core = core_module_test, .sdk = sdk_module_test, @@ -536,6 +556,13 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil // built above rather than rooting a second one at the widget — a file may belong to only // one module per compilation, and the plugin's own module already owns it. integration_module.addImport("text", text_module_test); + // Same reasoning for the markdown preview: its block virtualization is a claim about what + // gets *drawn*, which only a real headless frame can check. + integration_module.addImport("markdown", markdown_module_test); + integration_module.addAnonymousImport("markdown_sample", .{ .root_source_file = b.path("docs/PLUGINS.md") }); + // The document with the 45KB table — the case table-row culling exists for, and the one it + // could get wrong. + integration_module.addAnonymousImport("markdown_sample_tables", .{ .root_source_file = b.path("docs/PLUGIN_MANIFEST_PLAN.md") }); const integration_tests = b.addTest(.{ .name = "fizzy-integration-tests", @@ -596,6 +623,37 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil bench_step.dependOn(&run_bench.step); } + // `zig build bench-markdown` — markdown preview frame-cost benchmark. Same rules as + // `bench-text` above: its own step, prints timings instead of asserting, only comparable at + // equal `-Doptimize` (cmark and freetype build at the app's optimize level). + { + const bench_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("tests/bench/bench_markdown.zig"), + }); + bench_module.addImport("dvui", dvui_testing_dep.module("dvui_testing")); + bench_module.addImport("markdown", markdown_module_test); + // This repo's own docs, as anonymous imports rather than checked-in fixtures — the same + // reasoning as `bench-text`'s samples. `PLUGINS.md` is the document that prompted the + // benchmark. + bench_module.addAnonymousImport("sample_huge", .{ .root_source_file = b.path("docs/PLUGINS.md") }); + bench_module.addAnonymousImport("sample_prose", .{ .root_source_file = b.path("docs/PLUGIN_MANIFEST_PLAN.md") }); + bench_module.addAnonymousImport("sample_medium", .{ .root_source_file = b.path("CLAUDE.md") }); + bench_module.addAnonymousImport("sample_small", .{ .root_source_file = b.path("docs/MODULARIZATION_RELEASE_NOTES.md") }); + + const bench_markdown = b.addTest(.{ .name = "fizzy-bench-markdown", .root_module = bench_module }); + bench_markdown.root_module.link_libcpp = !target_is_windows_msvc; + if (target.result.os.tag == .windows) { + bench_markdown.root_module.linkSystemLibrary("comctl32", .{}); + } + + const bench_step = b.step("bench-markdown", "Benchmark the markdown preview's per-frame draw cost (prints timings)"); + const run_bench = b.addRunArtifact(bench_markdown); + run_bench.has_side_effects = true; + bench_step.dependOn(&run_bench.step); + } + // Pure-logic tests that nevertheless sit in a file importing `dvui` (or the SDK) // can't join the unit layer, so they get their own roots here. Rooting at // `src/sdk/sdk.zig` collects every SDK file reachable from it by relative diff --git a/build/common.zig b/build/common.zig index 17b974d2..cc759410 100644 --- a/build/common.zig +++ b/build/common.zig @@ -1,6 +1,6 @@ const std = @import("std"); -const plugin = @import("../sdk/plugin_sdk.zig"); +const plugin = @import("fizzy_sdk").plugin; const update = @import("../update.zig"); const GitDependency = update.GitDependency; diff --git a/build/exe.zig b/build/exe.zig index 92ab2269..9212704a 100644 --- a/build/exe.zig +++ b/build/exe.zig @@ -1,9 +1,10 @@ const std = @import("std"); -const dvui = @import("dvui"); +// dvui's build API via the SDK package, which owns the repo's only dvui pin. +const dvui = @import("fizzy_sdk").dvui; // Vendored Velopack glue — see build/velopack.zig header (never `@import("velopack_zig")`). const velopack = @import("velopack.zig"); -const plugin = @import("../sdk/plugin_sdk.zig"); -const core_mod = @import("../sdk/core_module.zig"); +const plugin = @import("fizzy_sdk").plugin; +const core_mod = @import("fizzy_sdk").core_module; const common = @import("common.zig"); const plugins = @import("plugins.zig"); const sdk = @import("sdk.zig"); @@ -79,7 +80,7 @@ pub fn addFizzyExecutableForTarget( velopack_enabled: bool, ) !FizzyExecutable { const dvui_dep = if (macos_sdl_paths) |p| - b.dependency("dvui", .{ + sdk.dvuiDependency(b, .{ .target = resolved_target, .optimize = optimize, .backend = .sdl3, @@ -89,9 +90,9 @@ pub fn addFizzyExecutableForTarget( .library_path = p.lib, }) else - b.dependency("dvui", .{ .target = resolved_target, .optimize = optimize, .backend = .sdl3, .accesskit = accesskit }); + sdk.dvuiDependency(b, .{ .target = resolved_target, .optimize = optimize, .backend = .sdl3, .accesskit = accesskit }); - const dvui_proxy_dep = b.dependency("dvui", .{ + const dvui_proxy_dep = sdk.dvuiDependency(b, .{ .target = resolved_target, .optimize = optimize, .backend = .proxy, @@ -149,7 +150,15 @@ pub fn addFizzyExecutableForTarget( }); _ = core_mod.addImports(b, core_proxy_module, dvui_proxy_mod, resolved_target, optimize); - if (b.lazyDependency("nightwatch", .{ .target = resolved_target, .optimize = optimize })) |dep| { + // `macos_fsevents` is load-bearing for `FolderWatcher`: it watches a whole project folder, + // and the kqueue fallback needs a file descriptor per directory *and* per file — exactly the + // shape that exhausts the fd limit on a real repo. FSEvents covers the subtree with one + // stream. The option only exists when nightwatch is built for macOS, hence the split. + const nightwatch_dep = if (resolved_target.result.os.tag == .macos) + b.lazyDependency("nightwatch", .{ .target = resolved_target, .optimize = optimize, .macos_fsevents = true }) + else + b.lazyDependency("nightwatch", .{ .target = resolved_target, .optimize = optimize }); + if (nightwatch_dep) |dep| { exe.root_module.addImport("nightwatch", dep.module("nightwatch")); } diff --git a/build/sdk.zig b/build/sdk.zig index 5e1f0cba..cb1e9f02 100644 --- a/build/sdk.zig +++ b/build/sdk.zig @@ -1,5 +1,29 @@ const std = @import("std"); +/// The repo's one dvui, borrowed from the `sdk/` package instead of pinned by the app. +/// +/// dvui is not a dependency of the root package at all: `sdk/build.zig.zon` declares the only pin +/// and this reaches through to it, so there is a single place to bump a version or point at a local +/// checkout. `args` is forwarded to dvui's own build untouched (backend, target, optimize, …), so +/// callers keep full control of *how* it is built; only *which* dvui is shared. +/// +/// Worth the indirection because the two are not free to disagree. dvui types reachable from the +/// plugin boundary feed `dylib.sdk_shape_fingerprint`, which both the app build and the plugin-SDK +/// build check against the single `recorded_sdk_shape_fingerprint` literal in `src/sdk/version.zig`. +/// When each build compiled a different dvui, they computed different fingerprints from that one +/// literal and no value satisfied both — every fix broke the other side, and the error blamed +/// `sdk_version`, which a bump cannot repair. One pin makes that state unreachable rather than +/// merely discouraged. +/// +/// The direction is forced: `sdk/` ships standalone as `fizzy-sdk-v*.tar.gz` for third-party +/// plugins, so it must carry its own pin and can never read anything above its own root. The app +/// can always reach down into it. +pub fn dvuiDependency(b: *std.Build, args: anytype) *std.Build.Dependency { + // Only the SDK package's resolved dependency table is wanted here, not its artifacts, so its + // own target/optimize are left at default; `args` carries the target dvui is really built for. + return b.dependency("fizzy_sdk", .{}).builder.dependency("dvui", args); +} + pub fn addProxyBridgeModule( b: *std.Build, target: std.Build.ResolvedTarget, diff --git a/build/web.zig b/build/web.zig index 4aa432ad..ceb26df5 100644 --- a/build/web.zig +++ b/build/web.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const core_mod = @import("../sdk/core_module.zig"); +const core_mod = @import("fizzy_sdk").core_module; const plugins = @import("plugins.zig"); const sdk = @import("sdk.zig"); @@ -24,7 +24,7 @@ pub fn addSteps( }), }); - const dvui_web_dep = b.dependency("dvui", .{ + const dvui_web_dep = sdk.dvuiDependency(b, .{ .target = web_target, .optimize = optimize, .backend = .web, diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 9ff965b4..9a78748a 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -481,6 +481,8 @@ plugin gets an Enabled-toggle-only row instead of its fields. domain work *inside* these generic phases (see the lifecycle table below for exactly when each fires). - **Folder lifecycle** — `onFolderClose` / `onFolderOpen`. +- **Filesystem** — `folderPathsChanged` (files changed on disk under the open folder, from Fizzy's + own recursive watch; see below). - **Save protocol** — `saveNeedsConfirmation(doc)` + `requestSaveConfirmation(doc, mode, …)`. - **Contributions** — `contributeMenu`, `contributeKeybinds`. - **New document** — `requestNewDocumentDialog`. @@ -523,10 +525,56 @@ paired `host.*` request. Call sites are in `src/editor/Editor.zig` (verify line | `drawOverlay` | broadcast | right after `tickKeybinds`, on top of the frame | Outside the frame loop: `onFolderClose` / `onFolderOpen` fire `[broadcast]` from -`setProjectFolder` / `closeProjectFolder`; `saveNeedsConfirmation` / `requestSaveConfirmation` +`setProjectFolder` / `closeProjectFolder`; `documentContentChanged` fires `[broadcast]` from +`host.notifyDocumentContentChanged`, which a document's **owner** calls when its buffer settles +after an edit (debounced — a lull in typing, and on save; never per keystroke). That hook is how a +plugin that owns no documents observes *unsaved* text: nothing else in the SDK exposes another +plugin's live buffer. Treat it as an overlay on what's on disk, not a reason to write anything +through. `saveNeedsConfirmation` / `requestSaveConfirmation` fire `[active-doc]` from the save / close / quit-all paths; `loadDocument` runs on a **background load-worker thread** (touch only the host allocator + the given buffer, no dvui). +#### `folderPathsChanged` — on-disk changes under the open folder + +`documentContentChanged` covers buffers *this editor* has open. `folderPathsChanged` covers the +rest of the tree: a note an agent wrote, a `git checkout`, a file deleted in Finder. It fires +`[broadcast]` from `FolderWatcher.tick` on the UI thread, with a coalesced batch: + +```zig +fn folderPathsChanged(state: *anyopaque, changes: sdk.Plugin.PathChanges) void { + const st: *State = @ptrCast(@alignCast(state)); + if (changes.truncated) return st.rescanEverything(); + for (changes.events) |e| switch (e.kind) { + .created, .modified => st.reindex(e.path), + .deleted => st.forget(e.path), + .renamed => { st.forget(e.old_path); st.reindex(e.path); }, + }; +} +``` + +Fizzy runs **one** watch over the folder and hands out the results, so a plugin that cares about +files does not pin a watcher library or stand up a thread of its own. Four things about the +contract are worth knowing before you rely on it: + +- **The slices live for the call only.** `changes`, every `event.path`, and every `old_path` are + borrowed. Copy anything you keep. +- **Already filtered.** Events are run through Fizzy's `IgnoreRules` first, so `.git`, build + output and gitignored paths never arrive. You do not need to re-derive that with + `host.isPathIgnored`. +- **`truncated` means "go look".** More changed than Fizzy could buffer, so `events` is an + incomplete picture — expect it during a build or a branch switch. A consumer that must not miss + anything should rescan rather than trust the list. +- **A rename may arrive as delete + create.** `.renamed` with `old_path` set is a best case + (Linux, Windows); elsewhere the two halves are separate events, so handle that shape regardless. + Likewise `event.object` can be `.unknown` when the object was already gone by the time Fizzy + looked. + +`host.folderWatchActive()` says whether a watch is actually running — false with no folder open, +on wasm, and when the platform watch could not start. A plugin that must stay correct either way +should keep a slow periodic rescan and simply stretch its interval when this returns true, rather +than dropping the fallback: "the watcher started" and "the watcher is still delivering" are +different claims, and the backends differ per platform. + ### 3.3 Reaching Fizzy: SDK-held injection, no storage file Plugin code can't import Fizzy, so Fizzy **injects pointers** into the plugin once at @@ -535,7 +583,7 @@ catches them into the SDK itself, so your code just reads: - **`sdk.allocator()`** — the persistent host allocator. - **`sdk.host()`** — Fizzy's `*Host`: registries, services, and the `EditorAPI` read surface - (open folder, active doc, arena allocator, save dialogs). + (open folder, active doc, arena allocator, save dialogs, `folderWatchActive()`). - **`sdk.refresh()`** — wake the app event loop for another frame. **Safe from any thread** (LSP workers, load jobs, PTY readers). Call this when background work finishes and the UI may be idle with no mouse/keyboard events — otherwise a sleeping draw loop will not pick up @@ -747,6 +795,43 @@ You do not need to handle JSON-RPC framing, threading, request/response id corre position-encoding negotiation, or server-initiated requests yourself — all of that is generic LSP-spec behavior `core.lsp.Client` already implements once, for every server. +### 3.10 Inter-plugin services + +`registerService(name, ptr, owner)` publishes an API under a string name; +`host.getServiceTyped(SomeApi)` looks it up by that API type's `service_name`. Fizzy stores only +an `*anyopaque` — it never interprets a service — so the API struct's *layout* is part of the ABI +fingerprint and every service type used across dylibs is listed in `dylib.zig`'s +`sdk_boundary_types`. + +The SDK ships definitions for the services plugins in this ecosystem publish, in +[`src/sdk/services/`](../src/sdk/services/): + +| Service | Provider | What it's for | +|---|---|---| +| `"workbench"` | `workbench` | Open/close/save documents, enumerate open tabs, file-tree operations, `revealPosition` | +| `"markdown"` | `markdown` | Render a markdown byte slice into the current dvui parent (native only — absent on web) | +| `"wikilink"` | any indexer (e.g. `brain`) | Resolve `[[Note]]` to a file, plus completion candidates and index state | + +**Every lookup must tolerate absence.** A service's provider may be uninstalled, disabled, or +simply not built for this target — `markdown` is missing on web, and `wikilink` is missing unless +the user installed an indexer. The idiom is one line, and the fallback is a real behavior, not an +error path: + +```zig +const wl = sdk.host().getServiceTyped(sdk.services.wikilink.Api) orelse { + // No resolver: `[[Note]]` is just text. Render it verbatim. + return renderPlain(literal); +}; +``` + +**`wikilink` splits into a pure half and a service half**, which is worth copying if you define a +service of your own. `wikilink.tokenize` — *what is a link* — is a plain function in the SDK, +compiled into both the renderer and the indexer, so the two can never disagree about the syntax. +Only *which file does this link mean* goes through the vtable, because only that needs an index. +Resolution results are memoized by the caller against `wikilink.generation()`, which is what makes +a link flip from broken to live when its target file appears — with no edit to the linking +document, and so no re-parse of it. + --- ## 4. Two plugins working together (`pixi` + `workbench`) @@ -1026,6 +1111,7 @@ drop straight into the plugins directory, exactly like §2.6. | `src/sdk/settings.zig` | Comptime settings API (`sdk.settings.Schema(T)`) — see §3.1.1 | | `src/editor/SettingsPluginsZon.zig` | ZON-AST byte-span surgery for `settings.zon`'s merged `.plugins.` fields — fizzy-only, not part of the SDK | | `src/editor/SettingsWatcher.zig` | Thin nightwatch adapter for live external `settings.zon` / dropped-in plugin reconciliation (see above) — fizzy-only, not part of the SDK | +| `src/editor/FolderWatcher.zig`, `folder_events.zig` | Recursive watch on the open folder, fanned out to plugins as `folderPathsChanged` (§3.2). The only watcher adapter whose output leaves fizzy; nightwatch stays behind the hook so it can be swapped per platform. `folder_events.zig` is the std-only buffering/filtering half, split out so it can be unit-tested | | `sdk/plugin_sdk.zig` | `fizzy.plugin.create` / `.install` / `.addCModule` — the build-side API a plugin's `build.zig` calls | | `src/plugins/text/` | Canonical document-owning editor plugin — copy to start a new editor plugin | | `src/plugins/image/` | Read-only image viewer (PNG/JPG/JPEG) with zoom/pan | diff --git a/docs/PLUGIN_MANIFEST_PLAN.md b/docs/PLUGIN_MANIFEST_PLAN.md index d5259f26..346cf59d 100644 --- a/docs/PLUGIN_MANIFEST_PLAN.md +++ b/docs/PLUGIN_MANIFEST_PLAN.md @@ -42,6 +42,9 @@ | R18 — publisher/author split + probe consolidation | done | 2026-07-30 — attribution was a single hand-typed `registry/.json` `author` string with **no fallback and no validation**, which had already silently drifted: `pixi`/`ghostty`/`zig` all read `"author": "foxnne"` while their `homepage` pointed at the `fizzyedit` org, and nothing cross-checks the two. Split into the two claims that were being conflated. **`publisher`** — derived at ingest by `ingest.publisherFromUrl` from `manifest_url`'s GitHub owner, i.e. from where the binary is actually served; not writable by any plugin, null for a self-hosted manifest the heuristic can't attribute (the store then shows the author alone rather than inventing one). New `plugins.publisher` column + `summary.json` field + client `SummaryEntry.publisher`. **`author`/`author_url`** — new *cosmetic* `Manifest` fields in `plugin.zig.zon`, self-asserted, with the usual registry→builtin→probe fallback; `author_url` rides the catalog too so an *uninstalled* store plugin's credit is still clickable (no local dylib to probe yet). Rendered by `drawAuthorLine` as `publisher · author`, each linked only where there's somewhere real to go — publisher's link is built from the publisher name itself, never from an author-supplied URL. **Security:** `author_url` reaches `dvui.openURL` → the OS URL handler, so `isSafeExternalUrl` restricts it to `http`/`https`; a `file:`/custom-scheme URL in a store-listed manifest would otherwise launch an arbitrary registered handler. **Probe consolidation (the reason this stayed small):** adding two more fields would have meant a 4th and 5th `probeX` + parallel cache, each re-`dlopen`ing the *same* embedded zon. Replaced `probeDescription`/`probeTags` with one `PluginLoader.probeManifestInfo` → `ProbedManifest`, `builtinDescription`/`builtinTags` with one `Editor.builtinManifest`, and `description_cache`+`tags_cache` with one `manifest_cache` — one dlopen + parse now serves description/tags/author/author_url. **Also fixed, found while doing this:** `sdk.manifest.parse` used strict `std.zon.parse`, so **every** field ever added to `Manifest` was a breaking change in two directions — a plugin declaring a newer field would fail to *build* against an older pinned SDK, and an older fizzy probing a newer plugin's embedded manifest would lose data it could otherwise read. Now `ignore_unknown_fields = true`, making the format forward-compatible by construction (tradeoff: a misspelled field is ignored, not diagnosed). Pipeline: `read_plugin_zon.py`/`assemble_manifest.py`/`build.yml` carry `author`/`author_url` (env-routed, not `${{ }}`-interpolated); `pixi`/`ghostty`/`zig` `plugin.zig.zon`s updated and probe-verified out of their rebuilt dylibs. Verified: fizzy `zig build`/`test`/`test-sdk-version`/`check-web`/`test-integration`, store `zig build`/`test` (new `publisherFromUrl` tests confirmed executing via a deliberate-failure check), all three external plugins rebuilt. **Not verified:** the rendered `publisher · author` line and its links — UI, needs eyes on it. **Registry note:** `registry.db` is committed and `CREATE TABLE IF NOT EXISTS` won't alter it, so `db.migrate` gained best-effort `ALTER TABLE … ADD COLUMN` calls for `publisher`/`author_url`. | | Old Phase 2 (sidecar enforcement) | **cancelled** | superseded by this revision | | R16 — Store detail page: VSCode-marketplace-style header + tabs, `description` in `Manifest` | done | 2026-07-30 — the store's center-provider README view (only the center; the sidebar list is untouched) is now a full detail page. **Manifest:** `description: []const u8 = ""` added to `Manifest` (`src/sdk/manifest.zig`) — the identity-only lock from R2 is deliberately relaxed here, since the detail page needs a description for every plugin, not just ones with a registry entry; not part of `sdk_boundary_types` (never crosses the C-ABI boundary, only ever `std.zon.parse`d from `plugin.zig.zon` text), so no SDK version/fingerprint bump. All 4 built-in `plugin.zig.zon`s got real one-liners. **Description resolution** (`PluginStore.descriptionFor`): registry's own (freshest) → `Editor.builtinDescription` (built-ins read their own compiled-in `plugin_options.manifest_zon` directly, no dylib involved) → `PluginLoader.probeDescription` (new, mirrors `probeName`: opens the on-disk dylib, reads the embedded `fizzy_plugin_manifest_zon` export, parses it) for anything else. **Header** (`drawDetailHeader`): logo (same fetch-or-fallback chain the card list uses) + a stacked name (`.heading` font, matching `SettingsTree`'s root-branch style)/id (small dim mono)/author (dim)/description (wrapped) column, with the existing `drawCardControls` (install/update/uninstall) reused as-is, right-justified. **Tabs** (`drawDetailTabs`): a plain two-tab DETAILS/CHANGELOG strip — same selected/unselected color convention every other tab bar in the app uses, but no drag/drop or scroll area (there are only ever two). Reconstructing the selected plugin's `StoreEntry` for the header needed its own helper (`selectedEntry`), since the center provider draws independently of the sidebar's list-building pass and registry data is only valid while the catalog lock is held for that one frame — same acquire/release-per-frame discipline the list already follows. **Background:** the README view's old rounded `sdk.pane_layout.emptyStateCard` (meant for a genuinely empty hint screen) is now `sdk.pane_layout.mainCanvasVbox` — a plain flat fill, the same background every other content pane in the app uses. **CHANGELOG tab** is a placeholder empty state ("Changelog coming soon") — real GitHub Releases fetching (per-release notes) is out of scope for this pass, by explicit choice. **Install counts** are out of scope entirely — there is no backend/analytics service to source them from; revisit once one exists. Verified: `zig build`, `zig build test`, `zig build test-sdk-version`, `zig build check-web` all clean; a live isolated-`HOME`/`TMPDIR` run rendered the header/tabs/flat-background README correctly end-to-end (ghostty, registry description + README both showing, "No compatible build in store" control state correct for an uninstalled entry). | +| R19 — `wikilink` service + `documentContentChanged` broadcast + markdown/text consumers | done | 2026-08-04 — the SDK seam for `[[wikilink]]` support, so an out-of-tree indexer (`brain`) can resolve links that the in-tree `markdown` renderer draws, without either importing the other. **New `src/sdk/services/wikilink.zig`**, split deliberately in two: (1) a **pure tokenizer** (`Token`, `tokenize`, `tokenizeAlloc`) — *what is a link* — living in the SDK rather than in either plugin, because a renderer and an indexer that disagree about the syntax produce graph edges the preview never drew (or vice versa); one implementation, one test suite (18 cases: alias/heading/block-id/embed forms, empty and unterminated targets, newline rejection, span recovery, out-buffer bounds, `tokenizeAlloc` parity). (2) an **`Api` vtable** — *which file does this link mean* — `resolve` / `generation` / `complete` / `indexing`. `complete` and `indexing` ship unused on day one on purpose: every field added later is another fingerprint bump that breaks every installed plugin. `resolve` is allocator-in/allocation-out rather than returning borrowed slices, because a background reindex can invalidate the provider's own strings between the call and the end of the frame; callers pass a frame arena. The `generation` counter is what lets a consumer memoize resolution *and* still have a link flip from broken to live when its target file appears — resolution can't be precomputed at parse time, since the linking document's bytes don't change when the target is created. Unlike `workbench`/`markdown`, both ends of this service are plugins (fizzy only stores the `*anyopaque`), so a shape mismatch would be dylib-to-dylib and invisible to the host — hence `Api`, `Api.VTable`, `Resolution`, `Candidate`, and `Token` all get explicit `sdk_boundary_types` entries (the `CompletionItem` lesson again: slices and by-value reaches aren't followed by `hashType`). **New `Plugin.VTable.documentContentChanged`** + `Host.notifyDocumentContentChanged` (a plain Host method over `plugins.items` — no `EditorAPI` vtable entry needed, so `EditorAPI`'s shape is untouched): a `[broadcast]` an owner fires when its buffer settles, letting a plugin that owns no documents see *unsaved* text at all. Owners debounce (a typing lull, plus on save); consumers treat it as an overlay on disk state. Tokenizer tests are wired as their own `addTest` root (`fizzy-sdk-wikilink-tests` in `build/app.zig`'s pure-logic list — std-only by design, so it must not sit under the SDK-rooted artifact that drags in dvui). sdk **0.1.49** (fingerprint `0x45dc3739334bebb`).

**Consumers, same pass.** `markdown` now renders wikilinks, and this turned up a real hazard the design had only flagged as a risk: `cmark_parser_finish` ends with `cmark_consolidate_text_nodes` (`blocks.c`), which merges every adjacent TEXT run into one literal — and since `handle_backslash` represents an escape as its own little text node, `\[\[A]]` and `[[A]]` arrive at the renderer as **the same literal**. Tokenizing the literal alone therefore turns deliberately-escaped text into a live link, with nothing in the AST to tell them apart. What survives is position: `make_literal` (`inlines.c`) sets `start_line`/`start_column` unconditionally (no `CMARK_OPT_SOURCEPOS` needed — that option only governs HTML *output*), and consolidation keeps the first fragment's start while extending `end_column`. New `src/md/wikilink_scan.zig` uses that to read the node's original bytes back out of the source, re-applies cmark's own escape rule to produce (bytes, was-escaped) pairs, and — **only when those bytes match the literal exactly** — drops links whose opening brackets were flagged. On any drift (smart punctuation rewrote a quote, an entity expanded) it **fails open** and the link renders: a link that appears where the author wanted literal text is visible and correctable, one that silently vanishes is an afternoon lost. Fast path is one `memchr` for a backslash. Tested against the **real vendored cmark** via a new `zig build test` step in the markdown plugin's own standalone `build.zig` (16 cases — it can't join fizzy's pure-logic list like `html_images`/`url_join`, which are std-only by design, because the whole point is a claim about what cmark does). Code spans and fenced blocks need no handling at all and now have tests pinning that: both get their own node types and never reach a TEXT node. Link *labels* do need a guard (`insideLinkOrImage`), since `[see [[A]]](url)` puts that text under a LINK parent.

Resolution is memoized per node+token against the resolver's `generation()` and explicitly **not** stored beside the parse (`RenderState.wikilinks` holds positions only): `Preview.ensureParsed` caches by content hash, so a scan-time resolution would freeze "broken" forever — the linking document's bytes don't change when its target is finally created. `tryRevealFileUri` split into `parseFileUri` + `revealPath` so a resolved wikilink reveals a path directly instead of round-tripping through a `file://` URI it would immediately re-parse (percent-encoding a path with a space or `#` is exactly where that goes wrong). `PreviewOptions.document_path` threads the source file down; empty disables wikilinks entirely, which is what keeps the store's fetched-README pane from resolving `[[Note]]` against the user's own local files. `markdown.Api.RenderOptions` deliberately untouched. `text` fires the new broadcast from `Document.tickContentChanged` (300ms typing-quiescence debounce keyed on `history.topOpId()` — already changes on exactly the right events, and comparing two integers beats hashing the buffer every frame) plus immediately in `save`, returning "still pending" up through a new `tickOpenDocuments` so the app keeps drawing until the burst settles rather than idling with a notification owed.

Verified: `zig build`, `zig build test`, `zig build test-sdk-version`, `zig build check-web`, `zig build test-integration` clean; markdown's own 16 cmark-backed tests; text's standalone build. **Live on macOS** in an isolated `HOME`/`TMPDIR` sandbox: `pixi`/`zig`/`ghostty` rebuilt against 0.1.49 all load, and a `.md` full of `[[links]]` with **no resolver installed** renders byte-identically to before — every form plain text, code span and fence untouched, ordinary markdown links still live. **Not done:** no resolver plugin exists yet, so the resolved/ambiguous/unresolved render paths are untested against a real provider; `pixi`/`zig`/`ghostty` are pinned to the local SDK path and still need a released `sdk-v0.1.49` tarball plus their own re-release before store installs work. | +| R20 — host folder watch + `folderPathsChanged` broadcast | done | 2026-08-05 — the missing half of R19. `documentContentChanged` tells a plugin about buffers *this editor* has open; nothing told it about the rest of the tree, so a `[[wikilink]]` deleted from a file nobody had open stayed in `brain`'s graph indefinitely — nothing ever re-read that file. The first fix was a 15s poll inside brain, which is the wrong place for it: every plugin that cares about files would end up pinning a watcher library and standing up its own thread over the same tree. **New `src/editor/FolderWatcher.zig`** — the third nightwatch adapter in `src/editor/`, and the only one whose output leaves fizzy (`SettingsWatcher` reconciles `settings.zon`, `DocumentWatcher` reloads open tabs). Three things put it on the host side of the boundary rather than in each plugin: (1) **thread hop** — nightwatch calls its handler on its own thread, and plugins are dylibs, so a callback arriving on a thread the plugin never created, possibly mid-unload, is a crash; events are buffered and fan out from `tick` on the UI thread. (2) **ignore rules** — only fizzy knows them (`IgnoreRules`), so `.git`, build output and gitignored paths never reach a plugin instead of every plugin re-deriving the same filter through `Host.isPathIgnored` one path at a time. (3) **one watch** — three interested plugins would otherwise mean three threads and three sets of fds or event streams over one tree. **Nightwatch is deliberately not exposed**: it is an implementation detail behind `folderPathsChanged`, so it can be swapped, forked, or replaced with per-platform code without any plugin noticing — which matters, since its Windows behavior is unproven and its macOS default needs the `macos_fsevents = true` build option (wired conditionally in `build/exe.zig` and `build/app.zig`) because the kqueue fallback wants a file descriptor per directory *and* per file, and a project folder is exactly the shape that exhausts the fd limit.

**SDK surface.** `Plugin.VTable.folderPathsChanged` (`[broadcast]`) plus `PathEvent`/`PathChanges` on `Plugin`, and `Host.notifyFolderPathsChanged` over `plugins.items`. `folderWatchActive` needed a real `EditorAPI` vtable entry (unlike R19's notify, which was a plain `Host` method) because the answer lives in the editor, not the SDK — so `EditorAPI`'s shape moves and `Editor.fizzyFolderWatchActive` joins the vtable. `have_impl` is false on wasm and unsupported targets; `folderWatchActive` reports false there and with no folder open, so a plugin knows to keep its own fallback. Slices in the batch are borrowed for the call only, and `truncated` reports overflow rather than growing the buffer — a branch switch emits events by the tens of thousands, and the useful answer for a consumer at that point is "rescan", not a longer list it still has to walk. `.renamed` carries `old_path` only where the backend can pair the halves (Linux, Windows); elsewhere it arrives as delete + create, which the doc now says explicitly because a consumer has to handle that shape regardless. sdk **0.1.50** (fingerprint `0x80448d5960ab4849`).

**Threading.** The producer never allocates: two fixed ring buffers with a flat path arena (one arena rather than a slot per event, so a handful of deep paths can't crowd out everything else and no path length is a special case), swapped under the lock so the fan-out reads a buffer nothing else can touch and no plugin call ever runs with the lock held. The lock is a spin over `std.atomic.Mutex` rather than a blocking primitive, because blocking would mean `std.Io.Mutex` and therefore `dvui.io` on nightwatch's thread — precisely what the other two adapters' doc comments single out as not to be touched from a watcher callback; both critical sections are a bounded memcpy or a pointer swap. A 200ms coalesce window means one logical save arrives as one batch and a consumer reindexing a file finds it finished being written. A cheap dot-segment reject runs on the watcher thread before the lock (pure string work — no allocation, no host call) so a `git checkout` or a build churning `.zig-cache` can't fill the ring before the authoritative `IgnoreRules` pass gets to run on the UI thread. `stopWatch` tears the watcher down entirely instead of calling nightwatch's `unwatch`, which drops only the path it was given and not the subdirectories its recursive walk added — a folder switch would otherwise leak watches on the old tree.

**Testing.** The buffering and filtering are split into **`src/editor/folder_events.zig`** (`Ring`, `underDotSegment`) and wired as its own `addTest` root (`fizzy-folder-events-tests`), for the same reason `keymap.zig` and `reveal.zig` are: `FolderWatcher.zig` reaches `fizzy.zig` and dvui and can only be exercised through a live editor, while the bugs that would actually bite (an overrun on a path that doesn't fit, a filter that lets `.git` through) live in the std-only half. `Ring` is generic over the event enums rather than importing them, since `Plugin.zig` imports dvui and would drag the file back into the module whose tests never run. 8 cases, including both halves of a rename counted together against the arena, and `empty()` distinguishing nothing-happened from everything-was-dropped — `tick` leans on that, because a batch that truncated with zero surviving events still has to be broadcast.

**Consumer.** brain's `Watcher.zig` routes markdown paths straight to `Indexer.enqueue` (create/modify/delete are all "re-read this path" — the worker treats missing-on-disk as the delete) and falls back to a quiet sweep for the two things a path alone can't identify: directories (one `mv notes/ archive/` moves every note beneath it, which is the tree walk the sweep already does) and attachments (the media table is only rebuilt by a walk). `truncated` goes straight to a sweep. The periodic sweep **stays** rather than being deleted, stretched from 15s to 5min while `folderWatchActive()` — "the watcher started" and "the watcher is still delivering" are different claims, the backends differ per platform, and a silently dead one should cost a few minutes of staleness instead of a permanently wrong graph.

Verified: `zig build`, `zig build check`, `zig build test` (8 new tests), `zig build test-sdk-version`, `zig build test-integration` clean on macOS; brain rebuilt against 0.1.50 (`zig build`, 291 tests). **Not done:** Windows and Linux are untested end to end; `pixi`/`zig`/`ghostty` need a released `sdk-v0.1.50` tarball and their own re-release before store installs work. | +| R21 — app consumes `sdk/` as a package, one dvui pin | done | 2026-08-05 — dvui was pinned twice, in `build.zig.zon` and `sdk/build.zig.zon`, and the two were only kept equal by discipline. When they drifted the failure was unfixable rather than merely annoying: dvui types reachable from the plugin boundary feed `dylib.sdk_shape_fingerprint`, which the app build and the plugin-SDK build each check against the *single* `recorded_sdk_shape_fingerprint` literal in `src/sdk/version.zig` — so two different dvuis compute two different fingerprints from one literal and no value satisfies both. Every value that let fizzy build made brain fail and vice versa, and both blamed `sdk_version`, which a bump cannot repair. Nothing in the error named dvui.

**Fix: dvui is no longer a dependency of the root package at all.** `sdk/build.zig.zon` owns the only pin and the app borrows it through `build/sdk.zig`'s `dvuiDependency` (`b.dependency("fizzy_sdk", .{}).builder.dependency("dvui", args)`), which forwards `args` untouched so each of the 5 call sites keeps full control of backend/target/optimize — only *which* dvui is shared. The direction is forced, not chosen: `sdk/` ships standalone as `fizzy-sdk-v*.tar.gz` and can never reach above its own root, while the app can always reach down into it.

**What made this look impossible at first.** The obvious move — add `.fizzy_sdk = .{ .path = "sdk/" }` — fails immediately with `file exists in modules 'root.@build' and 'root.@dependencies.sdk'`, because the root build scripts reached into `sdk/` by *relative path* in seven places, which claims those files for the root's build module; a file may belong to only one module, so the same files cannot also be a dependency's. That reads like a structural prohibition but is only a spelling problem. `sdk/build.zig` now re-exports what the app needs (`plugin`, `core_module`, `sdk_version`, and dvui's *build* API, since the app can no longer `@import("dvui")` itself) and the six root-side importers (`build.zig`, `build/{app,exe,web,common}.zig`) go through `@import("fizzy_sdk")` instead. dvui's build surface turned out to be one decl deep — `AccesskitOptions` — so re-exporting cost nothing. The seventh crossing ran the other way and surfaced only after the first six were fixed: `src/plugins/shared/build/helpers.zig` read the version triplet from `../../../../sdk/sdk_version.zig`, putting an `sdk/` file into the root build module from below. Its sibling line 46 (`../../../sdk/manifest_identity.zig`) is `src/sdk/`, a different directory, and correctly left alone.

**The pin-drift guard that is no longer needed.** An earlier pass in this session built a `build/sdk_pins.zig` — semantic `.zon` pin comparison, local paths resolved from two different depths, comment-toggled URLs ignored, unit-tested, failing the configure with a message naming dvui instead of `sdk_version`. It is deleted along with its `fizzy-sdk-pins-tests` root: it made the deadlock *legible*, but one pin makes it unreachable, and a guard against a state that cannot occur is upkeep with no claim behind it. Recorded here because the diff only shows the deletion.

**Note for release.** `recorded_sdk_shape_fingerprint` is dvui-pin-dependent, so it changes when the pin is flipped between `../dvui-dev` and a release tarball — it currently reads `0xd2de25bab58a617a` (local checkout), where R20 above recorded `0x80448d5960ab4849` (tarball). That is expected and no longer ambiguous: there is one pin to flip and both builds always agree on the answer.

Verified: `zig build`, `zig build check`, `zig build check-web`, `zig build test` (264 pass), `zig build test-sdk-version` clean; brain rebuilt against the shared pin (`zig build`, 300 tests) — the fingerprint conflict that motivated this is gone; standalone `src/plugins/{text,workbench}` builds clean, confirming `helpers.zig`'s new named import doesn't reach the standalone plugin path. **Not done:** `src/plugins/markdown`'s standalone build still hits the pre-existing, unrelated module-graph conflict noted in R9. | | R17 — `tags` in `Manifest` + registry-side description/tags dedup | done | 2026-07-30 — closes the gap R16 left for `description`: `tags` couldn't be authored anywhere except a hand-typed `registry/.json` PR in the separate `fizzyedit/plugins` repo, so a plugin with no registry entry yet (or one whose author never filled tags in) had zero search surface for them. **`Manifest`** (`src/sdk/manifest.zig`): `tags: []const []const u8 = &.{}` added, same off-`sdk_boundary_types` treatment as `description` (no fingerprint bump). All 4 built-in `plugin.zig.zon`s got real tags. **Resolution chain** (`PluginStore.tagsFor`, mirrors `descriptionFor` exactly): registry's own → `Editor.builtinTags` (new, mirrors `builtinDescription`) → `PluginLoader.probeTags` (new, mirrors `probeDescription`; returns a caller-owned `[][]u8` via a small `dupeTags` helper, since a manifest's `tags` — unlike `description` — is an array, not a single string) → `tags_cache` (new, same `StringArrayHashMapUnmanaged` shape as `description_cache`, cleared at the same two call sites: `refreshDiskScan` and `deinit`). **`scoreEntry`** now calls `descriptionFor`/`tagsFor` instead of reading `entry.registry.?.{description,tags}` directly, so a built-in or locally-probed dylib's own prose/tags contribute to store search even with no registry entry at all — `author` is the one field left with no fallback, since it was never a `plugin.zig.zon` concept to begin with (attribution, not something a build declares about itself). **No new UI** — tags still have no display surface (chips, filter row) anywhere in the store; this pass is resolution-chain-only, matching what already existed for `description` before R16's header. **Registry-side dedup** (separate repos, coordinated in this pass since the whole point was "don't require authors to hand-duplicate description/tags"): `fizzyedit/plugin-build-action`'s `read_plugin_zon.py` now also reads `description`/`tags` off `plugin.zig.zon`; `build.yml`'s setup job exposes them as job outputs (routed through `env:` rather than direct `${{ }}` interpolation into the assemble-manifest shell step, since these are free-form author-controlled strings — direct interpolation would be a script-injection hole); `assemble_manifest.py` embeds `name`/`description`/`tags` at the top level of the author's `manifest.json` (previously just `{id, releases}`). `fizzyedit/plugins`'s `store/src/manifest.zig` (the *aggregator's* copy of the author-manifest shape, distinct from `sdk/manifest.zig`) gained matching `name`/`description`/`tags` fields; `ingest.zig`'s `upsertPlugin`/`upsertTags` now fall back to the fetched manifest's values when `registry/.json` leaves its own `description`/`tags` empty — registry entry still wins when both are set, so a maintainer can override the store-listed copy without waiting on a plugin release. `docs/manifest.example.json` and both repos' `README.md` updated. **Not done, left for the user:** this is an interface change to `plugin-build-action`'s `build.yml`/`assemble_manifest.py` — existing `release.yml` callers pin `uses: .../build.yml@v3`, and `build.yml`'s own auxiliary-checkout step hardcodes the matching `ref="v3"` literal for its own script checkout, so nothing picks this up until a **new `v4` tag is cut and pushed** (a shared-CI action, deliberately not done automatically) and each external plugin repo (`pixi`/`ghostty`/`zig`/`json`/`markdown`) bumps its own `release.yml` to `@v4`; no `registry/.json` PR was reauthored to drop its now-optional `description`/`tags` either (a per-plugin-author call, not this repo's to make). | --- diff --git a/sdk/build.zig b/sdk/build.zig index 65bd50ca..eb220010 100644 --- a/sdk/build.zig +++ b/sdk/build.zig @@ -2,7 +2,20 @@ //! app-only deps like Velopack never enter their zon graph — see CLAUDE.md. const std = @import("std"); +// Build-time surface of this package, for the app as well as third-party plugins. The app consumes +// `sdk/` as a dependency and reaches these through it (`@import("fizzy_sdk").plugin`) rather than by +// relative path: a file may belong to only one module, and importing these from the root build +// scripts by path would claim them for the root's build module and make this package unusable as a +// dependency of it. Going through the dependency is also what lets the app share this package's +// dvui instead of pinning its own — see `build/sdk.zig`'s `dvuiDependency`. pub const plugin = @import("plugin_sdk.zig"); +pub const core_module = @import("core_module.zig"); +/// dvui's *build* API (`AccesskitOptions` and friends), re-exported because this package owns the +/// only dvui pin in the repo, so the app cannot `@import("dvui")` on its own. +pub const dvui = @import("dvui"); +/// The SDK version triplet's single edit site. Built-in plugins' build glue reads it from here +/// rather than by relative path for the one-module-per-file reason above. +pub const sdk_version = @import("sdk_version.zig"); pub fn build(b: *std.Build) !void { const target = b.standardTargetOptions(.{}); diff --git a/sdk/build.zig.zon b/sdk/build.zig.zon index 329e3ce6..564643d6 100644 --- a/sdk/build.zig.zon +++ b/sdk/build.zig.zon @@ -22,9 +22,18 @@ .hash = "icons-0.0.0-iJxA-VvGMwAgiKSXRe_Y0O7RpasdtEJhBfVx8IGGEBl_", .lazy = true, }, + // The repo's ONLY dvui pin — bump or repoint it here and nowhere else. dvui is deliberately + // absent from the root zon: the app consumes this package and borrows this entry through + // `build/sdk.zig`'s `dvuiDependency`, and build scripts take dvui's build API from + // `sdk/build.zig`'s re-export. Do not add `.dvui` back to the root zon to "fix" that. + // + // It lives here rather than there because this directory ships standalone as + // `fizzy-sdk-v*.tar.gz` and cannot reach anything above its own root, while the app can + // always reach down into it. .dvui = .{ - .url = "https://github.com/foxnne/dvui-dev/archive/ed2f1c67f0316184783c8dba7d79ed4c49d26f97.tar.gz", - .hash = "dvui-0.5.0-dev-AQFJmX1d_QA2wHjWCweU26ZxqIrA9LwWeysGFbfVMc7y", + .url = "https://github.com/foxnne/dvui-dev/archive/7f0957ef9e2d81b200694b767a1c6459e1d62403.tar.gz", + .hash = "dvui-0.5.0-dev-AQFJmbiLAQHGQ1I2OLlY3BgbEn_N9p1bk82yqM-JbOcE", + //.path = "../../dvui-dev", }, .zf = .{ .url = "git+https://github.com/natecraddock/zf#c35c421f84895193246db06c40683c1a30e616ef", diff --git a/sdk/plugin_sdk_check.zig b/sdk/plugin_sdk_check.zig index 211e4c80..c2cc5f62 100644 --- a/sdk/plugin_sdk_check.zig +++ b/sdk/plugin_sdk_check.zig @@ -55,7 +55,6 @@ pub fn main(main_init: std.process.Init) !void { const want_fingerprint = try std.fmt.allocPrint(arena, "0x{x}", .{sdk.dylib.abi_fingerprint}); std.debug.print("pinned fizzy SDK: {s} abi_fingerprint: {s}\n", .{ want_version, want_fingerprint }); - std.debug.print("(plugin-build-action v3+ derives these from the built dylib — no need to copy them into release.yml)\n", .{}); const file = std.Io.Dir.cwd().openFile(main_init.io, args[1], .{}) catch |err| switch (err) { error.FileNotFound => { diff --git a/sdk/sdk_version.zig b/sdk/sdk_version.zig index 7435d017..e3dae229 100644 --- a/sdk/sdk_version.zig +++ b/sdk/sdk_version.zig @@ -22,5 +22,5 @@ const std = @import("std"); pub const sdk_version = std.SemanticVersion{ .major = 0, .minor = 1, - .patch = 48, + .patch = 49, }; diff --git a/src/App.zig b/src/App.zig index a1864f30..b417a23b 100644 --- a/src/App.zig +++ b/src/App.zig @@ -58,6 +58,15 @@ const start_options_base: dvui.App.StartOptions = .{ }, }; +/// macOS only: is the process image inside a `.app` bundle (as opposed to a loose +/// `zig-out/bin/fizzy` from `zig build run`)? Mirrors `auto_update.installLayoutSupported`'s +/// probe, minus its Velopack gating. +fn runningFromAppBundle(io: std.Io) bool { + var buf: [std.fs.max_path_bytes]u8 = undefined; + const n = std.process.executablePath(io, &buf) catch return false; + return std.mem.indexOf(u8, buf[0..n], ".app/") != null; +} + fn startOptions() dvui.App.StartOptions { var opts = start_options_base; @@ -70,6 +79,15 @@ fn startOptions() dvui.App.StartOptions { if (comptime builtin.target.cpu.arch != .wasm32) { opts.gpa = appAllocator(); const main_init = dvui.App.main_init orelse return opts; + // SDL's Cocoa backend implements SDL_SetWindowIcon as `[NSApp setApplicationIconImage:]`, + // i.e. it replaces the whole *application* icon while we run. That hands AppKit a finished + // bitmap, skipping the system treatment (rounded-rect backdrop, mask) it applies to the + // bundle's `.icns` — so the Dock icon visibly loses its background the moment fizzy + // launches. Inside a bundle the `.icns` is already the right icon; leave it alone. Loose + // dev builds have no bundle icon at all, so there we still want the runtime one. + if (comptime builtin.os.tag == .macos) { + if (runningFromAppBundle(main_init.io)) opts.icon = null; + } if (paths.configFolderZ(&pref_path_buf, main_init.io, fizzy.processEnviron(), ".")) |pref_path| { pref_path_len = pref_path.len; opts.pref_path = pref_path_buf[0..pref_path_len :0]; @@ -98,7 +116,7 @@ pub const dvui_app: dvui.App = .{ }; pub fn main(main_init: std.process.Init) !u8 { - std.log.info("Fizzy version {s}", .{build_opts.app_version}); + std.log.info("Fizzy version {s} ({s})", .{ build_opts.app_version, @tagName(@import("builtin").mode) }); if (comptime auto_update.impl) { // appRunHook handles Velopack's install/uninstall/firstrun CLI flags and @@ -132,11 +150,30 @@ fn logFn(comptime level: std.log.Level, comptime scope: @EnumLiteral(), comptime dvui.App.logFn(level, scope, format, args); } +/// `FIZZY_LOG_REFRESH=1` logs every `dvui.refresh` with the source location that asked for it. +/// +/// This answers the one question a profiler cannot: when the app will not go to sleep, a profile +/// shows where the time goes, but "who keeps asking for another frame" is a different question and +/// usually a different culprit. dvui already tracks it — this just exposes the switch, since fizzy +/// does not surface dvui's debug window. +/// +/// Expect a lot of output: it logs per refresh, per frame. Pipe it and count by source line; the +/// caller that appears on every single frame is the one keeping the app awake. +fn initRefreshLogFromEnv() void { + if (comptime @import("builtin").target.cpu.arch == .wasm32) return; + const raw = std.c.getenv("FIZZY_LOG_REFRESH") orelse return; + if (std.mem.eql(u8, std.mem.span(raw), "0")) return; + _ = dvui.debug.logRefresh(true); + std.log.info("refresh logging on (FIZZY_LOG_REFRESH)", .{}); +} + // Runs before the first frame, after backend and dvui.Window.init() pub fn AppInit(win: *dvui.Window) !void { // Snapshot the platform from DVUI's keybind selection. On native this is a // no-op; on wasm it tells `fizzy.platform.isMacOS()` what browser we're in. fizzy.platform.cacheFromWindow(win); + fizzy.hitch.initFromEnv(); + initRefreshLogFromEnv(); // Apply the macOS window chrome and install the Space monitor while the // window is still hidden (see startOptions: opts.hidden = true), so the @@ -245,6 +282,8 @@ pub fn AppDeinit(_: *dvui.Window) void { // Run each frame to do normal UI pub fn AppFrame() !dvui.App.Result { + fizzy.hitch.frameBegin(); + defer fizzy.hitch.frameEnd(); singleton.drainPending(); return try fizzy.editor.tick(); } diff --git a/src/core/core.zig b/src/core/core.zig index 36a8428f..f4545ebb 100644 --- a/src/core/core.zig +++ b/src/core/core.zig @@ -22,6 +22,8 @@ fn defaultTrackpadPinchRatio() f32 { // Shared infrastructure re-exports. pub const image = @import("gfx/image.zig"); pub const perf = @import("gfx/perf.zig"); +/// TEMPORARY frame-hitch profiler (`FIZZY_HITCH_MS`). +pub const hitch = @import("hitch.zig"); pub const water_surface = @import("gfx/water_surface.zig"); pub const math = @import("math/math.zig"); pub const fs = @import("fs.zig"); diff --git a/src/core/dvui.zig b/src/core/dvui.zig index 48761b43..0b4c0386 100644 --- a/src/core/dvui.zig +++ b/src/core/dvui.zig @@ -421,6 +421,16 @@ pub fn hovered(wd: *dvui.WidgetData) bool { return false; } +/// Rest fill for a control that should be invisible until hovered. +/// +/// `Color.transparent` is transparent *black*, and dvui's hover fade lerps straight (non +/// premultiplied) RGBA, so a `.transparent` -> `hover` fade dips through a dark wash before it +/// reaches the hover tint. That is invisible on near-black themes and jarring on saturated ones +/// (Strawberry). Reusing the hover colour's RGB at zero alpha makes the fade ramp alpha only. +pub fn hoverRestFill(hover: dvui.Color) dvui.Color { + return hover.opacity(0); +} + pub fn reorder(src: std.builtin.SourceLocation, init_opts: ReorderWidget.InitOptions, opts: dvui.Options) *ReorderWidget { var ret = dvui.widgetAlloc(ReorderWidget); ret.init(src, init_opts, opts); diff --git a/src/core/hitch.zig b/src/core/hitch.zig new file mode 100644 index 00000000..fdd32149 --- /dev/null +++ b/src/core/hitch.zig @@ -0,0 +1,84 @@ +//! TEMPORARY frame-hitch profiler. Env-gated (`FIZZY_HITCH_MS=`); zero cost when off. +//! +//! Records per-frame wall time plus a handful of named phase timers, and logs any frame that +//! exceeds the threshold with the phase breakdown. Used to attribute the "opening a large +//! markdown file stalls the UI" report to a specific phase rather than guessing. + +const std = @import("std"); +const perf = @import("gfx/perf.zig"); + +pub const Phase = enum { + watchers, + loading_jobs, + rebuild_workspaces, + plugin_hooks, + draw, +}; + +const phase_count = @typeInfo(Phase).@"enum".fields.len; + +pub var enabled: bool = false; +var threshold_ns: u64 = 16 * std.time.ns_per_ms; + +var frame_start: i128 = 0; +var phase_ns: [phase_count]u64 = @splat(0); +var frame_index: u64 = 0; + +pub fn initFromEnv() void { + if (comptime @import("builtin").target.cpu.arch == .wasm32) return; + const raw = std.c.getenv("FIZZY_HITCH_MS") orelse return; + const thresh = std.fmt.parseInt(u64, std.mem.trim(u8, std.mem.span(raw), " \t"), 10) catch return; + enabled = true; + threshold_ns = thresh * std.time.ns_per_ms; + std.log.info("hitch profiler on, threshold {d} ms", .{thresh}); +} + +fn now() i128 { + return perf.nanoTimestamp(); +} + +pub const Timer = struct { + phase: Phase, + start: i128, + + pub fn end(self: Timer) void { + if (!enabled) return; + phase_ns[@intFromEnum(self.phase)] +%= @intCast(now() - self.start); + } +}; + +pub fn begin(phase: Phase) Timer { + return .{ .phase = phase, .start = if (enabled) now() else 0 }; +} + +pub fn frameBegin() void { + if (!enabled) return; + frame_index +%= 1; + frame_start = now(); + phase_ns = @splat(0); +} + +pub fn frameEnd() void { + if (!enabled) return; + const total: u64 = @intCast(now() - frame_start); + if (total < threshold_ns) return; + var accounted: u64 = 0; + for (phase_ns[0 .. phase_count - 1]) |v| accounted +%= v; + std.log.info( + "hitch frame {d}: {d:.1} ms | watchers {d:.1} loading {d:.1} workspaces {d:.1} plugin_hooks {d:.1} draw {d:.1} other {d:.1}", + .{ + frame_index, + ms(total), + ms(phase_ns[@intFromEnum(Phase.watchers)]), + ms(phase_ns[@intFromEnum(Phase.loading_jobs)]), + ms(phase_ns[@intFromEnum(Phase.rebuild_workspaces)]), + ms(phase_ns[@intFromEnum(Phase.plugin_hooks)]), + ms(phase_ns[@intFromEnum(Phase.draw)]), + ms(total -| accounted -| phase_ns[@intFromEnum(Phase.draw)]), + }, + ); +} + +fn ms(ns: u64) f64 { + return @as(f64, @floatFromInt(ns)) / @as(f64, std.time.ns_per_ms); +} diff --git a/src/core/paths.zig b/src/core/paths.zig index 2b90b688..c46c2168 100644 --- a/src/core/paths.zig +++ b/src/core/paths.zig @@ -16,6 +16,51 @@ pub fn normalize(allocator: std.mem.Allocator, path: []const u8) ![]u8 { return std.fs.path.resolve(allocator, &.{path}); } +/// True when `normalize(path)` would return `path` byte-for-byte, decided without allocating. +/// +/// `normalize` costs a heap allocation plus a full `resolve` walk, and the hot callers +/// (`Editor.docFromPath`, once per file-tree row per frame) hand it paths that were built by +/// joining an already-absolute project root — i.e. canonical the overwhelming majority of the +/// time. Testing first lets those callers skip the allocation entirely and fall back to +/// `normalize` only for the odd spellings it exists to repair. +pub fn isNormalizedAbsolute(path: []const u8) bool { + if (!std.fs.path.isAbsolute(path)) return false; + // On Windows `resolve` also rewrites separators and drive-letter case; not worth + // replicating, so only the POSIX shape claims the fast path. + if (builtin.os.tag == .windows) return false; + + if (std.mem.eql(u8, path, "/")) return true; + // A trailing separator is always dropped by `resolve`. + if (path[path.len - 1] == '/') return false; + + var it = std.mem.splitScalar(u8, path[1..], '/'); + while (it.next()) |component| { + // Empty component == a doubled separator; `.`/`..` get collapsed. + if (component.len == 0) return false; + if (std.mem.eql(u8, component, ".")) return false; + if (std.mem.eql(u8, component, "..")) return false; + } + return true; +} + +test isNormalizedAbsolute { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + + // Canonical shapes take the fast path, and agree with `normalize`. + for ([_][]const u8{ "/", "/a", "/a/b.txt", "/a/b/c", "/a/.hidden", "/a/..b", "/a/b../c" }) |p| { + try std.testing.expect(isNormalizedAbsolute(p)); + const n = try normalize(gpa, p); + defer gpa.free(n); + try std.testing.expectEqualStrings(p, n); + } + + // Non-canonical shapes must decline, so the caller still normalizes them. + for ([_][]const u8{ "relative", "a/b", "", "/a/", "/a//b", "/a/./b", "/a/../b", "/.", "/.." }) |p| { + try std.testing.expect(!isNormalizedAbsolute(p)); + } +} + /// `normalize` of `base` joined with `path`; an absolute `path` wins outright (so a /// cwd + argv pair resolves the way a shell would). pub fn normalizeJoin(allocator: std.mem.Allocator, base: []const u8, path: []const u8) ![]u8 { diff --git a/src/core/widgets/FloatingWindowWidget.zig b/src/core/widgets/FloatingWindowWidget.zig index 2506e6f8..8fa78443 100644 --- a/src/core/widgets/FloatingWindowWidget.zig +++ b/src/core/widgets/FloatingWindowWidget.zig @@ -662,7 +662,7 @@ pub fn processEventsAfter(self: *FloatingWindowWidget) void { // bottom_right corner happens in processEventsBefore const evts = dvui.events(); for (evts) |*e| { - if (!dvui.eventMatch(e, .{ .id = self.data().id, .r = rs.r, .cleanup = true })) + if (!dvui.eventMatch(e, .{ .id = self.data().id, .r = rs.r })) continue; switch (e.evt) { diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 39cc09ce..a40de16a 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -54,6 +54,7 @@ const SettingsPluginsZon = @import("SettingsPluginsZon.zig"); const SettingsWatcher = @import("SettingsWatcher.zig"); const Constants = @import("Constants.zig"); const DocumentWatcher = @import("DocumentWatcher.zig"); +const FolderWatcher = @import("FolderWatcher.zig"); pub const Workspace = workbench_mod.Workspace; pub const Explorer = @import("explorer/Explorer.zig"); @@ -250,6 +251,10 @@ settings_watcher: ?SettingsWatcher = null, /// `Plugin.reloadDocument`; dirty docs set a conflict flag and `save` shows /// `FileChangedOnDisk`. Null on wasm / unsupported OS / start failure — best-effort. document_watcher: ?DocumentWatcher = null, +/// Recursive watch on the open root folder, fanned out to plugins as `folderPathsChanged`. +/// Same final-address constraint as the two above — started in `postInit`, retargeted whenever +/// the root folder changes. +folder_watcher: ?FolderWatcher = null, /// Timestamp of the most recent touch press anywhere in the app, or null if there /// hasn't been one. `Editor.draw` forces a per-frame refresh during the post-press @@ -419,29 +424,38 @@ pub fn init( fizzy_dark.font_mono = .find(.{ .family = "CozetteVector", .size = editor.settings.font_mono_size }); var strawberry: dvui.Theme = fizzy_dark; + strawberry.dark = true; strawberry.name = "Strawberry"; strawberry.window = .{ - .fill = .{ .r = 84, .g = 12, .b = 26, .a = 255 }, - .border = .{ .r = 104, .g = 62, .b = 72, .a = 255 }, - .text = .{ .r = 255, .g = 200, .b = 210, .a = 255 }, + .fill = .{ .r = 96, .g = 12, .b = 32, .a = 255 }, + .border = .{ .r = 131, .g = 46, .b = 59, .a = 255 }, + .text = .{ .r = 205, .g = 25, .b = 48, .a = 255 }, }; strawberry.control = .{ - .fill = .{ .r = 206, .g = 54, .b = 76, .a = 255 }, - .border = .{ .r = 104, .g = 62, .b = 72, .a = 255 }, - .text = .{ .r = 220, .g = 45, .b = 57, .a = 255 }, + .fill = .{ .r = 188, .g = 46, .b = 72, .a = 255 }, + .fill_hover = .{ .r = 178, .g = 44, .b = 66, .a = 255 }, + .fill_press = .{ .r = 150, .g = 34, .b = 56, .a = 255 }, + .border = .{ .r = 102, .g = 19, .b = 42, .a = 255 }, + .text = .{ .r = 251, .g = 188, .b = 193, .a = 255 }, + .text_hover = .{ .r = 249, .g = 222, .b = 226, .a = 255 }, }; strawberry.highlight = .{ - .fill = .{ .r = 236, .g = 64, .b = 89, .a = 255 }, - .text = strawberry.window.fill.?, + .fill = .{ .r = 205, .g = 25, .b = 48, .a = 255 }, + .text = .{ .r = 249, .g = 242, .b = 243, .a = 255 }, }; strawberry.err = .{ - .fill = .{ .r = 255, .g = 10, .b = 20, .a = 255 }, + .fill = .{ .r = 199, .g = 17, .b = 20, .a = 255 }, + .text = .{ .r = 249, .g = 242, .b = 243, .a = 255 }, }; - strawberry.fill = .{ .r = 124, .g = 24, .b = 52, .a = 255 }; - strawberry.text = strawberry.window.text.?.lighten(-10); + strawberry.fill = .{ .r = 165, .g = 24, .b = 64, .a = 255 }; + strawberry.fill_hover = .{ .r = 148, .g = 30, .b = 63, .a = 255 }; + strawberry.fill_press = .{ .r = 104, .g = 18, .b = 42, .a = 255 }; + strawberry.text = .{ .r = 250, .g = 181, .b = 188, .a = 255 }; + strawberry.text_hover = .{ .r = 249, .g = 222, .b = 226, .a = 255 }; + strawberry.border = .{ .r = 131, .g = 46, .b = 59, .a = 255 }; strawberry.focus = strawberry.highlight.fill.?; var fizzy_light = fizzy_dark; @@ -1597,6 +1611,17 @@ pub fn postInit(editor: *Editor) !void { editor.document_watcher = null; }; } + + // Project-wide on-disk change broadcast for plugins (`folderPathsChanged`). Only the + // buffers are set up here; the watch itself is armed by `setProjectFolder`, which may + // already have run — hence the catch-up call below. + editor.folder_watcher = FolderWatcher.init(fizzy.app.allocator) catch |err| blk: { + dvui.log.warn("folder watcher: failed to init ({s}); plugins won't be told about on-disk changes", .{@errorName(err)}); + break :blk null; + }; + if (editor.folder_watcher) |*w| { + if (editor.folder) |f| w.setFolder(f); + } } } @@ -1643,6 +1668,7 @@ const fizzy_api_vtable: sdk.EditorAPI.VTable = .{ .recentFolderAt = fizzyRecentFolderAt, .openInFileBrowser = fizzyOpenInFileBrowser, .isPathIgnored = fizzyIsPathIgnored, + .folderWatchActive = fizzyFolderWatchActive, .explorerBranchIsOpen = fizzyExplorerBranchIsOpen, .setExplorerBranchOpen = fizzySetExplorerBranchOpen, .drawWorkspaces = fizzyDrawWorkspaces, @@ -1853,6 +1879,11 @@ fn fizzyRecentFolderAt(ctx: *anyopaque, index: usize) ?[]const u8 { fn fizzyOpenInFileBrowser(ctx: *anyopaque, path: []const u8) anyerror!void { return fizzyCtx(ctx).openInFileBrowser(path); } +fn fizzyFolderWatchActive(ctx: *anyopaque) bool { + const editor = fizzyCtx(ctx); + return if (editor.folder_watcher) |*w| w.active() else false; +} + fn fizzyIsPathIgnored( ctx: *anyopaque, project_root: []const u8, @@ -2103,8 +2134,16 @@ pub fn docFromPath(editor: *Editor, path: []const u8) ?sdk.DocHandle { if (std.mem.eql(u8, editor.docPath(doc), path)) return doc; } - const key = fizzy.paths.normalize(fizzy.app.allocator, path) catch return null; - defer fizzy.app.allocator.free(key); + // The file tree calls this once per row per frame, and the miss (file not open) is by far the + // common case — so every allocation below is paid on every non-open row. Both normalizes are + // skippable whenever the path is already canonical, which is the norm here: tree rows are + // joined onto an absolute project root. Checking costs a scan, not a heap allocation. + const path_canonical = fizzy.paths.isNormalizedAbsolute(path); + const key: []const u8 = if (path_canonical) + path + else + fizzy.paths.normalize(fizzy.app.allocator, path) catch return null; + defer if (!path_canonical) fizzy.app.allocator.free(@constCast(key)); for (editor.open_files.values()) |doc| { const stored = editor.docPath(doc); @@ -2113,6 +2152,9 @@ pub fn docFromPath(editor: *Editor, path: []const u8) ?sdk.DocHandle { // already equals `key`. Only needed when a pre-normalization doc still carries a `.` // component that the caller's key has already collapsed. if (std.mem.eql(u8, stored, path)) continue; + // A canonical `stored` normalizes to itself, and both comparisons above already ruled it + // out — no need to allocate a copy just to re-compare it. + if (fizzy.paths.isNormalizedAbsolute(stored)) continue; const stored_canon = fizzy.paths.normalize(fizzy.app.allocator, stored) catch continue; defer fizzy.app.allocator.free(stored_canon); if (std.mem.eql(u8, stored_canon, key)) return doc; @@ -2437,7 +2479,6 @@ pub fn reconcileExternalSettingsChange(editor: *Editor) void { editor.applyHoldMenuDuration(); editor.reconcilePluginEnabled(data); - editor.reconcileDiscoveredPlugins(); editor.reconcilePluginSettings(); // Mark this content as "known" now that it's fully applied, so neither the next autosave @@ -2556,7 +2597,14 @@ fn restampLoadedPlugin(editor: *Editor, id: []const u8) void { /// Rescans `/plugins/` for directories not already loaded / tracked-disabled / failed, /// and adds each as a disabled entry without writing settings.zon — a plugin dropped straight /// into the folder must not auto-execute (R12). Store installs write `.enabled = true` themselves. -fn reconcileDiscoveredPlugins(editor: *Editor) void { +/// +/// Driven from `SettingsWatcher.tick` and deliberately *outside* `reconcileExternalSettingsChange`, +/// for the same reason as `reconcileChangedPluginBinaries`: that one returns early unless +/// `settings.zon`'s content hash moved, and a brand-new `plugins//` directory never moves it. +/// Running it there meant a plugin built straight into the folder was only ever discovered at the +/// next launch — until then it had no `disabled_plugin_ids` entry, so the store drew it as a bare +/// `.on_disk` card with no way to load it. +pub fn reconcileDiscoveredPlugins(editor: *Editor) void { if (comptime builtin.target.cpu.arch == .wasm32) return; const gpa = fizzy.app.allocator; const plugins_dir = std.fs.path.join(gpa, &.{ editor.config_folder, "plugins" }) catch return; @@ -2725,6 +2773,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // mid-iteration. PluginStore.tick(); + const hitch_watchers = fizzy.hitch.begin(.watchers); // Pick up any external edit to settings.zon (see R11 in docs/PLUGIN_MANIFEST_PLAN.md). // Cheap no-op unless the watcher thread actually saw a change. if (editor.settings_watcher) |*w| w.tick(editor); @@ -2732,6 +2781,11 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // Reload clean open docs / flag dirty conflicts when files change on disk. if (editor.document_watcher) |*w| w.tick(editor); + // Fan out on-disk changes under the root folder to plugins. Cheap no-op unless the watcher + // thread buffered something. + if (editor.folder_watcher) |*w| w.tick(editor); + hitch_watchers.end(); + var needs_save_status_anim_tick = false; for (editor.host.plugins.items) |plugin| { if (plugin.tickOpenDocuments()) needs_save_status_anim_tick = true; @@ -2803,24 +2857,38 @@ pub fn tick(editor: *Editor) !dvui.App.Result { editor.setWindowStyle(); syncLoadedPluginDvuiContexts(editor); - for (editor.host.plugins.items) |plugin| plugin.beginFrame(); + { + const t = fizzy.hitch.begin(.plugin_hooks); + defer t.end(); + for (editor.host.plugins.items) |plugin| plugin.beginFrame(); + } if (fizzy.perf.record) fizzy.perf.beginFrame(); defer if (fizzy.perf.record) fizzy.perf.endFrameAndMaybeLog(); // Reap completed background file loads. Must run BEFORE `pending_composite_warmup` and any // workspace/file iteration so that a just-loaded file is visible to the rest of this frame. - editor.processLoadingJobs(); + { + const t = fizzy.hitch.begin(.loading_jobs); + defer t.end(); + editor.processLoadingJobs(); + } if (comptime builtin.target.cpu.arch == .wasm32) fizzy.backend.pollWebFileIo(editor); // Build workspaces AFTER reaping load jobs so a freshly-loaded file with a new grouping // (e.g. "Open to the side") gets its workspace created on the same frame it lands. // Otherwise the new pane only appears on the next frame, which won't happen until some // unrelated event (mouse move, key) wakes the loop. - editor.rebuildWorkspaces() catch { - dvui.log.err("Failed to rebuild workspaces", .{}); - }; + { + const t = fizzy.hitch.begin(.rebuild_workspaces); + defer t.end(); + editor.rebuildWorkspaces() catch { + dvui.log.err("Failed to rebuild workspaces", .{}); + }; + } if (editor.pending_composite_warmup) { + const t = fizzy.hitch.begin(.plugin_hooks); + defer t.end(); editor.pending_composite_warmup = false; for (editor.host.plugins.items) |plugin| plugin.prepareFrame(); } @@ -2843,6 +2911,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // ); // defer scaler.deinit(); + const hitch_draw = fizzy.hitch.begin(.draw); { // First, window color is set to the opaque color. @@ -3254,6 +3323,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { update_notify.drawAbove(infobar_y_physical, 4.0); } } + hitch_draw.end(); // look at demo() for examples of dvui widgets, shows in a floating window dvui.Examples.demo(.full); @@ -3563,10 +3633,14 @@ pub fn setProjectFolder(editor: *Editor, path_in: []const u8) !void { for (editor.host.plugins.items) |plugin| plugin.onFolderOpen(fizzy.app.allocator); editor.ignore = try IgnoreRules.load(fizzy.app.allocator, path); + // After `ignore` — `FolderWatcher.tick` filters through it, and arming first would let a + // burst arrive while the rules still belong to the previous folder. + if (editor.folder_watcher) |*w| w.setFolder(editor.folder); } pub fn closeProjectFolder(editor: *Editor) void { if (editor.folder) |folder| { + if (editor.folder_watcher) |*w| w.setFolder(null); editor.ignore.deinit(fizzy.app.allocator); for (editor.host.plugins.items) |plugin| plugin.onFolderClose(); fizzy.app.allocator.free(folder); @@ -4374,20 +4448,35 @@ fn closeDocumentResources(_: *Editor, doc: sdk.DocHandle) void { doc.owner.unregisterDocument(doc.id); } +/// Which tab becomes active when the doc at `index` closes: the nearest tab of the same +/// grouping to its right, else the nearest one to its left. Neighbor-based rather than +/// open-order/MRU so closing a run of tabs walks steadily in one direction instead of +/// snapping back to the first tab. +/// +/// Returned in post-removal coordinates: `orderedRemove` shifts every later entry down by +/// one, so a neighbor found after `index` is reported one lower than its current position. +fn replacementIndexAfterClose(editor: *Editor, index: usize, grouping: u64) ?usize { + const docs = editor.open_files.values(); + + var right = index + 1; + while (right < docs.len) : (right += 1) { + if (editor.docGrouping(docs[right]) == grouping) return right - 1; + } + + var left = index; + while (left > 0) { + left -= 1; + if (editor.docGrouping(docs[left]) == grouping) return left; + } + + return null; +} + pub fn rawCloseFile(editor: *Editor, index: usize) !void { const doc = editor.docAt(index) orelse return; const grouping = editor.docGrouping(doc); - // Post-removal coordinates: `orderedRemoveAt(index)` shifts every later entry down - // by one, so a neighbor found after `index` must be reported one lower than its - // pre-removal position. - const replacement_index: ?usize = blk: { - for (editor.open_files.values(), 0..) |d, i| { - if (i == index) continue; - if (editor.docGrouping(d) == grouping) break :blk if (i > index) i - 1 else i; - } - break :blk null; - }; + const replacement_index = editor.replacementIndexAfterClose(index, grouping); editor.workbench.adjustOpenFileIndexAfterClose(grouping, index, replacement_index); if (editor.document_watcher) |*w| w.untrack(doc.id); @@ -4400,14 +4489,7 @@ pub fn rawCloseFileID(editor: *Editor, id: u64) !void { const index = editor.open_files.getIndex(id) orelse return; const grouping = editor.docGrouping(doc); - // See `rawCloseFile`: neighbor index is reported in post-removal coordinates. - const replacement_index: ?usize = blk: { - for (editor.open_files.values(), 0..) |d, i| { - if (i == index) continue; - if (editor.docGrouping(d) == grouping) break :blk if (i > index) i - 1 else i; - } - break :blk null; - }; + const replacement_index = editor.replacementIndexAfterClose(index, grouping); editor.workbench.adjustOpenFileIndexAfterClose(grouping, index, replacement_index); if (editor.document_watcher) |*w| w.untrack(doc.id); @@ -4434,6 +4516,12 @@ pub fn deinit(editor: *Editor) !void { w.stop(); editor.settings_watcher = null; } + // Before the plugin `deinit` loop below: `tick` fans out into plugin vtables, and this + // joins the thread that feeds it. + if (editor.folder_watcher) |*w| { + w.deinit(); + editor.folder_watcher = null; + } // Tear workspaces down first: `Workspace.deinit` calls back into the owning plugin // (e.g. `removeCanvasPane`), so it must run while plugin state is still alive — i.e. before diff --git a/src/editor/FolderWatcher.zig b/src/editor/FolderWatcher.zig new file mode 100644 index 00000000..29d806be --- /dev/null +++ b/src/editor/FolderWatcher.zig @@ -0,0 +1,322 @@ +//! Watches the open root folder (recursive) for on-disk changes and broadcasts them to every +//! plugin via `Plugin.VTable.folderPathsChanged`. +//! +//! The third nightwatch adapter in this directory, and the only one whose output leaves fizzy. +//! `SettingsWatcher` reconciles `settings.zon`; `DocumentWatcher` reloads open tabs. Neither +//! helps a plugin that cares about files nobody has open — a file tree that should show what an +//! agent just created, a link indexer whose graph goes stale when a wikilink is deleted from a +//! closed file, a language server owing `didChangeWatchedFiles`. Before this, each of those +//! would have had to pin nightwatch itself and stand up its own thread over the same tree. +//! +//! Three things belong on this side of the SDK boundary rather than in each plugin: +//! +//! 1. **Thread hop.** Nightwatch calls its handler on its own thread. Plugins are dylibs; a +//! callback arriving on a thread the plugin never created — possibly mid-unload — is a +//! crash. Events are buffered here and fan out from `tick`, on the UI thread. +//! 2. **Ignore rules.** Only fizzy knows them (`IgnoreRules`). Filtering here means `.git`, +//! build output and gitignored paths never reach a plugin, instead of every plugin +//! re-deriving the same filter from `Host.isPathIgnored` one path at a time. +//! 3. **One watcher.** Three plugins each watching the project folder would mean three threads +//! and three sets of fds or event streams over the same tree. +//! +//! Nightwatch is deliberately not exposed: it is an implementation detail behind +//! `folderPathsChanged`, so it can be swapped, forked, or replaced with per-platform code +//! without any plugin noticing. +//! +//! `have_impl` is false on wasm and any unsupported OS — the watcher is simply not started +//! there, and `Host.folderWatchActive` reports false so a plugin knows to keep its own slow +//! rescan (same degrade-gracefully spirit as `SettingsWatcher` / `DocumentWatcher`). +const builtin = @import("builtin"); +const std = @import("std"); +const fizzy = @import("../fizzy.zig"); +const dvui = @import("dvui"); +const Allocator = std.mem.Allocator; + +const Plugin = fizzy.sdk.Plugin; +const IgnoreRules = @import("explorer/IgnoreRules.zig"); +const folder_events = @import("folder_events.zig"); +const underDotSegment = folder_events.underDotSegment; + +/// One side of the double buffer. The watcher thread fills `shared`; `tick` swaps it with +/// `staging` under the mutex and then reads at leisure, so no plugin call ever runs with the +/// lock held or races the producer. +const Buf = folder_events.Ring(Plugin.PathEvent.Kind, Plugin.PathEvent.ObjectType); + +const FolderWatcher = @This(); + +/// How long to keep coalescing further events once the first arrives. One logical save is +/// several raw filesystem events, and a consumer reindexing a file wants it to have finished +/// being written. +const debounce_ns: i128 = 200 * std.time.ns_per_ms; + +/// Ring capacity. Overflow is reported as `PathChanges.truncated` rather than grown: a branch +/// switch or an `npm install` emits events by the tens of thousands, and the honest answer for +/// a consumer at that point is "rescan", not a longer list it still has to walk. +const max_events: usize = 512; +/// Flat backing store for the paths. One arena rather than a fixed slot per event, so a handful +/// of deep paths can't crowd out everything else and no path length is special-cased. +const path_arena_bytes: usize = 64 * 1024; + +pub const have_impl = switch (builtin.os.tag) { + .macos, .linux, .windows => true, + else => false, +}; + +/// Spin lock over `std.atomic.Mutex` (which is try-lock only). A blocking primitive here would +/// mean `std.Io.Mutex` and therefore `dvui.io` on nightwatch's thread — the one thing the doc +/// comments on the other two adapters single out as not to be touched from a watcher callback. +/// Both critical sections are a bounded memcpy or a pointer swap, so there is nothing to block +/// on for long enough to be worth a real wait. +const Spin = struct { + inner: std.atomic.Mutex = .unlocked, + + fn lock(self: *Spin) void { + while (!self.inner.tryLock()) std.atomic.spinLoopHint(); + } + + fn unlock(self: *Spin) void { + self.inner.unlock(); + } +}; + +gpa: Allocator, +/// Owned copy of the folder currently watched, or null when nothing is. +folder: ?[]u8 = null, +impl: if (have_impl) Impl else void = if (have_impl) .{} else {}, + +/// Guards `shared` only. Held for a bounded memcpy on the producer and a pointer swap on the +/// consumer — never across a plugin call. +mutex: Spin = .{}, +shared: Buf, +staging: Buf, + +/// Main-thread coalesce deadline (`perf.nanoTimestamp()`); 0 = nothing pending. +coalesce_deadline_ns: i128 = 0, +/// Scratch for the fan-out, sized once so `tick` never allocates. +out: []Plugin.PathEvent, + +const Impl = if (have_impl) struct { + const nightwatch = @import("nightwatch"); + /// `Default` on purpose — this watches a whole tree, which is the case every backend's + /// default variant is built for. On macOS that is FSEvents (see the `macos_fsevents` build + /// option), which watches the subtree from a single stream; the kqueue fallback would want + /// a file descriptor per directory *and* per file, and a project folder is exactly the + /// shape that exhausts the fd limit. + const Watcher = nightwatch.Default; + const Handler = Watcher.Handler; + + handler: Handler = .{ .vtable = &vtable }, + nw: ?Watcher = null, + /// Set in `startWatch` once `FolderWatcher` is at its final address. + owner: ?*FolderWatcher = null, + + const vtable = Handler.VTable{ + .change = onChange, + .rename = onRename, + }; + + fn kindOf(ev: nightwatch.EventType) Plugin.PathEvent.Kind { + return switch (ev) { + .created => .created, + .modified, .closed => .modified, + .deleted => .deleted, + }; + } + + fn objectOf(obj: nightwatch.ObjectType) Plugin.PathEvent.ObjectType { + return switch (obj) { + .file => .file, + .dir => .dir, + .unknown => .unknown, + }; + } + + fn record( + h: *Handler, + path: []const u8, + old_path: []const u8, + kind: Plugin.PathEvent.Kind, + object: Plugin.PathEvent.ObjectType, + ) void { + const impl: *Impl = @fieldParentPtr("handler", h); + const self = impl.owner orelse return; + const folder = self.folder orelse return; + // Cheap dot-directory reject before taking the lock. The authoritative `IgnoreRules` + // pass happens on the main thread in `tick`; this one exists only so a `git checkout` + // or a build churning `.zig-cache` can't flood the ring before we get there. + if (underDotSegment(folder, path)) return; + + self.mutex.lock(); + self.shared.push(path, old_path, kind, object); + self.mutex.unlock(); + wake(); + } + + fn onChange(h: *Handler, path: []const u8, event_type: nightwatch.EventType, object_type: nightwatch.ObjectType) error{HandlerFailed}!void { + record(h, path, "", kindOf(event_type), objectOf(object_type)); + } + + fn onRename(h: *Handler, src: []const u8, dst: []const u8, object_type: nightwatch.ObjectType) error{HandlerFailed}!void { + record(h, dst, src, .renamed, objectOf(object_type)); + } +} else void; + +fn wake() void { + // Safe from any thread — see `Editor.zig`'s `fizzyRefresh` doc comment. + fizzy.app.window.backend.refresh(); +} + +/// Allocates the ring buffers. Does not start nightwatch — `setFolder` does, once a folder is +/// open and `self` is at its final address. +pub fn init(gpa: Allocator) !FolderWatcher { + if (comptime !have_impl) return error.Unsupported; + + const shared_paths = try gpa.alloc(u8, path_arena_bytes); + errdefer gpa.free(shared_paths); + const shared_events = try gpa.alloc(Buf.Event, max_events); + errdefer gpa.free(shared_events); + const staging_paths = try gpa.alloc(u8, path_arena_bytes); + errdefer gpa.free(staging_paths); + const staging_events = try gpa.alloc(Buf.Event, max_events); + errdefer gpa.free(staging_events); + const out = try gpa.alloc(Plugin.PathEvent, max_events); + + return .{ + .gpa = gpa, + .shared = .init(shared_paths, shared_events), + .staging = .init(staging_paths, staging_events), + .out = out, + }; +} + +pub fn deinit(self: *FolderWatcher) void { + self.stopWatch(); + self.gpa.free(self.shared.paths); + self.gpa.free(self.shared.events); + self.gpa.free(self.staging.paths); + self.gpa.free(self.staging.events); + self.gpa.free(self.out); + self.* = undefined; +} + +/// True when a watch is live, i.e. when `folderPathsChanged` can be relied on to fire. Backs +/// `Host.folderWatchActive`. +pub fn active(self: *const FolderWatcher) bool { + if (comptime !have_impl) return false; + return self.impl.nw != null; +} + +/// Point the watcher at `path` (or nowhere, when null). Must be called only once `self` is at +/// its **final** address — nightwatch retains `&self.impl.handler` for the watcher's lifetime, +/// the same constraint `SettingsWatcher.start` documents. +/// +/// Best-effort throughout: a folder that can't be watched is a degraded experience, never a +/// failure to open the folder. +pub fn setFolder(self: *FolderWatcher, path: ?[]const u8) void { + self.stopWatch(); + if (path) |p| { + self.folder = self.gpa.dupe(u8, p) catch { + dvui.log.warn("folder watcher: out of memory; plugins won't see on-disk changes under {s}", .{p}); + return; + }; + self.startWatch() catch |err| { + dvui.log.warn("folder watcher: failed to watch {s} ({s}); plugins won't see on-disk changes there", .{ p, @errorName(err) }); + self.stopWatch(); + }; + } +} + +fn startWatch(self: *FolderWatcher) !void { + if (comptime !have_impl) return error.Unsupported; + const folder = self.folder orelse return error.NoFolder; + self.impl.owner = self; + var nw = try Impl.Watcher.init(dvui.io, self.gpa, &self.impl.handler); + errdefer nw.deinit(); + try nw.watch(folder); + self.impl.nw = nw; +} + +/// Tears the watcher down entirely rather than calling `unwatch`: nightwatch's `unwatch` drops +/// only the path it was given, not the subdirectories its recursive walk added, so a folder +/// switch would otherwise leak watches on the old tree. +fn stopWatch(self: *FolderWatcher) void { + if (comptime have_impl) { + if (self.impl.nw) |*nw| { + nw.deinit(); + self.impl.nw = null; + } + self.impl.owner = null; + } + if (self.folder) |f| { + self.gpa.free(f); + self.folder = null; + } + self.mutex.lock(); + self.shared.reset(); + self.mutex.unlock(); + self.coalesce_deadline_ns = 0; +} + +/// Call once per frame. Cheap no-op unless the watcher thread actually buffered something. +pub fn tick(self: *FolderWatcher, editor: *fizzy.Editor) void { + if (comptime !have_impl) return; + + const now = fizzy.perf.nanoTimestamp(); + { + self.mutex.lock(); + defer self.mutex.unlock(); + if (!self.shared.empty()) self.coalesce_deadline_ns = now + debounce_ns; + } + if (self.coalesce_deadline_ns == 0) return; + if (now < self.coalesce_deadline_ns) { + // Keep the event loop alive until the coalesce window settles. + wake(); + return; + } + self.coalesce_deadline_ns = 0; + + // Swap rather than copy, so the producer is unblocked immediately and the fan-out below + // reads a buffer nothing else can touch. + { + self.mutex.lock(); + defer self.mutex.unlock(); + std.mem.swap(Buf, &self.shared, &self.staging); + self.shared.reset(); + } + defer self.staging.reset(); + + const folder = editor.folder orelse return; + var n: usize = 0; + for (self.staging.slice()) |e| { + const path = self.staging.pathOf(e); + const name = std.fs.path.basename(path); + // A deleted path can no longer be stat'd, so `.unknown` has to guess; `.file` is both + // the common case and the conservative one (directory rules are the broader filter). + const kind: std.Io.File.Kind = switch (e.object) { + .dir => .directory, + .file, .unknown => .file, + }; + if (editor.ignore.isIgnored(folder, path, name, kind)) continue; + self.out[n] = .{ + .path = path, + .kind = e.kind, + .object = e.object, + .old_path = self.staging.oldPathOf(e), + }; + n += 1; + } + + // A truncated batch still has to go out even when every surviving event was ignored: the + // dropped ones are precisely the events nobody got to inspect. + if (n == 0 and !self.staging.truncated) return; + editor.host.notifyFolderPathsChanged(.{ + .events = self.out[0..n], + .truncated = self.staging.truncated, + }); +} + +test { + // The buffering and filtering live in `folder_events.zig` (std-only, so its tests actually + // run); pull them in here too so they aren't orphaned if this file grows its own root. + _ = folder_events; +} diff --git a/src/editor/Infobar.zig b/src/editor/Infobar.zig index 45e7eb0a..f7809bc6 100644 --- a/src/editor/Infobar.zig +++ b/src/editor/Infobar.zig @@ -2,6 +2,7 @@ const std = @import("std"); const fizzy = @import("../fizzy.zig"); const dvui = @import("dvui"); const icons = @import("icons"); +const assets = @import("assets"); const update_notify = @import("../backend/update_notify.zig"); const Dialogs = fizzy.Editor.Dialogs; const Constants = @import("Constants.zig"); @@ -57,7 +58,7 @@ pub fn draw(_: Infobar) !void { .gravity_y = 0.5, .margin = .all(0), .padding = .all(0), - .color_fill = .transparent, + .color_fill = fizzy.dvui.hoverRestFill(dvui.themeGet().color(.control, .fill_hover)), .color_fill_hover = dvui.themeGet().color(.control, .fill_hover), .color_fill_press = dvui.themeGet().color(.control, .fill_press), }); @@ -68,15 +69,38 @@ pub fn draw(_: Infobar) !void { var box = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .margin = .all(0), .padding = .all(0) }); defer box.deinit(); - dvui.icon( - @src(), - "info_icon", - icons.tvg.entypo.@"info-circled", - .{ .fill_color = dvui.themeGet().color(.window, .text) }, - .{ .gravity_y = 0.5, .padding = .{ - .x = 4, - } }, - ); + // The pixel-art F (`icon.png`, not `fox.png`), same logo the settings tree and file + // explorer use. `.imageFile` so dvui caches the texture — `fromImageFileBytes` + // re-decodes every frame. Sized off the bar height so it never grows the infobar. + const logo_side = bar_h - 8; + const logo: dvui.ImageSource = .{ .imageFile = .{ + .bytes = assets.files.@"icon.png", + .name = "icon.png", + .interpolation = .nearest, + } }; + { + // Fixed slot (min == max) so the artwork fits the bar instead of dictating its + // height, same shape as `treeRowGlyph` but sized off `infobar_height`. + var logo_slot = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .gravity_y = 0.5, + .expand = .none, + .background = false, + .min_size_content = .{ .w = logo_side, .h = logo_side }, + .max_size_content = .size(.{ .w = logo_side, .h = logo_side }), + .padding = .all(0), + .margin = .{ .x = 4, .w = 2 }, + }); + defer logo_slot.deinit(); + + _ = dvui.image(@src(), .{ .source = logo, .shrink = .ratio }, .{ + .gravity_x = 0.5, + .gravity_y = 0.5, + .expand = .ratio, + .padding = .all(0), + .margin = .all(0), + .background = false, + }); + } dvui.label(@src(), "fizzy", .{}, .{ .font = font, .gravity_y = 0.5, .margin = .all(0) }); if (button.clicked()) { diff --git a/src/editor/KeybindSettings.zig b/src/editor/KeybindSettings.zig index 06c0ebae..a6ddf614 100644 --- a/src/editor/KeybindSettings.zig +++ b/src/editor/KeybindSettings.zig @@ -293,7 +293,7 @@ fn drawOwnerBranch( .expand = .horizontal, .color_fill_hover = theme.color(.control, .fill).opacity(0.5), .color_fill_press = theme.color(.control, .fill_press), - .color_fill = .transparent, + .color_fill = core.dvui.hoverRestFill(theme.color(.control, .fill)), .padding = dvui.Rect.all(1), }); defer b.deinit(); diff --git a/src/editor/PluginStore.zig b/src/editor/PluginStore.zig index 3009b844..be43ec26 100644 --- a/src/editor/PluginStore.zig +++ b/src/editor/PluginStore.zig @@ -1712,6 +1712,14 @@ const card_min_w: f32 = 280; /// longest description happens to be", which is exactly what the scrollArea must not do. const card_text_no_floor: f32 = 1; +/// The one card text line that does *not* get `card_text_no_floor`: the title. A card whose name +/// has been squeezed away is unusable — you can't tell which plugin the controls belong to — so +/// the title reports up to this much width and the scrollArea grows a horizontal bar rather than +/// eating into it. Bounded by construction (unlike a description, whose length is unbounded and +/// author-controlled): a title longer than this still reports only this much and ellipsizes, so +/// the card's min width can't drift with the catalog's longest name. +const card_title_min_w: f32 = 96; + /// Padding for every text line inside a card's info column. `LabelWidget.defaults` is /// `Rect.all(6)`, which across four always-drawn lines (title/description/author/row2) adds ~48px /// of pure whitespace to a card whose text is only ~64px tall. The lines are already separated by @@ -1827,7 +1835,7 @@ fn drawCardShell(entry: StoreEntry, controls: *const fn (StoreEntry) void, row2_ .font = title_font.withWeight(.bold), .expand = .horizontal, .padding = card_text_padding, - .max_size_content = .{ .w = card_text_no_floor, .h = std.math.floatMax(f32) }, + .max_size_content = .{ .w = card_title_min_w, .h = std.math.floatMax(f32) }, }); if (releaseDate(entry)) |date| { dvui.labelNoFmt(@src(), date, .{}, .{ @@ -2028,6 +2036,91 @@ const part_separator = " · "; /// words per line without going all the way out to the card's actual (fluid) width. const min_failure_wrap_w: f32 = 220; +/// The optimize class every published store build is produced in: the plugin release CI +/// (`fizzyedit/plugin-build-action`) always builds `-Doptimize=ReleaseFast`. A property of the +/// store, not of any one plugin. +const store_optimize_class = "fast"; + +/// False when this Fizzy is a `Debug`/`ReleaseSafe` build. Such a host folds the `"safe"` +/// optimize class into its `abi_fingerprint` (see `dylib.optimize_safety_class`), so it fetches a +/// shard URL the store never publishes under, and *every* plugin — including ones whose SDK +/// version matches this host exactly — reads "No compatible build in store". That message points +/// at the store, but the cause is entirely local and comptime-known, so say so instead. Same +/// condition the local load path reports as `error.AbiBuildEnvMismatch` ("SDK versions match, but +/// optimize mode does not match"). +const host_optimize_matches_store = std.mem.eql(u8, dylib.optimize_safety_class, store_optimize_class); + +/// Cap on the reported min width of the no-build message (same `max_size_content` trick as +/// `card_text_no_floor`, just with a usable floor instead of ~0). The message sits in the controls +/// column, which is *not* expand-horizontal: whatever it reports, it takes out of the info column +/// beside it. Left uncapped, a long message plus the icon reserved so much of a narrowed card that +/// the title/description — all of which report ~0 and yield — collapsed to nothing while the error +/// text alone stayed fully drawn. Capped, it ellipsizes (its tooltip carries the full text either +/// way) and the title keeps its own floor below. +const no_build_msg_max_w: f32 = 110; + +/// The "nothing here to install" message, shown wherever no host-compatible release resolved — +/// identical in both panes (store card, installed card, detail header) so a card never changes +/// width just by which list it's in. Out-of-class hosts (see `host_optimize_matches_store`) get +/// the optimize-mode wording plus the same alert icon a failed local load carries; the tooltip +/// holds the long-form explanation in both cases. +fn drawNoStoreBuild(opts: dvui.Options) void { + const theme = dvui.themeGet(); + + var no_build_box = dvui.box(@src(), .{ .dir = .horizontal }, opts.override(.{ .gravity_y = 0.5 })); + defer no_build_box.deinit(); + + if (!host_optimize_matches_store) { + dvui.icon( + @src(), + "StoreOptimizeMismatchIcon", + icons.tvg.lucide.@"circle-alert", + .{ .stroke_color = theme.color(.err, .fill), .fill_color = theme.color(.err, .fill) }, + .{ .gravity_y = 0.5, .margin = .{ .x = 2 }, .min_size_content = .{ .w = 14, .h = 14 } }, + ); + } + + dvui.labelNoFmt( + @src(), + if (host_optimize_matches_store) "No store build" else "Needs release", + .{}, + .{ + .color_text = theme.color(.err, .text), + .font = dvui.Font.theme(.mono), + .gravity_y = 0.5, + .max_size_content = .{ .w = no_build_msg_max_w, .h = std.math.floatMax(f32) }, + }, + ); + + if (host_optimize_matches_store) { + dvui.tooltip( + @src(), + .{ .active_rect = no_build_box.data().borderRectScale().r }, + "No compatible build in store (SDK {d}.{d}.{d} · ABI 0x{x} · {s})", + .{ version.sdk_version.major, version.sdk_version.minor, version.sdk_version.patch, dylib.abi_fingerprint, compat.hostKey() }, + .{}, + ); + } else { + dvui.tooltip( + @src(), + .{ .active_rect = no_build_box.data().borderRectScale().r }, + "This Fizzy is a {s} build. Store plugins are published ReleaseFast only, so the " ++ + "optimize mode does not match even when the SDK version does. Run zig build run -Doptimize=ReleaseFast, or build the plugin from source in {s}. " ++ + "(SDK {d}.{d}.{d} · ABI 0x{x} · {s})", + .{ + @tagName(builtin.mode), + @tagName(builtin.mode), + version.sdk_version.major, + version.sdk_version.minor, + version.sdk_version.patch, + dylib.abi_fingerprint, + compat.hostKey(), + }, + .{}, + ); + } +} + /// Join `parts` with " · ", truncating (rather than overflowing) if `buf` is too small. fn joinParts(buf: []u8, parts: []const []const u8) []const u8 { var len: usize = 0; @@ -2102,19 +2195,26 @@ fn drawCardControls(entry: StoreEntry) void { const loaded = editor.host.pluginById(entry.id) != null; const disabled = editor.isPluginDisabled(entry.id); + const failed = entry.kind == .failed or editor.isFailedUserPlugin(entry.id); + // On disk, never loaded, and nothing in memory describes it — a build dropped into + // `plugins//` that fizzy has not classified yet (no `.plugins..enabled` on record, no + // load attempt, no failure). `Editor.reconcileDiscoveredPlugins` normally promotes these to + // `.disabled` (which carries the Enabled checkbox), but it runs off the watcher, so a card can + // still be drawn in this state — it must offer a way *in*, not just Uninstall. + const untracked = !loaded and !disabled and !failed and (entry.kind == .on_disk or isOnDisk(entry.id)); // A build sitting in the plugins dir that isn't running: it failed to load (ABI/SDK mismatch, // etc.), or it is simply there with nothing in memory describing it. Either way it is on disk // like any installed plugin, so it must stay actionable (reinstall / uninstall) rather than // dead-ending at a bare "Failed" label — or, worse, at no card at all. - const broken = entry.kind == .failed or entry.kind == .on_disk or - editor.isFailedUserPlugin(entry.id) or (!loaded and !disabled and isOnDisk(entry.id)); + const broken = failed or untracked; // Present on disk in some form: loaded, disabled-on-disk, sideloaded local, or a broken build. if (loaded or disabled or entry.kind == .local or entry.kind == .disabled or broken) { - // Enable/disable only makes sense for a plugin that can actually load — a mismatched - // (never-loaded) build has nothing to toggle, so skip the checkbox for the pure-broken case. - if (loaded or disabled) { - var enabled = !disabled; + // Enable/disable for anything that has a build to load: running, disabled, or an + // unclassified directory. A *failed* build is the one case with nothing to toggle — it + // already tried and lost — so it gets the Retry button below instead. + if (loaded or disabled or untracked) { + var enabled = loaded; if (dvui.checkbox(@src(), &enabled, "Enabled", .{ .gravity_y = 0.5 })) queueSetEnabled(entry.id, enabled); } // Replace with a host-compatible registry build, just before uninstall: @@ -2132,31 +2232,28 @@ fn drawCardControls(entry: StoreEntry) void { startDownload(entry.id, rel, true); } } else if (broken) { + // A locally built plugin that lost its load has no registry release to reinstall + // *from*, so Retry is the whole fix once the author rebuilds it in place: it re-runs + // the load against whatever is on disk now and clears the failure record on success. + if (failed) { + if (dvui.button(@src(), "Retry", .{}, .{ .gravity_y = 0.5, .margin = .{ .x = 4 } })) + queueSetEnabled(entry.id, true); + } if (selectedRelease(entry)) |rel| { if (dvui.button(@src(), "Reinstall", .{}, .{ .gravity_y = 0.5, .margin = .{ .x = 4 } })) startDownload(entry.id, rel, false); - } else if (!have_snapshot) { + } else if (!have_snapshot or untracked) { // No catalog yet (offline, or the first fetch is still running), so we genuinely // don't know whether a build exists — say nothing rather than claim there is none. // Uninstall below still works; the card gains a Reinstall once a snapshot lands. + // An untracked build stays quiet too: its Enabled checkbox is the action here, and + // "no store build" is irrelevant noise for something never installed from the store. } else { // No build for this host in the fetched shard, so there is nothing to reinstall // *from*. Say why (short form — this card also carries the wrapped failure text, // and the controls row shares its width with it) instead of leaving a lone trash // icon next to an unexplained "Failed to load". - var no_build_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ .gravity_y = 0.5, .margin = .{ .x = 4 } }); - defer no_build_box.deinit(); - dvui.labelNoFmt(@src(), "No store build", .{}, .{ - .color_text = theme.color(.err, .text), - .font = dvui.Font.theme(.mono), - }); - dvui.tooltip( - @src(), - .{ .active_rect = no_build_box.data().borderRectScale().r }, - "No compatible build in store (SDK {d}.{d}.{d} · ABI 0x{x} · {s})", - .{ version.sdk_version.major, version.sdk_version.minor, version.sdk_version.patch, dylib.abi_fingerprint, compat.hostKey() }, - .{}, - ); + drawNoStoreBuild(.{ .margin = .{ .x = 4 } }); } } if (dvui.buttonIcon(@src(), "Uninstall", icons.tvg.lucide.@"trash-2", .{}, .{ .stroke_color = theme.color(.err, .text) }, .{ .gravity_y = 0.5 })) @@ -2179,21 +2276,7 @@ fn drawCardControls(entry: StoreEntry) void { // published a build for this exact Fizzy version/arch yet — nothing the user can fix // locally (unlike a failed local build, handled above), so the wording and the tooltip // both point at "the store doesn't have one" rather than "rebuild your plugin". - { - var no_build_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ .gravity_y = 0.5 }); - defer no_build_box.deinit(); - dvui.labelNoFmt(@src(), "No compatible build in store", .{}, .{ - .color_text = theme.color(.err, .text), - .font = dvui.Font.theme(.mono), - }); - dvui.tooltip( - @src(), - .{ .active_rect = no_build_box.data().borderRectScale().r }, - "No compatible build in store (SDK {d}.{d}.{d} · ABI 0x{x} · {s})", - .{ version.sdk_version.major, version.sdk_version.minor, version.sdk_version.patch, dylib.abi_fingerprint, compat.hostKey() }, - .{}, - ); - } + drawNoStoreBuild(.{}); } /// Upper-pane (store) card controls: browse-only. Just an in-flight job status, an Install @@ -2231,21 +2314,7 @@ fn drawStoreCardControls(entry: StoreEntry) void { // Registry row with no host-compatible release: the *store* hasn't published a build for // this exact Fizzy version/arch yet. - { - var no_build_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ .gravity_y = 0.5 }); - defer no_build_box.deinit(); - dvui.labelNoFmt(@src(), "No compatible build in store", .{}, .{ - .color_text = theme.color(.err, .text), - .font = dvui.Font.theme(.mono), - }); - dvui.tooltip( - @src(), - .{ .active_rect = no_build_box.data().borderRectScale().r }, - "No compatible build in store (SDK {d}.{d}.{d} · ABI 0x{x} · {s})", - .{ version.sdk_version.major, version.sdk_version.minor, version.sdk_version.patch, dylib.abi_fingerprint, compat.hostKey() }, - .{}, - ); - } + drawNoStoreBuild(.{}); } /// A repo URL plus an optional path within it to look under for `README.md` / `ICON.png`. diff --git a/src/editor/SettingsTree.zig b/src/editor/SettingsTree.zig index 77403427..fed668a3 100644 --- a/src/editor/SettingsTree.zig +++ b/src/editor/SettingsTree.zig @@ -368,7 +368,7 @@ fn drawBranch( .expand = .horizontal, .color_fill_hover = theme.color(.control, .fill).opacity(0.5), .color_fill_press = theme.color(.control, .fill_press), - .color_fill = .transparent, + .color_fill = core.dvui.hoverRestFill(theme.color(.control, .fill)), .padding = dvui.Rect.all(1), }); defer b.deinit(); diff --git a/src/editor/SettingsWatcher.zig b/src/editor/SettingsWatcher.zig index 587e0da0..846efbc9 100644 --- a/src/editor/SettingsWatcher.zig +++ b/src/editor/SettingsWatcher.zig @@ -145,4 +145,8 @@ pub fn tick(self: *SettingsWatcher, editor: *fizzy.Editor) void { // but never moves `settings.zon`'s hash, so it needs its own pass (which must run after the // settings one — an external enable/disable should settle before we consider reloading). editor.reconcileChangedPluginBinaries(); + // Same again for a plugin directory that appeared (a `zig build install` from a plugin repo, + // or a hand-copied build): also an event in this tree, also invisible to `settings.zon`'s + // hash. Tracks it as disabled — never auto-loads it (R12) — so the Plugins tab can offer it. + editor.reconcileDiscoveredPlugins(); } diff --git a/src/editor/Sidebar.zig b/src/editor/Sidebar.zig index 4ab24b59..7ec4fc9b 100644 --- a/src/editor/Sidebar.zig +++ b/src/editor/Sidebar.zig @@ -121,11 +121,14 @@ fn drawOption(view: *const SidebarView, index: usize, size: f32) !Action { const color: dvui.Color = if (selected) theme.color(.highlight, .fill) else if (bw.hovered()) theme.color(.window, .text) else theme.color(.window, .fill); + // Apply both fill and stroke: Entypo glyphs are fill-based, Lucide (and most + // plugin icons) are stroke-based. Setting only one leaves the other at DVUI's + // default white — which is how a stroke icon looks "full white" in the rail. dvui.icon( @src(), view.id, view.icon, - .{ .fill_color = color }, + .{ .fill_color = color, .stroke_color = color }, .{ .id_extra = index, .min_size_content = .{ .h = size }, diff --git a/src/editor/folder_events.zig b/src/editor/folder_events.zig new file mode 100644 index 00000000..43c46f87 --- /dev/null +++ b/src/editor/folder_events.zig @@ -0,0 +1,240 @@ +//! Buffering and filtering for `FolderWatcher` — the half that runs on nightwatch's thread. +//! +//! Split out and std-only on purpose. `FolderWatcher.zig` reaches `fizzy.zig` and `dvui`, so it +//! can only be exercised through a live editor; this is where the bugs would actually live (an +//! overrun on a path that doesn't fit, a filter that lets `.git` through) and it costs nothing +//! to test directly. Same reasoning as `keymap.zig` and `reveal.zig`. +//! +//! `Ring` is generic over the event enums rather than importing them: the real ones live on +//! `sdk.Plugin.PathEvent`, and `Plugin.zig` imports dvui, which would drag this file back out of +//! std-only territory and into the module whose tests never run. +const std = @import("std"); + +/// True when any path segment *below* `root` starts with a dot — `.git`, `.zig-cache`, `.env`. +/// +/// Pure string work: no allocation, no filesystem, no host call, so it is safe to run on the +/// watcher's own thread. It is not the authoritative ignore check — fizzy's `IgnoreRules` is, +/// and that runs later on the UI thread. This exists so a `git checkout` or a build churning a +/// cache directory can't fill the ring before anyone gets to apply the real rules. +/// +/// Only what is below `root` counts: a project folder may itself live under `~/.config`, which +/// is no reason to ignore every file in it. +pub fn underDotSegment(root: []const u8, path: []const u8) bool { + if (!std.mem.startsWith(u8, path, root)) return false; + var rest = path[root.len..]; + while (rest.len > 0) { + while (rest.len > 0 and (rest[0] == '/' or rest[0] == '\\')) rest = rest[1..]; + if (rest.len == 0) return false; + if (rest[0] == '.') return true; + const next = std.mem.indexOfAny(u8, rest, "/\\") orelse return false; + rest = rest[next..]; + } + return false; +} + +/// Fixed-capacity event buffer, filled by the watcher thread and drained by the UI thread. +/// +/// Nothing here allocates, and that is the whole point: the producer runs on a thread nightwatch +/// owns, where reaching for a shared allocator is exactly what the other watcher adapters in +/// this directory are careful never to do. When the buffer fills, the overflow is *reported* +/// (`truncated`) rather than absorbed — a branch switch emits events by the tens of thousands, +/// and the useful answer for a consumer at that point is "rescan", not a longer list it still +/// has to walk. +pub fn Ring(comptime Kind: type, comptime Object: type) type { + return struct { + const Self = @This(); + + pub const Event = struct { + off: u32, + len: u32, + old_off: u32 = 0, + old_len: u32 = 0, + kind: Kind, + object: Object, + }; + + /// Flat backing store for paths — one arena rather than a fixed slot per event, so a + /// handful of deep paths can't crowd out everything else and no path length is a + /// special case. + paths: []u8, + used: usize = 0, + events: []Event, + count: usize = 0, + /// Something didn't fit. Sticky until `reset`. + truncated: bool = false, + + pub fn init(paths: []u8, events: []Event) Self { + return .{ .paths = paths, .events = events }; + } + + pub fn reset(self: *Self) void { + self.used = 0; + self.count = 0; + self.truncated = false; + } + + /// Nothing to report. Distinct from `count == 0`, which is also true for a batch whose + /// every event was dropped — and that batch still has to go out. + pub fn empty(self: *const Self) bool { + return self.count == 0 and !self.truncated; + } + + /// Append one event, or mark the buffer truncated if it won't fit. `old_path` is the + /// pre-rename path, empty for everything else. + pub fn push(self: *Self, path: []const u8, old_path: []const u8, kind: Kind, object: Object) void { + if (self.count >= self.events.len or + self.used + path.len + old_path.len > self.paths.len) + { + self.truncated = true; + return; + } + const off: u32 = @intCast(self.used); + @memcpy(self.paths[self.used..][0..path.len], path); + self.used += path.len; + const old_off: u32 = @intCast(self.used); + @memcpy(self.paths[self.used..][0..old_path.len], old_path); + self.used += old_path.len; + + self.events[self.count] = .{ + .off = off, + .len = @intCast(path.len), + .old_off = old_off, + .old_len = @intCast(old_path.len), + .kind = kind, + .object = object, + }; + self.count += 1; + } + + pub fn pathOf(self: *const Self, e: Event) []const u8 { + return self.paths[e.off..][0..e.len]; + } + + pub fn oldPathOf(self: *const Self, e: Event) []const u8 { + return self.paths[e.old_off..][0..e.old_len]; + } + + pub fn slice(self: *const Self) []const Event { + return self.events[0..self.count]; + } + }; +} + +// -- tests ------------------------------------------------------------------------ + +const testing = std.testing; + +const TestKind = enum { created, modified, deleted, renamed }; +const TestObject = enum { file, dir, unknown }; +const TestRing = Ring(TestKind, TestObject); + +test "dot-segment reject keeps build and vcs churn out of the ring" { + const root = "/home/u/proj"; + try testing.expect(underDotSegment(root, "/home/u/proj/.git/index")); + try testing.expect(underDotSegment(root, "/home/u/proj/.zig-cache/o/abc/x.o")); + try testing.expect(underDotSegment(root, "/home/u/proj/src/.hidden/f.md")); + try testing.expect(underDotSegment(root, "/home/u/proj/.env")); + + try testing.expect(!underDotSegment(root, "/home/u/proj/README.md")); + try testing.expect(!underDotSegment(root, "/home/u/proj/src/index/Db.zig")); + // A dot *inside* a segment is a file extension, not a hidden entry. + try testing.expect(!underDotSegment(root, "/home/u/proj/docs/a.b.md")); + // The root may itself sit under a dot-directory; only what's below it is our business. + try testing.expect(!underDotSegment("/home/u/.config/vault", "/home/u/.config/vault/note.md")); + // Unrelated paths aren't ours to classify. + try testing.expect(!underDotSegment(root, "/etc/passwd")); +} + +test "dot-segment reject handles windows separators" { + const root = "C:\\proj"; + try testing.expect(underDotSegment(root, "C:\\proj\\.git\\HEAD")); + try testing.expect(!underDotSegment(root, "C:\\proj\\src\\main.zig")); +} + +test "push records paths and rename pairs" { + var paths: [64]u8 = undefined; + var events: [4]TestRing.Event = undefined; + var r = TestRing.init(&paths, &events); + + r.push("/a/one.md", "", .created, .file); + r.push("/a/new.md", "/a/old.md", .renamed, .file); + + try testing.expectEqual(@as(usize, 2), r.count); + try testing.expect(!r.truncated); + + const list = r.slice(); + try testing.expectEqualStrings("/a/one.md", r.pathOf(list[0])); + try testing.expectEqualStrings("", r.oldPathOf(list[0])); + try testing.expectEqualStrings("/a/new.md", r.pathOf(list[1])); + try testing.expectEqualStrings("/a/old.md", r.oldPathOf(list[1])); + try testing.expectEqual(TestKind.renamed, list[1].kind); +} + +test "running out of event slots truncates without corrupting what fit" { + var paths: [1024]u8 = undefined; + var events: [2]TestRing.Event = undefined; + var r = TestRing.init(&paths, &events); + + r.push("/a", "", .created, .file); + r.push("/b", "", .created, .file); + r.push("/c", "", .created, .file); + + try testing.expectEqual(@as(usize, 2), r.count); + try testing.expect(r.truncated); + // Truncation drops the tail; it never overwrites what was already recorded. + try testing.expectEqualStrings("/a", r.pathOf(r.slice()[0])); + try testing.expectEqualStrings("/b", r.pathOf(r.slice()[1])); +} + +test "a path too long for the arena truncates rather than overruns" { + var paths: [8]u8 = undefined; + var events: [4]TestRing.Event = undefined; + var r = TestRing.init(&paths, &events); + + r.push("/short", "", .created, .file); + r.push("/a/much/longer/path.md", "", .created, .file); + + try testing.expectEqual(@as(usize, 1), r.count); + try testing.expect(r.truncated); + try testing.expectEqualStrings("/short", r.pathOf(r.slice()[0])); +} + +test "a rename's two halves are counted together against the arena" { + // The pair is stored back to back, so capacity has to account for both or the second + // memcpy walks past the end. + var paths: [12]u8 = undefined; + var events: [4]TestRing.Event = undefined; + var r = TestRing.init(&paths, &events); + + r.push("/aaaaaa", "/bbbbbb", .renamed, .file); + try testing.expectEqual(@as(usize, 0), r.count); + try testing.expect(r.truncated); +} + +test "reset clears the truncation flag along with the events" { + var paths: [64]u8 = undefined; + var events: [1]TestRing.Event = undefined; + var r = TestRing.init(&paths, &events); + r.push("/a", "", .created, .file); + r.push("/b", "", .created, .file); + try testing.expect(r.truncated); + + r.reset(); + try testing.expectEqual(@as(usize, 0), r.count); + try testing.expectEqual(@as(usize, 0), r.used); + try testing.expect(!r.truncated); + try testing.expect(r.empty()); +} + +test "empty distinguishes nothing-happened from everything-was-dropped" { + // `FolderWatcher.tick` leans on this: a batch that truncated with zero surviving events + // still has to be broadcast, because the dropped ones are exactly what nobody got to see. + var paths: [4]u8 = undefined; + var events: [1]TestRing.Event = undefined; + var r = TestRing.init(&paths, &events); + try testing.expect(r.empty()); + + r.push("/aaaaaaaaaa", "", .created, .file); + try testing.expectEqual(@as(usize, 0), r.count); + try testing.expect(!r.empty()); +} diff --git a/src/fizzy.zig b/src/fizzy.zig index 71b3506b..6d117bd1 100644 --- a/src/fizzy.zig +++ b/src/fizzy.zig @@ -14,6 +14,7 @@ pub const version: std.SemanticVersion = .{ pub const fs = core.fs; pub const image = core.image; pub const perf = core.perf; +pub const hitch = core.hitch; pub const water_surface = core.water_surface; pub const math = core.math; diff --git a/src/plugins/image/README.md b/src/plugins/image/README.md index 0880d024..76ce5c46 100644 --- a/src/plugins/image/README.md +++ b/src/plugins/image/README.md @@ -1,5 +1,8 @@ # Image -Built-in Fizzy plugin. This plugin is responsible for intercepting documents that are registered images (.PNG, .JPEG, .JPG) and rendering them as an image viewer rather than allowing the Text plugin to render the plain text. +Built-in [Fizzy](../../../readme.md) plugin. This plugin is responsible for intercepting documents that are registered images (.PNG, .JPEG, .JPG) and rendering them as an image viewer rather than allowing the Text plugin to render the plain text. -It offers no editing functionality, and only allows viewing images, panning and zooming. \ No newline at end of file +| Features | Description | +|---|---| +| pan | smoothly pan and move the image using trackpad or middle mouse, click and drag also works with momentum +| zoom | smoothly zoom in and out of images using the scroll wheen on mouse or `cmd/ctrl + scroll` on a trackpad diff --git a/src/plugins/markdown/README.md b/src/plugins/markdown/README.md index 98675728..bf6b773d 100644 --- a/src/plugins/markdown/README.md +++ b/src/plugins/markdown/README.md @@ -1,5 +1,5 @@ # Markdown -Built-in Fizzy plugin. This plugin is responsible for rendering markdown previews and the markdown renderer for the plugin store. +Built-in [Fizzy](../../../readme.md) plugin. This plugin is responsible for rendering markdown previews and the markdown renderer for the plugin store. The editor's plugin store looks for each plugins README.md and renders it when the plugin is selected in the center area. diff --git a/src/plugins/markdown/build.zig b/src/plugins/markdown/build.zig index d2b27109..71c93807 100644 --- a/src/plugins/markdown/build.zig +++ b/src/plugins/markdown/build.zig @@ -9,6 +9,24 @@ pub fn build(b: *std.Build) void { linkCmark(b, target, optimize, plugin.module); fizzy.plugin.install(b, plugin.lib, .{}); + + // `zig build test` — the escape/source-position logic in `src/md/wikilink_scan.zig`, run + // against the **real vendored cmark**. It can't live in fizzy's own pure-logic test list + // (`build/app.zig`) like `html_images`/`url_join` do: those are std-only by design, and this + // one is a claim about what cmark itself does to backslash escapes, which only cmark can + // confirm. So it tests from here, where cmark is already linked. + const test_step = b.step("test", "Run the markdown plugin's unit tests"); + const scan_tests = b.addTest(.{ + .name = "markdown-wikilink-scan-tests", + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/md/wikilink_scan.zig"), + }), + }); + scan_tests.root_module.addImport("fizzy_sdk", plugin.module.import_table.get("fizzy_sdk").?); + linkCmark(b, target, optimize, scan_tests.root_module); + test_step.dependOn(&b.addRunArtifact(scan_tests).step); } /// Duplicated from `static/integration.zig`'s `linkCmark` — deliberately, not `@import`ed: diff --git a/src/plugins/markdown/plugin.zig b/src/plugins/markdown/plugin.zig index 8bcb3c22..c14b5029 100644 --- a/src/plugins/markdown/plugin.zig +++ b/src/plugins/markdown/plugin.zig @@ -15,6 +15,9 @@ pub const drawPreviewForDocument = md.drawPreviewForDocument; /// `src/editor/readme.zig` calls it directly; this plugin's `deinit` does the same for the /// dylib copy's separate globals. pub const deinitShared = md.deinitShared; +/// Exposed for `zig build bench-markdown` only (`tests/bench/bench_markdown.zig`), which reads +/// `render_ast.stats` after a frame. Nothing in the app reaches through here. +pub const render_ast = @import("src/md/render_ast.zig"); /// Injected at build time from `plugin.zig.zon` (see `static/integration.zig` / /// `src/plugins/shared/build/helpers.zig`'s `pluginOptions`) — one source of truth for @@ -49,6 +52,7 @@ const language_support: sdk.LanguageSupport = .{ const language_vtable: sdk.LanguageSupport.VTable = .{ .supportsPreview = supportsPreview, .previewPane = previewPane, + .previewReveal = previewReveal, }; var markdown_api: sdk.services.markdown.Api = .{ @@ -58,11 +62,16 @@ var markdown_api: sdk.services.markdown.Api = .{ const markdown_service_vtable: sdk.services.markdown.Api.VTable = .{ .render = svcRender, + .defaultView = svcDefaultView, + .setDefaultView = svcSetDefaultView, }; pub fn register(host: *sdk.Host) !void { + render_ast.initDiagFromEnv(); plugin.state = @ptrCast(&plugin_state); + plugin_state.loadSettings(host); try host.registerPlugin(&plugin); + try plugin_state.registerSettings(host, &plugin); try host.registerLanguageSupport(language_support); try host.registerService("markdown", &markdown_api, &plugin); } @@ -78,6 +87,16 @@ fn supportsPreview(_: *anyopaque, ext: []const u8) bool { return std.ascii.eqlIgnoreCase(ext, ".md") or std.ascii.eqlIgnoreCase(ext, ".markdown"); } +/// Remember the reveal against this pane's own preview state. The pane may never have been +/// drawn for `id_extra` — `previewFor` creates it either way, and the entry is what the very +/// next `previewPane` call picks up. +fn previewReveal(state: *anyopaque, ext: []const u8, path: []const u8, line: u32, id_extra: u64) void { + _ = ext; + _ = path; + const st: *State = @ptrCast(@alignCast(state)); + st.previewFor(sdk.allocator(), id_extra).revealLine(line); +} + fn previewPane(state: *anyopaque, ext: []const u8, path: []const u8, bytes: []const u8, id_extra: u64, gpa: std.mem.Allocator) !void { _ = ext; const st: *State = @ptrCast(@alignCast(state)); @@ -86,7 +105,17 @@ fn previewPane(state: *anyopaque, ext: []const u8, path: []const u8, bytes: []co md.drawPreviewForDocument(gop.value_ptr, path, bytes, gpa, .{ .io = dvui.io, .id_extra = id_extra, + // Transparent, same as the store's README pane: the document tab already paints the + // pane behind this, and the preview's own `.content` fill read as a visibly different + // panel sitting beside the editor rather than the other half of one document. + .background = false, + // This preview owns the full pane, so nothing else insets it: without a margin of its + // own the prose runs straight into the sash on one side and the pane edge on the other. + // Extra at the top clears the raw|split|preview pill, which floats over the content. + .content_padding = .{ .x = 20, .y = 16, .w = 20, .h = 16 }, }); + // `drawPreviewForDocument` fills in `document_path` from `path` — that's what enables + // `[[wikilinks]]` here but not in the store's README pane, which has no local file. } fn svcRender(ctx: *anyopaque, bytes: []const u8, gpa: std.mem.Allocator, opts: sdk.services.markdown.Api.RenderOptions) !void { @@ -99,6 +128,16 @@ fn svcRender(ctx: *anyopaque, bytes: []const u8, gpa: std.mem.Allocator, opts: s }); } +fn svcDefaultView(ctx: *anyopaque) sdk.services.markdown.Api.DefaultView { + const st: *State = @ptrCast(@alignCast(ctx)); + return st.defaultView(); +} + +fn svcSetDefaultView(ctx: *anyopaque, view: sdk.services.markdown.Api.DefaultView) void { + const st: *State = @ptrCast(@alignCast(ctx)); + st.setDefaultView(view); +} + comptime { sdk.Plugin.assertUtilityVTable(vtable); } diff --git a/src/plugins/markdown/src/Settings.zig b/src/plugins/markdown/src/Settings.zig new file mode 100644 index 00000000..e4f4bfd9 --- /dev/null +++ b/src/plugins/markdown/src/Settings.zig @@ -0,0 +1,18 @@ +//! The markdown plugin's user settings. Each field is a self-describing `sdk.settings.Value` +//! cell — see `sdk.settings` for the cell/schema contract. +const sdk = @import("fizzy_sdk"); +const settings = sdk.settings; + +/// How newly opened markdown documents start in the text editor's preview pane. +pub const DefaultMdView = enum { + raw, + split, + preview, +}; + +default_md_view: settings.Value(DefaultMdView, .{ + .name = "Markdown Default View", + .description = "How newly opened markdown documents start: editor only (Raw), editor and " ++ + "preview side by side (Split), or preview only. Clicking Raw, Split, or Preview on a " ++ + "document also updates this.", +}) = .init(.split), diff --git a/src/plugins/markdown/src/State.zig b/src/plugins/markdown/src/State.zig index 0d9e25de..1e7198be 100644 --- a/src/plugins/markdown/src/State.zig +++ b/src/plugins/markdown/src/State.zig @@ -1,9 +1,16 @@ -//! Markdown plugin state — caches parsed preview state keyed by fizzy document id. +//! Markdown plugin state — caches parsed preview state keyed by fizzy document id, plus +//! persisted settings. const std = @import("std"); +const sdk = @import("fizzy_sdk"); const Preview = @import("markdown.zig").Preview; +const Settings = @import("Settings.zig"); pub const State = struct { previews: std.AutoArrayHashMapUnmanaged(u64, Preview) = .empty, + /// Persisted via `Host.loadPluginSettings`/`storePluginSettings` — see `Settings.zig`. + settings: Settings = .{}, + + const Schema = sdk.settings.Schema(Settings); pub fn destroy(self: *State, gpa: std.mem.Allocator) void { for (self.previews.values()) |*p| p.deinit(); @@ -15,4 +22,34 @@ pub const State = struct { if (!gop.found_existing) gop.value_ptr.* = .{}; return gop.value_ptr; } + + pub fn loadSettings(self: *State, host: *sdk.Host) void { + Schema.load(host, "markdown", &self.settings); + } + + pub fn registerSettings(self: *State, host: *sdk.Host, plugin: *sdk.Plugin) !void { + try Schema.register(host, plugin, .{ + .title = "Markdown", + .value = &self.settings, + }); + } + + pub fn defaultView(self: *const State) sdk.services.markdown.Api.DefaultView { + return switch (self.settings.default_md_view.get()) { + .raw => .raw, + .split => .split, + .preview => .preview, + }; + } + + pub fn setDefaultView(self: *State, view: sdk.services.markdown.Api.DefaultView) void { + const setting: Settings.DefaultMdView = switch (view) { + .raw => .raw, + .split => .split, + .preview => .preview, + }; + if (self.settings.default_md_view.get() == setting) return; + self.settings.default_md_view.set(setting); + Schema.store(sdk.host(), "markdown", self.settings); + } }; diff --git a/src/plugins/markdown/src/markdown.zig b/src/plugins/markdown/src/markdown.zig index f9d18629..4f8b168a 100644 --- a/src/plugins/markdown/src/markdown.zig +++ b/src/plugins/markdown/src/markdown.zig @@ -19,35 +19,293 @@ pub fn deinitShared() void { /// Persistent preview state: caches parsed AST + precomputed render data keyed by content hash. pub const Preview = struct { scroll: dvui.ScrollInfo = .{}, + /// Where the reader is, as content identity rather than as pixels — see + /// `render_ast.Anchor`. Resolved into `scroll.viewport.y` at the top of every frame and + /// captured back at the bottom, which is what makes the position survive heights changing + /// underneath it. + anchor: ?render_ast.Anchor = null, + /// The offset `applyAnchor` last wrote. Anything else finding a different one there means the + /// position was set from outside the frame loop — see `applyAnchor`. + anchor_applied_y: ?f32 = null, content_hash: u64 = std.math.maxInt(u64), ast_root: ?*anyopaque = null, gpa: ?std.mem.Allocator = null, rs: render_ast.RenderState = .{}, + /// In-flight background parse for large documents; see `ensureParsed`. + parse_job: ?*ParseJob = null, pub fn deinit(self: *Preview) void { + if (self.parse_job) |job| { + // Job still owns its AST + scan maps until polled; `destroy` frees whatever remains. + job.destroy(); + self.parse_job = null; + } md_parse.freeCachedRoot(self.ast_root); self.ast_root = null; if (self.gpa) |gpa| self.rs.deinit(gpa); self.* = .{}; } + /// Bring 0-based source `line` to the top of the view. + /// + /// One assignment, and it lands on the next frame. This used to be a six-frame retry loop + /// that re-scrolled until the target block's height settled, because it had to place the + /// target in *pixels* against a total the renderer only half knew — `scrollToOffset` clamps + /// against `virtual_size`, so a jump deep into a document was clamped short and needed + /// another try. Naming the destination by line removes the problem rather than retrying it. + pub fn revealLine(self: *Preview, line: u32) void { + self.anchor = .{ .line = line, .offset_px = 0 }; + // Clearing this marks the new anchor as authoritative: `applyAnchor` treats a scroll + // offset it did not write as an outside instruction and re-derives from it, which would + // otherwise throw this request away before it was ever applied. + self.anchor_applied_y = null; + } + + /// The column width the heights were last laid out at, and the scroll room they imply. + /// + /// Both come from the *previous* frame, which is the point: the anchor has to be turned into + /// an offset before this frame's scroll area exists, so it is resolved against the geometry + /// that produced the offset being restored. Null before anything has been laid out — there is + /// no position to restore on a document's first frame. + fn anchorGeometry(self: *const Preview, opts: PreviewOptions) ?struct { column_w: f32, max_scroll: f32 } { + const column_w = self.rs.blocks.layout_width; + if (column_w < 0) return null; + if (self.rs.blocks.len() == 0) return null; + _ = opts; + return .{ + .column_w = column_w, + .max_scroll = @max(0, self.scroll.virtual_size.h - self.scroll.viewport.h), + }; + } + + fn applyAnchor(self: *Preview, opts: PreviewOptions) void { + const geo = self.anchorGeometry(opts) orelse return; + + // Did anything move the scroll position *between* frames? `scrollToOffset` from a + // command, dvui scrolling a focused widget into view, a caller restoring a saved + // position — none of those go through the scroll area's event handling, so none of them + // are reflected in the anchor. Restoring the anchor over the top of one would silently + // discard it, which is exactly what it did: a `scrollToOffset` was undone on the very + // next frame and the reader snapped back. + // + // An offset that differs from what this function last wrote is therefore an instruction, + // not drift. Adopt it, and re-derive the anchor from it. + if (self.anchor_applied_y) |prev| { + if (@abs(self.scroll.viewport.y - prev) > 0.01) self.anchor = null; + } + if (self.anchor == null) { + self.anchor = render_ast.anchorCapture( + &self.rs, + self.scroll.viewport.y, + geo.column_w, + opts.content_padding.y, + geo.max_scroll, + ); + } + + const a = self.anchor orelse return; + const y = render_ast.anchorResolve( + &self.rs, + a, + geo.column_w, + opts.content_padding.y, + geo.max_scroll, + ); + self.scroll.viewport.y = y; + self.anchor_applied_y = y; + } + + fn captureAnchor(self: *Preview, opts: PreviewOptions) void { + const geo = self.anchorGeometry(opts) orelse return; + + // Only re-derive the anchor when something actually moved the viewport this frame. If the + // offset is still exactly what `applyAnchor` wrote, nothing happened that the anchor does + // not already describe — and re-deriving it anyway means re-deriving it against heights + // that may be mid-reflow, which lets a single bad frame's geometry become the reader's + // stored position. That is how a pane resize could walk the reader into a different + // section: not one big jump, but sixty small ones, each faithfully recorded. + if (self.anchor != null) { + if (self.anchor_applied_y) |prev| { + if (@abs(self.scroll.viewport.y - prev) <= 0.01) return; + } + } + + if (render_ast.anchorCapture( + &self.rs, + self.scroll.viewport.y, + geo.column_w, + opts.content_padding.y, + geo.max_scroll, + )) |a| { + self.anchor = a; + self.anchor_applied_y = self.scroll.viewport.y; + } + } + fn ensureParsed(self: *Preview, content: []const u8, gpa: std.mem.Allocator) void { self.gpa = gpa; var hasher = std.hash.XxHash3.init(0); hasher.update(content); const h = hasher.final(); - if (self.content_hash == h and self.ast_root != null) return; + if (self.content_hash == h and (self.ast_root != null or self.parse_job != null)) return; + + // First open always hops to a worker — including the repo's own mid-size docs + // (PLUGINS.md / PLUGIN_MANIFEST_PLAN.md). Those used to parse+scan on the same frames + // as the preview sash / first layout, which is exactly when a hitch is most visible. + // Edits (ast already present) stay synchronous so we don't thrash workers per keystroke. + if (self.ast_root == null and self.parse_job == null) { + self.content_hash = h; + self.rs.clear(gpa); + dvui.log.info("markdown: async parse start ({d} bytes)", .{content.len}); + self.startParseJob(content, gpa, h); + return; + } + + // Content changed while a first-open parse was still running: wait for that worker, + // drop its now-stale AST, and parse the new bytes sync below. + if (self.parse_job) |job| { + job.destroy(); + self.parse_job = null; + } + + self.parseSync(content, gpa, h); + } + + fn parseSync(self: *Preview, content: []const u8, gpa: std.mem.Allocator, hash: u64) void { + // `scanNode` needs the original source, not just the AST: cmark's text nodes have had + // backslash escapes applied and adjacent runs merged, so `\[\[A]]` is indistinguishable + // from `[[A]]` by then. See `md/wikilink_scan.zig`. md_parse.freeCachedRoot(self.ast_root); self.ast_root = null; self.rs.clear(gpa); - self.content_hash = h; + self.content_hash = hash; + const t0 = std.Io.Clock.boot.now(dvui.io).nanoseconds; if (md_parse.parseMarkdown(content)) |ast| { self.ast_root = @ptrCast(ast.root.n); - _ = render_ast.scanNode(ast.root, &self.rs, gpa); + _ = render_ast.scanNode(ast.root, &self.rs, gpa, content); } + render_ast.stats.parse_ns +%= @intCast(std.Io.Clock.boot.now(dvui.io).nanoseconds - t0); + } + + fn startParseJob(self: *Preview, content: []const u8, gpa: std.mem.Allocator, hash: u64) void { + const bytes = gpa.dupe(u8, content) catch { + // OOM falling back to sync keeps the preview usable; a hitch beats a blank pane forever. + self.parseSync(content, gpa, hash); + return; + }; + const job = gpa.create(ParseJob) catch { + gpa.free(bytes); + self.parseSync(content, gpa, hash); + return; + }; + job.* = .{ + .bytes = bytes, + .hash = hash, + .gpa = gpa, + // Captured here, on the UI thread, because the worker cannot get it for itself: + // dvui's `current_window` is a plain global that is only meaningful between + // `Window.begin`/`end` on the UI thread. See `workerMain`. + .win = dvui.currentWindow(), + }; + self.parse_job = job; + // `Io.concurrent` matches the editor's file-load workers. Wasm never reaches here — + // this plugin is native-only (cmark + libc). + job.future = dvui.io.concurrent(ParseJob.workerMain, .{job}) catch { + // Thread pool unavailable: parse inline rather than leave the preview empty. + const owned = job.bytes; + const h = job.hash; + gpa.destroy(job); + self.parse_job = null; + self.parseSync(owned, gpa, h); + gpa.free(owned); + return; + }; + dvui.refresh(null, @src(), null); + } + + /// Pull a finished background parse into `ast_root` / `rs`. Returns true when the preview + /// should keep animating (job still running). + fn pollParseJob(self: *Preview) bool { + const job = self.parse_job orelse return false; + if (!job.done.load(.acquire)) { + dvui.refresh(null, @src(), null); + return true; + } + const gpa = self.gpa orelse job.gpa; + // Stale job: the document changed again while this one was parsing. Drop it. + if (job.hash != self.content_hash) { + job.destroy(); + self.parse_job = null; + return false; + } + md_parse.freeCachedRoot(self.ast_root); + self.ast_root = job.ast_root; + job.ast_root = null; + // Worker already filled `job.rs` (parse + scan). Swap it in — no UI-thread scan. + self.rs.clear(gpa); + self.rs.deinit(gpa); + self.rs = job.rs; + job.rs = .{}; + dvui.log.info("markdown: async parse ready ({d} bytes, {d} top-level blocks)", .{ + job.bytes.len, + self.rs.blocks.len(), + }); + job.destroy(); + self.parse_job = null; + dvui.refresh(null, @src(), null); + return false; } }; +const ParseJob = struct { + bytes: []u8, + hash: u64, + gpa: std.mem.Allocator, + /// The window to wake when the parse finishes. Captured on the UI thread — see `workerMain`. + win: *dvui.Window, + ast_root: ?*anyopaque = null, + /// Filled on the worker alongside `ast_root` so the UI thread only swaps pointers. + rs: render_ast.RenderState = .{}, + done: std.atomic.Value(bool) = .init(false), + future: ?std.Io.Future(void) = null, + + fn workerMain(self: *ParseJob) void { + defer { + self.done.store(true, .release); + // Wake the UI so `pollParseJob` runs without waiting for an unrelated input event. + // + // The window must be passed explicitly. `refresh(null, ...)` resolves through dvui's + // `current_window` global, which off the UI thread is either null (it logs an error + // and does nothing — which is what `zig build bench-markdown` caught) or, worse, a + // live pointer it then races on while only setting `extra_frames_needed`. Neither + // calls `backend.refresh()`, so neither actually wakes a sleeping app: the preview + // sat on "Loading preview…" until some unrelated input arrived. With the window in + // hand this takes `refreshBackend`, which is the documented cross-thread path. + dvui.refresh(self.win, @src(), null); + } + if (md_parse.parseMarkdown(self.bytes)) |ast| { + self.ast_root = @ptrCast(ast.root.n); + _ = render_ast.scanNode(ast.root, &self.rs, self.gpa, self.bytes); + } + } + + fn destroy(self: *ParseJob) void { + const gpa = self.gpa; + // Await the worker before freeing `bytes` / `rs` — it reads/writes them for the whole parse. + if (self.future) |*f| f.await(dvui.io); + gpa.free(self.bytes); + // Stale/abandoned jobs still own their AST + scan maps. + if (self.ast_root) |root| md_parse.freeCachedRoot(root); + self.rs.deinit(gpa); + gpa.destroy(self); + } +}; + +/// How far the pane's width may wander from the width already laid out before the preview accepts +/// it as a real resize — see where `column_w` is computed. Large enough to swallow the wobble a +/// pane picks up from an animated split ratio, small enough that a drag is followed closely. +const column_hysteresis: f32 = 6; + /// Floor for the preview's text column. The column tracks the scroll viewport above this, and /// below it the pane scrolls horizontally instead of crushing prose to one character per line. const min_preview_content_width: f32 = 360; @@ -60,6 +318,14 @@ pub const PreviewOptions = struct { image_base_dir: []const u8 = ".", /// Seed for widget ids so multiple previews don't collide. id_extra: u64 = 0, + /// Absolute path of the document being previewed, or `""` when it has none — an unsaved + /// buffer, or markdown fetched from the network (the store's README pane). + /// + /// Distinct from `image_base_dir`, which is a *directory* and may be a URL. This is the file + /// itself, and it's what `[[wikilink]]` resolution is relative to. Leaving it empty disables + /// wikilinks entirely: a fetched README must not resolve `[[Note]]` against the user's own + /// local files, and a link to nowhere is worse than the literal text it was written as. + document_path: []const u8 = "", /// Whether the preview paints fills behind its text at all — both the scroll area's own and /// each text widget's. `false` for a caller (the store's plugin detail page) that already /// draws its own background behind this and wants the preview to read as part of that pane @@ -73,6 +339,12 @@ pub const PreviewOptions = struct { /// Overrides the default `.content`-styled fill when `background` is true. Null keeps the /// existing look (a real `.md` file's own preview tab). color_fill: ?dvui.Color = null, + /// Inset between the pane's edges and the text column. Subtracted from the viewport to get + /// the column width, so widening it narrows the prose rather than pushing it into a + /// horizontal scroll. The default is the tight one wanted by a caller whose surrounding + /// panel already supplies breathing room (the store's detail page); a preview that runs + /// edge-to-edge in its own pane wants more (see `plugin.zig`'s `previewPane`). + content_padding: dvui.Rect = .{ .x = 8, .y = 8, .w = 8, .h = 8 }, }; /// Render `bytes` as a read-only markdown preview (own scroll area) into the current dvui parent. @@ -83,6 +355,7 @@ pub fn drawPreview( opts: PreviewOptions, ) void { state.ensureParsed(bytes, gpa); + const parsing = state.pollParseJob(); if (state.ast_root) |rp| { const root: md_parse.Node = .{ .n = @ptrCast(@alignCast(rp)) }; @@ -100,10 +373,21 @@ pub fn drawPreview( state.scroll.horizontal = .auto; state.scroll.vertical = .auto; + // Put the reader back where they were, *before* the scroll area reads `viewport.y` (it takes + // it in `init`). Doing this here rather than after `deinit` is the whole difference: the old + // code corrected the offset once the frame had already been drawn with the stale one, so + // every correction was visible as a jump instead of preventing one. + state.applyAnchor(opts); + var scroll = dvui.scrollArea(@src(), .{ .scroll_info = &state.scroll, .horizontal_bar = .auto, .vertical_bar = .auto_overlay, + // Deliberately not `lock_visible`. dvui's own anchoring pins to a *widget id*, and if + // that id is missing for one frame — which virtualized content cannot promise after a + // scrollbar jump — `ScrollContainerWidget` parks every child offscreen and the pane goes + // blank. Anchoring on a source line instead fails soft: a line that no longer exists + // resolves to the nearest preceding block. }, .{ .expand = .both, .background = opts.background, @@ -123,11 +407,35 @@ pub fn drawPreview( // past the viewport and parks centered content (the pixi logo) somewhere off to the // right. Long code fences keep their own horizontal scroll; images already fit to this // column. See KeybindSettings for the same scroll-container trap. - const pad: dvui.Rect = .{ .x = 8, .y = 8, .w = 8, .h = 8 }; + const pad = opts.content_padding; const pad_w = pad.x + pad.w; const viewport_w = state.scroll.viewport.w; const inner_w = if (viewport_w > pad_w) viewport_w - pad_w else 0; - const column_w = @max(min_preview_content_width, inner_w); + // Sticky: a width within `column_hysteresis` of the one already laid out reuses that + // exact value, so nothing downstream sees any change at all. + // + // The renderer treats a width change as "the pane is being resized": it stops trusting + // cached heights, stops spending its measuring budgets, unpins table heights, and asks for + // another frame. All correct for a sash drag — and catastrophic if the width never quite + // holds still, because then that state never ends. The pane's width comes from a split + // whose ratio is eased toward its target every frame, so "never quite holds still" is not + // hypothetical. + // + // Measured: a **one pixel** oscillation took the preview from idle on 197 of 400 frames to + // idle on *none* of them, while the reader drifted backwards as they scrolled forwards. + // Rounding to a grid does not fix it — two widths a pixel apart still land either side of + // a boundary. Reusing the previous value does, because the comparison downstream is + // against that same value. + // + // A real drag still registers: the reference only moves when it is actually adopted, so a + // slow drag accumulates against a fixed anchor and crosses the threshold within a few + // pixels of travel. + const raw_w = @max(min_preview_content_width, inner_w); + const column_w = blk: { + const prev = state.rs.blocks.layout_width; + if (prev > 0 and @abs(raw_w - prev) <= column_hysteresis) break :blk prev; + break :blk raw_w; + }; var v = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .none, @@ -147,7 +455,36 @@ pub fn drawPreview( .rs = &state.rs, .id_base = @intCast(opts.id_extra << 16), .background = opts.background, + .document_path = opts.document_path, + // What lets the renderer lay out only the blocks on screen. `viewport` is in the + // scroll area's virtual coordinates, where the column box starts at 0 — so the first + // block sits at its top padding. + // + // On a document's very first frame the `ScrollInfo` has not been laid out yet and its + // viewport is all zeros, which would read as "no viewport, draw everything" — and that + // frame is exactly the one that must not lay out a whole 60KB document, because it is + // the frame the preview pane opens on. The scroll area's own rect is already known by + // then, so it stands in. + .viewport = if (state.scroll.viewport.h > 0) + state.scroll.viewport + else + .{ .h = scroll.data().contentRect().h }, + .content_origin_y = pad.y, + .column_width = column_w, }); + } else if (parsing) { + dvui.labelNoFmt( + @src(), + "Loading preview…", + .{}, + .{ + .expand = .both, + .gravity_x = 0.5, + .gravity_y = 0.5, + .color_text = dvui.themeGet().color(.content, .text).opacity(0.55), + .id_extra = opts.id_extra, + }, + ); } else { dvui.labelNoFmt( @src(), @@ -165,6 +502,12 @@ pub fn drawPreview( scroll.deinit(); + // Record where the reader ended up, now that events, velocity and bounce have all had their + // say and `virtual_size` reflects what was actually laid out. Next frame's `applyAnchor` + // reconstructs this exact offset from the heights as they stand then — which is what lets a + // block above the reader change height without moving them. + state.captureAnchor(opts); + // `state.scroll` is the caller-owned `ScrollInfo` the area was driven by, so it stays valid // after `deinit` and holds this frame's final viewport/virtual size/offset. Horizontal // overflow only happens below the min column width. @@ -184,5 +527,6 @@ pub fn drawPreviewForDocument( "." else std.fs.path.dirname(document_path) orelse "."; + merged.document_path = document_path; drawPreview(state, bytes, gpa, merged); } diff --git a/src/plugins/markdown/src/md/block_heights.zig b/src/plugins/markdown/src/md/block_heights.zig new file mode 100644 index 00000000..f23f7903 --- /dev/null +++ b/src/plugins/markdown/src/md/block_heights.zig @@ -0,0 +1,1373 @@ +//! The markdown preview's top-level block height table — the bookkeeping half of the virtualized +//! block list in `render_ast.zig`. +//! +//! Deliberately std-only, exactly like `textcore` and `wikilink_scan`: every rule that decides +//! *where a block sits* and *whether its height can be trusted* lives here, so it can be unit +//! tested headlessly instead of only being observable as the preview misbehaving. `render_ast.zig` +//! keeps the half that needs dvui — laying blocks out and measuring them — and hands the numbers +//! back through `record`. +//! +//! The one invariant everything else serves: **a block's cached height is either a measurement or +//! an admission that it isn't one.** The renderer used to blur the two — an estimate could +//! overwrite a measurement on resize, and a knowingly-collapsed height could claim to be settled — +//! and every scroll instability downstream traced back to a number that was trusted more than it +//! deserved. + +const std = @import("std"); + +/// Font metrics the estimator needs, passed in rather than read from dvui so this file stays +/// testable. `em_w` is the width of an "M" in the body font. +pub const Metrics = struct { + line_h: f32, + em_w: f32, + + /// The metrics used throughout the tests below: a 16px line in a 10px-em font. Real values + /// come from `dvui.Font.theme(.body)`. + pub const test_default: Metrics = .{ .line_h = 16, .em_w = 10 }; +}; + +/// The span of markdown source one top-level block was parsed from — enough to guess its laid-out +/// height before it has ever been laid out, and to map a source line onto a block. +pub const SourceExtent = struct { + lines: u32 = 0, + bytes: u32 = 0, + /// 0-based first source line of the block, or `no_line` when cmark didn't report one. + start_line: u32 = no_line, + /// What kind of block this is, which is most of what decides how tall it lays out. + kind: BlockKind = .paragraph, + /// Hash of the block's source text, or 0 when there was none to hash. + /// + /// This is a block's identity *across re-parses*. Editing a document rebuilds the AST from + /// scratch, and every block index shifts the moment a line is added above — but a paragraph + /// nobody touched still hashes the same, so its measured height is still valid and can be + /// carried over instead of being thrown away and guessed at again. See `Table.by_source`. + hash: u64 = 0, + + pub const no_line: u32 = std.math.maxInt(u32); +}; + +/// The shapes a top-level block can take, as far as guessing its height is concerned. +/// +/// The estimator used to ignore this entirely: every block was "source lines, wrapped, times a +/// line height, times 0.8". That is roughly right for prose and badly wrong for everything else — +/// an image is one line of source and several hundred points tall, a table row is a line of source +/// and a line *plus cell padding* tall, a heading is a line of source in a much larger font. The +/// errors all point the same way, so a whole document came out at ~44% of its real length and the +/// scrollbar said so until every block had been measured. +pub const BlockKind = enum { + paragraph, + heading, + /// Fenced or indented code: monospace, and never wrapped. + code, + table, + /// A paragraph whose content is an image. + image, + list, + quote, + /// Thematic break. + rule, + html, +}; + +/// One top-level block's height, and how much that number is worth. +/// +/// This started as a single `settled: bool` doing two unrelated jobs: "this height is correct" and +/// "stop re-measuring this block". A table whose height could not yet be measured properly needed +/// the second and got the first for free, so a height everyone knew was wrong was marked +/// authoritative and never revisited. Splitting them is what lets `deferred` say "keep working on +/// this, and do not believe it yet". +pub const Height = struct { + h: f32, + state: State, + /// Consecutive contaminated measurements this block has produced. Bounds `deferred` so it + /// cannot demand frames for ever — see `wantsMeasure` and `deferred_max_attempts`. + attempts: u8 = 0, + + pub const State = enum { + /// Guessed from source extent; never laid out. Placement only. + estimated, + /// Laid out once at the current width. dvui sizes a widget from what its children + /// reported the frame before, so a first measurement is still settling. + measured, + /// Two consecutive measurements agreed. Trustworthy; don't re-measure. + settled, + /// Laid out, but the measurement was contaminated — a table inside it still has rows + /// that have never been measured, so the height it reported depends on which rows + /// happened to be culled this frame, and therefore on where the reader is scrolled. + /// The number is not an answer; the block keeps being drawn until it becomes one. + deferred, + }; + + /// Whether re-measuring this block could still improve the number. + /// + /// `deferred` says yes — drawing the block is what measures the table rows that made it + /// deferred in the first place, and excluding it meant those rows were never measured and the + /// block kept a wrong height for ever. + /// + /// But only `deferred_max_attempts` times. "Keep trying until it converges" is not a + /// termination argument, and when it does not converge the cost is not a wrong height, it is + /// an app that never sleeps: an unsatisfied block keeps `Stats.pending_measure` non-zero, + /// which asks for another frame, for ever. A table whose cells never settle (their measured + /// size disagreeing with the column width they are laid out in, frame after frame) is exactly + /// that case. After the cap the block keeps whatever height it has and stops asking; the next + /// width change resets the count, and scrolling to it draws it anyway. + pub fn wantsMeasure(self: Height) bool { + return switch (self.state) { + .estimated, .measured => true, + .deferred => self.attempts < deferred_max_attempts, + .settled => false, + }; + } + + /// Whether this height can be believed by the scrollbar's total. + pub fn trusted(self: Height) bool { + return self.state == .settled; + } +}; + +/// What one laid-out block reported back. +pub const Measurement = struct { + h: f32, + /// The measurement is contaminated and must not be believed — a table in this block still + /// has rows that have never been measured, so its height this frame depends on which rows + /// were culled, and therefore on where the reader is scrolled. Filed as `.deferred`. + partial: bool = false, +}; + +/// How many contaminated measurements a block may produce before it stops asking to be re-drawn. +/// +/// Sized for the legitimate case, not the pathological one: a table converges by measuring a few +/// KB of its rows per frame (`render_ast.table_measure_bytes`), so the 45KB table in +/// docs/PLUGIN_MANIFEST_PLAN.md needs a dozen-odd passes and a much larger one proportionally +/// more. At 60fps this cap is about four seconds — long enough that nothing real hits it, short +/// enough that a block which will *never* settle stops holding the app awake. +/// +/// An earlier value of 8 was tight enough to cut legitimate convergence short, which showed up as +/// the settle helper returning while tables were still hundreds of points from their real height. +pub const deferred_max_attempts: u8 = 240; + +/// Heights agreeing within this many pixels count as the same height. +/// +/// Exact float equality was the original rule, and a block whose layout jittered by a fraction of +/// a pixel between frames could therefore never reach `settled`: it re-measured forever, consuming +/// the frame's budget and perturbing the document total every frame it did. +pub const settle_epsilon: f32 = 0.5; + +/// Column widths within this many pixels count as unchanged. +pub const width_epsilon: f32 = 0.5; + +/// A scroll position expressed as *content identity* rather than as a pixel offset. +/// +/// This is the whole point of the anchor. An absolute `viewport.y` only means something relative +/// to a total height, and this renderer's total changes constantly — blocks get measured, the pane +/// resizes, the document is re-parsed under an edit. Every one of those silently redefined what +/// the reader's scroll offset pointed at, which is what made the preview jump. A line number does +/// not move when the block above it turns out to be 40px taller than guessed. +pub const Anchor = struct { + /// Hash of the anchored block's source, or 0 when it had none. + /// + /// Preferred over `line` when resolving, because it is the only identity an *edit* preserves: + /// inserting a line above the reader shifts every line number below it, so a line-based anchor + /// silently starts naming different content. The block itself is unchanged and hashes the + /// same. `line` remains as the fallback — a `revealLine` request has a line and no hash, and a + /// block whose text the edit did change has to land somewhere sensible. + hash: u64 = 0, + /// 0-based source line of the anchored block. + line: u32, + /// Pixels from that block's top down to the viewport top. + /// + /// Pixels, not a fraction of the block: a fraction re-scales when the anchor block's own + /// height settles, sliding the very text the reader is looking at. + offset_px: f32, + /// The reader is parked at the end of the document. A line anchor alone would drift up off the + /// bottom as blocks below it settle taller, so "at the end" is held as its own fact. + at_end: bool = false, +}; + +/// How close to `max_scroll` still counts as parked at the end. +pub const end_epsilon: f32 = 1.0; + +/// The block height table, in document order. +pub const Table = struct { + heights: std.ArrayListUnmanaged(Height) = .empty, + extents: std.ArrayListUnmanaged(SourceExtent) = .empty, + /// Column width `heights` was measured at. Negative means "nothing measured yet". + layout_width: f32 = -1, + + /// Measured heights keyed by block source hash, surviving re-parses — the thing that makes + /// typing in a live preview bearable. + /// + /// Without it, every keystroke re-parsed the document, cleared the height table, and left all + /// 50 blocks as estimates: the document's total collapsed from 20,093 to 8,886 and the reader + /// was thrown hundreds of points for several frames, once per character. Almost every block is + /// unchanged by an edit, so almost every height is still good — it just has to be findable by + /// something other than its index, which the edit moved. + by_source: std.AutoHashMapUnmanaged(u64, Height) = .empty, + + /// Ceiling on `by_source` before it is dropped wholesale. Entries for blocks that no longer + /// exist accumulate as a document is edited, and nothing else prunes them; heights are cheap + /// to relearn, so a bounded cache that occasionally forgets beats one that grows forever. + const by_source_max: usize = 8192; + + pub fn deinit(self: *Table, gpa: std.mem.Allocator) void { + self.heights.deinit(gpa); + self.extents.deinit(gpa); + self.by_source.deinit(gpa); + self.* = .{}; + } + + /// Full reset — a different document, or one whose layout must be rebuilt from nothing. + pub fn clear(self: *Table) void { + self.clearForReparse(); + self.by_source.clearRetainingCapacity(); + self.layout_width = -1; + } + + /// Reset for a re-parse of the *same* document: drop the positional arrays, whose indices the + /// edit just invalidated, but keep the heights that are keyed by content and the width they + /// were measured at. Blocks the edit did not touch are then re-seeded with their real heights + /// as their extents are re-recorded, instead of collapsing back to estimates. + pub fn clearForReparse(self: *Table) void { + self.heights.clearRetainingCapacity(); + self.extents.clearRetainingCapacity(); + } + + /// Record one block's source span, and seed its height from a previous parse when this exact + /// source has been measured before. + /// + /// Always appends exactly one height, so the two arrays stay index-for-index aligned. They have + /// to: a seeded entry landing at the wrong index would give one block another block's height. + /// A block with nothing to seed from gets a zero-height `.estimated` placeholder, which + /// `heightAt` resolves through `estimate` rather than reading back as zero. + pub fn appendExtent(self: *Table, gpa: std.mem.Allocator, e: SourceExtent) void { + self.extents.append(gpa, e) catch return; + const seeded: ?Height = if (e.hash != 0) self.by_source.get(e.hash) else null; + // Carry the *state* across too, not just the number. + // + // Flattening everything to `.measured` looked harmless and was not: `record` reads + // `.measured` as "a height from before a width change", and discards it in favour of even a + // contaminated measurement. A re-parse hands every table exactly one contaminated + // measurement — the cell sizes the renderer keys by AST node pointer die with the old tree + // — so on every keystroke a settled 900pt table was replaced by whatever that frame's + // half-culled layout reported. Measured at 42pt in the test below: an 858pt lurch under the + // reader, once per character typed. + // + // The source is byte-identical and the column has not moved, so the height has not changed + // either, and the state that vouched for it still holds. `attempts` starts fresh: this is a + // new tree, and whatever the old one struggled with is not this one's debt. + const entry: Height = if (seeded) |h| + (if (h.h > 0) Height{ .h = h.h, .state = h.state, .attempts = 0 } else .{ .h = 0, .state = .estimated }) + else + .{ .h = 0, .state = .estimated }; + self.heights.append(gpa, entry) catch { + // Keep the arrays aligned even under OOM — a short `heights` is recoverable + // (`ensureSlot` refills it), a misaligned one silently corrupts every later block. + _ = self.extents.pop(); + }; + } + + /// How many blocks the table knows about. Extents are recorded once per parse, so they are the + /// authority on block count; heights grow to match as blocks are placed. + pub fn len(self: *const Table) usize { + return self.extents.items.len; + } + + /// Rough laid-out height for a block that has never been drawn. + /// + /// Biased low on purpose, and the bias is only sound for deciding *what to draw*: guessing + /// short draws a few extra blocks, while guessing tall skips one that is really on screen and + /// flashes a gap. It is not sound as a scrollbar total, which is why a measurement must never + /// be replaced by one of these — see `invalidateForWidth`. + pub fn estimate(self: *const Table, index: usize, m: Metrics, column_width: f32) ?f32 { + if (index >= self.extents.items.len) return null; + const extent = self.extents.items[index]; + if (extent.lines == 0) return null; + + const src_lines: f32 = @floatFromInt(extent.lines); + const bytes: f32 = @floatFromInt(extent.bytes); + + // How many lines this much text wraps to at this column width. `em_w * 0.5` because + // ordinary prose averages a good deal narrower than an "M". + const avg_char_w = @max(1, m.em_w * 0.5); + const chars_per_line = @max(20, column_width / avg_char_w); + const wrapped = @ceil(bytes / chars_per_line); + + return switch (extent.kind) { + // Wrapped prose: however many lines it takes, whichever of the two counts is larger. + .paragraph, .html => @max(src_lines, wrapped) * m.line_h, + // List items each start a line, so the source line count is the floor, and wrapping + // adds to it rather than replacing it. + .list => (@max(src_lines, wrapped) + src_lines * 0.15) * m.line_h, + // Quotes wrap like prose and add their own padding. + .quote => @max(src_lines, wrapped) * m.line_h + 8, + // Headings are one line in a much bigger font. + .heading => src_lines * m.line_h * 1.8, + // Code never wraps — one source line is one laid-out line — plus the panel's padding. + .code => src_lines * m.line_h + 12, + // A table's height is driven by how much its cells *wrap*, not by how many rows it + // has: the 45KB table in docs/PLUGIN_MANIFEST_PLAN.md is 25 source lines and 13,310pt + // tall. Cell text wraps inside a column rather than across the whole width, so it + // needs far more lines than the same bytes of prose — measured at roughly a third of + // the full column width across the sample documents, which is what the divisor is. + // Calibrated, not derived; a table with very different proportions will be off. + .table => blk: { + const cell_chars = @max(8, chars_per_line / 1.7); + const text_lines = @ceil(bytes / cell_chars); + // Rows are the floor (an empty row still occupies one), and each pays the cell + // padding whatever its content does. + break :blk @max(src_lines, text_lines) * m.line_h + src_lines * 10 + 16; + }, + // Nothing in the source says how tall an image is; this is the middle of the range + // the renderer clamps them to (see `max_image_display_height`). Wrong either way, + // but wrong by a few hundred points instead of by five hundred. + .image => 240, + .rule => m.line_h, + }; + } + + /// This block's height for placement purposes: its cached value, or an estimate, or zero when + /// there is nothing to go on at all. + pub fn heightAt(self: *const Table, index: usize, m: Metrics, column_width: f32) f32 { + if (index < self.heights.items.len) { + const e = self.heights.items[index]; + // An estimate is re-derived rather than read back. It is a function of the current + // column width, and a stored one is whatever width happened to be in force when the + // slot was created — which after a re-parse is not necessarily this one. + if (e.state == .estimated) return self.estimate(index, m, column_width) orelse e.h; + return e.h; + } + return self.estimate(index, m, column_width) orelse 0; + } + + pub fn attemptsAt(self: *const Table, index: usize) u8 { + if (index < self.heights.items.len) return self.heights.items[index].attempts; + return 0; + } + + pub fn stateAt(self: *const Table, index: usize) Height.State { + if (index < self.heights.items.len) return self.heights.items[index].state; + return .estimated; + } + + /// Whether this block can be positioned at all — it has been laid out, or it has enough source + /// to guess from. A block that is neither (cmark reported no span for it) has no height and no + /// way to get one except being drawn, so the renderer must draw it unconditionally. That is + /// what keeps the table filling in without ever leaving a gap. + pub fn placeable(self: *const Table, index: usize) bool { + if (index < self.heights.items.len and self.heights.items[index].state != .estimated) return true; + return index < self.extents.items.len and self.extents.items[index].lines != 0; + } + + /// Make sure `heights` has a slot for `index`, filling any gap with estimates. Blocks are + /// placed in document order, so filling forward never skips a slot that later needs a + /// different value. + pub fn ensureSlot(self: *Table, gpa: std.mem.Allocator, index: usize, m: Metrics, column_width: f32) void { + while (self.heights.items.len <= index) { + const i = self.heights.items.len; + const h = self.estimate(i, m, column_width) orelse 0; + self.heights.append(gpa, .{ .h = h, .state = .estimated }) catch return; + } + } + + /// Virtual `y` of a block's top, by prefix sum. + pub fn yAt(self: *const Table, index: usize, m: Metrics, column_width: f32, origin_y: f32) f32 { + var y = origin_y; + var i: usize = 0; + while (i < index and i < self.len()) : (i += 1) y += self.heightAt(i, m, column_width); + return y; + } + + /// Total laid-out height of the document as currently believed. + pub fn total(self: *const Table, m: Metrics, column_width: f32) f32 { + var sum: f32 = 0; + var i: usize = 0; + while (i < self.len()) : (i += 1) sum += self.heightAt(i, m, column_width); + return sum; + } + + /// Fold a fresh measurement in. + /// + /// A `partial` measurement never overwrites a height we already have: an off-screen table + /// reporting its header height is strictly less informative than whatever the block last + /// measured on screen. + pub fn record(self: *Table, gpa: std.mem.Allocator, index: usize, mm: Measurement, m: Metrics, column_width: f32) void { + self.ensureSlot(gpa, index, m, column_width); + if (index >= self.heights.items.len) return; // allocation failed; nothing to update + // Resolved through `heightAt`, not read raw: an `.estimated` slot carries no stored height + // (it is derived from the current width on demand), so reading the field would see zero + // and treat a perfectly good guess as "nothing to keep". + const prev: Height = .{ + .h = self.heightAt(index, m, column_width), + .state = self.heights.items[index].state, + .attempts = self.heights.items[index].attempts, + }; + + if (mm.partial) { + // Keep what we had, but only when it is worth keeping: a height already confirmed at + // *this* width. A `.measured` entry here is one a width change just invalidated, so + // it describes a column the document no longer has — holding onto it would freeze the + // table at its pre-resize size. A contaminated measurement at the right width beats a + // clean one at the wrong width. + // Only `.measured` is excluded, and only because a width change is what produces it + // here: that height describes a column the document no longer has. An `.estimated` + // entry is kept — it is crude, but it is derived from the source and does not lurch + // when the reader scrolls, which a contaminated measurement very much does. + const keep = prev.h > 0 and prev.state != .measured; + const entry: Height = .{ + .h = if (keep) prev.h else mm.h, + .state = .deferred, + .attempts = prev.attempts +| 1, + }; + self.heights.items[index] = entry; + // Published as well: a large table may never reach `.settled`, and without this its + // height is the one thing a re-parse cannot recover — leaving the block to fall back + // to an estimate on every keystroke. + self.publish(gpa, index, entry); + return; + } + + const agrees = switch (prev.state) { + .measured, .settled => @abs(prev.h - mm.h) <= settle_epsilon, + .estimated, .deferred => false, + }; + // A clean measurement clears the strike count: whatever was wrong with this block has + // stopped being wrong, and it deserves the full budget again if it recurs. + const entry: Height = .{ .h = mm.h, .state = if (agrees) .settled else .measured, .attempts = 0 }; + self.heights.items[index] = entry; + self.publish(gpa, index, entry); + } + + /// Remember a measured height against its block's source, so a re-parse can find it again. + fn publish(self: *Table, gpa: std.mem.Allocator, index: usize, entry: Height) void { + if (index >= self.extents.items.len) return; + const hash = self.extents.items[index].hash; + if (hash == 0 or entry.h <= 0) return; + if (self.by_source.count() >= by_source_max and !self.by_source.contains(hash)) { + self.by_source.clearRetainingCapacity(); + } + self.by_source.put(gpa, hash, entry) catch {}; + } + + /// React to the text column changing width. + /// + /// Every wrapped block reflows, so no height is *current* any more — but a stale measurement is + /// still far closer to the truth than an estimate, so heights are kept and merely demoted to + /// re-measurable. They must not be clamped toward the estimate: an image or a table occupies + /// one line of source, so its estimate is a dozen pixels against a real several hundred, and + /// clamping collapsed the whole document's height model on every sash drag. + /// + /// Returns true when the width actually moved. + pub fn invalidateForWidth(self: *Table, column_width: f32) bool { + if (self.layout_width >= 0 and @abs(self.layout_width - column_width) <= width_epsilon) return false; + self.layout_width = column_width; + // `deferred` is demoted too. Its height is every bit as much a *previous width's* height + // as a settled one, and leaving it alone meant a table pinned to its old-width height + // could never learn the new one — the pin forced each measurement to equal the pin, so + // the block agreed with itself forever and the table never reflowed. + for (self.heights.items) |*e| { + e.state = .measured; + // A new width is a fresh problem — give every block its attempts back. + e.attempts = 0; + } + return true; + } + + /// Index of the block whose source hashes to `hash`, disambiguated by `near_line` when more + /// than one matches. Null for 0 (no hash recorded), or when the edit changed that block's + /// text, in which case the caller falls back to the line. + /// + /// Duplicates are not an edge case — a source hash identifies *text*, and real documents + /// repeat themselves. docs/PLUGIN_MANIFEST_PLAN.md has seven top-level blocks sharing one + /// hash. Taking the first match threw the reader to whichever copy came earliest in the + /// document, which is why anchoring by hash alone sent them to the top. + pub fn blockForHash(self: *const Table, hash: u64, near_line: u32) ?usize { + if (hash == 0) return null; + var best: ?usize = null; + var best_dist: u64 = std.math.maxInt(u64); + for (self.extents.items, 0..) |ext, i| { + if (ext.hash != hash) continue; + // Nearest by source line: an edit shifts lines a little, never across the document. + const line = if (ext.start_line == SourceExtent.no_line) 0 else ext.start_line; + const dist: u64 = if (line > near_line) line - near_line else near_line - line; + if (dist < best_dist) { + best_dist = dist; + best = i; + } + } + return best; + } + + /// Index of the top-level block containing 0-based source `line`: the last block that starts at + /// or before it. + /// + /// Blocks are in document order and their start lines ascend, but not every block has one — + /// cmark reports positions for the ones it parsed from source, and a block without one is + /// skipped rather than allowed to end the search early. + pub fn blockForLine(self: *const Table, line: u32) ?usize { + var best: ?usize = null; + for (self.extents.items, 0..) |ext, i| { + if (ext.start_line == SourceExtent.no_line) continue; + if (ext.start_line > line) break; + best = i; + } + return best; + } + + /// Where an anchor points, as a scroll offset against the *current* heights. + /// + /// Clamped to `max_scroll` by the caller's reckoning rather than by a stale internal total — + /// the scroll container is the authority on how much room there is, and the old code's habit + /// of clamping against a half-known total is what made a jump into a long document land short. + pub fn resolveAnchor( + self: *const Table, + a: Anchor, + m: Metrics, + column_width: f32, + origin_y: f32, + max_scroll: f32, + ) f32 { + const limit = @max(0, max_scroll); + if (a.at_end) return limit; + const idx = self.blockForHash(a.hash, a.line) orelse self.blockForLine(a.line) orelse return 0; + const y = self.yAt(idx, m, column_width, origin_y); + return std.math.clamp(y + a.offset_px, 0, limit); + } + + /// Turn the current scroll offset back into an anchor. Inverse of `resolveAnchor` while the + /// heights hold still, which is what makes the position a fixed point across frames. + pub fn captureAnchor( + self: *const Table, + viewport_y: f32, + m: Metrics, + column_width: f32, + origin_y: f32, + max_scroll: f32, + ) ?Anchor { + const n = self.len(); + if (n == 0) return null; + if (max_scroll > 0 and viewport_y >= max_scroll - end_epsilon) { + return .{ .line = 0, .offset_px = 0, .at_end = true }; + } + + // The block the viewport top falls inside. Past the end of the last block (bounce, or a + // total that shrank under us) anchors to that last block rather than to nothing. + var idx: usize = n - 1; + var idx_y: f32 = origin_y; + { + var y = origin_y; + var i: usize = 0; + while (i < n) : (i += 1) { + const h = self.heightAt(i, m, column_width); + if (viewport_y < y + h or i == n - 1) { + idx = i; + idx_y = y; + break; + } + y += h; + } + } + + // Anchor identity is a source line, and not every block has one. Walk back to the nearest + // block that does, rolling the skipped heights into the offset so the position stays + // exact rather than merely close. + while (self.extents.items[idx].start_line == SourceExtent.no_line) { + if (idx == 0) return null; + idx -= 1; + idx_y -= self.heightAt(idx, m, column_width); + } + + return .{ + .hash = self.extents.items[idx].hash, + .line = self.extents.items[idx].start_line, + .offset_px = viewport_y - idx_y, + }; + } + + /// Inclusive range of blocks that must be laid out to cover the viewport (plus slack). + pub const Range = struct { + first: usize, + last: usize, + + pub fn contains(self: Range, i: usize) bool { + return i >= self.first and i <= self.last; + } + + pub fn count(self: Range) usize { + return self.last - self.first + 1; + } + }; + + /// Which blocks the viewport covers, with `slack` pixels of over-draw each way. + /// + /// Guaranteed non-empty for a non-empty document, and that guarantee is the point. The old code + /// had no such invariant: it decided per block whether to draw, so a height table that had + /// drifted (say, every height still sized for a narrower column mid-sash-drag) could conclude + /// that *nothing* overlapped the viewport and render a blank pane. Stating the guarantee once, + /// here, is what makes that unrepresentable — rather than approximating it by biasing every + /// height downward and hoping the error lands the safe way. + pub fn visibleRange( + self: *const Table, + viewport_y: f32, + viewport_h: f32, + slack: f32, + m: Metrics, + column_width: f32, + origin_y: f32, + ) ?Range { + const n = self.len(); + if (n == 0) return null; + + const top = viewport_y - slack; + const bot = viewport_y + viewport_h + slack; + + var first: ?usize = null; + var last: usize = 0; + var y = origin_y; + var i: usize = 0; + while (i < n) : (i += 1) { + const h = self.heightAt(i, m, column_width); + const y_end = y + h; + // A zero-height block still counts as overlapping the point it sits at, so use `>=` + // for its end: otherwise a run of them at the viewport top selects nothing. + if (y_end >= top and y < bot) { + if (first == null) first = i; + last = i; + } + y = y_end; + } + + if (first) |f| return .{ .first = f, .last = @max(f, last) }; + + // Nothing overlapped: the viewport is off the end of what the table currently believes + // (or before its start). Fall back to the nearest block rather than drawing nothing. + if (viewport_y <= origin_y) return .{ .first = 0, .last = 0 }; + return .{ .first = n - 1, .last = n - 1 }; + } +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; +const tm = Metrics.test_default; + +/// Build a table of `n` blocks, each `lines` long, with ascending start lines. +fn testTable(gpa: std.mem.Allocator, n: usize, lines: u32) Table { + var t: Table = .{}; + var i: usize = 0; + while (i < n) : (i += 1) { + t.appendExtent(gpa, .{ + .lines = lines, + .bytes = lines * 40, + .start_line = @intCast(i * lines), + }); + } + return t; +} + +test "estimate is positive and grows with source" { + const gpa = testing.allocator; + var t = testTable(gpa, 3, 2); + defer t.deinit(gpa); + + const e = t.estimate(0, tm, 600).?; + try testing.expect(e > 0); + + var wide = testTable(gpa, 1, 40); + defer wide.deinit(gpa); + try testing.expect(wide.estimate(0, tm, 600).? > e); +} + +test "estimate is null without a source extent" { + const gpa = testing.allocator; + var t: Table = .{}; + defer t.deinit(gpa); + t.appendExtent(gpa, .{}); + try testing.expectEqual(@as(?f32, null), t.estimate(0, tm, 600)); +} + +test "measurement settles only after two agreeing draws" { + const gpa = testing.allocator; + var t = testTable(gpa, 1, 2); + defer t.deinit(gpa); + + try testing.expectEqual(Height.State.estimated, t.stateAt(0)); + + t.record(gpa, 0, .{ .h = 100 }, tm, 600); + try testing.expectEqual(Height.State.measured, t.stateAt(0)); + try testing.expect(!t.heights.items[0].trusted()); + + t.record(gpa, 0, .{ .h = 100 }, tm, 600); + try testing.expectEqual(Height.State.settled, t.stateAt(0)); + try testing.expect(t.heights.items[0].trusted()); + try testing.expect(!t.heights.items[0].wantsMeasure()); +} + +test "sub-pixel jitter still settles" { + const gpa = testing.allocator; + var t = testTable(gpa, 1, 2); + defer t.deinit(gpa); + + t.record(gpa, 0, .{ .h = 100.0 }, tm, 600); + t.record(gpa, 0, .{ .h = 100.2 }, tm, 600); + // Exact equality was the old rule; this block would have re-measured forever. + try testing.expectEqual(Height.State.settled, t.stateAt(0)); +} + +test "a real disagreement does not settle" { + const gpa = testing.allocator; + var t = testTable(gpa, 1, 2); + defer t.deinit(gpa); + + t.record(gpa, 0, .{ .h = 100 }, tm, 600); + t.record(gpa, 0, .{ .h = 140 }, tm, 600); + try testing.expectEqual(Height.State.measured, t.stateAt(0)); + try testing.expectEqual(@as(f32, 140), t.heights.items[0].h); +} + +test "partial measurement never overwrites a real height and is not trusted" { + const gpa = testing.allocator; + var t = testTable(gpa, 1, 2); + defer t.deinit(gpa); + + t.record(gpa, 0, .{ .h = 300 }, tm, 600); + t.record(gpa, 0, .{ .h = 300 }, tm, 600); + try testing.expectEqual(Height.State.settled, t.stateAt(0)); + + // A row inside it turns out never to have been measured, so this frame's height is a + // scroll-dependent mix of real rows and placeholders. + t.record(gpa, 0, .{ .h = 24, .partial = true }, tm, 600); + try testing.expectEqual(@as(f32, 300), t.heights.items[0].h); + try testing.expectEqual(Height.State.deferred, t.stateAt(0)); + // The old code marked this `settled` — claiming a height it knew was a placeholder. + try testing.expect(!t.heights.items[0].trusted()); + // ...and it must keep being drawn, because drawing it is what measures those rows. + try testing.expect(t.heights.items[0].wantsMeasure()); +} + +test "a block that never settles eventually stops asking to be re-measured" { + const gpa = testing.allocator; + var t = testTable(gpa, 1, 1); + defer t.deinit(gpa); + + // A table whose cells never agree with the column width they are laid out in produces a + // contaminated measurement every single frame. "Keep trying until it converges" is not a + // termination argument, and the cost of not terminating is not a wrong height — it is an app + // that never sleeps, because an unsatisfied block keeps asking for another frame. + var i: usize = 0; + while (i < deferred_max_attempts + 10) : (i += 1) { + t.record(gpa, 0, .{ .h = 100 + @as(f32, @floatFromInt(i % 7)), .partial = true }, tm, 600); + } + try testing.expectEqual(Height.State.deferred, t.stateAt(0)); + try testing.expect(!t.heights.items[0].wantsMeasure()); +} + +test "a clean measurement gives a struggling block its attempts back" { + const gpa = testing.allocator; + var t = testTable(gpa, 1, 1); + defer t.deinit(gpa); + + var i: usize = 0; + while (i < deferred_max_attempts + 10) : (i += 1) t.record(gpa, 0, .{ .h = 100, .partial = true }, tm, 600); + try testing.expect(!t.heights.items[0].wantsMeasure()); + + t.record(gpa, 0, .{ .h = 100 }, tm, 600); + try testing.expectEqual(@as(u8, 0), t.heights.items[0].attempts); + try testing.expect(t.heights.items[0].wantsMeasure()); +} + +test "a width change restores a exhausted block's attempts" { + const gpa = testing.allocator; + var t = testTable(gpa, 1, 1); + defer t.deinit(gpa); + + var i: usize = 0; + while (i < deferred_max_attempts + 10) : (i += 1) t.record(gpa, 0, .{ .h = 100, .partial = true }, tm, 600); + try testing.expect(!t.heights.items[0].wantsMeasure()); + + // A new column is a fresh problem, and worth spending the budget on again. + _ = t.invalidateForWidth(900); + try testing.expect(t.heights.items[0].wantsMeasure()); +} + +test "a re-parse keeps a table's height instead of a contaminated first measurement" { + const gpa = testing.allocator; + var t = testTable(gpa, 1, 1); + defer t.deinit(gpa); + t.extents.items[0].hash = 4242; + _ = t.invalidateForWidth(600); + + // A table that settled at 900pt before the edit. + t.record(gpa, 0, .{ .h = 900 }, tm, 600); + t.record(gpa, 0, .{ .h = 900 }, tm, 600); + try testing.expectEqual(Height.State.settled, t.stateAt(0)); + + // The user types elsewhere. The document re-parses: block indices are rebuilt, and the cell + // measurements the renderer keys by AST node pointer are gone with the old tree — so this + // table's very next measurement is contaminated, every time, on every keystroke. + t.clearForReparse(); + t.appendExtent(gpa, .{ .lines = 1, .bytes = 40, .start_line = 0, .kind = .table, .hash = 4242 }); + t.record(gpa, 0, .{ .h = 42, .partial = true }, tm, 600); + + // Its source is byte-identical and the column has not moved, so its height has not changed. + // Taking the contaminated 42 here is a jump of 858pt under the reader — once per character. + try testing.expectEqual(@as(f32, 900), t.heightAt(0, tm, 600)); +} + +test "a contaminated measurement does not displace a source estimate" { + const gpa = testing.allocator; + var t = testTable(gpa, 1, 2); + defer t.deinit(gpa); + + const est = t.estimate(0, tm, 600).?; + t.record(gpa, 0, .{ .h = 24, .partial = true }, tm, 600); + // The estimate does not move when the reader scrolls; the measurement does. Between two + // wrong numbers, prefer the stable one. + try testing.expectEqual(est, t.heights.items[0].h); + try testing.expectEqual(Height.State.deferred, t.stateAt(0)); +} + +test "a contaminated measurement is used only when there is nothing at all" { + const gpa = testing.allocator; + var t: Table = .{}; + defer t.deinit(gpa); + t.appendExtent(gpa, .{}); // no source span, so no estimate + + t.record(gpa, 0, .{ .h = 24, .partial = true }, tm, 600); + try testing.expectEqual(@as(f32, 24), t.heights.items[0].h); + try testing.expectEqual(Height.State.deferred, t.stateAt(0)); +} + +test "width change keeps measurements instead of clamping them to estimates" { + const gpa = testing.allocator; + // One block, one line of source — an image or a table. Its estimate is tiny; its real + // height is not. + var t = testTable(gpa, 1, 1); + defer t.deinit(gpa); + + _ = t.invalidateForWidth(600); + t.record(gpa, 0, .{ .h = 540 }, tm, 600); + t.record(gpa, 0, .{ .h = 540 }, tm, 600); + + const est = t.estimate(0, tm, 900).?; + try testing.expect(est < 100); // the estimator really is this wrong for such a block + + try testing.expect(t.invalidateForWidth(900)); + + // The regression this guards: the height used to be clamped to `@min(h, estimate)`, which + // collapsed 540 to ~13 on every sash drag and took the document's height model with it. + try testing.expectEqual(@as(f32, 540), t.heights.items[0].h); + try testing.expectEqual(Height.State.measured, t.stateAt(0)); + try testing.expect(t.heights.items[0].wantsMeasure()); +} + +test "width change within epsilon is not a change" { + const gpa = testing.allocator; + var t = testTable(gpa, 1, 2); + defer t.deinit(gpa); + + _ = t.invalidateForWidth(600); + t.record(gpa, 0, .{ .h = 80 }, tm, 600); + t.record(gpa, 0, .{ .h = 80 }, tm, 600); + try testing.expectEqual(Height.State.settled, t.stateAt(0)); + + try testing.expect(!t.invalidateForWidth(600.2)); + try testing.expectEqual(Height.State.settled, t.stateAt(0)); +} + +test "a width change invalidates a deferred height too" { + const gpa = testing.allocator; + var t = testTable(gpa, 1, 1); + defer t.deinit(gpa); + + _ = t.invalidateForWidth(600); + t.record(gpa, 0, .{ .h = 400 }, tm, 600); + t.record(gpa, 0, .{ .h = 18, .partial = true }, tm, 600); + try testing.expectEqual(Height.State.deferred, t.stateAt(0)); + + // A deferred height is still a height measured at the *old* width. Leaving it deferred let + // the renderer keep pinning the block to its pre-resize size, and because a pinned block + // measures exactly its pin, it agreed with itself forever and never reflowed. + _ = t.invalidateForWidth(900); + try testing.expectEqual(Height.State.measured, t.stateAt(0)); + try testing.expect(t.heights.items[0].wantsMeasure()); + + // ...and now a contaminated measurement at the new width is preferred over the old-width + // number, because the old number describes a column that no longer exists. + t.record(gpa, 0, .{ .h = 55, .partial = true }, tm, 900); + try testing.expectEqual(@as(f32, 55), t.heights.items[0].h); + try testing.expectEqual(Height.State.deferred, t.stateAt(0)); +} + +test "blockForLine picks the last block starting at or before the line" { + const gpa = testing.allocator; + var t = testTable(gpa, 4, 10); // blocks start at lines 0, 10, 20, 30 + defer t.deinit(gpa); + + try testing.expectEqual(@as(?usize, 0), t.blockForLine(0)); + try testing.expectEqual(@as(?usize, 0), t.blockForLine(9)); + try testing.expectEqual(@as(?usize, 1), t.blockForLine(10)); + try testing.expectEqual(@as(?usize, 3), t.blockForLine(30)); + // Past the end of the document: still the last block, not null. + try testing.expectEqual(@as(?usize, 3), t.blockForLine(9999)); +} + +test "blockForLine skips blocks cmark gave no position" { + const gpa = testing.allocator; + var t: Table = .{}; + defer t.deinit(gpa); + t.appendExtent(gpa, .{ .lines = 1, .bytes = 10, .start_line = 0 }); + t.appendExtent(gpa, .{ .lines = 1, .bytes = 10 }); // no_line + t.appendExtent(gpa, .{ .lines = 1, .bytes = 10, .start_line = 5 }); + + // The unpositioned block must not end the search early. + try testing.expectEqual(@as(?usize, 2), t.blockForLine(7)); + try testing.expectEqual(@as(?usize, 0), t.blockForLine(1)); +} + +test "blockForLine is null before the first positioned block" { + const gpa = testing.allocator; + var t: Table = .{}; + defer t.deinit(gpa); + t.appendExtent(gpa, .{ .lines = 1, .bytes = 10, .start_line = 4 }); + try testing.expectEqual(@as(?usize, null), t.blockForLine(0)); +} + +test "yAt is the prefix sum and total is the whole document" { + const gpa = testing.allocator; + var t = testTable(gpa, 3, 2); + defer t.deinit(gpa); + + var i: usize = 0; + while (i < 3) : (i += 1) t.record(gpa, i, .{ .h = 50 }, tm, 600); + + try testing.expectEqual(@as(f32, 0), t.yAt(0, tm, 600, 0)); + try testing.expectEqual(@as(f32, 50), t.yAt(1, tm, 600, 0)); + try testing.expectEqual(@as(f32, 100), t.yAt(2, tm, 600, 0)); + try testing.expectEqual(@as(f32, 150), t.total(tm, 600)); + + // origin offsets every block equally. + try testing.expectEqual(@as(f32, 58), t.yAt(1, tm, 600, 8)); +} + +test "visibleRange covers the viewport" { + const gpa = testing.allocator; + var t = testTable(gpa, 10, 2); + defer t.deinit(gpa); + var i: usize = 0; + while (i < 10) : (i += 1) t.record(gpa, i, .{ .h = 100 }, tm, 600); + + // Viewport 250..450, no slack → blocks 2,3,4. + const r = t.visibleRange(250, 200, 0, tm, 600, 0).?; + try testing.expectEqual(@as(usize, 2), r.first); + try testing.expectEqual(@as(usize, 4), r.last); + try testing.expect(r.contains(3)); + try testing.expect(!r.contains(5)); +} + +test "visibleRange widens with slack" { + const gpa = testing.allocator; + var t = testTable(gpa, 10, 2); + defer t.deinit(gpa); + var i: usize = 0; + while (i < 10) : (i += 1) t.record(gpa, i, .{ .h = 100 }, tm, 600); + + const r = t.visibleRange(250, 200, 100, tm, 600, 0).?; + try testing.expectEqual(@as(usize, 1), r.first); + try testing.expectEqual(@as(usize, 5), r.last); +} + +test "visibleRange is never empty, even past the end of the document" { + const gpa = testing.allocator; + var t = testTable(gpa, 5, 2); + defer t.deinit(gpa); + var i: usize = 0; + while (i < 5) : (i += 1) t.record(gpa, i, .{ .h = 100 }, tm, 600); + + // Scrolled far beyond anything the table believes exists — the blank-pane case. + const r = t.visibleRange(100_000, 200, 0, tm, 600, 0).?; + try testing.expect(r.count() >= 1); + try testing.expectEqual(@as(usize, 4), r.first); + + // And above the start. + const r2 = t.visibleRange(-5000, 200, 0, tm, 600, 0).?; + try testing.expect(r2.count() >= 1); + try testing.expectEqual(@as(usize, 0), r2.first); +} + +test "visibleRange still covers the reader after a narrow-to-wide resize" { + const gpa = testing.allocator; + var t = testTable(gpa, 20, 3); + defer t.deinit(gpa); + + // Laid out narrow: every block wrapped tall. + _ = t.invalidateForWidth(300); + var i: usize = 0; + while (i < 20) : (i += 1) { + t.record(gpa, i, .{ .h = 200 }, tm, 300); + t.record(gpa, i, .{ .h = 200 }, tm, 300); + } + + // Reader is in the middle, then the pane is widened. Heights are now all too tall, but they + // are kept (not clamped), so the range still resolves to real blocks around the viewport. + _ = t.invalidateForWidth(900); + const r = t.visibleRange(2000, 400, 200, tm, 900, 0).?; + try testing.expect(r.count() >= 1); + try testing.expect(r.contains(10)); +} + +test "visibleRange handles zero-height blocks without selecting nothing" { + const gpa = testing.allocator; + var t = testTable(gpa, 5, 1); + defer t.deinit(gpa); + var i: usize = 0; + while (i < 5) : (i += 1) t.record(gpa, i, .{ .h = 0 }, tm, 600); + + const r = t.visibleRange(0, 100, 0, tm, 600, 0).?; + try testing.expect(r.count() >= 1); +} + +test "visibleRange is null only for an empty document" { + const gpa = testing.allocator; + var t: Table = .{}; + defer t.deinit(gpa); + try testing.expectEqual(@as(?Table.Range, null), t.visibleRange(0, 500, 100, tm, 600, 0)); +} + +test "never-measured blocks are placed by estimate, not stacked at zero" { + const gpa = testing.allocator; + var t = testTable(gpa, 50, 4); + defer t.deinit(gpa); + + // Nothing measured at all — the first-open case. Every block must still get a distinct + // position, or virtualization piles the whole document at y=0 and draws all of it. + try testing.expect(t.total(tm, 600) > 0); + try testing.expect(t.yAt(49, tm, 600, 0) > t.yAt(1, tm, 600, 0)); + + const r = t.visibleRange(0, 500, 100, tm, 600, 0).?; + try testing.expect(r.last < 49); // i.e. it really did skip most of the document +} + +test "heights stay index-aligned with extents, and gaps read as estimates" { + const gpa = testing.allocator; + var t = testTable(gpa, 5, 3); + defer t.deinit(gpa); + + // One height slot per extent from the moment the extent is recorded. They must not drift + // apart: a seeded height landing at the wrong index gives one block another block's size. + try testing.expectEqual(t.extents.items.len, t.heights.items.len); + + // Record block 3 first; 0..2 must still be placed sensibly. + t.record(gpa, 3, .{ .h = 77 }, tm, 600); + try testing.expectEqual(@as(usize, 5), t.heights.items.len); + var i: usize = 0; + while (i < 3) : (i += 1) { + try testing.expectEqual(Height.State.estimated, t.stateAt(i)); + // Read through `heightAt`: an estimated slot stores no height of its own, because an + // estimate is a function of the column width it is asked about. + try testing.expect(t.heightAt(i, tm, 600) > 0); + } + try testing.expectEqual(@as(f32, 77), t.heightAt(3, tm, 600)); +} + +test "a block cmark gave no span is not placeable until it is drawn" { + const gpa = testing.allocator; + var t: Table = .{}; + defer t.deinit(gpa); + t.appendExtent(gpa, .{ .lines = 2, .bytes = 80, .start_line = 0 }); + t.appendExtent(gpa, .{}); // no span at all + + try testing.expect(t.placeable(0)); + // Must be drawn unconditionally: it has no height and no way to acquire one otherwise. + try testing.expect(!t.placeable(1)); + + t.record(gpa, 1, .{ .h = 60 }, tm, 600); + try testing.expect(t.placeable(1)); +} + +test "an estimated-but-placeable block does not demand a draw" { + const gpa = testing.allocator; + var t = testTable(gpa, 3, 2); + defer t.deinit(gpa); + // Never measured, but every block has source to guess from. + var i: usize = 0; + while (i < 3) : (i += 1) try testing.expect(t.placeable(i)); +} + +// -- anchoring --------------------------------------------------------------------------------- + +/// Every block 100px tall, blocks starting at source lines 0, 10, 20, ... +fn anchoredTable(gpa: std.mem.Allocator, n: usize) Table { + var t = testTable(gpa, n, 10); + var i: usize = 0; + while (i < n) : (i += 1) { + t.record(gpa, i, .{ .h = 100 }, tm, 600); + t.record(gpa, i, .{ .h = 100 }, tm, 600); + } + return t; +} + +test "an anchor follows its block through an edit that shifts every line" { + const gpa = testing.allocator; + var t = anchoredTable(gpa, 20); + defer t.deinit(gpa); + // Give each block a distinct source hash, as `recordBlockExtents` does. + for (t.extents.items, 0..) |*e, i| e.hash = 1000 + @as(u64, i); + + const want = t.yAt(10, tm, 600, 0) + 30; + const a = t.captureAnchor(want, tm, 600, 0, 10_000).?; + try testing.expectEqual(@as(u64, 1010), a.hash); + + // An edit inserts two blocks near the top. Every line number below shifts; the hashes do not. + var t2: Table = .{}; + defer t2.deinit(gpa); + var i: usize = 0; + while (i < 22) : (i += 1) { + const src: u64 = if (i < 2) 900 + @as(u64, i) else 1000 + @as(u64, i - 2); + // Lines all shifted by two relative to the original document. + t2.appendExtent(gpa, .{ .lines = 10, .bytes = 400, .start_line = @intCast(i * 10), .hash = src }); + } + i = 0; + while (i < 22) : (i += 1) { + t2.record(gpa, i, .{ .h = 100 }, tm, 600); + t2.record(gpa, i, .{ .h = 100 }, tm, 600); + } + + // The block that was index 10 is now index 12, and its *line* is no longer 100 — anchoring by + // line would land on whatever now occupies line 100. By hash it lands exactly. + try testing.expectEqual(@as(?usize, 12), t2.blockForHash(1010, a.line)); + try testing.expectApproxEqAbs(t2.yAt(12, tm, 600, 0) + 30, t2.resolveAnchor(a, tm, 600, 0, 10_000), 0.001); +} + +test "duplicate block sources resolve to the nearest one, not the first" { + const gpa = testing.allocator; + var t = anchoredTable(gpa, 20); + defer t.deinit(gpa); + // A document that repeats itself: blocks 2, 10 and 17 are the same text (a rule, a stock + // one-liner — real documents are full of these). + for (t.extents.items, 0..) |*e, i| e.hash = 1000 + @as(u64, i); + t.extents.items[2].hash = 777; + t.extents.items[10].hash = 777; + t.extents.items[17].hash = 777; + + // Anchored on the middle copy. Taking the first match would have sent the reader to block 2 — + // near the top of the document, which is exactly what it did. + const a = t.captureAnchor(t.yAt(10, tm, 600, 0), tm, 600, 0, 10_000).?; + try testing.expectEqual(@as(u64, 777), a.hash); + try testing.expectEqual(@as(?usize, 10), t.blockForHash(777, a.line)); + try testing.expectApproxEqAbs(t.yAt(10, tm, 600, 0), t.resolveAnchor(a, tm, 600, 0, 10_000), 0.001); + + // ...and the last copy resolves to itself too, not to either of the earlier ones. + const b = t.captureAnchor(t.yAt(17, tm, 600, 0), tm, 600, 0, 10_000).?; + try testing.expectEqual(@as(?usize, 17), t.blockForHash(777, b.line)); +} + +test "an anchor whose block the edit rewrote falls back to its line" { + const gpa = testing.allocator; + var t = anchoredTable(gpa, 20); + defer t.deinit(gpa); + for (t.extents.items, 0..) |*e, i| e.hash = 1000 + @as(u64, i); + + const a = t.captureAnchor(t.yAt(10, tm, 600, 0), tm, 600, 0, 10_000).?; + + // The reader's own block is the one that changed, so its hash is gone. The line still points + // at roughly the right place, which is the best available answer. + t.extents.items[10].hash = 424242; + try testing.expectEqual(@as(?usize, null), t.blockForHash(a.hash, a.line)); + try testing.expectApproxEqAbs(t.yAt(10, tm, 600, 0), t.resolveAnchor(a, tm, 600, 0, 10_000), 0.001); +} + +test "capture then resolve is the identity" { + const gpa = testing.allocator; + var t = anchoredTable(gpa, 20); + defer t.deinit(gpa); + + for ([_]f32{ 0, 50, 137, 400, 1250 }) |v| { + const a = t.captureAnchor(v, tm, 600, 0, 10_000).?; + try testing.expectApproxEqAbs(v, t.resolveAnchor(a, tm, 600, 0, 10_000), 0.001); + } +} + +test "capture then resolve is the identity with a content origin" { + const gpa = testing.allocator; + var t = anchoredTable(gpa, 20); + defer t.deinit(gpa); + + for ([_]f32{ 8, 137, 900 }) |v| { + const a = t.captureAnchor(v, tm, 600, 8, 10_000).?; + try testing.expectApproxEqAbs(v, t.resolveAnchor(a, tm, 600, 8, 10_000), 0.001); + } +} + +test "a block growing ABOVE the reader does not move the reader" { + const gpa = testing.allocator; + var t = anchoredTable(gpa, 20); + defer t.deinit(gpa); + + // Parked partway into block 10. + const before = t.yAt(10, tm, 600, 0) + 30; + const a = t.captureAnchor(before, tm, 600, 0, 10_000).?; + try testing.expectEqual(@as(u32, 100), a.line); // block 10 starts at source line 100 + try testing.expectApproxEqAbs(@as(f32, 30), a.offset_px, 0.001); + + // Blocks 0..4 turn out to be 60px taller each than they were guessed at — the warm-up sweep + // arriving, or a table finally measuring its rows. + var i: usize = 0; + while (i < 5) : (i += 1) { + t.record(gpa, i, .{ .h = 160 }, tm, 600); + t.record(gpa, i, .{ .h = 160 }, tm, 600); + } + + // The reader is still 30px into the same block. The offset absorbed the whole 300px, which + // under an absolute scroll offset would have shoved five blocks' worth of text past them. + const after = t.resolveAnchor(a, tm, 600, 0, 10_000); + try testing.expectApproxEqAbs(t.yAt(10, tm, 600, 0) + 30, after, 0.001); + try testing.expectApproxEqAbs(before + 300, after, 0.001); +} + +test "a block growing BELOW the reader does not move the reader at all" { + const gpa = testing.allocator; + var t = anchoredTable(gpa, 20); + defer t.deinit(gpa); + + const before = t.yAt(10, tm, 600, 0) + 30; + const a = t.captureAnchor(before, tm, 600, 0, 10_000).?; + + var i: usize = 15; + while (i < 20) : (i += 1) { + t.record(gpa, i, .{ .h = 400 }, tm, 600); + t.record(gpa, i, .{ .h = 400 }, tm, 600); + } + + try testing.expectApproxEqAbs(before, t.resolveAnchor(a, tm, 600, 0, 10_000), 0.001); +} + +test "a width change keeps the reader on the same block" { + const gpa = testing.allocator; + var t = anchoredTable(gpa, 20); + defer t.deinit(gpa); + + const a = t.captureAnchor(t.yAt(10, tm, 600, 0), tm, 600, 0, 10_000).?; + + // Pane widened; everything reflows shorter. + _ = t.invalidateForWidth(900); + var i: usize = 0; + while (i < 20) : (i += 1) { + t.record(gpa, i, .{ .h = 70 }, tm, 900); + t.record(gpa, i, .{ .h = 70 }, tm, 900); + } + + // Still at the top of block 10, wherever that now is. + try testing.expectApproxEqAbs(t.yAt(10, tm, 900, 0), t.resolveAnchor(a, tm, 900, 0, 10_000), 0.001); +} + +test "an anchor survives a reparse that shifts block indices" { + const gpa = testing.allocator; + var t = anchoredTable(gpa, 20); + defer t.deinit(gpa); + + // Reader at the top of the block starting on source line 100. + const a = t.captureAnchor(t.yAt(10, tm, 600, 0), tm, 600, 0, 10_000).?; + try testing.expectEqual(@as(u32, 100), a.line); + + // An edit inserts two new blocks near the top. Indices shift by two; source lines do not. + var t2: Table = .{}; + defer t2.deinit(gpa); + var i: usize = 0; + while (i < 22) : (i += 1) { + // Two extra blocks at lines 1 and 2, then the original blocks keep their lines. + const line: u32 = if (i < 2) @intCast(i + 1) else @intCast((i - 2) * 10); + t2.appendExtent(gpa, .{ .lines = 10, .bytes = 400, .start_line = line }); + } + i = 0; + while (i < 22) : (i += 1) { + t2.record(gpa, i, .{ .h = 100 }, tm, 600); + t2.record(gpa, i, .{ .h = 100 }, tm, 600); + } + + // The same source line now lives at index 12 — and that is where the reader ends up. + try testing.expectEqual(@as(?usize, 12), t2.blockForLine(100)); + try testing.expectApproxEqAbs(t2.yAt(12, tm, 600, 0), t2.resolveAnchor(a, tm, 600, 0, 10_000), 0.001); +} + +test "parked at the end stays at the end as the document grows" { + const gpa = testing.allocator; + var t = anchoredTable(gpa, 20); + defer t.deinit(gpa); + + const a = t.captureAnchor(1300, tm, 600, 0, 1300).?; + try testing.expect(a.at_end); + // The total grew by 500; "the end" moved with it. + try testing.expectApproxEqAbs(@as(f32, 1800), t.resolveAnchor(a, tm, 600, 0, 1800), 0.001); +} + +test "not-quite-at-the-end is not treated as at-the-end" { + const gpa = testing.allocator; + var t = anchoredTable(gpa, 20); + defer t.deinit(gpa); + + const a = t.captureAnchor(1200, tm, 600, 0, 1300).?; + try testing.expect(!a.at_end); +} + +test "a short document that cannot scroll is not parked at the end" { + const gpa = testing.allocator; + var t = anchoredTable(gpa, 2); + defer t.deinit(gpa); + + // max_scroll 0: everything is trivially "at the end", which must not latch. + const a = t.captureAnchor(0, tm, 600, 0, 0).?; + try testing.expect(!a.at_end); +} + +test "anchoring skips back over a block with no source position" { + const gpa = testing.allocator; + var t: Table = .{}; + defer t.deinit(gpa); + t.appendExtent(gpa, .{ .lines = 1, .bytes = 40, .start_line = 0 }); + t.appendExtent(gpa, .{ .lines = 1, .bytes = 40 }); // no_line + var i: usize = 0; + while (i < 2) : (i += 1) { + t.record(gpa, i, .{ .h = 100 }, tm, 600); + t.record(gpa, i, .{ .h = 100 }, tm, 600); + } + + // Inside the unpositioned block: anchors to the previous positioned one, with the skipped + // height rolled into the offset, so the round trip is still exact. + const a = t.captureAnchor(150, tm, 600, 0, 10_000).?; + try testing.expectEqual(@as(u32, 0), a.line); + try testing.expectApproxEqAbs(@as(f32, 150), a.offset_px, 0.001); + try testing.expectApproxEqAbs(@as(f32, 150), t.resolveAnchor(a, tm, 600, 0, 10_000), 0.001); +} + +test "capture is null for an empty document" { + const gpa = testing.allocator; + var t: Table = .{}; + defer t.deinit(gpa); + try testing.expectEqual(@as(?Anchor, null), t.captureAnchor(0, tm, 600, 0, 100)); +} + +test "resolve clamps into range" { + const gpa = testing.allocator; + var t = anchoredTable(gpa, 20); + defer t.deinit(gpa); + + // An anchor deep in the document, resolved against a total that cannot reach it. + const a: Anchor = .{ .line = 190, .offset_px = 0 }; + try testing.expectApproxEqAbs(@as(f32, 500), t.resolveAnchor(a, tm, 600, 0, 500), 0.001); + // And never negative. + const b: Anchor = .{ .line = 0, .offset_px = -9999 }; + try testing.expectApproxEqAbs(@as(f32, 0), t.resolveAnchor(b, tm, 600, 0, 500), 0.001); +} + +test "clear resets the width so the next layout re-invalidates" { + const gpa = testing.allocator; + var t = testTable(gpa, 2, 2); + defer t.deinit(gpa); + + _ = t.invalidateForWidth(600); + try testing.expect(!t.invalidateForWidth(600)); + t.clear(); + try testing.expect(t.invalidateForWidth(600)); +} diff --git a/src/plugins/markdown/src/md/cmark_parse.zig b/src/plugins/markdown/src/md/cmark_parse.zig index 6c10dc67..b7fa66f6 100644 --- a/src/plugins/markdown/src/md/cmark_parse.zig +++ b/src/plugins/markdown/src/md/cmark_parse.zig @@ -15,6 +15,11 @@ pub const Node = struct { return .{ .n = ptr }; } + pub fn parent(n: Node) ?Node { + const ptr = c.cmark_node_parent(n.n) orelse return null; + return .{ .n = ptr }; + } + pub fn nextSibling(n: Node) ?Node { const ptr = c.cmark_node_next(n.n) orelse return null; return .{ .n = ptr }; @@ -34,6 +39,27 @@ pub const Node = struct { return std.mem.span(ptr); } + /// Source position, 1-based. Populated for inline nodes unconditionally (`make_literal` in + /// cmark's `inlines.c`) — `CMARK_OPT_SOURCEPOS` only governs whether positions are *emitted* + /// in HTML output, not whether they're tracked. `cmark_consolidate_text_nodes` keeps the + /// first fragment's start and extends `end_column`, so a merged TEXT node still describes + /// the whole run it came from. See `wikilink_scan.zig` for what that's used for. + pub fn startLine(n: Node) i32 { + return c.cmark_node_get_start_line(n.n); + } + + pub fn endLine(n: Node) i32 { + return c.cmark_node_get_end_line(n.n); + } + + pub fn startColumn(n: Node) i32 { + return c.cmark_node_get_start_column(n.n); + } + + pub fn endColumn(n: Node) i32 { + return c.cmark_node_get_end_column(n.n); + } + pub fn linkUrl(n: Node) ?[:0]const u8 { const ptr = c.cmark_node_get_url(n.n) orelse return null; return std.mem.span(ptr); diff --git a/src/plugins/markdown/src/md/render_ast.zig b/src/plugins/markdown/src/md/render_ast.zig index bbd6c618..86b6654a 100644 --- a/src/plugins/markdown/src/md/render_ast.zig +++ b/src/plugins/markdown/src/md/render_ast.zig @@ -10,9 +10,213 @@ const net_image = @import("net_image.zig"); const html_images_mod = @import("html_images.zig"); const image_format = @import("image_format.zig"); const url_join = @import("url_join.zig"); +const wikilink_scan = @import("wikilink_scan.zig"); +const bh = @import("block_heights.zig"); + +const WikilinkApi = sdk.services.wikilink.Api; + +/// Where one `[[wikilink]]` resolved to, memoized per resolver generation. +pub const ResolvedLink = struct { + status: WikilinkApi.Status, + /// Absolute target path, gpa-owned. Empty unless `status` is `.resolved`/`.ambiguous`. + path: []u8 = &.{}, + /// 0-based line to reveal (a `#heading` that was found). + line: u32 = 0, +}; + +/// Memo key for one link: which text node, and which link within it. Node pointers are stable +/// for the life of the AST, and the whole memo is dropped when the AST is rebuilt. +fn wikilinkMemoKey(node: md.Node, token_index: usize) u64 { + return std.hash.Wyhash.hash(@intFromPtr(node.n), std.mem.asBytes(&token_index)); +} const is_windows = builtin.target.os.tag == .windows; +/// What one `renderDocument` call emitted, for `zig build bench-markdown`. Wall time varies by +/// machine and build mode; these counts don't, so they're the reproducible half of a before/after +/// comparison — and they're what the wall time is a function of, since every widget here costs a +/// layout pass and every `addText` costs text shaping. +/// +/// Always on: incrementing a counter next to a widget construction is unmeasurable against the +/// widget itself, and a build-mode gate would mean the numbers stop existing in exactly the +/// release build worth checking. +pub const Stats = struct { + /// `renderBlock` calls (every block node reached, visible or not). + blocks: u32 = 0, + /// `dvui.textLayout` widgets created. + text_layouts: u32 = 0, + /// `dvui.box` widgets created. + boxes: u32 = 0, + add_text_calls: u32 = 0, + add_text_bytes: u64 = 0, + /// Off-screen blocks and table rows whose height this frame is a cached guess that hasn't been + /// confirmed by two agreeing layout passes yet — the work the per-frame measuring budgets + /// (`resettle_budget`, `table_measure_bytes`) have deferred. + /// + /// `renderDocument` asks for another frame while this is non-zero, which is what makes the + /// budgets a way of *spreading* layout across frames rather than skipping it. Without that, + /// deferred work stalls whenever a frame happens to change no widget's size: dvui only + /// redraws when something asks it to, and a cached height asks for nothing by construction. + pending_measure: u32 = 0, + /// Nanoseconds spent parsing + pre-scanning the document, **accumulated** — one-time work + /// that lands entirely on the frame a document is opened on, which is the frame the user + /// feels as a hitch. Kept separate from `render_ns` so the two can be told apart. + parse_ns: u64 = 0, + /// Nanoseconds inside `renderDocument`, **accumulated** across frames — the benchmark zeroes + /// it and divides by its own iteration count. Everything else is per-document-draw. + render_ns: u64 = 0, +}; + +pub var stats: Stats = .{}; + +/// Per-top-level-block timing for `zig build bench-markdown`. Off unless a profiler is installed, +/// because it costs two clock reads per block. +pub const BlockSample = struct { + index: usize, + kind: [:0]const u8, + ns: u64, + text_layouts: u32, + add_text_bytes: u64, +}; +pub var block_profile: ?*std.ArrayListUnmanaged(BlockSample) = null; +pub var block_profile_gpa: ?std.mem.Allocator = null; + +/// Where a block sits and how much its height can be believed — see `block_heights.zig`. That +/// bookkeeping is deliberately dvui-free and unit tested; this file owns the half that needs a +/// layout pass, and feeds measurements back through `Table.record`. +pub const SourceExtent = bh.SourceExtent; + +/// One table cell's measured content size. `settled` follows the same two-agreeing-draws rule as +/// `bh.Height`, and for the same reason. +pub const CellSize = struct { + size: dvui.Size, + /// The column width the size was measured at. A wrapped cell's height is a function of the + /// width it was laid out in, so a height on its own says nothing — and the widths a grid + /// hands out before any cell has reported what it needs are placeholders (`colWidth` returns + /// a flat 100 for a column it has never sized). Two draws at a placeholder width agree with + /// each other perfectly, so without recording the width, a cell measured on a table's first + /// frames settles at one line and stays there. + col_w: f32, + settled: bool, +}; + +/// One table's column widths, and the inputs they were computed from. Recomputed when either +/// input moves — the pane resizing, or the theme's body font changing size under it. +pub const TableLayout = struct { + /// What each column wants on one unwrapped line, padding included (gpa-owned). + /// + /// Held separately from `widths` because it depends only on the table's *content* and the + /// fonts — never on how much room the table has. Measuring it means walking every cell in the + /// table and shaping its text, which on a 45KB table is milliseconds; recomputing that on + /// every frame of a sash drag (where only `avail` is moving) was most of what made dragging + /// the splitter crawl. + natural: []f32, + /// `natural` squeezed into `avail` (gpa-owned). Recomputed whenever `avail` moves, which is + /// cheap — `fitColumns` is a couple of passes over one float per column. + widths: []f32, + /// Width the table had to fit into. + avail: f32, + /// The width of an "M" in each font the widths were measured with, body and mono. A probe + /// rather than `Font.size`, because the size is not what moves: the app installs its themed + /// fonts a few frames into startup, so a table measured before that was measured in a + /// different *family* at the same nominal size, and every column came out too narrow. + body_m: f32, + mono_m: f32, +}; + +/// Escape hatch for tests: with this off, `renderTopLevel` lays out every block (and every table +/// row), on screen or not. +/// +/// Turned off only by `tests/integration.zig`'s "skipping off-screen blocks lays the document out +/// identically", which is what keeps this honest — it asserts that virtualized and full layouts +/// agree on **every top-level block's height and the resulting virtual size**, at several scroll +/// positions, on both sample documents. Not on pixels: dvui's testing backend has no render +/// targets, so `capturePng` is unavailable. Layout equality is the property that matters anyway — +/// a remembered height drifting from the measured one is exactly what would shift the document +/// and make the scrollbar lie. +/// +/// It can stay a plain global because that same test is what would catch a divergence if some +/// future caller ever set it. +pub var virtualize_blocks: bool = true; + +/// `FIZZY_MD_DIAG=1` logs the two things that make this preview jump: the column width being +/// treated as "still resizing", and a block's height changing under the reader. +/// +/// Env-gated because the useful version is noisy — it prints per block per frame — and because +/// every real cause found in this file so far was found by measuring, not by reasoning. A profile +/// says where time goes; this says what moved. +pub var diag: bool = false; + +pub fn initDiagFromEnv() void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + const raw = std.c.getenv("FIZZY_MD_DIAG") orelse return; + if (std.mem.eql(u8, std.mem.span(raw), "0")) return; + diag = true; + dvui.log.info("markdown: height diagnostics on (FIZZY_MD_DIAG)", .{}); +} + +/// A height change at least this large is worth reporting — bigger than any settling wobble. +const diag_height_jump: f32 = 50; + +/// Off-screen blocks `renderTopLevel` may re-measure per frame after a width change. Bounds what +/// a resize costs: without it, every frame of a window drag or a panel's open animation is a +/// full-document layout, which is the whole cost this virtualization exists to remove. +/// +/// Each block needs two draws to settle (dvui sizes a widget from what its children reported the +/// frame before), so a 180-block document is fully accurate again about 30 frames after the drag +/// stops. Until then the only thing that is off is the scrollbar's idea of the total height. +const resettle_below_budget: usize = 12; + +/// Off-screen blocks *above* the viewport top that may be re-measured per frame. Smaller than +/// `resettle_below_budget`: re-measuring one of these shifts the content under the reader for a +/// single frame before the anchor restores it. Non-zero because "never" leaves the scrollbar's +/// total permanently wrong. See the two-budget note in `renderTopLevel`. +const resettle_above_budget: usize = 4; + +/// Off-screen table text (in bytes of markdown) that may be measured per frame the first time a +/// table is seen. Same idea as `resettle_budget`, one level down: a 45KB table +/// (docs/PLUGIN_MANIFEST_PLAN.md has one) costs several milliseconds to lay out in full, and +/// doing that on the frame the document opens is the hitch this whole file is about. Spread over +/// frames instead, the table's height is briefly short — by however many rows are still unmeasured +/// — and settles within half a second. +/// +/// Counted in bytes rather than rows because rows differ wildly: this document's big table runs +/// ~2KB to a row, where an ordinary one runs ~50. A row budget that is gentle for the second is +/// several milliseconds a frame for the first. +const table_measure_bytes: u64 = 4000; + +/// Table text (in bytes) laid out on the frame a table is first seen, before any of its geometry +/// exists — enough to fill a screen with something. +/// +/// On that frame every row reports the grid's default height (a single line), so *every* row of a +/// tall table looks like it fits on screen and the visibility test above lets all of them through +/// — which is how one 45KB table came to cost 4.6ms on the frame its document opened. Capping the +/// first sight to roughly a screenful, and letting `table_measure_bytes` bring in the rest over +/// the next frames, is what keeps that frame cheap. From the second frame on the row heights are +/// real and the cap no longer applies. +const table_first_sight_bytes: u64 = 8000; + +inline fn statBlock() void { + stats.blocks += 1; +} + +/// `dvui.textLayout` + the counter, so no call site can add one without the other. +inline fn textLayout(src: std.builtin.SourceLocation, init_opts: dvui.TextLayoutWidget.InitOptions, opts: dvui.Options) *dvui.TextLayoutWidget { + stats.text_layouts += 1; + return dvui.textLayout(src, init_opts, opts); +} + +inline fn box(src: std.builtin.SourceLocation, init_opts: dvui.BoxWidget.InitOptions, opts: dvui.Options) *dvui.BoxWidget { + stats.boxes += 1; + return dvui.box(src, init_opts, opts); +} + +inline fn addText(tl: *dvui.TextLayoutWidget, txt: []const u8, opts: dvui.Options) void { + stats.add_text_calls += 1; + stats.add_text_bytes += txt.len; + tl.addText(txt, opts); +} + // Extension node kinds that cmark-gfm identifies by type string rather than // integer constant. Precomputed once after parsing so rendering never calls // typeString() or any C FFI inside the per-frame draw loop. @@ -35,6 +239,12 @@ pub const RenderState = struct { ext_node_kinds: std.AutoHashMapUnmanaged(usize, ExtNodeKind) = .empty, /// Set of @intFromPtr(node.n) for every node whose subtree contains an IMAGE. subtree_has_image: std.AutoHashMapUnmanaged(usize, void) = .empty, + /// Set of @intFromPtr(node.n) for every node whose subtree contains a TABLE. A table is + /// drawn with `dvui.grid`, which is a scroll container — and a scroll container lays out only + /// the rows inside its own viewport, so an off-screen table measures as its header alone. + /// That makes it the one block whose height `renderTopLevel` may not believe unless the block + /// was really on screen. + subtree_has_table: std.AutoHashMapUnmanaged(usize, void) = .empty, /// @intFromPtr(table_node.n) → column count (from header row). /// Avoids re-traversing the header row every render frame. table_col_counts: std.AutoHashMapUnmanaged(usize, usize) = .empty, @@ -45,6 +255,56 @@ pub const RenderState = struct { /// @intFromPtr(html_node.n) → every `` in that raw-HTML node (src + requested size), in /// document order (gpa-owned). Absent when the node has no ``. html_images: std.AutoHashMapUnmanaged(usize, []html_images_mod.Image) = .empty, + /// @intFromPtr(text_node.n) → the `[[wikilinks]]` in that node's literal, in document order + /// (gpa-owned). Absent when the node has none, which is the common case and the fast path. + /// + /// Content-derived only — token *positions*, never resolution results. Resolution lives in + /// `wikilink_resolved` below and deliberately does not belong here: this map is rebuilt only + /// when the document's content hash changes, but a link flips from broken to resolved when + /// its *target file* is created, which doesn't touch this document at all. + wikilinks: std.AutoHashMapUnmanaged(usize, []wikilink_scan.Token) = .empty, + /// `wikilinkMemoKey(node, token_index)` → where that link resolved to. Valid only while + /// `wikilink_generation` matches the resolver's `generation()`; cleared wholesale when it + /// moves. Owns its paths (gpa). + wikilink_resolved: std.AutoHashMapUnmanaged(u64, ResolvedLink) = .empty, + /// Resolver generation `wikilink_resolved` was populated against. `maxInt` means "nothing + /// memoized yet", which no real generation counter will collide with. + wikilink_generation: u64 = std.math.maxInt(u64), + + /// Where every top-level block sits, in document order: its source extent (enough to guess a + /// height before it has ever been laid out) and its measured height once it has. Without the + /// guess, opening a document costs one full-document layout (~13-20ms in ReleaseFast for a + /// 60KB file, landing on the frame a panel is animating open), because a block with no height + /// cannot be placed and so cannot be skipped. + blocks: bh.Table = .{}, + /// Content size each table cell measured at, keyed by @intFromPtr(cell node) — what a culled + /// row hands the grid in place of its contents. It has to be the cell's *own* measurement and + /// not the grid's row height / column width: the grid takes the max across a column, so + /// feeding those back widens the column a little, which rewraps a visible cell and moves the + /// whole table. (Measured the hard way: it showed up as one extra line of text in one row.) + cell_sizes: std.AutoHashMapUnmanaged(usize, CellSize) = .empty, + /// Rows in the block currently being laid out whose cells have never been measured, so they + /// stood in with a placeholder height this frame. + /// + /// This is what makes a table block's measurement *untrustworthy*: the grid reports a height + /// built from whatever mix of real and placeholder rows this frame happened to have, and that + /// mix is a function of where the reader is scrolled — so the same table measures differently + /// depending on how you arrived at it. Believing those numbers is what made the document jump + /// when scrolling past a table. Reset per top-level block by `renderTopLevel`. + block_rows_pending: usize = 0, + /// The block being laid out has spent its re-measure attempts (see + /// `block_heights.deferred_max_attempts`). Its table rows must then stop reporting themselves + /// as owed work: `Stats.pending_measure` asks for another frame, and a table whose cells never + /// settle would otherwise keep the whole app awake for ever. + block_measure_exhausted: bool = false, + /// Whether the text column changed width this frame — a sash drag, a window resize, a panel + /// animating open. Set by `renderTopLevel`, read by the table renderer, which spends its own + /// measuring budgets and needs the same answer for the same reason: work done at a width that + /// is about to change again is work thrown away. + width_in_flux: bool = false, + /// @intFromPtr(table_node.n) → the column widths that table is laid out at (gpa-owned), plus + /// what they were computed for. See `tableColumnWidths`. + table_layouts: std.AutoHashMapUnmanaged(usize, TableLayout) = .empty, pub fn deinit(self: *RenderState, gpa: std.mem.Allocator) void { self.clear(gpa); @@ -53,9 +313,15 @@ pub const RenderState = struct { self.image_decode_failed.deinit(gpa); self.ext_node_kinds.deinit(gpa); self.subtree_has_image.deinit(gpa); + self.subtree_has_table.deinit(gpa); self.table_col_counts.deinit(gpa); self.task_items.deinit(gpa); self.html_images.deinit(gpa); + self.wikilinks.deinit(gpa); + self.wikilink_resolved.deinit(gpa); + self.blocks.deinit(gpa); + self.cell_sizes.deinit(gpa); + self.table_layouts.deinit(gpa); } pub fn clear(self: *RenderState, gpa: std.mem.Allocator) void { @@ -69,11 +335,38 @@ pub const RenderState = struct { self.image_decode_failed.clearRetainingCapacity(); self.ext_node_kinds.clearRetainingCapacity(); self.subtree_has_image.clearRetainingCapacity(); + self.subtree_has_table.clearRetainingCapacity(); self.table_col_counts.clearRetainingCapacity(); self.task_items.clearRetainingCapacity(); var hi = self.html_images.valueIterator(); while (hi.next()) |urls| html_images_mod.free(urls.*, gpa); self.html_images.clearRetainingCapacity(); + var wi = self.wikilinks.valueIterator(); + while (wi.next()) |toks| gpa.free(toks.*); + self.wikilinks.clearRetainingCapacity(); + // Deliberately `clearForReparse`, not `clear`: this runs on every content change, which + // during editing is every keystroke. It drops the positional arrays (the edit invalidated + // their indices) while keeping the heights keyed by block source, so blocks the edit did + // not touch keep their measured heights instead of collapsing back to estimates. + self.blocks.clearForReparse(); + self.cell_sizes.clearRetainingCapacity(); + var tl_it = self.table_layouts.valueIterator(); + while (tl_it.next()) |tl| { + gpa.free(tl.natural); + gpa.free(tl.widths); + } + self.table_layouts.clearRetainingCapacity(); + self.clearResolvedWikilinks(gpa); + } + + /// Drop every memoized resolution. Called when the content changes (`clear`) and when the + /// resolver's generation moves — a new file appearing is exactly the case that has to + /// invalidate a "this link is broken" answer without the document itself changing. + pub fn clearResolvedWikilinks(self: *RenderState, gpa: std.mem.Allocator) void { + var it = self.wikilink_resolved.valueIterator(); + while (it.next()) |r| gpa.free(r.path); + self.wikilink_resolved.clearRetainingCapacity(); + self.wikilink_generation = std.math.maxInt(u64); } }; @@ -109,12 +402,36 @@ pub const RenderContext = struct { /// block's surrounding panel, the HTML-block tint, table header/row banding, and task /// bullets. Those are part of how the element reads, not a background behind the text. background: bool = true, + /// Absolute path of the document being rendered, `""` when it has none. Wikilinks resolve + /// relative to it, and are disabled entirely when it's empty — see `PreviewOptions`. + document_path: []const u8 = "", + /// The scroll area's visible region, in its own virtual coordinates, and the virtual `y` the + /// first top-level block starts at. Together they say which blocks are on screen, which is + /// what lets everything else be skipped — see `renderTopLevel`. A zero-height viewport + /// disables the skipping and draws the whole document (what a caller with no scroll area of + /// its own would want). + viewport: dvui.Rect = .{}, + content_origin_y: f32 = 0, + /// Width the blocks lay out at. Only used to notice it changed, which invalidates every + /// cached block height. + column_width: f32 = 0, + /// The `"wikilink"` resolver, looked up once per document draw rather than per link. + /// Null whenever wikilinks are off: no resolver plugin installed, or no `document_path`. + /// When null, `[[Note]]` renders as the literal text it always was. + wikilink: ?*WikilinkApi = null, }; /// Top/bottom margin every paragraph's `textLayout` carries. List markers match the top half so /// they line up with the paragraph they label (see `CMARK_NODE_LIST`). const paragraph_margin_y: f32 = 4; +/// Horizontal inset for the *framed* blocks — tables, code fences, blockquotes, raw HTML. Prose +/// runs the full column width; anything that draws its own panel sits a step in from it, so the +/// frame reads as a distinct object placed in the document rather than another line of text that +/// happens to have a border. Nested framed blocks (a fence inside a quote) inset again, which is +/// the intent: each level of containment is one step further in. +const block_inset_x: f32 = 14; + const max_image_bytes: usize = 16 * 1024 * 1024; const max_image_display_width: f32 = 720; const max_image_display_height: f32 = 540; @@ -135,11 +452,101 @@ inline fn hasImageSubtree(ctx: RenderContext, n: md.Node) bool { // AST pre-scan (called once after parsing, results stored in State) // --------------------------------------------------------------------------- -/// Walk the AST once, populating rs.ext_node_kinds and rs.subtree_has_image. +/// Original markdown source, for the one thing the AST can't answer on its own — see +/// `wikilink_scan.zig`. Built once per parse rather than per node. +const ScanSource = struct { + bytes: []const u8, + index: ?wikilink_scan.LineIndex, + + fn spanFor(self: ScanSource, node: md.Node) ?[]const u8 { + const index = self.index orelse return null; + return wikilink_scan.sourceSpanFor(self.bytes, index, node); + } +}; + +/// Walk the AST once, populating rs.ext_node_kinds, rs.subtree_has_image, and rs.wikilinks. /// Returns true when any node in the subtree rooted at `node` is an IMAGE. -pub fn scanNode(node: md.Node, rs: *RenderState, gpa: std.mem.Allocator) bool { +/// +/// `source` is the markdown these nodes were parsed from. +pub fn scanNode(node: md.Node, rs: *RenderState, gpa: std.mem.Allocator, source: []const u8) bool { + // A failed line index only costs escape detection, so scanning continues without it. + var index: ?wikilink_scan.LineIndex = wikilink_scan.LineIndex.build(gpa, source) catch null; + defer if (index) |*i| i.deinit(gpa); + const scan_source: ScanSource = .{ .bytes = source, .index = index }; + recordBlockExtents(node, rs, gpa, scan_source); + return scanNodeInner(node, rs, gpa, scan_source); +} + +/// What shape a top-level block is, for the height estimator. A `table` here is the GFM extension +/// node, which cmark reports through `typeString` rather than as a `CMARK_NODE_*` constant, so it +/// comes from the kind map the scan built — except that the scan has not run yet the first time +/// through, which is why the node's own type is checked first and the map only consulted for the +/// extension types it is the only source for. +fn blockKind(n: md.Node, rs: *const RenderState) bh.BlockKind { + switch (n.nodeType()) { + md.c.CMARK_NODE_HEADING => return .heading, + md.c.CMARK_NODE_CODE_BLOCK => return .code, + md.c.CMARK_NODE_LIST => return .list, + md.c.CMARK_NODE_BLOCK_QUOTE => return .quote, + md.c.CMARK_NODE_THEMATIC_BREAK => return .rule, + md.c.CMARK_NODE_HTML_BLOCK => return .html, + md.c.CMARK_NODE_PARAGRAPH => { + // A paragraph that exists to hold a picture is nothing like a paragraph of prose: one + // line of source, and up to `max_image_display_height` on screen. + var c = n.firstChild(); + while (c) |x| : (c = x.nextSibling()) { + if (x.nodeType() == md.c.CMARK_NODE_IMAGE) return .image; + } + return .paragraph; + }, + else => {}, + } + if (rs.ext_node_kinds.get(@intFromPtr(n.n))) |k| { + if (k == .table) return .table; + } + if (std.mem.eql(u8, n.typeString(), "table")) return .table; + return .paragraph; +} + +/// Record each top-level block's source span, for `renderTopLevel`'s first-sight height guess. +/// cmark reports 1-based line numbers for block nodes; a node whose lines don't fit the source +/// (nothing observed doing this, but the API doesn't promise it) simply gets a zero extent, and a +/// zero extent means "no guess available" — that block is drawn rather than estimated. +fn recordBlockExtents(doc_node: md.Node, rs: *RenderState, gpa: std.mem.Allocator, source: ScanSource) void { + var child = doc_node.firstChild(); + while (child) |ch| : (child = ch.nextSibling()) { + var extent: SourceExtent = .{ .kind = blockKind(ch, rs) }; + const start = ch.startLine(); + const end = ch.endLine(); + if (start >= 1 and end >= start) { + extent.lines = @intCast(end - start + 1); + extent.start_line = @intCast(start - 1); + if (source.index) |idx| { + const starts = idx.starts; + const first: usize = @intCast(start - 1); + const after: usize = @intCast(end); + if (first < starts.len) { + const from = starts[first]; + const to = if (after < starts.len) starts[after] else @as(u32, @intCast(source.bytes.len)); + if (to > from) { + extent.bytes = to - from; + // The block's identity across re-parses. Hashed from the source rather + // than the AST because that is what an edit actually changes — a + // paragraph nobody touched hashes the same however far its index moved. + extent.hash = std.hash.XxHash3.hash(0, source.bytes[from..to]); + } + } + } + } + rs.blocks.appendExtent(gpa, extent); + } +} + +fn scanNodeInner(node: md.Node, rs: *RenderState, gpa: std.mem.Allocator, source: ScanSource) bool { + var self_has_table = false; const ts = node.typeString(); if (std.mem.eql(u8, ts, "table")) { + self_has_table = true; rs.ext_node_kinds.put(gpa, @intFromPtr(node.n), .table) catch {}; // Count columns once from the header (or first body row) so the render // loop never needs to re-traverse the row for this. @@ -167,6 +574,22 @@ pub fn scanNode(node: md.Node, rs: *RenderState, gpa: std.mem.Allocator) bool { if (node.nodeType() == md.c.CMARK_NODE_ITEM and node.isTaskListItem()) rs.task_items.put(gpa, @intFromPtr(node.n), node.taskListItemChecked()) catch {}; + // `[[wikilinks]]`. Only TEXT nodes: inline code (CMARK_NODE_CODE), fenced/indented code + // blocks, and raw HTML all have their own node types and never reach here, so "don't link + // inside code" needs no work. Link *labels* do — `[see [[A]]](http://x)` puts that text + // under a LINK parent, and turning part of a link's own label into a second link is not a + // thing a `TextLayoutWidget` can express. + if (node.nodeType() == md.c.CMARK_NODE_TEXT and !insideLinkOrImage(node)) { + if (node.literal()) |t| { + if (wikilink_scan.tokensFor(gpa, t, source.spanFor(node))) |toks| { + if (toks.len > 0) + rs.wikilinks.put(gpa, @intFromPtr(node.n), toks) catch gpa.free(toks) + else + gpa.free(toks); + } else |_| {} + } + } + var self_has_image = (node.nodeType() == md.c.CMARK_NODE_IMAGE); // Raw HTML: GitHub READMEs routinely wrap their hero image in `

`, @@ -185,14 +608,30 @@ pub fn scanNode(node: md.Node, rs: *RenderState, gpa: std.mem.Allocator) bool { var child = node.firstChild(); while (child) |ch| : (child = ch.nextSibling()) { - if (scanNode(ch, rs, gpa)) self_has_image = true; + if (scanNodeInner(ch, rs, gpa, source)) self_has_image = true; + if (rs.subtree_has_table.contains(@intFromPtr(ch.n))) self_has_table = true; } if (self_has_image) rs.subtree_has_image.put(gpa, @intFromPtr(node.n), {}) catch {}; + if (self_has_table) + rs.subtree_has_table.put(gpa, @intFromPtr(node.n), {}) catch {}; return self_has_image; } +/// True when `node` sits inside a markdown link or image, where its text is a label rather than +/// body prose. Walks parents once per parse, never per frame. +fn insideLinkOrImage(node: md.Node) bool { + var p = node.parent(); + while (p) |parent| : (p = parent.parent()) { + switch (parent.nodeType()) { + md.c.CMARK_NODE_LINK, md.c.CMARK_NODE_IMAGE => return true, + else => {}, + } + } + return false; +} + // --------------------------------------------------------------------------- // Image preloading (keep GPU textures warm every frame, even when pane is closed) // --------------------------------------------------------------------------- @@ -341,42 +780,131 @@ fn resolveImageBytes(ctx: RenderContext, arena: std.mem.Allocator, raw_url: []co return .{ .bytes = fresh }; } -/// Clickable markdown hyperlink. `file://` URLs (including zls hover's `file:///path#L12` -/// form) open in the editor via workbench `revealPosition`; everything else falls through to -/// `dvui.openURL`. Middle-click / Ctrl/Cmd+click requests a side split for file targets (and -/// a new browser window for http(s)), matching `TextLayoutWidget.addLink`. -fn addMarkdownLink(tl: *dvui.TextLayoutWidget, url: []const u8, text: ?[]const u8, opts: dvui.Options) void { +/// Clickable markdown hyperlink. Resolution order: +/// 1. `file://` URIs (including zls hover's `file:///path#L12`) → editor +/// 2. Scheme-less relative/absolute paths against the document directory → editor +/// (brain's `[Title](../note.md)` inserts, and ordinary in-vault markdown links) +/// 3. Everything else → `dvui.openURL` (http(s), mailto, …) +/// +/// Middle-click / Ctrl/Cmd+click requests a side split for file targets (and a new browser +/// window for http(s)), matching `TextLayoutWidget.addLink`. +fn addMarkdownLink( + tl: *dvui.TextLayoutWidget, + url: []const u8, + text: ?[]const u8, + opts: dvui.Options, + ctx: RenderContext, +) void { const defs: dvui.Options = .{ .color_text = dvui.themeGet().focus, .font = dvui.Font.theme(.body).withUnderline(.{}) }; if (tl.addTextClick(text orelse url, defs.override(opts))) |click_event| { const open_side = (click_event == .mouse and (click_event.mouse.button == .middle or click_event.mouse.mod.matchBind("ctrl/cmd"))); - openMarkdownUrl(url, open_side); + openMarkdownUrl(url, open_side, ctx); } } -fn openMarkdownUrl(url: []const u8, open_side: bool) void { +fn openMarkdownUrl(url: []const u8, open_side: bool, ctx: RenderContext) void { if (tryRevealFileUri(url, open_side)) return; + if (tryRevealRelativePath(url, open_side, ctx)) return; // `untitled://` (zls hover for unsaved buffers) and other non-http schemes have nowhere // useful to go via the system opener — skip them rather than hand SDL a junk URL. if (std.ascii.startsWithIgnoreCase(url, "untitled:")) return; _ = dvui.openURL(.{ .url = url, .new_window = open_side }); } +/// Resolve a scheme-less link against the document's directory and open it in the editor. +/// Returns false for URLs with a scheme (`http:`, `mailto:`, …), when there's no local base, +/// or when resolution fails. Fragments (`#heading`) are stripped for the path lookup; line +/// stays 0 for now (heading→line needs the brain index and can land later). +fn tryRevealRelativePath(url: []const u8, open_side: bool, ctx: RenderContext) bool { + const trimmed = std.mem.trim(u8, url, " \t\r\n"); + if (trimmed.len == 0) return false; + + // Anything with `://` is a real URL. A single `:` could be a Windows drive (`C:…`) — we + // only treat that as local when it looks like `X:/` or `X:\`; otherwise bail to openURL. + if (std.mem.indexOf(u8, trimmed, "://") != null) return false; + if (std.mem.indexOfScalar(u8, trimmed, ':')) |colon| { + const windows_drive = colon == 1 and std.ascii.isAlphabetic(trimmed[0]) and + trimmed.len > 2 and (trimmed[2] == '/' or trimmed[2] == '\\'); + if (!windows_drive) return false; + } + + var path_part = trimmed; + if (std.mem.indexOfScalar(u8, path_part, '#')) |hash| path_part = path_part[0..hash]; + if (path_part.len == 0) return false; + + // Percent-decode `%20` etc. so brain's encoded inserts round-trip. + const arena = dvui.currentWindow().arena(); + const decoded = percentDecode(arena, path_part) catch return false; + + const abs = blk: { + if (std.fs.path.isAbsolute(decoded)) + break :blk std.fs.path.resolve(arena, &.{decoded}) catch return false; + const base = ctx.image_base_dir orelse dirnameOf(ctx.document_path) orelse return false; + // Remote README bases are URLs — relative *page* links aren't editor targets. + if (std.mem.indexOf(u8, base, "://") != null) return false; + break :blk std.fs.path.resolve(arena, &.{ base, decoded }) catch return false; + }; + + return revealPath(abs, 0, 0, open_side); +} + +fn dirnameOf(path: []const u8) ?[]const u8 { + if (path.len == 0) return null; + return std.fs.path.dirname(path); +} + +fn percentDecode(arena: std.mem.Allocator, src: []const u8) ![]const u8 { + if (std.mem.indexOfScalar(u8, src, '%') == null) return src; + var out: std.ArrayList(u8) = .empty; + try out.ensureTotalCapacity(arena, src.len); + var i: usize = 0; + while (i < src.len) { + if (src[i] == '%' and i + 2 < src.len) { + const byte = std.fmt.parseInt(u8, src[i + 1 .. i + 3], 16) catch { + try out.append(arena, src[i]); + i += 1; + continue; + }; + try out.append(arena, byte); + i += 3; + } else { + try out.append(arena, src[i]); + i += 1; + } + } + return out.toOwnedSlice(arena); +} + /// Opens a `file://` URI (optionally with a `#L` / `#LC` fragment) in the editor. /// Returns false when the URL isn't a file URI or workbench isn't available. fn tryRevealFileUri(url: []const u8, open_side: bool) bool { - const wb = sdk.host().getServiceTyped(sdk.services.workbench.Api) orelse return false; const arena = dvui.currentWindow().arena(); const parsed = parseFileUri(arena, url) orelse return false; // zls (and VS Code-style `#L` fragments) are 1-based; workbench is 0-based. const line: u32 = if (parsed.line_1based > 0) parsed.line_1based - 1 else 0; const character: u32 = if (parsed.character_1based > 0) parsed.character_1based - 1 else 0; - _ = wb.revealPosition(parsed.path, line, character, open_side) catch |err| { - dvui.log.err("markdown: revealPosition failed for {s}: {any}", .{ parsed.path, err }); - return true; // still a file URI — don't fall through to openURL - }; + // Reaching workbench is what actually opens it, but a `file://` URL is *ours* either way — + // returning true even when that fails keeps a broken editor link from being handed to the + // system browser. + _ = revealPath(parsed.path, line, character, open_side); return true; } +/// Opens `path` (native, absolute) in the editor at a 0-based `line`/`character`, splitting to +/// the side when `open_side`. Returns false when workbench isn't available or refused. +/// +/// Split out of `tryRevealFileUri` so a caller that already *has* a path — a resolved wikilink — +/// doesn't have to encode it into a `file://` URI just to have it decoded straight back. That +/// round trip isn't merely wasteful: it has to percent-encode, and a path containing a space or +/// a `#` is exactly where a hand-rolled encoder goes wrong. +fn revealPath(path: []const u8, line: u32, character: u32, open_side: bool) bool { + const wb = sdk.host().getServiceTyped(sdk.services.workbench.Api) orelse return false; + return wb.revealPosition(path, line, character, open_side) catch |err| { + dvui.log.err("markdown: revealPosition failed for {s}: {any}", .{ path, err }); + return false; + }; +} + const ParsedFileUri = struct { path: []const u8, line_1based: u32 = 0, @@ -510,14 +1038,14 @@ fn renderUndecodableImage(alt: []const u8, url: []const u8, ctx: RenderContext, renderMarkdownImagePlaceholder(text, ids); return; } - var tl = dvui.textLayout(@src(), .{}, .{ + var tl = textLayout(@src(), .{}, .{ .expand = .horizontal, .margin = .{ .y = 2, .h = 2 }, .background = ctx.background, .id_extra = ids.next(), }); defer tl.deinit(); - addMarkdownLink(tl, url, text, .{ .font = dvui.Font.theme(.mono).larger(-1) }); + addMarkdownLink(tl, url, text, .{ .font = dvui.Font.theme(.mono).larger(-1) }, ctx); } fn renderMarkdownImagePlaceholder(msg: []const u8, ids: *IdGen) void { @@ -534,7 +1062,7 @@ fn renderMarkdownImage(img: md.Node, span: dvui.Options, ctx: RenderContext, ids _ = span; const arena = dvui.currentWindow().arena(); const raw_url = img.linkUrl() orelse { - var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ + var outer = box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .margin = .{ .y = 4, .h = 4 }, .id_extra = ids.next(), @@ -585,7 +1113,7 @@ fn renderImageUrl(raw_url: []const u8, alt: []const u8, want: RequestedSize, ctx else => {}, } - var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ + var outer = box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .margin = .{ .y = 4, .h = 4 }, .id_extra = ids.next(), @@ -594,7 +1122,12 @@ fn renderImageUrl(raw_url: []const u8, alt: []const u8, want: RequestedSize, ctx // Hard ceiling so an unconstrained (or still-too-large) image can't take over the pane. // Percentage widths are *not* resolved against this — see below. - const avail_w = outer.data().contentRect().w; + // During a sash/resize frame the wrapper's content rect can briefly report 0 — fall back to + // the column width so the hero doesn't collapse to nothing for that frame. + const avail_w = blk: { + const w = outer.data().contentRect().w; + break :blk if (w > 1) w else ctx.column_width; + }; const bytes: []const u8 = switch (resolved) { .bytes => |b| b, @@ -606,13 +1139,13 @@ fn renderImageUrl(raw_url: []const u8, alt: []const u8, want: RequestedSize, ctx renderMarkdownImagePlaceholder(msg, ids); // A remote image that failed to load is still worth reaching: offer the link. if (net_image.isRemote(url_trim)) { - var tl = dvui.textLayout(@src(), .{}, .{ + var tl = textLayout(@src(), .{}, .{ .expand = .horizontal, .background = ctx.background, .id_extra = ids.next(), }); defer tl.deinit(); - addMarkdownLink(tl, url_trim, "open", .{ .font = dvui.Font.theme(.mono) }); + addMarkdownLink(tl, url_trim, "open", .{ .font = dvui.Font.theme(.mono) }, ctx); } return; }, @@ -704,14 +1237,14 @@ fn alignGravityX(want: RequestedSize) f32 { fn renderImageCaption(alt: []const u8, ctx: RenderContext, ids: *IdGen) void { if (alt.len == 0) return; - var cap = dvui.textLayout(@src(), .{}, .{ + var cap = textLayout(@src(), .{}, .{ .expand = .horizontal, .margin = .{ .y = 2, .h = 0 }, .background = ctx.background, .id_extra = ids.next(), }); defer cap.deinit(); - cap.addText(alt, .{ + addText(cap, alt, .{ .font = dvui.Font.theme(.body).larger(-1), .color_text = dvui.themeGet().color(.control, .text).opacity(0.65), }); @@ -754,7 +1287,7 @@ const MarkerMetrics = struct { fn renderTaskCheckbox(checked: bool, m: MarkerMetrics, ids: *IdGen) void { const theme = dvui.themeGet(); - var b = dvui.box(@src(), .{ .dir = .horizontal }, .{ + var b = box(@src(), .{ .dir = .horizontal }, .{ .min_size_content = .{ .w = m.side, .h = m.side }, .max_size_content = .{ .w = m.side, .h = m.side }, .gravity_y = 0, @@ -827,13 +1360,13 @@ fn renderInlineFlowContainer(container: md.Node, span: dvui.Options, ctx: Render } else if (node.firstChild()) |_| { renderInlineFlowContainer(node, span, ctx, ids); } else if (node.literal()) |t| { - var tl = dvui.textLayout(@src(), .{}, .{ + var tl = textLayout(@src(), .{}, .{ .expand = .horizontal, .background = span.background, .id_extra = ids.next(), }); defer tl.deinit(); - tl.addText(t, .{ .font = span.font, .color_text = span.color_text }); + addText(tl, t, .{ .font = span.font, .color_text = span.color_text }); } }, } @@ -852,7 +1385,7 @@ fn renderInlineFlowContainer(container: md.Node, span: dvui.Options, ctx: Render scan = s.nextSibling(); } - var tl = dvui.textLayout(@src(), .{}, .{ + var tl = textLayout(@src(), .{}, .{ .expand = .horizontal, .margin = .{ .y = 2, .h = 2 }, .background = span.background, @@ -880,20 +1413,88 @@ fn renderInlines(tl: *dvui.TextLayoutWidget, n: md.Node, span: dvui.Options, ctx } } +/// A run of body text, with any `[[wikilinks]]` in it drawn as links. +/// +/// The no-wikilinks path — no resolver, or none in this node — must produce **exactly** what +/// this used to: one `addText` of the whole literal, brackets and all. That's not just an +/// optimization, it's the contract that markdown renders identically with no indexer plugin +/// installed, and it's why the fast path is a single hash miss. +fn renderTextWithWikilinks( + tl: *dvui.TextLayoutWidget, + node: md.Node, + literal: []const u8, + span: dvui.Options, + ctx: RenderContext, +) void { + const plain: dvui.Options = .{ .font = span.font, .color_text = span.color_text }; + if (ctx.wikilink == null) return addText(tl, literal, plain); + const tokens = ctx.rs.wikilinks.get(@intFromPtr(node.n)) orelse return addText(tl, literal, plain); + + var cursor: usize = 0; + for (tokens, 0..) |tok, i| { + if (tok.start > cursor) addText(tl, literal[cursor..tok.start], plain); + renderWikilink(tl, node, i, tok, span, ctx); + cursor = tok.end; + } + if (cursor < literal.len) addText(tl, literal[cursor..], plain); +} + +fn renderWikilink( + tl: *dvui.TextLayoutWidget, + node: md.Node, + token_index: usize, + tok: wikilink_scan.Token, + span: dvui.Options, + ctx: RenderContext, +) void { + const theme = dvui.themeGet(); + const label = tok.label(); + const res = resolveWikilink(ctx, node, token_index, tok); + + switch (res.status) { + // Still scanning. Deliberately unstyled: painting every link red for the second after a + // folder opens, then flipping them all blue, is worse than showing nothing at all. + .indexing => addText(tl, label, .{ .font = span.font, .color_text = span.color_text }), + + .resolved, .ambiguous => { + const color = if (res.status == .ambiguous) theme.color(.err, .fill) else theme.focus; + const opts = span.override(.{ + .font = span.fontGet().withUnderline(.{}), + .color_text = color, + }); + if (tl.addTextClick(label, opts)) |click| { + const open_side = click == .mouse and + (click.mouse.button == .middle or click.mouse.mod.matchBind("ctrl/cmd")); + _ = revealPath(res.path, res.line, 0, open_side); + } + }, + + // Nothing to open — but a link to a note you haven't written yet is a completely normal + // thing to have in a wiki, not an error. So: still visibly a link, just unfinished — a + // hairline underline and dimmed text, rather than the error red a broken URL would get. + // (dvui's `Underline` carries thickness only, no dash style, so weight is what's + // available to say "provisional" with.) Inert until there's a create-note flow. + .unresolved => addText(tl, label, .{ + .font = span.fontGet().withUnderline(.{ .thick = 0.04 }), + .color_text = (span.color_text orelse theme.color(.content, .text)).opacity(0.6), + }), + } +} + fn renderInlineNodeToTl(tl: *dvui.TextLayoutWidget, x: md.Node, span: dvui.Options, ctx: RenderContext, ids: *IdGen) void { switch (x.nodeType()) { md.c.CMARK_NODE_TEXT => { - if (x.literal()) |t| tl.addText(t, .{ .font = span.font, .color_text = span.color_text }); + if (x.literal()) |t| renderTextWithWikilinks(tl, x, t, span, ctx); }, md.c.CMARK_NODE_SOFTBREAK => { - tl.addText(" ", .{}); + addText(tl, " ", .{}); }, md.c.CMARK_NODE_LINEBREAK => { - tl.addText("\n", .{}); + addText(tl, "\n", .{}); }, md.c.CMARK_NODE_CODE => { if (x.literal()) |t| { - tl.addText(t, .{ + addText(tl, t, .{ // Match the editor's monospace size (also `Font.theme(.mono)`). .font = dvui.Font.theme(.mono), .color_text = dvui.themeGet().color(.control, .text).opacity(0.9), @@ -922,7 +1523,7 @@ fn renderInlineNodeToTl(tl: *dvui.TextLayoutWidget, x: md.Node, span: dvui.Optio } else { const arena = dvui.currentWindow().arena(); if (linkLabelPlainText(x, arena)) |display| { - addMarkdownLink(tl, url, if (display.len == 0) null else display, link_opts); + addMarkdownLink(tl, url, if (display.len == 0) null else display, link_opts, ctx); } else |_| { if (x.firstChild()) |_| renderInlines(tl, x, link_opts, ctx, ids); } @@ -930,7 +1531,7 @@ fn renderInlineNodeToTl(tl: *dvui.TextLayoutWidget, x: md.Node, span: dvui.Optio }, md.c.CMARK_NODE_IMAGE => unreachable, md.c.CMARK_NODE_HTML_INLINE => { - if (x.literal()) |t| tl.addText(t, .{ + if (x.literal()) |t| addText(tl, t, .{ .font = dvui.Font.theme(.mono), .color_text = dvui.themeGet().color(.err, .text), }); @@ -939,9 +1540,9 @@ fn renderInlineNodeToTl(tl: *dvui.TextLayoutWidget, x: md.Node, span: dvui.Optio if (x.literal()) |t| { const fn_font = dvui.Font.theme(.mono).larger(-1); const fn_color = dvui.themeGet().focus.opacity(0.8); - tl.addText("[^", .{ .font = fn_font, .color_text = fn_color }); - tl.addText(t, .{ .font = fn_font, .color_text = fn_color }); - tl.addText("]", .{ .font = fn_font, .color_text = fn_color }); + addText(tl, "[^", .{ .font = fn_font, .color_text = fn_color }); + addText(tl, t, .{ .font = fn_font, .color_text = fn_color }); + addText(tl, "]", .{ .font = fn_font, .color_text = fn_color }); } }, else => { @@ -952,23 +1553,465 @@ fn renderInlineNodeToTl(tl: *dvui.TextLayoutWidget, x: md.Node, span: dvui.Optio } else if (x.firstChild()) |_| { renderInlines(tl, x, span, ctx, ids); } else if (x.literal()) |t| { - tl.addText(t, .{ .font = span.font, .color_text = span.color_text }); + addText(tl, t, .{ .font = span.font, .color_text = span.color_text }); } }, } } +/// Draw the document's top-level blocks, laying out only the ones near the viewport. +/// +/// Why this exists: every widget the renderer emits costs a full layout pass, and a +/// `TextLayoutWidget` re-shapes all of its text every frame — there is no per-string shaping +/// cache in dvui, and a paragraph is far too small for `cache_layout` (which skips *within* one +/// widget) to help. So the old "walk the whole AST every frame" cost was linear in the document's +/// **bytes**, not in what was on screen: docs/PLUGINS.md spent ~34ms/frame in Debug laying out +/// 58KB of text to show maybe 3KB of it, and scrolling to the end cost exactly the same as +/// sitting at the top. See `tests/bench/bench_markdown.zig`. +/// +/// Each top-level block gets a wrapper box carrying its measured height. Off screen, the wrapper +/// is emitted with that height and its contents are skipped entirely; the scroll container still +/// sees the document's true total height, so the scrollbar and every scroll position stay exactly +/// as they were. A block whose height isn't known at all is always drawn, so the cache fills in +/// without ever showing a gap — which makes a document's first frame one full layout, and only +/// one. +/// +/// Widths change every frame while a panel animates open or a window is dragged, which is +/// handled by `resettle_budget` rather than by throwing the cache away — see below. +/// +/// The wrapper is also what makes the ids stable: widget ids inside a block are relative to it, +/// so `ids` restarts per block and a skipped neighbour can't shift anything. +fn renderTopLevel(doc_node: md.Node, ids: *IdGen, ctx: RenderContext) void { + const rs = ctx.rs; + const metrics = currentMetrics(); + + // Width in flux this frame (sash open animation, first layout of a new pane, window resize + // drag). Cached heights are for a different column, so they stop being trusted — but they are + // *kept*, not clamped toward the estimate. Clamping was how a narrow→wide resize used to + // collapse the document's height model: an image or a table occupies one line of source, so + // its estimate is a dozen pixels against a real several hundred, and every sash drag crushed + // it to that. The blank pane that clamping was meant to prevent is now prevented properly, + // by `visibleRange` being guaranteed non-empty. + const width_in_flux = rs.blocks.invalidateForWidth(ctx.column_width); + rs.width_in_flux = width_in_flux; + if (diag and width_in_flux) { + dvui.log.warn("md-diag: column width now {d:.2} — every cached height distrusted, tables unpinned", .{ctx.column_width}); + } + if (width_in_flux) { + // Come back next frame — if the width has stopped moving, resettle can start. + dvui.refresh(null, @src(), null); + } + + // Draw beyond the viewport by half a screen each way. dvui needs the widget to exist for a + // frame before it can be scrolled onto properly, and a keyboard/scrollbar jump can move the + // viewport by more than a wheel tick does; the margin absorbs both without being large + // enough to matter for cost. + const virtualize = virtualize_blocks and ctx.viewport.h > 0; + const slack = @max(200, ctx.viewport.h * 0.5); + const vis_top = ctx.viewport.y - slack; + const vis_bot = ctx.viewport.y + ctx.viewport.h + slack; + // The viewport top: the line between "re-measuring this is free" and "re-measuring this + // moves the reader, and costs a frame to put them back". See the budgets below. + const anchor_y = ctx.viewport.y; + + // The blocks that must be laid out to cover the viewport, decided up front against the height + // table rather than block-by-block during the walk. Doing it here is what makes "the pane is + // never blank" a property of one function with tests behind it (`Table.visibleRange`) instead + // of an emergent hope about the per-block predicate below. + const must_draw: ?bh.Table.Range = if (!virtualize) null else rs.blocks.visibleRange( + ctx.viewport.y, + ctx.viewport.h, + slack, + metrics, + ctx.column_width, + ctx.content_origin_y, + ); + + // Two budgets, split by what a re-measure *costs the reader* rather than by distance. + // + // Below the viewport top is free: the anchor holds the reader's position against the block + // they are on, so a block further down changing height moves nothing they can see. + // Above the viewport top is not free: it shifts everything below it, and the anchor only puts + // the reader back on the *next* frame (positions are resolved before layout, and a height + // discovered during layout arrives too late for it). Correct, but briefly visible. + // + // Neither budget may be zero. A block that is never re-measured keeps its `estimate`, + // estimates are biased low on purpose, and a document whose height is mostly low guesses has + // a scrollbar that lies by thousands of pixels — which scrolling then "discovers" a screen at + // a time. That was the reported instability, and the earlier distance gate caused it by + // starving distant blocks outright (164 of 185 unmeasured after 600 frames on + // docs/PLUGIN_MANIFEST_PLAN.md). Both budgets being non-zero is what makes the sweep + // terminate: once every block has been measured twice, nothing wants a re-measure, + // `pending_measure` hits zero, and the refresh loop stops. A bounded warm-up, not a + // permanent wake. + var resettle_below_left: usize = if (width_in_flux) 0 else resettle_below_budget; + var resettle_above_left: usize = if (width_in_flux) 0 else resettle_above_budget; + + // Skipped blocks used to each get their own empty `box` with `min_size_content = h`. On a + // multi-thousand-block document that meant thousands of widgets per frame even when only a + // handful were on screen — the open hitch and the steady ~20ms frames while a large preview + // sat idle. Contiguous skips collapse into one spacer instead. + var skip_run_h: f32 = 0; + var skip_run_id: usize = 0; + + const flushSkip = struct { + fn f(run_h: *f32, run_id: usize) void { + if (run_h.* <= 0) return; + var spacer = box(@src(), .{ .dir = .vertical }, .{ + .expand = .horizontal, + // Stable across frames for a given skip-run start so scroll anchoring stays + // coherent when a neighbouring run's height changes. + .id_extra = run_id +% 0x7000_0000, + .min_size_content = .{ .h = run_h.* }, + }); + spacer.deinit(); + run_h.* = 0; + } + }.f; + + var y = ctx.content_origin_y; + var index: usize = 0; + var child = doc_node.firstChild(); + while (child) |ch| : ({ + child = ch.nextSibling(); + index += 1; + }) { + // A block never laid out yet is placed by a guess from how much source it came from. The + // guess only has to be good enough to decide "near the viewport or not", and it is + // deliberately biased *low* (see `Table.estimate`): guessing short draws a few extra + // blocks, while guessing tall would skip one that is actually on screen and flash a gap. + rs.blocks.ensureSlot(ctx.gpa, index, metrics, ctx.column_width); + const known_h = rs.blocks.heightAt(index, metrics, ctx.column_width); + const state = rs.blocks.stateAt(index); + + // `y` is always this block's start (skip runs advance it too; the spacer is just how + // that reserved height reaches the scroll container). + // + // Two independent reasons to draw, and the union of them is deliberate. `must_draw` is + // computed up front from the pre-frame height table, so it is the one that can *promise* + // a non-empty result; the running `y` is more accurate within the frame, because blocks + // above this one may have re-measured since that promise was made. Neither alone is both. + const on_screen_now = y < vis_bot and (y + known_h) > vis_top; + var draw = on_screen_now or + !rs.blocks.placeable(index) or + if (must_draw) |r| r.contains(index) else true; + if (!draw and (bh.Height{ .h = known_h, .state = state }).wantsMeasure()) { + // Entirely above the reader, or not? That is the only distinction that matters — + // see the budgets above. + const above = (y + known_h) <= anchor_y; + const budget = if (above) &resettle_above_left else &resettle_below_left; + if (budget.* > 0) { + draw = true; + budget.* -= 1; + } else { + // Owed a re-measure that this frame's budget couldn't pay for; see + // `Stats.pending_measure`. + stats.pending_measure += 1; + } + } + + if (!draw) { + if (skip_run_h == 0) skip_run_id = index; + skip_run_h += known_h; + y += known_h; + continue; + } + + // Emit the spacer for everything we skipped above this block before laying it out. + flushSkip(&skip_run_h, skip_run_id); + + // A block whose last measurement could not be trusted gets its height *pinned* to the one + // we do trust, rather than being allowed to report whatever this frame's layout produces. + // + // Refusing to record an untrustworthy measurement is not enough on its own: the widget is + // still emitted at whatever height it came out to, and the scroll container builds + // `virtual_size` from the widgets, not from this file's height table. A table under row + // culling is spectacularly unstable that way — the same block measured 46pt on one frame + // and 29,995pt on another (real height ~6,132) as the visible row set changed — and each + // swing moved the scrollbar and everything below it. Pinning both ends of the size makes + // the block occupy exactly what the table says it occupies, so layout and bookkeeping + // cannot disagree. + // + // The pin is released as soon as the block produces a measurement worth believing, which + // for a table means every one of its rows has been measured at least once. + // Scoped to blocks containing a table, and to states where the cached number is one we + // believe. Reacting to *this* frame's contamination would always be a frame late — the + // garbage measurement has already been emitted by then — so the pin goes on as soon as + // there is something worth holding, and comes off only when a width change demotes the + // entry back to `.measured` and the block genuinely has to be re-measured. + const block_state = rs.blocks.stateAt(index); + // ...and never while the column width is moving: that is precisely when the block has to + // be allowed to relearn its height, and a pin there would hold it at its pre-resize size. + const pin_h: ?f32 = if (known_h > 0 and !width_in_flux and + (block_state == .settled or block_state == .deferred) and + rs.subtree_has_table.contains(@intFromPtr(ch.n))) known_h else null; + var wrapper = box(@src(), .{ .dir = .vertical }, .{ + .expand = .horizontal, + .id_extra = index, + .min_size_content = if (pin_h) |h| .{ .h = h } else null, + .max_size_content = if (pin_h) |h| .height(h) else null, + }); + const wrapper_id = wrapper.data().id; + const prof_t0 = if (block_profile == null) 0 else std.Io.Clock.boot.now(dvui.io).nanoseconds; + const prof_tl = stats.text_layouts; + const prof_bytes = stats.add_text_bytes; + ids.n = 0; + rs.block_rows_pending = 0; + rs.block_measure_exhausted = !(bh.Height{ + .h = known_h, + .state = block_state, + .attempts = rs.blocks.attemptsAt(index), + }).wantsMeasure(); + renderBlock(ch, ids, ctx); + wrapper.deinit(); + if (block_profile) |list| { + list.append(block_profile_gpa.?, .{ + .index = index, + .kind = ch.typeString(), + .ns = @intCast(std.Io.Clock.boot.now(dvui.io).nanoseconds - prof_t0), + .text_layouts = stats.text_layouts - prof_tl, + .add_text_bytes = stats.add_text_bytes - prof_bytes, + }) catch {}; + } + + const measured = (dvui.minSizeGet(wrapper_id) orelse dvui.Size{}).h; + // The height is known — but is it *worth* knowing? A table whose rows have not all been + // measured reports a height built from a scroll-position-dependent mix of real rows and + // placeholders, so it measures differently depending on where the reader came from. That + // is a height that cannot be believed, and believing it is what made the document jump + // when scrolling past a table. `record` files it as `.deferred`: keep what we had, and + // stop short of calling it an answer. + // + // This used to key off "was the block off screen?" instead, on the theory that an + // off-screen table renders only its header. That stopped being true once culled cells + // started reporting a real placeholder height, and by then the test had inverted: it + // froze the *collapsed* first-frame measurement of a table the reader had never visited + // and never revisited it, leaving the document ~1300px short per table. + const partial = rs.block_rows_pending > 0; + rs.blocks.record(ctx.gpa, index, .{ .h = measured, .partial = partial }, metrics, ctx.column_width); + const after_h = rs.blocks.heightAt(index, metrics, ctx.column_width); + if (diag and known_h > 0 and @abs(after_h - known_h) > diag_height_jump) { + dvui.log.warn( + "md-diag: block {d} ({s}) height {d:.0} -> {d:.0} ({s}, rows_pending={d}) at viewport y={d:.0}", + .{ index, @tagName(rs.blocks.extents.items[index].kind), known_h, after_h, @tagName(rs.blocks.stateAt(index)), rs.block_rows_pending, ctx.viewport.y }, + ); + } + // No scroll compensation here any more. A height change above the reader used to need a + // delta accumulated and applied to `viewport.y` after the fact; the anchor makes the + // reader's position a function of the current heights, so it simply re-derives. + y += after_h; + } + flushSkip(&skip_run_h, skip_run_id); +} + +/// Font metrics for `block_heights.zig`, which is deliberately dvui-free and so cannot read the +/// theme itself. +/// +/// `sizeM` is the width of an "M"; ordinary prose averages a good deal narrower than that, and +/// erring narrow means erring toward *more* estimated lines, which is the safe direction. +pub fn currentMetricsForTest() bh.Metrics { + return currentMetrics(); +} + +fn currentMetrics() bh.Metrics { + const font = dvui.Font.theme(.body); + return .{ .line_h = font.lineHeight(), .em_w = font.sizeM(1, 1).w }; +} + +pub const Anchor = bh.Anchor; + +/// Where `a` points, as a scroll offset against the heights as they currently stand. Applied by +/// the preview *before* the scroll area is built — see `markdown.drawPreview`. +pub fn anchorResolve(rs: *const RenderState, a: Anchor, column_width: f32, origin_y: f32, max_scroll: f32) f32 { + return rs.blocks.resolveAnchor(a, currentMetrics(), column_width, origin_y, max_scroll); +} + +/// Turn the settled scroll offset back into an anchor, after the scroll area has committed. +pub fn anchorCapture(rs: *const RenderState, viewport_y: f32, column_width: f32, origin_y: f32, max_scroll: f32) ?Anchor { + return rs.blocks.captureAnchor(viewport_y, currentMetrics(), column_width, origin_y, max_scroll); +} + +/// True when any cell in this table row still needs a real layout pass — never measured, measured +/// only once and so possibly still settling, or measured against a column width the grid has +/// since changed its mind about. +fn rowNeedsMeasure(ctx: RenderContext, g: *dvui.GridWidget, row: md.Node) bool { + var col: usize = 0; + var cl = row.firstChild(); + while (cl) |cell| : (cl = cell.nextSibling()) { + if (extKind(ctx, cell) != .table_cell) continue; + defer col += 1; + const cached = ctx.rs.cell_sizes.get(@intFromPtr(cell.n)) orelse return true; + if (!cached.settled or cached.col_w != g.colWidth(col)) return true; + } + return false; +} + +/// Column widths for one table: natural where the table fits, squeezed to `avail` where it +/// doesn't. Cached per table node (see `RenderState.table_layouts`) — the walk below visits every +/// cell in the table, which is exactly the work the render path goes to such lengths to avoid +/// doing per frame. +/// +/// Why compute widths here at all, rather than let the grid auto-size them? Because the grid's +/// only inputs are the cells' *laid-out* min sizes, and a cell that wrapped reports the width it +/// wrapped to. Feed those back and the columns ratchet: shrink-to-fit narrows a column, the cell +/// inside re-wraps narrower, the next frame's measurement is narrower still, and a short cell +/// ends up one character wide. Natural widths are measured from the text instead, so they are the +/// same every frame no matter what the table currently looks like. +fn tableColumnWidths(n: md.Node, num_cols: usize, avail: f32, cell_padding: dvui.Rect, ctx: RenderContext) []const f32 { + const font = dvui.Font.theme(.body); + const body_m = font.sizeM(1, 1).w; + const mono_m = dvui.Font.theme(.mono).sizeM(1, 1).w; + const key = @intFromPtr(n.n); + if (ctx.rs.table_layouts.getPtr(key)) |cached| { + if (cached.body_m == body_m and cached.mono_m == mono_m and cached.natural.len == num_cols) { + if (cached.avail == avail) return cached.widths; + // Only the space available changed — which is every frame of a sash drag. The natural + // widths are still valid, so re-fit them instead of re-measuring the whole table. + @memcpy(cached.widths, cached.natural); + fitColumns(cached.widths, avail, ctx.gpa); + cached.avail = avail; + return cached.widths; + } + } + + const natural = ctx.gpa.alloc(f32, num_cols) catch return &.{}; + @memset(natural, 0); + const widths = ctx.gpa.alloc(f32, num_cols) catch { + ctx.gpa.free(natural); + return &.{}; + }; + + // What a cell adds around its text: the grid cell's own padding, plus the `textLayout` the + // content is drawn in (`TextLayoutWidget` has non-zero default padding, which is why the + // grid's own default minimum is padded the same way). Leaving this out measured every column + // a little too narrow, and a one-character column too narrow to show its character at all. + const pad_w = cell_padding.x + cell_padding.w + + dvui.TextLayoutWidget.defaults.paddingGet().x + dvui.TextLayoutWidget.defaults.paddingGet().w; + + var row = n.firstChild(); + while (row) |r| : (row = r.nextSibling()) { + const rk = extKind(ctx, r); + if (rk != .table_row and rk != .table_header) continue; + const cell_font = if (rk == .table_header) font.withWeight(.bold) else font; + var col: usize = 0; + var cl = r.firstChild(); + while (cl) |cell| : (cl = cell.nextSibling()) { + if (extKind(ctx, cell) != .table_cell) continue; + defer col += 1; + if (col >= num_cols) continue; + // Rounded up: text measurement and layout disagree by fractions of a point, and a + // column a fraction under what its text needs wraps a whole character. + natural[col] = @max(natural[col], @ceil(inlineNaturalWidth(cell, cell_font) + pad_w) + 1); + } + } + + @memcpy(widths, natural); + fitColumns(widths, avail, ctx.gpa); + + const old = ctx.rs.table_layouts.fetchPut(ctx.gpa, key, .{ + .natural = natural, + .widths = widths, + .avail = avail, + .body_m = body_m, + .mono_m = mono_m, + }) catch { + // Out of memory: hand back nothing and let the grid auto-size this table, rather than + // leak a width vector nothing owns. + ctx.gpa.free(natural); + ctx.gpa.free(widths); + return &.{}; + }; + if (old) |kv| { + ctx.gpa.free(kv.value.natural); + ctx.gpa.free(kv.value.widths); + } + return widths; +} + +/// The width one table cell's contents want on a single unwrapped line. Walks the inlines rather +/// than flattening to plain text so each run is measured in the font it will actually be drawn +/// in: `` `--verbose` `` is monospace and wider than the same characters in the body font, and a +/// column measured in the wrong font is a column that wraps when it shouldn't. +fn inlineNaturalWidth(node: md.Node, font: dvui.Font) f32 { + var total: f32 = 0; + var c = node.firstChild(); + while (c) |x| : (c = x.nextSibling()) { + switch (x.nodeType()) { + md.c.CMARK_NODE_TEXT, md.c.CMARK_NODE_HTML_INLINE => { + if (x.literal()) |t| total += font.textSize(t).w; + }, + md.c.CMARK_NODE_CODE => { + if (x.literal()) |t| total += dvui.Font.theme(.mono).textSize(t).w; + }, + md.c.CMARK_NODE_SOFTBREAK, md.c.CMARK_NODE_LINEBREAK => total += font.textSize(" ").w, + md.c.CMARK_NODE_STRONG => total += inlineNaturalWidth(x, font.withWeight(.bold)), + md.c.CMARK_NODE_EMPH => total += inlineNaturalWidth(x, font.withStyle(.italic)), + else => total += inlineNaturalWidth(x, font), + } + } + return total; +} + +/// Squeeze `widths` (natural, in place) into `avail`, leaving them alone when they already fit. +/// +/// Max-min fair: a column narrower than an equal share of the space keeps its natural width in +/// full, and only the columns wider than their share give anything up — proportionally, and only +/// as far as the space that's left over once the narrow ones are paid. Repeated until no more +/// columns fall under the share, since paying the narrow ones raises it for everyone else. +/// +/// The naive alternative — scale every column by the same factor — is what made `| flag | +/// description |` wrap the *flag*: the long description alone can cover the whole overflow, but +/// proportional scaling still takes its cut out of a column that had nothing to spare. +fn fitColumns(widths: []f32, avail: f32, gpa: std.mem.Allocator) void { + if (widths.len == 0) return; + var total: f32 = 0; + for (widths) |w| total += w; + if (total <= avail) return; + + const settled = gpa.alloc(bool, widths.len) catch return; + defer gpa.free(settled); + @memset(settled, false); + + var avail_left = avail; + var cols_left: usize = widths.len; + while (cols_left > 0) { + const share = avail_left / @as(f32, @floatFromInt(cols_left)); + var any = false; + for (widths, settled) |w, *s| { + if (s.* or w > share) continue; + s.* = true; + any = true; + avail_left -= w; + cols_left -= 1; + } + if (!any) break; + } + + // Whatever is left over goes to the columns still over their share, in proportion to what + // they asked for. `cols_left == 0` means everything fit after all, which the total check + // above already ruled out — but the loop is the only thing that proves it, so don't divide + // by a zero it could produce. + if (cols_left == 0) return; + var over_total: f32 = 0; + for (widths, settled) |w, s| { + if (!s) over_total += w; + } + if (over_total <= 0) return; + const scale = @max(0, avail_left) / over_total; + for (widths, settled) |*w, s| { + if (!s) w.* *= scale; + } +} + fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { + statBlock(); const t = n.nodeType(); switch (t) { - md.c.CMARK_NODE_DOCUMENT => { - var c = n.firstChild(); - while (c) |ch| : (c = ch.nextSibling()) renderBlock(ch, ids, ctx); - }, + md.c.CMARK_NODE_DOCUMENT => renderTopLevel(n, ids, ctx), md.c.CMARK_NODE_BLOCK_QUOTE => { - var outer = dvui.box(@src(), .{ .dir = .horizontal }, .{ + var outer = box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, - .margin = .{ .x = 4, .y = 4, .w = 4, .h = 4 }, + .margin = .{ .x = block_inset_x, .y = 4, .w = block_inset_x, .h = 4 }, .id_extra = ids.next(), }); defer outer.deinit(); @@ -982,7 +2025,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { .id_extra = ids.next(), }); - var content = dvui.box(@src(), .{ .dir = .vertical }, .{ + var content = box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .padding = .{ .x = 10, .y = 4, .w = 0, .h = 4 }, .id_extra = ids.next(), @@ -1005,7 +2048,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { renderBlock(item_node, ids, ctx); continue; } - var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ + var row = box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .margin = .{ .y = 1 }, .id_extra = ids.next(), @@ -1021,7 +2064,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { if (list_kind == .ol) idx += 1; { - var pb = dvui.box(@src(), .{ .dir = .horizontal }, .{ + var pb = box(@src(), .{ .dir = .horizontal }, .{ .min_size_content = .{ .w = col_w, .h = 0 }, .gravity_y = 0, // The item's content is a paragraph, and `CMARK_NODE_PARAGRAPH` gives its @@ -1045,7 +2088,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 5, .h = 0 }, .id_extra = ids.next() }); - var col = dvui.box(@src(), .{ .dir = .vertical }, .{ + var col = box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .id_extra = ids.next(), }); @@ -1064,9 +2107,9 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { md.c.CMARK_NODE_CODE_BLOCK => { const info = n.fenceInfo() orelse ""; const code = n.literal() orelse ""; - var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ + var outer = box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, - .margin = .{ .y = 6 }, + .margin = .{ .x = block_inset_x, .y = 6, .w = block_inset_x, .h = 6 }, .background = true, .color_fill = dvui.themeGet().color(.window, .fill).opacity(0.9), .corners = dvui.CornerRect.all(6), @@ -1077,7 +2120,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { defer outer.deinit(); if (info.len > 0) { - var hdr = dvui.box(@src(), .{ .dir = .horizontal }, .{ + var hdr = box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .padding = .{ .x = 10, .y = 5, .w = 10, .h = 5 }, .background = true, @@ -1085,28 +2128,28 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { .id_extra = ids.next(), }); defer hdr.deinit(); - var tl_i = dvui.textLayout(@src(), .{}, .{ .expand = .horizontal, .background = false, .id_extra = ids.next() }); - tl_i.addText(info, .{ + var tl_i = textLayout(@src(), .{}, .{ .expand = .horizontal, .background = false, .id_extra = ids.next() }); + addText(tl_i, info, .{ .font = dvui.Font.theme(.mono).withWeight(.bold), .color_text = dvui.themeGet().color(.control, .text).opacity(0.55), }); tl_i.deinit(); } - var tl_c = dvui.textLayout(@src(), .{}, .{ + var tl_c = textLayout(@src(), .{}, .{ .expand = .horizontal, .padding = .{ .x = 10, .y = 8, .w = 10, .h = 8 }, .background = false, .id_extra = ids.next(), }); defer tl_c.deinit(); - tl_c.addText(code, .{ .font = dvui.Font.theme(.mono) }); + addText(tl_c, code, .{ .font = dvui.Font.theme(.mono) }); }, md.c.CMARK_NODE_HTML_BLOCK => { // `

` is how most READMEs carry their hero image; render // the images and drop the wrapper markup rather than dumping the tags as raw text. if (ctx.rs.html_images.get(@intFromPtr(n.n))) |urls| { - var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ + var outer = box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .id_extra = ids.next(), }); @@ -1121,16 +2164,16 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { return; } if (n.literal()) |h| { - var tl = dvui.textLayout(@src(), .{}, .{ + var tl = textLayout(@src(), .{}, .{ .expand = .horizontal, - .margin = .{ .y = 2 }, + .margin = .{ .x = block_inset_x, .y = 2, .w = block_inset_x, .h = 2 }, .padding = .{ .x = 8, .y = 4, .w = 8, .h = 4 }, .background = true, .color_fill = dvui.themeGet().color(.err, .fill).opacity(0.08), .id_extra = ids.next(), }); defer tl.deinit(); - tl.addText(h, .{ + addText(tl, h, .{ .font = dvui.Font.theme(.mono), .color_text = dvui.themeGet().color(.err, .text).opacity(0.85), }); @@ -1138,7 +2181,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { }, md.c.CMARK_NODE_PARAGRAPH => { if (!hasImageSubtree(ctx, n)) { - var tl = dvui.textLayout(@src(), .{}, .{ + var tl = textLayout(@src(), .{}, .{ .expand = .horizontal, .margin = .{ .y = paragraph_margin_y, .h = paragraph_margin_y }, .background = ctx.background, @@ -1147,7 +2190,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { defer tl.deinit(); renderInlines(tl, n, .{ .background = ctx.background }, ctx, ids); } else { - var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ + var outer = box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .margin = .{ .y = paragraph_margin_y, .h = paragraph_margin_y }, .id_extra = ids.next(), @@ -1175,7 +2218,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { const span: dvui.Options = .{ .font = heading_font, .background = ctx.background }; if (!hasImageSubtree(ctx, n)) { - var tl = dvui.textLayout(@src(), .{}, .{ + var tl = textLayout(@src(), .{}, .{ .expand = .horizontal, .margin = .{ .y = top_margin, .h = 2 }, .font = heading_font, @@ -1185,7 +2228,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { defer tl.deinit(); renderInlines(tl, n, span, ctx, ids); } else { - var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ + var outer = box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .margin = .{ .y = top_margin, .h = 2 }, .id_extra = ids.next(), @@ -1204,7 +2247,7 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { }, md.c.CMARK_NODE_FOOTNOTE_DEFINITION => { if (n.literal()) |name| { - var tl = dvui.textLayout(@src(), .{}, .{ + var tl = textLayout(@src(), .{}, .{ .expand = .horizontal, .margin = .{ .y = 4 }, .background = ctx.background, @@ -1212,9 +2255,9 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { }); const fn_font = dvui.Font.theme(.mono).larger(-1); const fn_color = dvui.themeGet().focus.opacity(0.8); - tl.addText("[^", .{ .font = fn_font, .color_text = fn_color }); - tl.addText(name, .{ .font = fn_font, .color_text = fn_color }); - tl.addText("]: ", .{ .font = fn_font, .color_text = fn_color }); + addText(tl, "[^", .{ .font = fn_font, .color_text = fn_color }); + addText(tl, name, .{ .font = fn_font, .color_text = fn_color }); + addText(tl, "]: ", .{ .font = fn_font, .color_text = fn_color }); tl.deinit(); } var c = n.firstChild(); @@ -1227,16 +2270,48 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { const num_cols = ctx.rs.table_col_counts.get(@intFromPtr(n.n)) orelse return; if (num_cols == 0) return; - var table_wrap = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .none, - .margin = .{ .y = 6 }, + // Holds the table's inset from the prose column (see `block_inset_x`). Expands so + // the grid inside it is offered the full inset width — how much of that the table + // actually takes is the grid's business, below. + var table_wrap = box(@src(), .{ .dir = .vertical }, .{ + .expand = .horizontal, + .margin = .{ .x = block_inset_x, .y = 8, .w = block_inset_x, .h = 8 }, .id_extra = ids.next(), }); defer table_wrap.deinit(); + // `layout_only`: this is a content-sized preview table, not a spreadsheet. (The + // row-culling path below still supplies `min_size_content` for skipped cells; + // `layout_only` is what makes those heights stick every frame.) + // + // The width the table has to work with. Taken from the wrap rather than from + // `ctx.column_width` so a table nested in a quote or list gets its container's + // width, and — because the wrap expands — it is a *fixed* number rather than one + // derived from how wide the table currently is. That distinction is the whole + // trick: column widths computed against a number the columns themselves feed into + // ratchet down a little every frame. + const table_avail = blk: { + const w = table_wrap.data().contentRect().w; + // Headroom for the grid's own border, plus a point of slack. Without it, a + // squeezed table's columns sum to exactly the wrap width, the grid finds its + // viewport a hair narrower than that, and shaves the difference off every + // column — enough (0.3pt was the measured case) to push a column that fit its + // text perfectly into wrapping one character onto a second line. + break :blk @max(60, (if (w > 0) w else ctx.column_width - 2 * block_inset_x) - 4); + }; + + // `.expand = .none`: the table is as wide as its columns and no wider. A + // two-column `| a | b |` stretched across the pane is harder to read than the same + // table at its natural size. Widths come from `tableColumnWidths` below, which is + // what makes "as wide as its columns" mean "up to the pane, then wrap". + // + // Scrolling stays off in both directions: the table scrolls with the document. var g = dvui.grid(@src(), .{ + .layout_only = true, .scroll_opts = .{ - .horizontal_bar = .auto, + .horizontal = .none, + .vertical = .none, + .horizontal_bar = .hide, .vertical_bar = .hide, }, }, .{ @@ -1252,6 +2327,24 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { const cell_padding: dvui.Rect = .{ .x = 8, .y = 5, .w = 8, .h = 5 }; + // Install our own column widths, and with them off the grid's plate, leave it to + // auto-size rows only. Row heights are safe to measure (a wrapped cell's height is + // what we want to learn), and uncapped: the grid's default max is ~5 lines, which + // clips a wrapped cell mid-sentence with nowhere else to read the rest. + // + // `col_widths` is empty on a table's first frame — the grid learns its column + // count from the cells it saw last frame — so that frame falls back to the grid's + // own auto-sizing and the frame after picks these up. + const col_ws = tableColumnWidths(n, num_cols, table_avail, cell_padding, ctx); + if (col_ws.len == num_cols and g.col_widths.len == num_cols) { + @memcpy(g.col_widths, col_ws); + g.autoSize(.{ + .auto = .rows, + .min_height = 0, + .max_height = dvui.max_float_safe, + }); + } + // dvui dropped `CellStyle.Banded` along with the grid rework, so the zebra // striping is applied here: odd body rows get the alternate fill, everything // else keeps the grid's own background. @@ -1266,6 +2359,28 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { } }; + // Screen-space band a row has to touch to be laid out, with the same half-screen + // of slack `renderTopLevel` gives blocks. + const cull_rows = virtualize_blocks and ctx.viewport.h > 0; + const clip = dvui.clipGet(); + const row_slack = @max(200 * clip.h / @max(1, ctx.viewport.h), clip.h * 0.5); + const row_clip_top = clip.y - row_slack; + const row_clip_bot = clip.y + clip.h + row_slack; + var row_anchor: ?struct { screen_y: f32, scale: f32, row_offset: f32 } = null; + // The *off-screen* budget goes to zero while the column is still moving, for the + // same reason `renderTopLevel` zeroes its block budget: a row measured at this + // frame's width is invalid at the next frame's, so a sash drag would pay for the + // whole table over and over and keep none of it (~5.4KB of text shaped per frame + // on docs/PLUGIN_MANIFEST_PLAN.md, discarded every time). Those rows stay owed — + // `pending_measure` keeps frames coming — and get measured once the width holds. + // + // `first_sight_left` is deliberately *not* zeroed. It gates rows that are on + // screen, and during a resize every row counts as never-measured (the column + // width they were measured at just changed), so zeroing it made every visible row + // cull itself and the table rendered blank for the whole drag. + var measure_bytes_left: u64 = if (ctx.rs.width_in_flux) 0 else table_measure_bytes; + var first_sight_left: u64 = table_first_sight_bytes; + var body_row: usize = 0; var c = n.firstChild(); while (c) |row| : (c = row.nextSibling()) { @@ -1290,15 +2405,119 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { col += 1; } } else { + // Is this row anywhere near the screen? A markdown table is drawn as a + // *content-sized* grid — it scrolls with the page rather than inside + // itself — so dvui's own row virtualization (`GridWidget.rowsVisible`) + // can't help: as far as the grid is concerned its whole body is in view. + // Without this, one 45KB table (docs/PLUGIN_MANIFEST_PLAN.md has one) is + // laid out in full on every frame it appears on, which costs more than + // the rest of that document put together. + // + // The anchor comes from the first body cell rather than from the grid's + // internals: every cell's rect is placed from the grid's own cached row + // heights, so one real cell rect plus `rowOffset`/`rowHeight` gives every + // other row's position exactly. + const measured_before = !rowNeedsMeasure(ctx, g, row); + var row_visible = if (!cull_rows or row_anchor == null) true else blk: { + const a = row_anchor.?; + const top = a.screen_y + (g.rowOffset(body_row) - a.row_offset) * a.scale; + const h = g.rowHeight(body_row) * a.scale; + break :blk top < row_clip_bot and (top + h) > row_clip_top; + }; + // A row with no measurements behind it has no trustworthy position either + // — see `table_first_sight_rows`. + if (row_visible and !measured_before and cull_rows and first_sight_left == 0) + row_visible = false; + + // An off-screen row whose cells have never been measured (or whose + // measurement hasn't been confirmed by a second draw) is worth one + // measuring pass so the table's height is right — but only a few such + // rows per frame, so a big table costs a little on each of several frames + // instead of all of it on the frame the document opens. + const measure_row = !row_visible and !measured_before and measure_bytes_left > 0; + // An off-screen row that isn't settled yet is work owed, not work dropped: + // `renderDocument` asks for another frame while any remains. It stays + // owed on the frame it *is* measured on, because a measurement only + // counts once a second pass agrees with it. + if (!measured_before and !row_visible and !ctx.rs.block_measure_exhausted) stats.pending_measure += 1; + // Any row that has not been measured for real leaves this table's height + // a guess, however it is drawn — see `RenderState.block_rows_pending`. + if (!measured_before) ctx.rs.block_rows_pending += 1; + const row_text_before = stats.add_text_bytes; + var col: usize = 0; var cl = row.firstChild(); while (cl) |cell| : (cl = cell.nextSibling()) { if (extKind(ctx, cell) != .table_cell) continue; - const cell_box = g.cell(.{ .col = col, .row = body_row }, banded.opts(body_row, cell_padding)); - defer cell_box.deinit(); - renderInlineFlowContainer(cell, .{ .background = false }, ctx, ids); + // A skipped cell still has to hand the grid the size its contents + // would have, or the row collapses and the column shrinks to whatever + // happens to be on screen. A cell with no measurement yet — or one + // whose measurement hasn't been confirmed by a second draw — is drawn + // regardless of where it is, which is what makes the table's total + // height right from the first frame it appears on. + const cell_key = @intFromPtr(cell.n); + const cached = ctx.rs.cell_sizes.get(cell_key); + // Read before the cell exists, so it is the width this cell is about + // to be laid out in rather than the one it produces. + const cell_w = g.colWidth(col); + const draw_cell = row_visible or measure_row; + // An unmeasured culled cell used to hand the grid a *zero* size, which + // is the same low-bias mistake `Table.estimate` makes one level up and + // it compounds: on a 45KB table most rows are unmeasured on first + // sight, so the block came out a fraction of its real height and then + // inflated by hundreds of points per frame as the budget caught up. + // One line is the honest floor — most table cells are exactly that — + // and the block's height is roughly right immediately instead. + const cell_placeholder: dvui.Size = if (cached) |cs| + cs.size + else + .{ .w = 0, .h = dvui.Font.theme(.body).lineHeight() }; + const cell_box = g.cell( + .{ .col = col, .row = body_row }, + banded.opts(body_row, cell_padding).override( + if (draw_cell) .{} else .{ .min_size_content = cell_placeholder }, + ), + ); + if (row_anchor == null) { + const rs = cell_box.data().rectScale(); + row_anchor = .{ + .screen_y = rs.r.y, + .scale = rs.s, + .row_offset = g.rowOffset(body_row), + }; + } + if (draw_cell) { + // Ids inside a cell hang off the cell widget, so restarting them + // per cell keeps a skipped neighbour from shifting anything. + ids.n = 0; + renderInlineFlowContainer(cell, .{ .background = false }, ctx, ids); + // Read before `deinit`, and with the padding taken back off: + // `min_size_content` has the padding added to it again, so + // storing the padded size would grow the cell every frame. + const measured: dvui.Size = .{ + .w = @max(0, cell_box.data().min_size.w - cell_padding.x - cell_padding.w), + .h = @max(0, cell_box.data().min_size.h - cell_padding.y - cell_padding.h), + }; + const agrees = cached != null and cached.?.col_w == cell_w and + cached.?.size.w == measured.w and cached.?.size.h == measured.h; + ctx.rs.cell_sizes.put(ctx.gpa, cell_key, .{ + .size = measured, + .col_w = cell_w, + .settled = agrees, + }) catch {}; + } + cell_box.deinit(); col += 1; } + // Charge whichever budget paid for this row. Measured after the fact: a + // row's size is only known once it has been laid out, so a budget can be + // overshot by at most the one row that exhausts it. + const row_text = stats.add_text_bytes - row_text_before; + if (row_visible and !measured_before) { + first_sight_left -|= row_text; + } else if (measure_row) { + measure_bytes_left -|= row_text; + } body_row += 1; } } @@ -1311,6 +2530,77 @@ fn renderBlock(n: md.Node, ids: *IdGen, ctx: RenderContext) void { } pub fn renderDocument(root: md.Node, ctx: RenderContext) void { + // Per-draw counters reset here; `render_ns` deliberately accumulates (see `Stats`). + const carried_ns = stats.render_ns; + const carried_parse_ns = stats.parse_ns; + stats = .{ .render_ns = carried_ns, .parse_ns = carried_parse_ns }; + // wasm has no monotonic clock wired into `dvui.io` (`std.Io.failing` returns zero for every + // timestamp), so the counters above are the whole story there; timing is native-only. + const t0: i128 = if (comptime builtin.target.cpu.arch == .wasm32) 0 else std.Io.Clock.boot.now(dvui.io).nanoseconds; + + var resolved_ctx = ctx; + resolved_ctx.wikilink = wikilinkResolver(ctx); + var ids: IdGen = .{ .n = ctx.id_base }; - renderBlock(root, &ids, ctx); + renderBlock(root, &ids, resolved_ctx); + + // Keep frames coming until the measuring budgets have caught up — see `Stats.pending_measure`. + if (stats.pending_measure > 0) dvui.refresh(null, @src(), null); + + if (comptime builtin.target.cpu.arch != .wasm32) { + stats.render_ns +%= @intCast(std.Io.Clock.boot.now(dvui.io).nanoseconds - t0); + } +} + +/// The wikilink resolver to use for this document draw, or null when wikilinks are off. +/// +/// Also the point where a stale resolution memo is dropped: the resolver bumps `generation()` +/// on every committed index change, and a link that was broken a moment ago becomes live the +/// instant its target file exists — with no edit to *this* document, so nothing else in the +/// pipeline would notice. +fn wikilinkResolver(ctx: RenderContext) ?*WikilinkApi { + // A document with no path on disk has nothing to resolve *relative to*, and — for the + // store's README pane — is remote content that must not reach into the user's own files. + if (ctx.document_path.len == 0) return null; + const api = sdk.host().getServiceTyped(WikilinkApi) orelse return null; + + const gen = api.generation(); + if (ctx.rs.wikilink_generation != gen) { + ctx.rs.clearResolvedWikilinks(ctx.gpa); + ctx.rs.wikilink_generation = gen; + } + return api; +} + +/// Resolve one link, memoized against the resolver generation. Called every frame for every +/// visible link, so the steady-state path must be the hash lookup and nothing more. +fn resolveWikilink( + ctx: RenderContext, + node: md.Node, + token_index: usize, + tok: wikilink_scan.Token, +) ResolvedLink { + const api = ctx.wikilink orelse return .{ .status = .unresolved }; + const key = wikilinkMemoKey(node, token_index); + if (ctx.rs.wikilink_resolved.get(key)) |hit| return hit; + + const res = api.resolve(tok.target, tok.heading, ctx.document_path, ctx.gpa) catch + return .{ .status = .unresolved }; + // `resolve` allocates from the allocator we hand it, and we hand it the persistent one so + // the memo can outlive the frame. `title` is not kept — nothing renders it yet, and holding + // it would mean freeing two strings per entry instead of one. + ctx.gpa.free(res.title); + + const entry: ResolvedLink = .{ + .status = res.status, + .path = @constCast(res.path), + .line = res.line, + }; + ctx.rs.wikilink_resolved.put(ctx.gpa, key, entry) catch { + // Out of memory for the memo only — the answer is still good for this frame, it just + // costs a resolve again next frame. + ctx.gpa.free(res.path); + return .{ .status = res.status, .line = res.line }; + }; + return entry; } diff --git a/src/plugins/markdown/src/md/wikilink_scan.zig b/src/plugins/markdown/src/md/wikilink_scan.zig new file mode 100644 index 00000000..b4565b2a --- /dev/null +++ b/src/plugins/markdown/src/md/wikilink_scan.zig @@ -0,0 +1,289 @@ +//! Finding `[[wikilinks]]` in a parsed cmark AST — specifically, the part the SDK's pure +//! tokenizer cannot do alone: telling `[[A]]` apart from `\[\[A]]`. +//! +//! **Why this file exists.** `cmark_parser_finish` ends with `cmark_consolidate_text_nodes` +//! (`blocks.c`), which merges every run of adjacent `CMARK_NODE_TEXT` siblings into one node by +//! concatenating their literals. A backslash escape is parsed by `handle_backslash` into its own +//! little text node holding just the escaped character, so after consolidation `\[\[A]]` and +//! `[[A]]` produce **the same literal** — `[[A]]`. Tokenizing the literal therefore turns a +//! deliberately escaped link into a real one, and there is no way to tell from the AST alone. +//! +//! What survives is position: `make_literal` in `inlines.c` sets `start_line`/`start_column` +//! unconditionally (no `CMARK_OPT_SOURCEPOS` needed), and consolidation keeps the first +//! fragment's start while extending `end_column`. So a consolidated text node still knows the +//! source span it came from, and the original bytes — backslashes included — can be read back +//! out of the document. +//! +//! **The rule.** Re-apply cmark's own escape handling to that source span, producing the bytes +//! it would have yielded plus a flag per byte for "this came from an escape". When those bytes +//! match the node's literal exactly, the flags line up with it positionally and a link whose +//! opening brackets are flagged is dropped. When they *don't* match — smart punctuation +//! (`CMARK_OPT_SMART` is on) rewrote a quote, or an HTML entity expanded — the mapping is +//! untrustworthy and we **fail open**: the link renders. A link that renders when the author +//! wanted literal text is a visible, correctable annoyance; a link that silently vanishes is a +//! bug someone spends an afternoon on. +const std = @import("std"); +const md = @import("cmark_parse.zig"); +const wikilink = @import("fizzy_sdk").services.wikilink; + +pub const Token = wikilink.Token; + +/// Byte offset of the start of each 1-based source line. Built once per parse (`scanNode`), +/// not per node — locating a node's span is then two array reads. +pub const LineIndex = struct { + /// `starts[i]` is the offset of line `i + 1`. Always begins with 0. + starts: []const u32, + + pub fn build(gpa: std.mem.Allocator, source: []const u8) !LineIndex { + var starts: std.ArrayList(u32) = .empty; + errdefer starts.deinit(gpa); + try starts.append(gpa, 0); + for (source, 0..) |b, i| { + if (b == '\n') try starts.append(gpa, @intCast(i + 1)); + } + return .{ .starts = try starts.toOwnedSlice(gpa) }; + } + + pub fn deinit(self: *LineIndex, gpa: std.mem.Allocator) void { + gpa.free(self.starts); + self.* = .{ .starts = &.{} }; + } + + /// Source bytes for one line, without its newline. + pub fn line(self: LineIndex, source: []const u8, line_1based: u32) ?[]const u8 { + if (line_1based == 0 or line_1based > self.starts.len) return null; + const start = self.starts[line_1based - 1]; + if (start > source.len) return null; + const end = if (line_1based < self.starts.len) + @max(start, self.starts[line_1based] -| 1) + else + source.len; + return source[start..@min(end, source.len)]; + } +}; + +/// Raw source bytes a consolidated TEXT node came from, or null when its recorded span doesn't +/// fit the document (a node cmark synthesized rather than read, say). +/// +/// Inline nodes never span lines — a line break becomes its own SOFTBREAK/LINEBREAK node — so +/// this only ever needs `start_line`. +pub fn sourceSpanFor(source: []const u8, index: LineIndex, node: md.Node) ?[]const u8 { + const start_line = node.startLine(); + const start_col = node.startColumn(); + const end_col = node.endColumn(); + if (start_line <= 0 or start_col <= 0 or end_col < start_col) return null; + + const text = index.line(source, @intCast(start_line)) orelse return null; + const from: usize = @intCast(start_col - 1); + const to: usize = @intCast(end_col); + if (from > text.len or to > text.len) return null; + return text[from..to]; +} + +/// Bytes `span` would produce after cmark's backslash-escape handling, and a parallel flag per +/// byte marking the ones that came from an escape. +const Unescaped = struct { + bytes: []u8, + escaped: []bool, + + fn deinit(self: *Unescaped, gpa: std.mem.Allocator) void { + gpa.free(self.bytes); + gpa.free(self.escaped); + } +}; + +/// Mirrors `handle_backslash` in cmark's `inlines.c`: a backslash before ASCII punctuation +/// yields that punctuation literally; anything else keeps the backslash as-is. +fn unescape(gpa: std.mem.Allocator, span: []const u8) !Unescaped { + var bytes: std.ArrayList(u8) = .empty; + errdefer bytes.deinit(gpa); + var escaped: std.ArrayList(bool) = .empty; + errdefer escaped.deinit(gpa); + + var i: usize = 0; + while (i < span.len) { + if (span[i] == '\\' and i + 1 < span.len and isCmarkPunct(span[i + 1])) { + try bytes.append(gpa, span[i + 1]); + try escaped.append(gpa, true); + i += 2; + } else { + try bytes.append(gpa, span[i]); + try escaped.append(gpa, false); + i += 1; + } + } + return .{ + .bytes = try bytes.toOwnedSlice(gpa), + .escaped = try escaped.toOwnedSlice(gpa), + }; +} + +/// `cmark_ispunct` — ASCII punctuation only, which is exactly the escapable set. +fn isCmarkPunct(c: u8) bool { + return switch (c) { + '!'...'/', ':'...'@', '['...'`', '{'...'~' => true, + else => false, + }; +} + +/// Wikilinks in one consolidated TEXT node's `literal`, with backslash-escaped ones removed. +/// +/// `source_span` is that node's original bytes (from `sourceSpanFor`); pass null when they can't +/// be located, which disables escape detection rather than dropping links. Offsets in the +/// returned tokens index `literal`, so the caller can slice display text straight out of it. +/// +/// Returns an owned slice, empty when there are no links. +pub fn tokensFor( + gpa: std.mem.Allocator, + literal: []const u8, + source_span: ?[]const u8, +) ![]Token { + const tokens = try wikilink.tokenizeAlloc(gpa, literal); + if (tokens.len == 0) return tokens; + errdefer gpa.free(tokens); + + const span = source_span orelse return tokens; + // The overwhelmingly common case: no backslash anywhere in this run of text, so nothing + // can have been escaped and the literal is the source. Costs one memchr. + if (std.mem.indexOfScalar(u8, span, '\\') == null) return tokens; + + var un = try unescape(gpa, span); + defer un.deinit(gpa); + + // Fail open on any drift between what we reconstructed and what cmark actually produced + // (smart punctuation, entity expansion) — the flags would no longer line up positionally. + if (!std.mem.eql(u8, un.bytes, literal)) return tokens; + + var kept: usize = 0; + for (tokens) |tok| { + // `start` points at `!` for an embed; the brackets follow it. + const open = if (tok.embed) tok.start + 1 else tok.start; + if (open + 1 < un.escaped.len and (un.escaped[open] or un.escaped[open + 1])) continue; + tokens[kept] = tok; + kept += 1; + } + if (kept == tokens.len) return tokens; + return gpa.realloc(tokens, kept) catch tokens[0..kept]; +} + +// -- tests ------------------------------------------------------------------------------ +// +// These run the **real vendored cmark**, not a stand-in. The whole point of this file is a +// claim about what cmark does to escapes and source positions, and only cmark can confirm it. + +const testing = std.testing; + +/// Parse `src`, walk every TEXT node, and collect the wikilinks `tokensFor` finds in it. +fn linksIn(gpa: std.mem.Allocator, src: []const u8, out: *std.ArrayList([]const u8)) !void { + const ast = md.parseMarkdown(src) orelse return error.ParseFailed; + var index = try LineIndex.build(gpa, src); + defer index.deinit(gpa); + try walk(gpa, ast.root, src, index, out); +} + +fn walk( + gpa: std.mem.Allocator, + node: md.Node, + src: []const u8, + index: LineIndex, + out: *std.ArrayList([]const u8), +) !void { + if (node.nodeType() == md.c.CMARK_NODE_TEXT) { + if (node.literal()) |lit| { + const toks = try tokensFor(gpa, lit, sourceSpanFor(src, index, node)); + defer gpa.free(toks); + for (toks) |t| try out.append(gpa, try gpa.dupe(u8, t.target)); + } + } + var child = node.firstChild(); + while (child) |c| : (child = c.nextSibling()) try walk(gpa, c, src, index, out); +} + +fn expectTargets(src: []const u8, expected: []const []const u8) !void { + const gpa = testing.allocator; + var found: std.ArrayList([]const u8) = .empty; + defer { + for (found.items) |s| gpa.free(s); + found.deinit(gpa); + } + try linksIn(gpa, src, &found); + + testing.expectEqual(expected.len, found.items.len) catch |err| { + std.debug.print("source: {s}\nfound:", .{src}); + for (found.items) |s| std.debug.print(" [[{s}]]", .{s}); + std.debug.print("\n", .{}); + return err; + }; + for (expected, found.items) |want, got| try testing.expectEqualStrings(want, got); +} + +test "a plain wikilink is found" { + try expectTargets("See [[Note]] here.\n", &.{"Note"}); +} + +test "several wikilinks in one paragraph" { + try expectTargets("[[A]] and [[B]] and [[C]]\n", &.{ "A", "B", "C" }); +} + +test "escaped brackets are not a wikilink" { + // The regression this whole file exists for. cmark consolidates the escape into the + // surrounding text, so the literal here is indistinguishable from a real link. + try expectTargets("\\[\\[Note]] is how you write a link.\n", &.{}); +} + +test "escaping only the first bracket is enough" { + try expectTargets("\\[[Note]]\n", &.{}); +} + +test "an escape elsewhere in the line does not suppress a real link" { + try expectTargets("\\*not emphasis\\* but [[Note]] is a link\n", &.{"Note"}); +} + +test "inline code is never a wikilink" { + // Not handled here at all — cmark gives inline code its own CMARK_NODE_CODE node, so it + // never reaches a TEXT node. This test pins that assumption. + try expectTargets("Write `[[Note]]` to link.\n", &.{}); +} + +test "fenced code is never a wikilink" { + try expectTargets("```\n[[Note]]\n```\n", &.{}); +} + +test "indented code is never a wikilink" { + try expectTargets(" [[Note]]\n", &.{}); +} + +test "a wikilink inside emphasis is still found" { + try expectTargets("*see [[Note]]*\n", &.{"Note"}); +} + +test "wikilinks survive inside a list item and a blockquote" { + try expectTargets("- [[A]]\n\n> [[B]]\n", &.{ "A", "B" }); +} + +test "alias and heading forms round-trip through the parser" { + try expectTargets("[[A|shown]] and [[B#Heading]]\n", &.{ "A", "B" }); +} + +test "an embed is found" { + try expectTargets("![[Note]]\n", &.{"Note"}); +} + +test "an escaped embed is not" { + try expectTargets("!\\[\\[Note]]\n", &.{}); +} + +test "smart punctuation next to an escape fails open rather than dropping the link" { + // CMARK_OPT_SMART rewrites the quotes, so the reconstructed bytes can't match the literal + // and escape detection is skipped. The documented, deliberate outcome is that the link + // renders — not that it disappears. + try expectTargets("\"quoted\" \\* [[Note]]\n", &.{"Note"}); +} + +test "a wikilink spanning a line break is not a link" { + try expectTargets("[[A\nB]]\n", &.{}); +} + +test "unclosed brackets are not a link" { + try expectTargets("[[A and then nothing\n", &.{}); +} diff --git a/src/plugins/shared/build/helpers.zig b/src/plugins/shared/build/helpers.zig index a0ddcd68..2a29a1b2 100644 --- a/src/plugins/shared/build/helpers.zig +++ b/src/plugins/shared/build/helpers.zig @@ -37,7 +37,9 @@ pub const current_sdk_version: []const u8 = std.fmt.comptimePrint("{d}.{d}.{d}", version_number.sdk_version.minor, version_number.sdk_version.patch, }); -const version_number = @import("../../../../sdk/sdk_version.zig"); +// Through the `sdk/` dependency rather than by relative path into it: the app consumes that +// directory as a package so the two share one dvui pin, and a file may belong to only one module. +const version_number = @import("fizzy_sdk").sdk_version; /// Identity read from a built-in's `plugin.zig.zon` at configure time, plus its raw source. /// Same type as `plugin_sdk.IdentityManifest` (both `@import` `manifest_identity.zig` directly) diff --git a/src/plugins/text/README.md b/src/plugins/text/README.md index 229db0ec..9b517439 100644 --- a/src/plugins/text/README.md +++ b/src/plugins/text/README.md @@ -1,4 +1,14 @@ # Text -Built-in Fizzy plugin. Text is the universal fallback editor: it opens any file no other plugin + +Built-in [Fizzy](../../../readme.md) plugin. Text is the universal fallback editor: it opens any file no other plugin claims, as an editable, monospace document tab in the Workbench workspace. + +--- + +| Features | Description | +|---|---| +| rainbow brackets | matches brackets with a color palette | +| LSP client | Interface for providing LSP support for languages | + +See also [markdown](../markdown/README.md) which is a plugin that extends this one. diff --git a/src/plugins/text/plugin.zig b/src/plugins/text/plugin.zig index c39b8e59..8a95f5a2 100644 --- a/src/plugins/text/plugin.zig +++ b/src/plugins/text/plugin.zig @@ -59,6 +59,7 @@ const vtable: sdk.Plugin.VTable = .{ .documentHasNativeExtension = documentHasNativeExtension, .documentHasRecognizedSaveExtension = documentHasRecognizedSaveExtension, // rendering + lifecycle + .tickOpenDocuments = tickOpenDocuments, .drawDocument = drawDocument, .closeDocument = closeDocument, .reloadDocument = reloadDocument, @@ -276,6 +277,9 @@ fn revealPosition(_: *anyopaque, handle: DocHandle, line: u32, character: u32) v const doc = docFrom(handle) orelse return; doc.pending_sel = .collapsed(doc.byteOffsetForLineCharacter(line, character)); doc.pending_scroll_line = line; + // Both panes show the same document, so a reveal means both. Set unconditionally — whether + // the extension has a preview, and whether it is on screen, is `TextEditor`'s to know. + doc.pending_preview_line = line; } fn bindDocumentToPane(_: *anyopaque, _: DocHandle, _: dvui.Id, _: *anyopaque, _: bool) void { // Text editing needs no pane/canvas binding; the text widget manages its own state. @@ -306,6 +310,18 @@ fn reloadDocument(_: *anyopaque, handle: DocHandle) anyerror!void { fn isDirty(_: *anyopaque, handle: DocHandle) bool { return (docFrom(handle) orelse return false).isDirty(); } + +/// Drive each open document's content-change debounce. Returns true while any of them still +/// owes a notification, so fizzy keeps drawing until the burst settles instead of idling with +/// one pending. +fn tickOpenDocuments(state: *anyopaque) bool { + const st: *State = @ptrCast(@alignCast(state)); + var pending = false; + for (st.docs.values()) |doc| { + if (doc.tickContentChanged()) pending = true; + } + return pending; +} fn saveDocument(state: *anyopaque, handle: DocHandle) anyerror!void { const doc = docFrom(handle) orelse return; const st: *State = @ptrCast(@alignCast(state)); diff --git a/src/plugins/text/src/Document.zig b/src/plugins/text/src/Document.zig index 7b375262..44bd3b24 100644 --- a/src/plugins/text/src/Document.zig +++ b/src/plugins/text/src/Document.zig @@ -5,6 +5,7 @@ const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); const sdk = @import("fizzy_sdk"); +const perf = @import("core").perf; const tc = @import("textcore/textcore.zig"); const TextEntryWidget = @import("widgets/TextEntryWidget.zig"); @@ -31,6 +32,32 @@ pub const PreviewMode = enum { } }; +/// Last `.split` sash position chosen this session. The mode itself persists as the markdown +/// plugin setting `default_md_view` (via the `"markdown"` service); the ratio is session-only +/// so a one-off drag doesn't rewrite settings.zon every frame of a sash move. +pub var sticky_split_ratio: f32 = 0.5; + +/// Record the user's raw|split|preview choice: persists markdown's `default_md_view` and +/// remembers the split sash ratio for documents opened later this session. +pub fn rememberPreviewMode(mode: PreviewMode, user_ratio: f32) void { + sticky_split_ratio = user_ratio; + const md = sdk.host().getServiceTyped(sdk.services.markdown.Api) orelse return; + md.setDefaultView(switch (mode) { + .raw => .raw, + .split => .split, + .preview => .preview, + }); +} + +fn defaultPreviewMode() PreviewMode { + const md = sdk.host().getServiceTyped(sdk.services.markdown.Api) orelse return .split; + return switch (md.defaultView()) { + .raw => .raw, + .split => .split, + .preview => .preview, + }; +} + /// Fizzy document id (monotonic, allocated from the host). id: u64, /// Absolute path on disk, heap-owned. @@ -87,6 +114,12 @@ scroll_y: f32 = 0, /// (`break_lines = false`), so one source line is exactly one visual row and `line * /// line_height` is an exact, not approximate, scroll target. pending_scroll_line: ?u32 = null, +/// The same reveal, for the preview pane — `LanguageSupport.previewReveal`, consumed by +/// `TextEditor.drawPreviewPane`. Separate from `pending_scroll_line` because the two are +/// consumed by different panes, and either may be the only one showing: `.raw` never draws a +/// preview, `.preview` never draws the editor. Dropped unconsumed at the end of a frame that +/// drew no preview, so turning one on later doesn't replay a jump from minutes ago. +pending_preview_line: ?u32 = null, /// Owned completion candidates for the current completion list, if any — each `.label`/`.text` /// is a copy (`sdk.language.CompletionItem` fields from `sdk.host().completionFor(...)` are @@ -123,6 +156,22 @@ history: tc.History = .{}, /// edit gets a fresh id that never collides with the one recorded at save time. clean_op_id: u64 = 0, +/// Debounce state for `Host.notifyDocumentContentChanged` — see `tickContentChanged`. +/// +/// Keyed on `history.topOpId()` rather than a hash of the text: the id already changes on +/// exactly the events we care about (any genuinely new edit) and comparing two integers costs +/// nothing per frame, whereas hashing a large file every frame to find out it didn't change is +/// the sort of thing that quietly eats a millisecond on every keystroke. +notify_seen_op_id: u64 = 0, +notify_sent_op_id: u64 = 0, +/// `perf.nanoTimestamp()` after which the current burst counts as settled. +notify_due_ns: i128 = 0, + +/// How long the text has to stop changing before observers hear about it. Long enough that +/// ordinary typing produces one notification per pause rather than per character, short enough +/// that it feels immediate when you stop. +const notify_debounce_ns: i128 = 300 * std.time.ns_per_ms; + /// 64 MiB — generous for source files; guards against opening something huge by mistake. const max_file_bytes: usize = 64 * 1024 * 1024; @@ -134,10 +183,17 @@ pub fn fromBytes(path: []const u8, bytes: []const u8) !Document { try text.appendSlice(gpa, bytes); const path_copy = try gpa.dupe(u8, path); errdefer gpa.free(path_copy); + // Seed from the persisted `default_md_view` setting (and this session's sash ratio). Start + // the sash *at* the mode's resting position rather than animating there: the tray sliding + // open is feedback for a choice the user just made, not for every file they open. + const mode = defaultPreviewMode(); var doc = Document{ .id = sdk.host().allocDocId(), .path = path_copy, .text = text, + .preview_mode = mode, + .preview_split_ratio = mode.splitRatio(sticky_split_ratio), + .preview_split_ratio_user = sticky_split_ratio, }; doc.refreshLineCount(); return doc; @@ -243,6 +299,37 @@ pub fn isDirty(self: *const Document) bool { return self.history.topOpId() != self.clean_op_id; } +/// Broadcast this document's live contents to every plugin, now. +/// +/// The text plugin owns `.md` (and everything else nothing claimed), so a plugin that indexes +/// markdown links, counts words, or previews structure can only see unsaved text if we hand it +/// over — nothing in the SDK exposes another plugin's buffer. +pub fn notifyContentChanged(self: *Document) void { + self.notify_seen_op_id = self.history.topOpId(); + self.notify_sent_op_id = self.notify_seen_op_id; + sdk.host().notifyDocumentContentChanged(self.path, self.text.items); +} + +/// Per-frame half of the debounce. Returns true while a notification is still pending, which +/// the caller passes up through `tickOpenDocuments` to keep frames coming — otherwise the app +/// idles the moment you stop typing and the pending notification waits for whatever happens to +/// wake it next. +pub fn tickContentChanged(self: *Document) bool { + const top = self.history.topOpId(); + if (top != self.notify_seen_op_id) { + // Still changing — restart the clock. A held key or a paste storm therefore produces + // one notification at the end, not one per event. + self.notify_seen_op_id = top; + self.notify_due_ns = perf.nanoTimestamp() + notify_debounce_ns; + return true; + } + if (self.notify_sent_op_id == top) return false; + if (perf.nanoTimestamp() < self.notify_due_ns) return true; + + self.notifyContentChanged(); + return false; +} + /// Write the current contents back to `path`. pub fn save(self: *Document) !void { if (comptime is_wasm) return error.Unsupported; @@ -253,6 +340,10 @@ pub fn save(self: *Document) !void { // same reason. self.history.closeGroup(); self.clean_op_id = self.history.topOpId(); + // Immediately, not on the debounce: an observer that also watches the filesystem is about + // to see this write land, and it should have our version of the contents first so it can + // recognize the on-disk change as already accounted for. + self.notifyContentChanged(); } /// Replace in-memory contents from disk and clear undo history (external change / discard). diff --git a/src/plugins/text/src/TextEditor.zig b/src/plugins/text/src/TextEditor.zig index 60cdf21c..f733b395 100644 --- a/src/plugins/text/src/TextEditor.zig +++ b/src/plugins/text/src/TextEditor.zig @@ -85,6 +85,7 @@ pub fn draw(doc: *Document, id_extra: u64, gpa: std.mem.Allocator) !bool { doc.preview_mode = .split; doc.preview_split_ratio_user = paned.split_ratio.*; } + Document.rememberPreviewMode(doc.preview_mode, doc.preview_split_ratio_user); } else { // `animateSplit` is a no-op once the ratio is already there, so this is safe every frame. // Opening eases with `outBack` and closing with `outQuint`, matching the explorer and @@ -100,6 +101,10 @@ pub fn draw(doc: *Document, id_extra: u64, gpa: std.mem.Allocator) !bool { } if (paned.showSecond()) { try drawPreviewPane(doc, preview.?, ext, id_extra + 0x2000, gpa); + } else { + // No preview on screen to consume it. Drop it rather than let it sit until the user + // opens the preview and gets yanked to a heading they clicked on ages ago. + doc.pending_preview_line = null; } return changed; @@ -114,6 +119,13 @@ fn drawPreviewPane( ) !void { const hook = provider.vtable.previewPane orelse return; const owner = provider.owner orelse return; + // Before the draw, so the provider can apply it on this very frame rather than the next. + if (doc.pending_preview_line) |line| { + if (provider.vtable.previewReveal) |reveal| { + reveal(owner.state, ext, doc.path, line, id_extra); + } + doc.pending_preview_line = null; + } try hook(owner.state, ext, doc.path, doc.text.items, id_extra, gpa); } @@ -153,6 +165,8 @@ fn drawPreviewPillButton(doc: *Document, label: []const u8, mode: Document.Previ // Each button names a mode outright — the old pair toggled *and* selected, so the same // click meant different things depending on the state you couldn't see. doc.preview_mode = mode; + // Sticky: the next document opened starts in whatever was picked here. + Document.rememberPreviewMode(mode, doc.preview_split_ratio_user); } } diff --git a/src/plugins/text/src/widgets/TextEntryWidget.zig b/src/plugins/text/src/widgets/TextEntryWidget.zig index 349a6833..98b907a8 100644 --- a/src/plugins/text/src/widgets/TextEntryWidget.zig +++ b/src/plugins/text/src/widgets/TextEntryWidget.zig @@ -938,53 +938,58 @@ pub fn draw(self: *TextEntryWidget) void { defer dvui.c.ts_query_cursor_delete(qc); dvui.c.ts_query_cursor_set_match_limit(qc, tree_sitter_match_limit); - dvui.c.ts_query_cursor_exec(qc, ts_parser.query, root); - - var iter = ts_parser.queryCursorCaptureIterator(qc.?, self.text); - iter.debug = ts.log_captures; - // Restrict the capture walk to what's actually on screen — this is the dominant - // per-frame cost of a highlighted document (see `highlightByteRange` for why it - // can't just reuse dvui's layout range). Text outside the queried range still - // renders via the gap/leftover chunks below; it's just uncolored until scrolled - // into range. - if (self.highlightByteRange()) |r| { - iter.setByteRange(r.start, r.end); + // per-frame cost of a highlighted document, and it comes as several ranges rather + // than one (see `highlightRanges`). Text outside them still renders via the + // gap/leftover chunks below; it's just uncolored until scrolled into range. + var range_buf: [max_highlight_ranges]ByteRange = undefined; + var ranges = self.highlightRanges(&range_buf); + if (ranges.len == 0) { + range_buf[0] = .{ .start = 0, .end = self.len }; + ranges = range_buf[0..1]; } - while (true) { - //const capture_start = perfBegin(); - const maybe_match = iter.next(); - //perfAccumCapture(capture_start); - const match = maybe_match orelse break; - - const nstart = dvui.c.ts_node_start_byte(match.node); - const nend = dvui.c.ts_node_end_byte(match.node); - if (start < nstart) { - // render non highlighted text up to this node - //const shape_start = perfBegin(); - self.emitChunk(start, self.text[start..nstart], .{}, false, true); - //perfAccumShape(shape_start); - } else if (nstart < start) { - // this match is inside (or overlapping) the previous match - // maybe we could be smarter here, but for now drop it - continue; - } - var opts: dvui.Options = .{}; - const capture_name = match.captureName(); - for (0..ts.highlights.len) |i| { - const sh = ts.highlights[ts.highlights.len - i - 1]; - if (std.mem.startsWith(u8, capture_name, sh.name)) { - opts = sh.opts; - break; + for (ranges) |r| { + dvui.c.ts_query_cursor_exec(qc, ts_parser.query, root); + var iter = ts_parser.queryCursorCaptureIterator(qc.?, self.text); + iter.debug = ts.log_captures; + iter.setByteRange(r.start, r.end); + + while (true) { + //const capture_start = perfBegin(); + const maybe_match = iter.next(); + //perfAccumCapture(capture_start); + const match = maybe_match orelse break; + + const nstart = dvui.c.ts_node_start_byte(match.node); + const nend = dvui.c.ts_node_end_byte(match.node); + if (start < nstart) { + // render non highlighted text up to this node + //const shape_start = perfBegin(); + self.emitChunk(start, self.text[start..nstart], .{}, false, true); + //perfAccumShape(shape_start); + } else if (nstart < start) { + // this match is inside (or overlapping) the previous match + // maybe we could be smarter here, but for now drop it + continue; } - } - //const shape_start = perfBegin(); - self.emitChunk(nstart, self.text[nstart..nend], opts, true, captureAllowsRainbow(capture_name)); - //perfAccumShape(shape_start); + var opts: dvui.Options = .{}; + const capture_name = match.captureName(); + for (0..ts.highlights.len) |i| { + const sh = ts.highlights[ts.highlights.len - i - 1]; + if (std.mem.startsWith(u8, capture_name, sh.name)) { + opts = sh.opts; + break; + } + } + + //const shape_start = perfBegin(); + self.emitChunk(nstart, self.text[nstart..nend], opts, true, captureAllowsRainbow(capture_name)); + //perfAccumShape(shape_start); - start = nend; + start = nend; + } } if (start < self.len) { @@ -1063,6 +1068,50 @@ pub fn highlightByteRange(self: *TextEntryWidget) ?ByteRange { }; } +/// The most byte ranges `highlightRanges` will query in one frame. Every range costs another +/// `ts_query_cursor_exec` and tree descent, and dvui reports at most +/// `TextLayoutWidget.VisibleRanges.max` runs anyway. +const max_highlight_ranges = dvui.TextLayoutWidget.VisibleRanges.max; + +/// `highlightByteRange` split into the runs actually worth querying, in increasing byte order. +/// +/// The single interval isn't enough on its own: a line wider than the viewport is on screen at +/// its left edge and again — as a different line — below it, so an interval covering both also +/// covers the line's off-screen middle. That middle is where a pathological line keeps all of its +/// syntax nodes, so querying it costs the whole frame (36ms for one 200k-character line) to color +/// pixels that don't exist. dvui reports which bytes its layout actually put on screen last +/// frame, gaps included; each run gets its own query pass, and the gaps between them come out as +/// uncolored text nobody can see. +/// +/// Each run is padded, and the result clipped back to `highlightByteRange`, for the same reason +/// that range is padded: dvui's runs are a frame stale, so a scroll or edit has to be able to +/// land inside them and still be colored. +fn highlightRanges(self: *TextEntryWidget, buf: *[max_highlight_ranges]ByteRange) []const ByteRange { + const range = self.highlightByteRange() orelse return &.{}; + const visible = self.textLayout.visibleBytesLastFrame(); + if (visible.len == 0) { + buf[0] = range; + return buf[0..1]; + } + + var n: usize = 0; + for (visible) |vis| { + const headroom = @max(2 * (vis.end -| vis.start), 4096); + const start = @max(range.start, vis.start -| headroom); + const end = @min(range.end, vis.end +| headroom); + if (end <= start) continue; + // Padding can make neighbouring runs meet; two passes over one span would emit the same + // captures twice, which the emit loop reads as overlapping matches and drops. + if (n > 0 and start <= buf[n - 1].end) { + buf[n - 1].end = @max(buf[n - 1].end, end); + } else { + buf[n] = .{ .start = start, .end = end }; + n += 1; + } + } + return buf[0..n]; +} + /// One ghost-text splice resolved for this frame: `text` shown dimmed at byte offset `anchor`. /// `emitChunk` sources this from `current_completion` (acceptable via Tab/Enter) when showing, /// else `signature_hint` (purely informational, never acceptable) — see `signature_hint`'s doc diff --git a/src/plugins/workbench/README.md b/src/plugins/workbench/README.md index 28e5a1db..6c9c1b68 100644 --- a/src/plugins/workbench/README.md +++ b/src/plugins/workbench/README.md @@ -1,13 +1,15 @@ # Workbench -Built-in Fizzy plugin. Workbench is the frame every other plugin's content sits inside: it owns the **Files** -sidebar (browsing and opening files from the current folder) and the **workspace** center — the +Built-in [Fizzy](../../../readme.md) plugin. Workbench is the frame every other plugin's content sits inside: it owns the **Files** +sidebar (browsing and opening files from the current folder) and the **workspace** center, the tabbed/paned area where open documents (from Workbench itself or from any other plugin, like Pixi) are laid out and switched between. Workbench has no document type of its own to edit; it contributes navigation and layout so editor plugins can focus purely on their own document. -- **Files** — a tree view of the open folder for browsing and opening files. -- **Workspaces** — the tab strip + pane-splitting surface that hosts open documents from any - plugin. +| Features | Description | +|---|---| +| Files | a tree view of the open folder for browsing and opening files | +| Workspaces | the tab strip + pane-splitting surface that hosts open documents from any plugin. | + diff --git a/src/plugins/workbench/plugin.zig b/src/plugins/workbench/plugin.zig index 56b5da65..12afff9b 100644 --- a/src/plugins/workbench/plugin.zig +++ b/src/plugins/workbench/plugin.zig @@ -43,6 +43,7 @@ var plugin: sdk.Plugin = .{ const vtable: sdk.Plugin.VTable = .{ .contributeKeybinds = contributeKeybinds, + .folderPathsChanged = folderPathsChanged, }; /// When false at compile time (`-Dworkbench-file-tree=false`), the Files sidebar is not registered. @@ -75,6 +76,40 @@ fn drawCenter(_: ?*anyopaque) anyerror!dvui.App.Result { return runtime.host().drawWorkspaces(0); } +/// Keeps the file tree's cached directory listings honest. The tree no longer re-reads every +/// expanded directory each frame (that is what made a folder with a few hundred thousand files +/// unusable), so this is how a change made outside fizzy — or by another tool — reaches it. +/// +/// Only the *parent* of each changed path is dropped: a file appearing in `a/b/c.md` says +/// nothing about `a`. A truncated batch means the event list is an incomplete picture, so the +/// whole cache goes instead. +fn folderPathsChanged(_: *anyopaque, changes: sdk.Plugin.PathChanges) void { + if (!has_file_tree) return; + + if (changes.truncated) { + files.invalidateDirCache(); + return; + } + + for (changes.events) |event| { + // A file's *contents* changing leaves every listing exactly as it was, and this is by + // far the most common event there is — every save of every open document. Re-reading a + // directory for it would put the full cost of a quarter-million-entry listing back on + // the frame after each keystroke-triggered autosave. + if (event.kind == .modified and event.object == .file) continue; + + if (std.fs.path.dirname(event.path)) |parent| files.invalidateDirCacheFor(parent); + // A rename's two halves can sit in different directories. + if (event.old_path.len > 0) { + if (std.fs.path.dirname(event.old_path)) |parent| files.invalidateDirCacheFor(parent); + } + // A directory that itself appeared or vanished changes its own listing too. `.unknown` + // is included deliberately: the object is already gone by the time fizzy looks, so it + // could be either. + if (event.object != .file) files.invalidateDirCacheFor(event.path); + } +} + /// File-management keybinds (open / save). Fizzy registers its own /// global/region binds in `Keybinds.register`; this fills in the file half. fn contributeKeybinds(_: *anyopaque, win: *dvui.Window) anyerror!void { diff --git a/src/plugins/workbench/src/Workbench.zig b/src/plugins/workbench/src/Workbench.zig index c21ecee7..2fa6572b 100644 --- a/src/plugins/workbench/src/Workbench.zig +++ b/src/plugins/workbench/src/Workbench.zig @@ -56,6 +56,7 @@ pub fn init(allocator: std.mem.Allocator) Workbench { } pub fn deinit(self: *Workbench) void { + files.deinitCaches(); self.decorators.deinit(self.allocator); for (self.pending_reveals.items) |pr| self.allocator.free(pr.path); self.pending_reveals.deinit(self.allocator); diff --git a/src/plugins/workbench/src/Workspace.zig b/src/plugins/workbench/src/Workspace.zig index 2dfda1b7..60ecb6b6 100644 --- a/src/plugins/workbench/src/Workspace.zig +++ b/src/plugins/workbench/src/Workspace.zig @@ -880,7 +880,7 @@ pub fn drawHomePage(_: *Workspace) !void { .gravity_x = 0.5, .expand = .horizontal, .padding = dvui.Rect.all(2), - .color_fill = .transparent, + .color_fill = wdvui.hoverRestFill(dvui.themeGet().color(.window, .fill_hover)), .color_fill_hover = dvui.themeGet().color(.window, .fill_hover), .color_fill_press = dvui.themeGet().color(.window, .fill_press), }); @@ -908,7 +908,7 @@ pub fn drawHomePage(_: *Workspace) !void { .gravity_x = 0.5, .expand = .horizontal, .padding = dvui.Rect.all(2), - .color_fill = .transparent, + .color_fill = wdvui.hoverRestFill(dvui.themeGet().color(.window, .fill_hover)), .color_fill_hover = dvui.themeGet().color(.window, .fill_hover), .color_fill_press = dvui.themeGet().color(.window, .fill_press), }); @@ -936,7 +936,7 @@ pub fn drawHomePage(_: *Workspace) !void { .gravity_x = 0.5, .expand = .horizontal, .padding = dvui.Rect.all(2), - .color_fill = .transparent, + .color_fill = wdvui.hoverRestFill(dvui.themeGet().color(.window, .fill_hover)), .color_fill_hover = dvui.themeGet().color(.window, .fill_hover), .color_fill_press = dvui.themeGet().color(.window, .fill_press), }); @@ -999,7 +999,7 @@ pub fn drawHomePage(_: *Workspace) !void { .id_extra = i, .margin = dvui.Rect.all(1), .padding = dvui.Rect.all(2), - .color_fill = .transparent, + .color_fill = wdvui.hoverRestFill(dvui.themeGet().color(.window, .fill_hover)), .color_fill_hover = dvui.themeGet().color(.window, .fill_hover), .color_fill_press = dvui.themeGet().color(.window, .fill_press), .color_text = dvui.themeGet().color(.control, .text).opacity(0.5), diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig index b96cd16a..74a9d9c3 100644 --- a/src/plugins/workbench/src/files.zig +++ b/src/plugins/workbench/src/files.zig @@ -18,9 +18,10 @@ pub var edit_id: ?usize = null; pub var selected_paths: std.AutoArrayHashMapUnmanaged(usize, []u8) = .empty; pub var selection_anchor: ?usize = null; -/// Visible file/folder rows in depth-first tree order for the current frame (shift-range selection). +/// One row in depth-first tree order, for resolving a shift-range. Built on demand — see +/// `flushPendingFileShiftRange` — because the tree only builds widgets for rows near the +/// viewport and a shift anchor is usually scrolled well off screen. const FileVisRow = struct { id: usize, path: []const u8 }; -var visible_file_rows_order: std.ArrayListUnmanaged(FileVisRow) = .empty; /// Shift-range uses row order built incrementally during draw; applying mid-traverse misses the anchor /// when it appears later in DFS than the clicked row. Flush after the tree pass completes. @@ -141,6 +142,10 @@ fn drawWeb() !void { } pub fn drawFiles(path: []const u8, tree: *wdvui.TreeWidget) !void { + // Nothing is mid-walk at this point, so this is the one safe moment to free listings that + // last frame's draw invalidated while it was still reading them. + releaseRetiredListings(); + const unique_id = dvui.parentGet().extendId(@src(), 0); runtime.workbench().file_tree_data_id = unique_id; @@ -375,6 +380,14 @@ pub fn invalidateFilterIndex() void { filter_cache_valid = false; } +/// Both caches, for the disk-mutating helpers below. Deliberately *not* folded into +/// `invalidateFilterIndex`: that one also fires every frame the filter box is empty, which would +/// drop the listing cache continuously and undo the whole point of having it. +fn invalidateAfterDiskChange() void { + invalidateFilterIndex(); + invalidateDirCache(); +} + fn freeFilterIndex() void { const gpa = runtime.allocator(); for (filter_index.items) |p| gpa.free(p); @@ -499,6 +512,200 @@ fn rankedFilterRows(root_directory: []const u8, filter_text: []const u8) []const return filter_cache_rows.items; } +// ---- directory listing cache --------------------------------------------------------------- +// +// The unfiltered tree used to re-read every expanded directory straight from disk on *every +// frame*: `openDir` + `iterate`, an arena dupe per name, a full sort, and an `isPathIgnored` +// call per entry. On a normal project that is invisible. On a vault with a few hundred thousand +// markdown files in one directory it is megabytes of arena churn and a sort of the whole listing +// per frame, which is half of why such a folder drops the app to single-digit FPS. (The other +// half is drawing a widget per row — see the virtualized file run in `search`.) +// +// So a listing is read once and kept. Freshness comes from `folderPathsChanged`, the watcher +// fizzy already runs on the open root; when there is no watcher backend for the platform, +// entries fall back to a short TTL so outside edits still show up. + +const CachedEntry = struct { + name: []u8, + /// Always `.file` or `.directory`. Anything else on disk (a symlink, a fifo) is resolved to + /// whichever it behaves as, so a sorted listing is always a directory run followed by a file + /// run — the split `search` needs to virtualize the file half. It also fixes a small + /// pre-existing bug: an entry of any other kind used to fall through the draw loop's `switch` + /// and leave a blank, unlabelled row in the tree. + kind: std.Io.File.Kind, +}; + +const CachedListing = struct { + /// Sorted by `cachedLessThan` and already screened against fizzy's ignore rules. + entries: []CachedEntry, + /// Count of leading `.directory` entries; `entries[dir_count..]` is the uniform-height run. + dir_count: usize, + read_at_ms: i64, +}; + +/// Keyed by absolute directory path (owned). Values are boxed because a listing is borrowed +/// across a whole `search` call and the map rehashes as nested directories are read, which would +/// otherwise move the value out from under the loop iterating it. +var dir_cache: std.StringArrayHashMapUnmanaged(*CachedListing) = .empty; + +/// Listings unlinked from the cache but possibly still being read by the draw in progress. +/// +/// Invalidation can fire *during* a draw — a context menu that deletes or renames a file runs +/// inside the row it belongs to, several `search` frames deep, each of which is iterating a +/// listing. Freeing eagerly there is a use-after-free in the enclosing loops, so an unlinked +/// listing is parked here and released at the top of the next frame instead. +var dir_cache_retired: std.ArrayListUnmanaged(*CachedListing) = .empty; + +/// Directories held at once. A tree with more than this expanded isn't a UI anyone is reading; +/// dropping the whole cache beats maintaining an LRU for a case nobody reaches. +const dir_cache_max_dirs: usize = 1024; + +/// Re-read interval used *only* when fizzy has no live folder watcher, so the tree still +/// notices outside edits on a platform with no watcher backend. +const dir_cache_unwatched_ttl_ms: i64 = 1000; + +/// Monotonic milliseconds. The boot clock rather than a wall clock: a TTL must not be +/// perturbed by the system clock stepping. +fn nowMs() i64 { + return @intCast(@divTrunc(std.Io.Clock.boot.now(dvui.io).nanoseconds, std.time.ns_per_ms)); +} + +fn cachedLessThan(_: void, lhs: CachedEntry, rhs: CachedEntry) bool { + if (lhs.kind == .directory and rhs.kind != .directory) return true; + if (lhs.kind != .directory and rhs.kind == .directory) return false; + return std.mem.order(u8, lhs.name, rhs.name) == .lt; +} + +fn freeListing(listing: *CachedListing) void { + const gpa = runtime.allocator(); + for (listing.entries) |e| gpa.free(e.name); + gpa.free(listing.entries); + gpa.destroy(listing); +} + +/// Unlink one listing, parking it for release on the next frame (see `dir_cache_retired`). +fn retireCachedListingAt(index: usize) void { + const gpa = runtime.allocator(); + const listing = dir_cache.values()[index]; + gpa.free(dir_cache.keys()[index]); + dir_cache.swapRemoveAt(index); + dir_cache_retired.append(gpa, listing) catch freeListing(listing); +} + +/// Release listings unlinked during earlier frames. Called once at the top of the tree draw, +/// which is the only point at which nothing can still be reading one. +fn releaseRetiredListings() void { + for (dir_cache_retired.items) |listing| freeListing(listing); + dir_cache_retired.clearRetainingCapacity(); +} + +/// Drop every cached listing. The tree re-reads whatever it draws on the next frame. +pub fn invalidateDirCache() void { + while (dir_cache.count() > 0) retireCachedListingAt(dir_cache.count() - 1); +} + +/// Drop the listing for one directory. `folderPathsChanged` calls this with the parent of each +/// changed path — a file appearing in `a/b/c.md` only invalidates `a/b`. +pub fn invalidateDirCacheFor(directory: []const u8) void { + if (dir_cache.getIndex(directory)) |idx| retireCachedListingAt(idx); +} + +/// Cached, sorted, ignore-screened listing for `directory`, reading it from disk on a miss. +/// Null when the directory can't be opened. +fn listDir(directory: []const u8) ?*const CachedListing { + const gpa = runtime.allocator(); + const now = nowMs(); + + if (dir_cache.getIndex(directory)) |idx| { + const listing = dir_cache.values()[idx]; + if (runtime.host().folderWatchActive() or now - listing.read_at_ms < dir_cache_unwatched_ttl_ms) { + return listing; + } + retireCachedListingAt(idx); + } + + if (dir_cache.count() >= dir_cache_max_dirs) invalidateDirCache(); + + const io = dvui.io; + var dir = std.Io.Dir.cwd().openDir(io, directory, .{ .access_sub_paths = true, .iterate = true }) catch return null; + defer dir.close(io); + + var entries: std.ArrayListUnmanaged(CachedEntry) = .empty; + const proj_root = runtime.host().folder(); + // The ignore check wants an absolute path but doesn't keep it, so it's built into a stack + // buffer: joining through an allocator here would mean one allocation per entry on a listing + // that can be hundreds of thousands long. + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + + var iter = dir.iterate(); + while (iter.next(io) catch null) |entry| { + const abs_path: ?[]const u8 = std.fmt.bufPrint( + &path_buf, + "{s}" ++ std.fs.path.sep_str ++ "{s}", + .{ directory, entry.name }, + ) catch null; + + if (proj_root) |root| { + const abs = abs_path orelse continue; + if (runtime.host().isPathIgnored(root, abs, entry.name, entry.kind)) continue; + } + + const kind: std.Io.File.Kind = switch (entry.kind) { + .directory => .directory, + .file => .file, + else => if (abs_path) |abs| + (if (pathIsDirAbsolute(abs)) .directory else .file) + else + .file, + }; + + const name = gpa.dupe(u8, entry.name) catch continue; + entries.append(gpa, .{ .name = name, .kind = kind }) catch { + gpa.free(name); + continue; + }; + } + + const owned = entries.toOwnedSlice(gpa) catch { + for (entries.items) |e| gpa.free(e.name); + entries.deinit(gpa); + return null; + }; + std.mem.sort(CachedEntry, owned, {}, cachedLessThan); + + var dir_count: usize = 0; + while (dir_count < owned.len and owned[dir_count].kind == .directory) dir_count += 1; + + const listing = gpa.create(CachedListing) catch { + for (owned) |e| gpa.free(e.name); + gpa.free(owned); + return null; + }; + listing.* = .{ .entries = owned, .dir_count = dir_count, .read_at_ms = now }; + + const key = gpa.dupe(u8, directory) catch { + freeListing(listing); + return null; + }; + dir_cache.put(gpa, key, listing) catch { + gpa.free(key); + freeListing(listing); + return null; + }; + return listing; +} + +/// Free everything this module holds across frames. Called from `Workbench.deinit`. +pub fn deinitCaches() void { + deinitFilterIndex(); + invalidateDirCache(); + releaseRetiredListings(); + dir_cache.deinit(runtime.allocator()); + dir_cache_retired.deinit(runtime.allocator()); + selectionFreeAll(); + selected_paths.deinit(runtime.allocator()); +} + /// One row to draw. `dir` is normally null — the row's parent directory is whichever directory /// the walk is currently in. Filtered rows come from all over the project at once (a flat ranked /// list, not a walk), so those carry their own parent explicitly. @@ -508,12 +715,54 @@ const SimpleEntry = struct { dir: ?[]const u8 = null, }; -fn lessThan(_: void, lhs: SimpleEntry, rhs: SimpleEntry) bool { - if (lhs.kind == .directory and rhs.kind == .file) return true; - if (lhs.kind == .file and rhs.kind == .directory) return false; - - return std.mem.order(u8, lhs.name, rhs.name) == .lt; -} +// ---- file-run virtualization ---------------------------------------------------------------- +// +// Every row in the tree is a real widget stack — a branch, a caret slot, an icon slot, a label, +// a context menu — so a directory's row count is a *per-frame* cost even for rows scrolled far +// out of sight. A quarter-million-file directory is therefore unusable no matter how fast the +// listing is read, which is why the cache above is only half the fix. +// +// Only the file run is virtualized. File rows are uniform height, so a leading and trailing +// spacer can stand in for the rows outside the viewport and keep both the scrollbar and the +// scroll offset exactly where they'd otherwise be. Directory rows are not: an expanded folder is +// as tall as its whole subtree. They always draw, which is fine because directories are the +// small half of every real tree. + +/// Below this many files in one directory, virtualizing costs more than it saves. +const virtual_min_rows: usize = 64; + +/// Rows drawn beyond each edge of the viewport. Overscan is not just polish here: dvui drops a +/// widget's min size the moment it goes undrawn (`min_sizes` is put-only tracked), so a row +/// scrolled back into view reports zero height on its first frame again. Drawing it a few rows +/// early means it has settled by the time it is actually on screen. +const virtual_overscan: f32 = 16; + +/// Rows drawn on the very first frame purely to measure the row pitch, before which there is no +/// way to know where the viewport falls in the run. One frame, then it self-corrects. +const virtual_probe_rows: usize = 32; + +/// Natural-unit height budget for one file run. +/// +/// dvui clamps *every* widget's reported min size to `dvui.max_float_safe` (2e6) so layout +/// arithmetic stays inside f32's exact-integer range. At ~21.5 natural px per row that caps a +/// run at roughly 93k rows — a 283k-file directory would silently lose two thirds of its scroll +/// range, stopping partway down the list with no indication anything was cut. +/// +/// Past that many rows the run therefore stops being drawn at one pixel per pixel: rows keep +/// their true height, but the *mapping* from scroll offset to row index is compressed to fit the +/// budget (see `virt_pitch`). The scrollbar then covers the whole directory, at the cost of one +/// pixel of travel meaning more than one row. Sized under the limit to leave room for the +/// directory rows and chrome sharing the same box. +const virtual_run_budget: f32 = 1_800_000; + +/// Measured spacing between consecutive file rows, in physical pixels. +/// +/// Module-level rather than per-directory dvui data on purpose: every file row in the tree is +/// built identically, so one measurement serves all of them, and it survives the file tree not +/// being drawn for a frame (switching sidebar tabs). Stored per widget, it would be reaped +/// along with the widget, and the run would fall back to the probe path — which briefly reports +/// a tiny content height and yanks the scroll position back to the top. +var row_pitch_px: f32 = 0; /// `query`, when non-null, is the active filter — the bytes of `label` it matched are tinted so /// a row explains *why* it survived the filter (the same treatment the settings tree gives its @@ -669,7 +918,6 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u var color_i: usize = 0; var id_extra: usize = 0; - visible_file_rows_order.clearRetainingCapacity(); errdefer pending_file_shift_range = null; const recursor = struct { @@ -680,56 +928,166 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u /// The filtered case used to run through the walk too, re-reading *every* directory in /// the project from disk on *every frame* and testing each basename with a substring /// match. That is what made typing in the filter box scale with project size. - fn search(directory: []const u8, tree: *wdvui.TreeWidget, inner_unique_id: dvui.Id, inner_id_extra: *usize, color_id: *usize, filter_text: []const u8, parent_branch: ?*wdvui.TreeWidget.Branch, rows: ?[]const SimpleEntry) !void { - const io = dvui.io; - + fn search(directory: []const u8, tree: *wdvui.TreeWidget, inner_unique_id: dvui.Id, inner_id_extra: *usize, color_id: *usize, filter_text: []const u8, parent_branch: ?*wdvui.TreeWidget.Branch, rows: ?[]const SimpleEntry) anyerror!void { // Borrows `filter_text`, which outlives this call — see `fuzzy.Query`. const query = fuzzy.Query.init(filter_text); const active_query: ?*const fuzzy.Query = if (query.isEmpty()) null else &query; - var files = std.array_list.Managed(SimpleEntry).init(dvui.currentWindow().arena()); + // Two sources of rows: a caller-supplied ranked list while a filter is active (flat, + // all files, already capped and screened), or this directory's cached listing. + // Neither is copied — a listing can be hundreds of thousands of entries and only the + // handful actually drawn below is touched. + const listing: ?*const CachedListing = if (rows == null) (listDir(directory) orelse return) else null; + const total: usize = if (rows) |r| r.len else listing.?.entries.len; + const file_run_start: usize = if (listing) |l| l.dir_count else 0; + + const entryAt = struct { + fn get(r: ?[]const SimpleEntry, l: ?*const CachedListing, i: usize) SimpleEntry { + if (r) |ranked| return ranked[i]; + const e = l.?.entries[i]; + return .{ .name = e.name, .kind = e.kind }; + } + }.get; - if (rows) |ranked| { - // Already filtered, ranked, and carrying their own parent directories. - try files.appendSlice(ranked); - } else { - var dir = std.Io.Dir.cwd().openDir(io, directory, .{ .access_sub_paths = true, .iterate = true }) catch return; - defer dir.close(io); - - var iter = dir.iterate(); - while (try iter.next(io)) |entry| { - try files.append(.{ - .name = dvui.currentWindow().arena().dupe(u8, entry.name) catch "Arena failed to allocate", - .kind = entry.kind, - }); + // Directory rows: variable height, always drawn (see the virtualization notes above). + for (0..file_run_start) |i| { + _ = try drawRow(entryAt(rows, listing, i), directory, tree, inner_unique_id, inner_id_extra, color_id, filter_text, active_query, parent_branch); + } + + const file_count = total - file_run_start; + if (file_count == 0) return; + + // Anchors the top of the file run in screen space. Placed after the directory rows + // precisely so their (variable, possibly animating) height doesn't have to be + // predicted — whatever it came out to, the run starts here. + const anchor = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 0, .h = 0 } }); + const anchor_rs = anchor.rectScale(); + const pitch: f32 = row_pitch_px; + + const scale = if (anchor_rs.s > 0) anchor_rs.s else 1; + const count_f: f32 = @floatFromInt(file_count); + + // Geometry pitch: the real row pitch normally, squeezed to fit `virtual_run_budget` + // once the run is too tall for dvui to express. Rows are still *drawn* at `pitch`; + // only the spacers and the offset-to-row mapping use this. + const virt_pitch = @min(pitch, virtual_run_budget * scale / count_f); + + var lo: usize = 0; + var hi: usize = file_count; + const clip = dvui.clipGet(); + if (file_count > virtual_min_rows) { + if (pitch > 0.5 and virt_pitch > 0.01 and clip.h > 0) { + // Clamped as floats before the conversion: `@intFromFloat` is undefined for a + // value outside the integer's range, and nothing here bounds the arithmetic. + const limit: f32 = count_f; + // Where the viewport starts is a question about the compressed mapping... + const top = (clip.y - anchor_rs.r.y) / virt_pitch - virtual_overscan; + lo = @intFromFloat(std.math.clamp(@floor(top), 0, limit)); + + // ...but how many rows it takes to *fill* the viewport is a question about + // real row height, which compression must not change. + const span: usize = @intFromFloat(std.math.clamp( + @ceil(clip.h / pitch + 2 * virtual_overscan), + 1, + limit, + )); + + // Once the block can no longer start that far down without overflowing the + // end of the run, the lead spacer below pins it to the end — so it has to + // show the rows that actually *live* at the end, not the ones the mapping + // nominally points at. + // + // The test is in pixels, not row counts. Under compression the viewport + // spans far more virtual rows than the block draws (~135 vs ~67 here), so + // at max scroll `lo + span` still sits ~68 rows short of the last row and a + // row-count test never fires — which is exactly how the final entries ended + // up drawn but positioned past the scrollable area, and unreachable. + const span_px = @as(f32, @floatFromInt(span)) * pitch; + const max_lead_px = @max(0, count_f * virt_pitch - span_px); + if (@as(f32, @floatFromInt(lo)) * virt_pitch > max_lead_px) { + lo = file_count -| span; + } + hi = @min(file_count, lo + span); + } else { + // No pitch yet (first frame for this run) — draw a bounded probe to measure it. + hi = @min(file_count, virtual_probe_rows); } + } - std.mem.sort( - SimpleEntry, - files.items, - {}, - lessThan, - ); + // Both spacers are drawn unconditionally, even at zero height: a widget that comes + // and goes as you scroll churns ids for no benefit. + // + // The lead is also held back so the drawn block cannot run past the end of the run. + // Under compression the block is taller than the virtual space it maps to, so near + // the bottom `lo * virt_pitch` would push it past `run_px`, the trailing spacer + // would bottom out at zero, and the run would grow — moving the scroll end, which + // moves `lo`. Pinning the last screenful to the end keeps the height invariant. + // Uncompressed this is never binding: `hi <= file_count` already guarantees it. + const run_px = count_f * virt_pitch; + const block_px = @as(f32, @floatFromInt(hi - lo)) * pitch; + const lead_px = @max(0, @min(@as(f32, @floatFromInt(lo)) * virt_pitch, run_px - block_px)); + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 0, .h = lead_px / scale } }); + + // Pitch is the *largest* gap between consecutive drawn rows, not their average. + // + // A row that just scrolled into view has no min size yet and lays out zero-height + // for one frame, so an average is biased low by however many rows are settling — + // and a pitch that shrinks shrinks the content height, which is exactly the + // feedback the trailing spacer above exists to prevent. The largest gap is the + // pitch of a settled pair, and there is essentially always one in the window. + var widest_gap: f32 = 0; + var prev_y: f32 = 0; + var drawn: usize = 0; + for (file_run_start + lo..file_run_start + hi) |i| { + const y = try drawRow(entryAt(rows, listing, i), directory, tree, inner_unique_id, inner_id_extra, color_id, filter_text, active_query, parent_branch); + if (drawn > 0) widest_gap = @max(widest_gap, y - prev_y); + prev_y = y; + drawn += 1; } + if (widest_gap > 0.5) row_pitch_px = widest_gap; + + // The trailing spacer is sized from what the rows *actually* occupied this frame, + // not from `(file_count - hi) * pitch`, so the run is always exactly + // `file_count * pitch` tall no matter what happened above. + // + // That invariant is the whole fix for a scroll area that fought the user: dvui drops + // a widget's min size as soon as it goes undrawn, so every row scrolled back into + // view is zero-height for one frame. With a fixed trailing spacer that made the + // content height collapse by a screenful whenever the visible window moved, which + // re-clamped the scroll offset, which moved the window again. Absorbing the + // difference here keeps the total constant and breaks the loop. + const marker = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 0, .h = 0 } }); + const consumed_px = marker.rectScale().r.y - anchor_rs.r.y; + _ = dvui.spacer(@src(), .{ .min_size_content = .{ + .w = 0, + .h = @max(0, run_px - consumed_px) / scale, + } }); + } - for (files.items) |entry| { + /// Draw one file or folder row, returning its top edge in physical screen coordinates + /// (which is what `search` measures the run's row pitch from). + fn drawRow( + entry: SimpleEntry, + directory: []const u8, + tree: *wdvui.TreeWidget, + inner_unique_id: dvui.Id, + inner_id_extra: *usize, + color_id: *usize, + filter_text: []const u8, + active_query: ?*const fuzzy.Query, + parent_branch: ?*wdvui.TreeWidget.Branch, + // `anyerror` breaks the inferred-error-set cycle with `search`, which this calls back + // into for an expanded folder. + ) anyerror!f32 { + var row_y: f32 = 0; + { const entry_dir = entry.dir orelse directory; const abs_path = try std.fs.path.join( dvui.currentWindow().arena(), &.{ entry_dir, entry.name }, ); - // Ranked rows were already screened when the index was built. - if (rows == null) { - if (runtime.host().folder()) |proj_root| { - if (runtime.host().isPathIgnored(proj_root, abs_path, entry.name, entry.kind)) { - continue; - } - } - } - inner_id_extra.* = dvui.Id.update(tree.data().id, abs_path).asUsize(); - try visible_file_rows_order.append(runtime.allocator(), .{ .id = inner_id_extra.*, .path = abs_path }); // Fixed Fizzy palette (theme-independent) so row accents stay stable across // theme switches and line up with rainbow bracket colours in the editor. @@ -773,11 +1131,16 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u //.color_fill_hover = .fill, .color_fill_hover = dvui.themeGet().color(.control, .fill).opacity(0.5), .color_fill_press = dvui.themeGet().color(.control, .fill_press), - .color_fill = if (selected and tree.drag_point == null) dvui.themeGet().color(.control, .fill).opacity(0.5) else .transparent, + .color_fill = if (selected and tree.drag_point == null) + dvui.themeGet().color(.control, .fill).opacity(0.5) + else + wdvui.hoverRestFill(dvui.themeGet().color(.control, .fill)), .padding = dvui.Rect.all(1), }); defer branch.deinit(); + row_y = branch.data().borderRectScale().r.y; + if (new_file_path) |path| { if (std.mem.eql(u8, path, abs_path)) { if (!dvui.firstFrame(branch.data().id)) { @@ -791,25 +1154,25 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u } const current_point = dvui.currentWindow().mouse_pt; - - const max_distance = if (!expanded) branch.data().borderRectScale().r.h * 3.0 else branch.data().borderRectScale().r.w / 8.0; + const rect = branch.data().borderRectScale().r; + const max_distance = if (!expanded) rect.h * 3.0 else rect.w / 8.0; var dx: f32 = std.math.floatMax(f32); - if (current_point.x < branch.data().borderRectScale().r.x + if (expanded) (expanded_indent * dvui.currentWindow().natural_scale) else 0.0) { + if (current_point.x < rect.x + if (expanded) (expanded_indent * dvui.currentWindow().natural_scale) else 0.0) { dx = std.math.floatMax(f32); - } else if (current_point.x > branch.data().borderRectScale().r.bottomRight().x) { - dx = @abs(current_point.x - branch.data().borderRectScale().r.bottomRight().x); + } else if (current_point.x > rect.bottomRight().x) { + dx = @abs(current_point.x - rect.bottomRight().x); } else { dx = 0.0; } var dy: f32 = std.math.floatMax(f32); - if (current_point.y < branch.data().borderRectScale().r.y) { - dy = @abs(current_point.y - branch.data().borderRectScale().r.y); - } else if (current_point.y > branch.data().borderRectScale().r.bottomRight().y) { - dy = @abs(current_point.y - branch.data().borderRectScale().r.bottomRight().y); + if (current_point.y < rect.y) { + dy = @abs(current_point.y - rect.y); + } else if (current_point.y > rect.bottomRight().y) { + dy = @abs(current_point.y - rect.bottomRight().y); } else { dy = 0.0; } @@ -1010,10 +1373,13 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u } } + const doc = runtime.host().docFromPath(abs_path); + const file_label = if (filter_text.len > 0) std.fs.path.relativePosix(dvui.currentWindow().arena(), ".", runtime.host().folder().?, abs_path) catch entry.name else entry.name; + editableLabel( inner_id_extra.*, - if (filter_text.len > 0) std.fs.path.relativePosix(dvui.currentWindow().arena(), ".", runtime.host().folder().?, abs_path) catch entry.name else entry.name, - if (runtime.host().docFromPath(abs_path) != null) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text), + file_label, + if (doc != null) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text), entry.kind, abs_path, active_query, @@ -1021,8 +1387,8 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u dvui.log.err("Failed to draw editable label", .{}); }; - if (runtime.host().docFromPath(abs_path)) |doc| { - if (doc.owner.showsSaveStatusIndicator(doc)) { + if (doc) |d| { + if (d.owner.showsSaveStatusIndicator(d)) { wdvui.bubbleSpinner(@src(), .{ .id_extra = inner_id_extra.* +% 4001, .expand = .none, @@ -1031,7 +1397,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u .gravity_y = 0.5, .color_text = dvui.themeGet().color(.window, .text), }, .{ - .complete_elapsed_ns = doc.owner.timeSinceSaveCompleteNs(doc), + .complete_elapsed_ns = d.owner.timeSinceSaveCompleteNs(d), }); } } @@ -1137,18 +1503,18 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u else => {}, } } + return row_y; } - }.search; + }; if (outer_filter_text.len > 0) { const ranked = rankedFilterRows(root_directory, outer_filter_text); - try recursor(root_directory, outer_tree, unique_id, &id_extra, &color_i, outer_filter_text, null, ranked); + try recursor.search(root_directory, outer_tree, unique_id, &id_extra, &color_i, outer_filter_text, null, ranked); + flushPendingFileShiftRange(root_directory, outer_tree, ranked); } else { - try recursor(root_directory, outer_tree, unique_id, &id_extra, &color_i, outer_filter_text, null, null); + try recursor.search(root_directory, outer_tree, unique_id, &id_extra, &color_i, outer_filter_text, null, null); + flushPendingFileShiftRange(root_directory, outer_tree, null); } - flushPendingFileShiftRange(); - - return; } pub fn isFileSelected(id: usize) bool { @@ -1215,14 +1581,55 @@ fn applyFileClick(id: usize, path: []const u8, mode: wdvui.TreeSelection.ClickMo } } -fn flushPendingFileShiftRange() void { +/// Depth-first order of every row the tree would show, matching draw order (a directory's +/// children immediately follow it, directories before files within each listing). +/// +/// This walks the cached listings rather than recording rows as they draw, because the tree +/// only builds widgets for rows near the viewport — a shift anchor is usually scrolled far off +/// screen, and recording only drawn rows would silently reduce every long-range shift-click to a +/// single-row selection. Costs one pass on the frame a shift-click lands and nothing otherwise. +fn appendRowOrder( + arena: std.mem.Allocator, + tree_id: dvui.Id, + directory: []const u8, + out: *std.ArrayListUnmanaged(FileVisRow), +) void { + const listing = listDir(directory) orelse return; + for (listing.entries) |e| { + const abs = std.fs.path.join(arena, &.{ directory, e.name }) catch continue; + const branch_id = tree_id.update(abs); + out.append(arena, .{ .id = branch_id.asUsize(), .path = abs }) catch return; + if (e.kind == .directory and runtime.host().explorerBranchIsOpen(branch_id)) { + appendRowOrder(arena, tree_id, abs, out); + } + } +} + +fn flushPendingFileShiftRange( + root_directory: []const u8, + tree: *wdvui.TreeWidget, + ranked: ?[]const SimpleEntry, +) void { const p = pending_file_shift_range orelse return; pending_file_shift_range = null; - applyFileShiftRange(p.clicked_id, p.clicked_path, p.anchor_id); + + const arena = dvui.currentWindow().arena(); + var rows: std.ArrayListUnmanaged(FileVisRow) = .empty; + + if (ranked) |list| { + // A filter is active: row order is the ranked list, not the tree. + for (list) |e| { + const abs = std.fs.path.join(arena, &.{ e.dir orelse root_directory, e.name }) catch continue; + rows.append(arena, .{ .id = tree.data().id.update(abs).asUsize(), .path = abs }) catch break; + } + } else { + appendRowOrder(arena, tree.data().id, root_directory, &rows); + } + + applyFileShiftRange(rows.items, p.clicked_id, p.clicked_path, p.anchor_id); } -fn applyFileShiftRange(clicked_id: usize, clicked_path: []const u8, anchor_id: usize) void { - const rows = visible_file_rows_order.items; +fn applyFileShiftRange(rows: []const FileVisRow, clicked_id: usize, clicked_path: []const u8, anchor_id: usize) void { var a_idx: ?usize = null; var c_idx: ?usize = null; for (rows, 0..) |row, i| { @@ -1435,6 +1842,7 @@ pub fn moveOnePath(source_path: []const u8, target_dir: []const u8, arena: std.m dvui.log.err("Failed to move {s} to {s}", .{ source_path, new_path }); return false; }; + invalidateAfterDiskChange(); if (runtime.host().docFromPath(source_path)) |doc| { doc.owner.setDocumentPath(doc, new_path) catch { @@ -1454,7 +1862,7 @@ pub fn moveOnePath(source_path: []const u8, target_dir: []const u8, arena: std.m /// every open document beneath it; a file rename rewrites that document. Logs and /// continues on a filesystem failure (matches the explorer's inline behavior). pub fn renamePath(full_path: []const u8, new_path: []const u8, kind: std.Io.File.Kind) !void { - invalidateFilterIndex(); + invalidateAfterDiskChange(); switch (kind) { .directory => { std.Io.Dir.renameAbsolute(full_path, new_path, dvui.io) catch dvui.log.err("Failed to rename folder: {s} to {s}", .{ std.fs.path.basename(full_path), std.fs.path.basename(new_path) }); @@ -1489,7 +1897,7 @@ pub fn renamePath(full_path: []const u8, new_path: []const u8, kind: std.Io.File /// Delete `path` from disk (a directory must be empty — mirrors the explorer's /// inline Delete). Logs and continues on failure. pub fn deletePath(path: []const u8) void { - invalidateFilterIndex(); + invalidateAfterDiskChange(); if (pathIsDirAbsolute(path)) { std.Io.Dir.deleteDirAbsolute(dvui.io, path) catch dvui.log.err("Failed to delete folder: {s}", .{path}); } else { @@ -1499,14 +1907,14 @@ pub fn deletePath(path: []const u8) void { /// Create an empty file at absolute `path`. pub fn createFilePath(path: []const u8) !void { - invalidateFilterIndex(); + invalidateAfterDiskChange(); var handle = try std.Io.Dir.createFileAbsolute(dvui.io, path, .{}); handle.close(dvui.io); } /// Create a directory at absolute `path` (parents must already exist). pub fn createDirPath(path: []const u8) !void { - invalidateFilterIndex(); + invalidateAfterDiskChange(); try std.Io.Dir.createDirAbsolute(dvui.io, path, .default_dir); } diff --git a/src/sdk/EditorAPI.zig b/src/sdk/EditorAPI.zig index c9db673e..74f428a9 100644 --- a/src/sdk/EditorAPI.zig +++ b/src/sdk/EditorAPI.zig @@ -125,6 +125,10 @@ pub const VTable = struct { name: []const u8, kind: std.Io.File.Kind, ) bool, + /// True when fizzy has a live filesystem watcher on the open root folder, i.e. when + /// `Plugin.VTable.folderPathsChanged` can be relied on to fire. False with no folder open, + /// on a platform with no watcher backend, or when starting one failed. + folderWatchActive: *const fn (ctx: *anyopaque) bool, /// Explorer tree branch expanded state. explorerBranchIsOpen: *const fn (ctx: *anyopaque, branch_id: dvui.Id) bool, setExplorerBranchOpen: *const fn (ctx: *anyopaque, branch_id: dvui.Id, open: bool) void, @@ -346,6 +350,10 @@ pub fn isPathIgnored( return self.vtable.isPathIgnored(self.ctx, project_root, abs_path, name, kind); } +pub fn folderWatchActive(self: EditorAPI) bool { + return self.vtable.folderWatchActive(self.ctx); +} + pub fn explorerBranchIsOpen(self: EditorAPI, branch_id: dvui.Id) bool { return self.vtable.explorerBranchIsOpen(self.ctx, branch_id); } diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig index 0299a758..1cc3f692 100644 --- a/src/sdk/Host.zig +++ b/src/sdk/Host.zig @@ -355,6 +355,14 @@ pub fn isPathIgnored( return if (self.fizzy_api) |a| a.isPathIgnored(project_root, abs_path, name, kind) else false; } +/// True when fizzy has a live filesystem watcher on the open root folder — i.e. when +/// `Plugin.VTable.folderPathsChanged` will actually fire. A plugin that must stay correct +/// (an index, a file tree) should keep a slow rescan for when this is false, and can skip it +/// entirely when it is true. +pub fn folderWatchActive(self: *Host) bool { + return if (self.fizzy_api) |a| a.folderWatchActive() else false; +} + pub fn explorerBranchIsOpen(self: *Host, branch_id: dvui.Id) bool { return if (self.fizzy_api) |a| a.explorerBranchIsOpen(branch_id) else false; } @@ -654,6 +662,28 @@ pub fn pluginById(self: *Host, id: []const u8) ?*Plugin { return null; } +/// Broadcast an open document's in-memory content change to every registered plugin. +/// +/// Called by the document's **owner** when its buffer settles after an edit — see +/// `Plugin.VTable.documentContentChanged` for the debouncing contract. This is how a plugin +/// that owns nothing (a link indexer, a word counter) sees unsaved text at all: nothing else +/// in the SDK exposes another plugin's live buffer. +/// +/// The owner is included in the fan-out. That's deliberate — filtering it out would mean +/// owners behave differently from everyone else for no reason, and an owner that doesn't want +/// its own notification simply doesn't implement the hook. +pub fn notifyDocumentContentChanged(self: *Host, path: []const u8, bytes: []const u8) void { + for (self.plugins.items) |plugin| plugin.documentContentChanged(path, bytes); +} + +/// Broadcast a coalesced batch of on-disk changes under the open root folder to every plugin. +/// +/// Called by `FolderWatcher.tick` on the UI thread, never from the watcher's own thread — see +/// `Plugin.VTable.folderPathsChanged` for the contract this upholds. +pub fn notifyFolderPathsChanged(self: *Host, changes: Plugin.PathChanges) void { + for (self.plugins.items) |plugin| plugin.folderPathsChanged(changes); +} + /// First registered plugin that implements `createDocument` (for fizzy New File flows). pub fn pluginWithCreateDocument(self: *Host) ?*Plugin { for (self.plugins.items) |plugin| { diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig index f694f1e9..36aff6ad 100644 --- a/src/sdk/Plugin.zig +++ b/src/sdk/Plugin.zig @@ -19,6 +19,35 @@ pub const Plugin = @This(); /// claim so `Host.pluginForExtension` only picks it as a fallback. pub const file_type_fallback_priority: u8 = 100; +/// One filesystem change under the open root folder, delivered via +/// `VTable.folderPathsChanged`. +pub const PathEvent = struct { + /// Absolute path of the affected object. Valid only for the duration of the call. + path: []const u8, + kind: Kind, + object: ObjectType, + /// The pre-rename path, for `.renamed` only — and only on platforms whose watcher can pair + /// the two halves (Linux, Windows). Elsewhere a rename arrives as `.deleted` + `.created`, + /// which is why a consumer must handle that shape regardless. + old_path: []const u8 = "", + + pub const Kind = enum { created, modified, deleted, renamed }; + /// `.unknown` happens when the object is already gone by the time fizzy looks (a delete on + /// Windows, mostly) — treat it as "could be either". + pub const ObjectType = enum { file, dir, unknown }; +}; + +/// A coalesced batch of filesystem changes. +pub const PathChanges = struct { + /// Valid only for the duration of the call — copy anything you keep. Already filtered + /// against fizzy's ignore rules, so `.git`, build caches and gitignored paths never appear. + events: []const PathEvent, + /// More changes arrived than fizzy could buffer, so `events` is an incomplete picture of + /// what happened. A consumer that must not miss anything should rescan the folder rather + /// than trusting the list. Expect this during a build, a branch switch, or an npm install. + truncated: bool, +}; + /// Opaque, plugin-owned state passed back to every vtable call. state: *anyopaque, vtable: *const VTable, @@ -183,6 +212,45 @@ pub const VTable = struct { /// plugin can load state it keyed to that folder. onFolderOpen: ?*const fn (state: *anyopaque, allocator: std.mem.Allocator) void = null, + // ---- document content ---- + /// [broadcast] An open document's in-memory contents changed. Fired for *every* registered + /// plugin, not just the owner — the point is to let a plugin that doesn't own the document + /// observe it anyway (a link indexer watching markdown it will never render, say). The + /// owner is the one that reports the change, via `Host.notifyDocumentContentChanged`. + /// + /// Owners are expected to **debounce**: report after a short lull in typing (a few hundred + /// ms) and immediately on save, never per keystroke. `path` is the document's path, empty + /// for an unsaved buffer. `bytes` is the live buffer and is only valid for the duration of + /// the call — copy anything you keep. + /// + /// This is a hint about *unsaved* state; the file on disk still says something else. A + /// consumer that also watches the filesystem should treat this as an overlay it can drop + /// once the on-disk version catches up, not as a reason to write anything through. + documentContentChanged: ?*const fn (state: *anyopaque, path: []const u8, bytes: []const u8) void = null, + + // ---- filesystem ---- + /// [broadcast] Files under the open root folder changed **on disk**. Fired for every + /// registered plugin — a file tree keeping itself current, a link indexer, a language + /// server syncing `didChangeWatchedFiles`. + /// + /// The counterpart to `documentContentChanged`, and the two are not interchangeable: that + /// one reports *unsaved buffers* fizzy already knows about, this one reports *the disk*, + /// including files nothing has open and changes fizzy had no part in — an agent editing + /// the tree, a `git checkout`, another editor. Neither implies the other, and a plugin + /// wanting a complete picture wants both. + /// + /// Contract: + /// - Delivered on the **UI thread**, from fizzy's frame tick — never the watcher's thread. + /// A dylib must not take a callback on a thread it did not create. + /// - **Coalesced** (~200ms) so one logical save doesn't arrive as five events, and + /// **pre-filtered** against fizzy's ignore rules, so `.git`, build output and gitignored + /// paths are already gone. + /// - Best-effort. Not every platform has a working watcher, and one that does can still + /// drop events under load (see `PathChanges.truncated`). Treat this as a prompt to go + /// look, not as a ledger. `host.folderWatchActive()` says whether it is running at all; + /// a plugin that must stay correct should keep a slow rescan for when it isn't. + folderPathsChanged: ?*const fn (state: *anyopaque, changes: PathChanges) void = null, + // ---- save protocol ---- /// [active-doc] True when the owner wants a confirmation before `saveDocument` (e.g. a save /// that would flatten lossy data, change encoding, or overwrite an on-disk change). When @@ -285,6 +353,14 @@ pub fn onFolderOpen(self: Plugin, allocator: std.mem.Allocator) void { if (self.vtable.onFolderOpen) |f| f(self.state, allocator); } +pub fn documentContentChanged(self: Plugin, path: []const u8, bytes: []const u8) void { + if (self.vtable.documentContentChanged) |f| f(self.state, path, bytes); +} + +pub fn folderPathsChanged(self: Plugin, changes: PathChanges) void { + if (self.vtable.folderPathsChanged) |f| f(self.state, changes); +} + pub fn bindDocumentToPane(self: Plugin, doc: DocHandle, canvas_id: dvui.Id, workspace_handle: *anyopaque, center: bool) void { if (self.vtable.bindDocumentToPane) |f| f(self.state, doc, canvas_id, workspace_handle, center); } diff --git a/src/sdk/dylib.zig b/src/sdk/dylib.zig index 5f72917c..5c014061 100644 --- a/src/sdk/dylib.zig +++ b/src/sdk/dylib.zig @@ -26,6 +26,7 @@ const regions = @import("regions.zig"); const language_mod = @import("language.zig"); const workbench_service = @import("services/workbench.zig"); const markdown_service = @import("services/markdown.zig"); +const wikilink_service = @import("services/wikilink.zig"); /// C ABI — host loader injects host-owned pointers into the plugin image before `register`. /// @@ -131,6 +132,24 @@ const sdk_boundary_types = .{ workbench_service.Api.VTable, markdown_service.Api, markdown_service.Api.VTable, + // Reached only through `VTable.defaultView`/`setDefaultView` parameter/return types — a + // *data* path `hashType` does walk via fn signatures, but list it explicitly so a tag + // rename can't be mistaken for "shape unchanged" when reading the boundary inventory. + markdown_service.Api.DefaultView, + // Unlike `workbench`/`markdown`, this service's producer and consumer are *both* plugins + // (an indexer registers it, the markdown renderer calls it) — fizzy only stores the + // `*anyopaque`. So a mismatch here is dylib-to-dylib and the host would never notice: + // a provider built with a 4-slot vtable and a consumer built against a 5-slot one both + // pass every other check, and the consumer calls a fn pointer past the end. Listing both + // turns that into a clean `err_abi_mismatch` at load. + wikilink_service.Api, + wikilink_service.Api.VTable, + // `Resolution`/`Candidate` are only reached through a slice or by value across the + // boundary — same lesson as `CompletionItem` above; give them explicit entries so a field + // added later can't change the real layout without moving the fingerprint. + wikilink_service.Api.Resolution, + wikilink_service.Api.Candidate, + wikilink_service.Token, VersionTriplet, }; @@ -177,7 +196,12 @@ const dvui_shared_state_types = .{ /// zero-size it. A host and plugin in different classes have genuinely incompatible offsets even /// with an identical boundary shape, so this is folded into `abi_fingerprint` — the one /// real-layout axis the shape hash deliberately ignores. -const optimize_safety_class: []const u8 = switch (builtin.mode) { +/// +/// Public because it is the *only* fingerprint input a host can explain to the user in isolation: +/// the plugin store publishes `"fast"` builds exclusively, so a `"safe"` host knows up front that +/// no store shard can ever match it, whatever the SDK version says (see `PluginStore`'s +/// `host_optimize_matches_store`). +pub const optimize_safety_class: []const u8 = switch (builtin.mode) { .Debug, .ReleaseSafe => "safe", .ReleaseFast, .ReleaseSmall => "fast", }; diff --git a/src/sdk/language.zig b/src/sdk/language.zig index 291d7aed..80129816 100644 --- a/src/sdk/language.zig +++ b/src/sdk/language.zig @@ -56,6 +56,16 @@ pub const LanguageSupport = struct { /// dylibs, and each `.dylib` gets its own private copy of every SDK global, so a /// write from the host's copy is invisible inside the plugin's. previewPane: ?*const fn (state: *anyopaque, ext: []const u8, path: []const u8, bytes: []const u8, id_extra: u64, gpa: std.mem.Allocator) anyerror!void = null, + /// Scroll the preview for `id_extra` so that 0-based source `line` is in view. Called + /// just before `previewPane` on the frame a reveal lands (`workbench.revealPosition`), + /// and not otherwise — a reveal is a one-shot event, so it is its own call rather than an + /// argument to the per-frame draw the provider would then have to de-duplicate. + /// + /// A provider that renders source into something with a shape of its own has to map the + /// line to its own layout; one that can't is free to leave this null, and the raw editor + /// still moves. Nothing guarantees the pane has ever been drawn for this `id_extra` yet, + /// so treat it as "remember this and apply it when you next draw". + previewReveal: ?*const fn (state: *anyopaque, ext: []const u8, path: []const u8, line: u32, id_extra: u64) void = null, /// Non-blocking: called when the text editor opens (or reloads) a document. Intended /// for language-server warmup — spawn the server and send `textDocument/didOpen` so /// analysis can start before the first hover/completion, rather than paying cold-start diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig index a988717e..f516648d 100644 --- a/src/sdk/sdk.zig +++ b/src/sdk/sdk.zig @@ -62,10 +62,11 @@ pub const document = @import("document.zig"); pub const manifest = @import("manifest.zig"); pub const Manifest = manifest.Manifest; -/// Inter-plugin services (`"workbench"`, `"markdown"`). +/// Inter-plugin services (`"workbench"`, `"markdown"`, `"wikilink"`). pub const services = struct { pub const workbench = @import("services/workbench.zig"); pub const markdown = @import("services/markdown.zig"); + pub const wikilink = @import("services/wikilink.zig"); }; /// SDK version + ABI fingerprint lock (`sdk_version`, `recorded_abi_fingerprints`). diff --git a/src/sdk/services/markdown.zig b/src/sdk/services/markdown.zig index af89829f..07e84fdd 100644 --- a/src/sdk/services/markdown.zig +++ b/src/sdk/services/markdown.zig @@ -14,6 +14,15 @@ pub const Api = struct { ctx: *anyopaque, vtable: *const VTable, + /// How newly opened markdown documents start in the text editor's preview pane. Owned by + /// the markdown plugin's `default_md_view` setting; the text plugin reads/writes it through + /// this service so it does not have to import markdown. + pub const DefaultView = enum { + raw, + split, + preview, + }; + pub const RenderOptions = struct { /// Base dir for resolving relative `![alt](path)` image links. image_base_dir: []const u8 = ".", @@ -24,6 +33,8 @@ pub const Api = struct { pub const VTable = struct { render: *const fn (ctx: *anyopaque, bytes: []const u8, gpa: std.mem.Allocator, opts: RenderOptions) anyerror!void, + defaultView: *const fn (ctx: *anyopaque) DefaultView, + setDefaultView: *const fn (ctx: *anyopaque, view: DefaultView) void, }; /// Render `bytes` as read-only markdown (own scroll area — don't nest inside another) into @@ -31,4 +42,12 @@ pub const Api = struct { pub fn render(self: Api, bytes: []const u8, gpa: std.mem.Allocator, opts: RenderOptions) !void { return self.vtable.render(self.ctx, bytes, gpa, opts); } + + pub fn defaultView(self: Api) DefaultView { + return self.vtable.defaultView(self.ctx); + } + + pub fn setDefaultView(self: Api, view: DefaultView) void { + self.vtable.setDefaultView(self.ctx, view); + } }; diff --git a/src/sdk/services/wikilink.zig b/src/sdk/services/wikilink.zig new file mode 100644 index 00000000..40649c60 --- /dev/null +++ b/src/sdk/services/wikilink.zig @@ -0,0 +1,421 @@ +//! Wikilink inter-plugin service — SDK-facing definition of the `"wikilink"` service. +//! +//! Two halves that serve opposite directions of the same feature: +//! +//! - **The tokenizer** (`Token`, `tokenize`, `tokenizeAlloc`) is pure and lives here rather than +//! in either plugin *deliberately*. A renderer (markdown) and an indexer (brain) both have to +//! agree, byte for byte, on what counts as a wikilink — if they drift, the graph shows edges +//! the preview didn't draw, or the preview links to something the index never recorded. One +//! implementation in the package both sides already pin makes that class of bug impossible. +//! +//! - **`Api`** is the resolver: *which file does `[[Note]]` mean?* That answer needs a whole +//! index of the open folder, so it's provided by a plugin (brain) and consumed by whoever +//! renders or navigates wikilinks. Like `markdown`, a missing service is a normal, expected +//! case — with no resolver registered, callers must render `[[Note]]` as the literal text it +//! is, not as a broken link. +//! +//! Note that `Api` deliberately says nothing about *how* resolution works (shortest-unique-name +//! matching, aliases, phantom notes). That's the provider's policy, and keeping it out of the +//! ABI means the rules can improve without a fingerprint bump. +const std = @import("std"); + +/// One `[[wikilink]]` found in a run of text. +/// +/// `target`/`heading`/`block_id`/`alias` are all slices *into the input literal*, so they live +/// exactly as long as it does — copy them if the tokens outlive the buffer. +pub const Token = struct { + /// Byte range of the whole link within the literal it was found in, brackets included + /// (and the leading `!` for an embed). `literal[start..end]` reproduces it exactly, which + /// is what a renderer needs to emit the untouched original when there's no resolver. + start: usize, + end: usize, + /// The link target: everything before `|`, `#` and `^`. Never empty — a link with an empty + /// target isn't a link and is skipped entirely. + target: []const u8, + /// Heading anchor after `#`, `""` when absent. + heading: []const u8 = "", + /// Block anchor after `#^`, `""` when absent. Parsed so the syntax round-trips; no + /// consumer acts on it yet. + block_id: []const u8 = "", + /// Display text after `|`, `""` when absent (render `target` then). + alias: []const u8 = "", + /// `![[…]]` rather than `[[…]]` — a transclusion request. Renderers that don't implement + /// transclusion draw it as an ordinary link; indexers should still record the edge. + embed: bool = false, + + /// What to show the user for this link. + pub fn label(self: Token) []const u8 { + return if (self.alias.len > 0) self.alias else self.target; + } +}; + +/// Scan `literal` for wikilinks, writing at most `out.len` of them and returning the filled +/// prefix. Allocation-free — intended for the common case where a caller has a small stack +/// buffer and just wants to know whether a run of text contains any links at all. +/// +/// `literal` is expected to be the contents of a single markdown text node or source line: a +/// link may not span a newline, and one containing `\n` is not a link. +pub fn tokenize(literal: []const u8, out: []Token) []Token { + var n: usize = 0; + var i: usize = 0; + while (i + 1 < literal.len and n < out.len) { + if (!(literal[i] == '[' and literal[i + 1] == '[')) { + i += 1; + continue; + } + const tok = scanAt(literal, i) orelse { + // Not a link after all (unterminated, empty, or newline inside). Step one byte + // rather than past the `[[` so `[[[A]]` still finds `[A]`… and, more importantly, + // so a stray `[[` can't swallow a real link that follows it. + i += 1; + continue; + }; + out[n] = tok; + n += 1; + i = tok.end; + } + return out[0..n]; +} + +/// `tokenize` into a freshly allocated slice sized to the result. Returns an empty (but still +/// allocated) slice when there are no links, so callers can free unconditionally. +pub fn tokenizeAlloc(gpa: std.mem.Allocator, literal: []const u8) ![]Token { + var list: std.ArrayList(Token) = .empty; + errdefer list.deinit(gpa); + + var i: usize = 0; + while (i + 1 < literal.len) { + if (!(literal[i] == '[' and literal[i + 1] == '[')) { + i += 1; + continue; + } + const tok = scanAt(literal, i) orelse { + i += 1; + continue; + }; + try list.append(gpa, tok); + i = tok.end; + } + return list.toOwnedSlice(gpa); +} + +/// Parse one link starting at `open` (which must point at `[[`), or null when what's there +/// isn't a well-formed wikilink. +fn scanAt(literal: []const u8, open: usize) ?Token { + const body_start = open + 2; + // Find the closing `]]`. A `]` inside the body is fine (`[[a]b]]` targets `a]b`) as long + // as it isn't doubled, which matches how Obsidian behaves in practice. + var j = body_start; + const close = while (j + 1 < literal.len) : (j += 1) { + if (literal[j] == '\n') return null; // links don't span lines + if (literal[j] == ']' and literal[j + 1] == ']') break j; + } else return null; + + const body = literal[body_start..close]; + if (body.len == 0) return null; + + // `!` immediately before `[[` makes it an embed, and is part of the token's span so the + // renderer's "emit the original" path reproduces it. + const embed = open > 0 and literal[open - 1] == '!'; + const start = if (embed) open - 1 else open; + + // Split off the alias first: everything after the *first* `|` is display text, and a `#` + // inside the alias is just a character. + var link = body; + var alias: []const u8 = ""; + if (std.mem.indexOfScalar(u8, body, '|')) |bar| { + link = body[0..bar]; + alias = std.mem.trim(u8, body[bar + 1 ..], " \t"); + } + + // Then the anchor. `#^id` is a block ref, plain `#text` is a heading. + var target = link; + var heading: []const u8 = ""; + var block_id: []const u8 = ""; + if (std.mem.indexOfScalar(u8, link, '#')) |hash| { + target = link[0..hash]; + const anchor = link[hash + 1 ..]; + if (anchor.len > 0 and anchor[0] == '^') { + block_id = std.mem.trim(u8, anchor[1..], " \t"); + } else { + heading = std.mem.trim(u8, anchor, " \t"); + } + } + + target = std.mem.trim(u8, target, " \t"); + if (target.len == 0) return null; + + return .{ + .start = start, + .end = close + 2, + .target = target, + .heading = heading, + .block_id = block_id, + .alias = alias, + .embed = embed, + }; +} + +pub const Api = struct { + pub const service_name = "wikilink"; + + ctx: *anyopaque, + vtable: *const VTable, + + pub const Status = enum(u8) { + /// Exactly one target, or a clear winner. `path` is set. + resolved, + /// No note matches. Renderers should style this distinctly (a "broken" link) — but + /// note it is a completely normal state in a wiki: it's how you plan a note before + /// writing it. + unresolved, + /// Several notes match and the tie-break picked one. `path` is set; renderers may + /// warn. + ambiguous, + /// The provider doesn't know yet — an index build is in flight. Callers should render + /// neutrally and ask again next frame, so opening a folder doesn't flash every link + /// red for a second. + indexing, + }; + + pub const Resolution = struct { + status: Status, + /// Absolute path to the target file. Set when `.resolved` or `.ambiguous`, empty + /// otherwise. Allocated from the caller's allocator. + path: []const u8 = "", + /// 0-based line of the requested `#heading` within the target, when one was requested + /// and found. 0 (the top of the file) otherwise — a heading that doesn't exist is not + /// an error, it just doesn't scroll. + line: u32 = 0, + /// The display title the provider would use for this target (front-matter title, the + /// matched alias, or the file stem). Allocated from the caller's allocator. + title: []const u8 = "", + }; + + pub const Candidate = struct { + /// Text to insert between the brackets to link to this note. + target: []const u8, + /// Absolute path, for a preview or tooltip. Empty for a phantom. + path: []const u8, + title: []const u8, + /// The note doesn't exist yet (something links to it, nothing wrote it) — a picker + /// can offer to create it. + phantom: bool = false, + }; + + pub const VTable = struct { + /// Resolve `target` (already stripped of `|alias` and any anchor) as seen from + /// `source_path`, an absolute path to the linking document. `source_path` may be empty + /// — an unsaved buffer, or content fetched from the network — in which case the + /// provider must skip any relative/same-directory rules; callers must accept + /// `.unresolved` for content that has no place in the folder. + /// + /// `heading` is `""` when the link had no anchor. Strings in the returned `Resolution` + /// are allocated from `gpa` and owned by the caller — pass a frame arena. Returning + /// borrowed slices was rejected on purpose: a background reindex can invalidate the + /// provider's own strings between the call and the end of the frame. + /// + /// Called from the UI thread during draw, so it must not block. Providers are expected + /// to answer from an in-memory or local index; callers memoize against `generation`. + resolve: *const fn ( + ctx: *anyopaque, + target: []const u8, + heading: []const u8, + source_path: []const u8, + gpa: std.mem.Allocator, + ) anyerror!Resolution, + + /// Monotonic counter, bumped once per committed change to the index. Cheap (an atomic + /// load) — call it once per frame and drop any memoized `resolve` results when it + /// moves. This is what makes a link go from broken to live when its target file + /// appears, *without* the linking document changing at all. + generation: *const fn (ctx: *anyopaque) u64, + + /// Candidates for a `[[`-completion popup, best first, at most `limit`. The returned + /// slice and its strings are allocated from `gpa` and owned by the caller. + complete: *const fn ( + ctx: *anyopaque, + prefix: []const u8, + source_path: []const u8, + limit: usize, + gpa: std.mem.Allocator, + ) anyerror![]Candidate, + + /// True while a scan is in flight. Distinct from `.indexing` on a single resolution: + /// this is the whole-provider state a progress indicator wants. + indexing: *const fn (ctx: *anyopaque) bool, + }; + + pub fn resolve( + self: Api, + target: []const u8, + heading: []const u8, + source_path: []const u8, + gpa: std.mem.Allocator, + ) !Resolution { + return self.vtable.resolve(self.ctx, target, heading, source_path, gpa); + } + pub fn generation(self: Api) u64 { + return self.vtable.generation(self.ctx); + } + pub fn complete( + self: Api, + prefix: []const u8, + source_path: []const u8, + limit: usize, + gpa: std.mem.Allocator, + ) ![]Candidate { + return self.vtable.complete(self.ctx, prefix, source_path, limit, gpa); + } + pub fn indexing(self: Api) bool { + return self.vtable.indexing(self.ctx); + } +}; + +// -- tests ------------------------------------------------------------------------------ + +const testing = std.testing; + +fn expectOne(literal: []const u8) Token { + var buf: [8]Token = undefined; + const toks = tokenize(literal, &buf); + testing.expectEqual(@as(usize, 1), toks.len) catch @panic("expected exactly one token"); + return toks[0]; +} + +fn expectNone(literal: []const u8) !void { + var buf: [8]Token = undefined; + try testing.expectEqual(@as(usize, 0), tokenize(literal, &buf).len); +} + +test "plain target" { + const t = expectOne("[[A]]"); + try testing.expectEqualStrings("A", t.target); + try testing.expectEqualStrings("", t.alias); + try testing.expect(!t.embed); + try testing.expectEqual(@as(usize, 0), t.start); + try testing.expectEqual(@as(usize, 5), t.end); +} + +test "alias" { + const t = expectOne("[[A|B]]"); + try testing.expectEqualStrings("A", t.target); + try testing.expectEqualStrings("B", t.alias); + try testing.expectEqualStrings("B", t.label()); +} + +test "heading" { + const t = expectOne("[[A#H]]"); + try testing.expectEqualStrings("A", t.target); + try testing.expectEqualStrings("H", t.heading); + try testing.expectEqualStrings("", t.block_id); +} + +test "heading and alias" { + const t = expectOne("[[A#H|B]]"); + try testing.expectEqualStrings("A", t.target); + try testing.expectEqualStrings("H", t.heading); + try testing.expectEqualStrings("B", t.alias); +} + +test "block id" { + const t = expectOne("[[A#^abc123]]"); + try testing.expectEqualStrings("A", t.target); + try testing.expectEqualStrings("abc123", t.block_id); + try testing.expectEqualStrings("", t.heading); +} + +test "embed spans the bang" { + const t = expectOne("![[A]]"); + try testing.expect(t.embed); + try testing.expectEqualStrings("A", t.target); + try testing.expectEqual(@as(usize, 0), t.start); + try testing.expectEqual(@as(usize, 6), t.end); +} + +test "relative path target" { + const t = expectOne("[[../rel/A]]"); + try testing.expectEqualStrings("../rel/A", t.target); +} + +test "surrounding text is excluded from the span" { + const src = "see [[A]] now"; + const t = expectOne(src); + try testing.expectEqualStrings("[[A]]", src[t.start..t.end]); +} + +test "two links" { + var buf: [8]Token = undefined; + const toks = tokenize("[[A]] and [[B]]", &buf); + try testing.expectEqual(@as(usize, 2), toks.len); + try testing.expectEqualStrings("A", toks[0].target); + try testing.expectEqualStrings("B", toks[1].target); +} + +test "nested brackets keep the inner link" { + // `[[[A]]]` — the first `[[` opens, `]]` closes, so the target is `[A`. What matters is + // that exactly one link is found and the untouched original is recoverable. + var buf: [8]Token = undefined; + const toks = tokenize("[[[A]]]", &buf); + try testing.expectEqual(@as(usize, 1), toks.len); +} + +test "whitespace around target and alias is trimmed" { + const t = expectOne("[[ A | B ]]"); + try testing.expectEqualStrings("A", t.target); + try testing.expectEqualStrings("B", t.alias); +} + +test "empty target is not a link" { + try expectNone("[[]]"); + try expectNone("[[ ]]"); + try expectNone("[[|B]]"); + try expectNone("[[#H]]"); +} + +test "unterminated is not a link" { + try expectNone("[[A"); + try expectNone("A]]"); + try expectNone("[[A]"); + try expectNone(""); + try expectNone("["); +} + +test "a link may not span a newline" { + try expectNone("[[A\nB]]"); +} + +test "a stray open bracket does not swallow the next link" { + var buf: [8]Token = undefined; + const toks = tokenize("[[ oops \n [[A]]", &buf); + try testing.expectEqual(@as(usize, 1), toks.len); + try testing.expectEqualStrings("A", toks[0].target); +} + +test "out buffer bounds are respected" { + var buf: [2]Token = undefined; + const toks = tokenize("[[A]] [[B]] [[C]]", &buf); + try testing.expectEqual(@as(usize, 2), toks.len); +} + +test "tokenizeAlloc matches tokenize" { + const src = "[[A]] x ![[B|b]] y [[C#H]]"; + const toks = try tokenizeAlloc(testing.allocator, src); + defer testing.allocator.free(toks); + + var buf: [8]Token = undefined; + const stack = tokenize(src, &buf); + + try testing.expectEqual(stack.len, toks.len); + for (stack, toks) |a, b| { + try testing.expectEqual(a.start, b.start); + try testing.expectEqual(a.end, b.end); + try testing.expectEqualStrings(a.target, b.target); + } +} + +test "tokenizeAlloc returns a freeable empty slice" { + const toks = try tokenizeAlloc(testing.allocator, "no links here"); + defer testing.allocator.free(toks); + try testing.expectEqual(@as(usize, 0), toks.len); +} diff --git a/src/sdk/version.zig b/src/sdk/version.zig index 3a80faa9..0ce92ecf 100644 --- a/src/sdk/version.zig +++ b/src/sdk/version.zig @@ -71,7 +71,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value (from the `@compileError` it triggers) and bump `sdk_version` in the same commit /// whenever it changes. -pub const recorded_sdk_shape_fingerprint: u64 = 0x5140f93c991d777d; +pub const recorded_sdk_shape_fingerprint: u64 = 0x8ba70679c3d580c3; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/src/web_main.zig b/src/web_main.zig index f558d06b..b1fa9f6d 100644 --- a/src/web_main.zig +++ b/src/web_main.zig @@ -19,7 +19,7 @@ const fizzy = @import("fizzy.zig"); // symbols whose files import `@import("backend")` (SDL3) at file scope. Zig's // lazy analysis means a dead/unused file-scope `const` never triggers its // `@import`. We only pay the wasm-incompatibility cost when a reachable function -// actually calls into native APIs. See WEB_PORT_PLAN.md. +// actually calls into native APIs. comptime { // Pure constants / re-exports _ = fizzy.version; diff --git a/tests/bench/bench_markdown.zig b/tests/bench/bench_markdown.zig new file mode 100644 index 00000000..ddd37659 --- /dev/null +++ b/tests/bench/bench_markdown.zig @@ -0,0 +1,305 @@ +//! `zig build bench-markdown` — frame-cost benchmark for the markdown preview's draw path. +//! +//! Drives the real preview renderer (`src/plugins/markdown`, the same entry point the editor's +//! preview pane and the plugin store's README pane call) over real markdown documents in dvui's +//! headless testing backend, and reports microseconds per frame. Deliberately *not* part of +//! `zig build test`: it prints timings rather than asserting, and timings are machine-dependent. +//! +//! What it can and can't tell you: the testing backend does no GPU work, so this measures the +//! CPU side — the cmark AST walk, widget construction, text shaping and layout. That is where +//! the preview's per-frame time actually goes; the GPU submission it omits doesn't change with +//! document size. +//! +//! Alongside the wall time it prints the renderer's own counters (`render_ast.stats`): how many +//! blocks were visited and how many text layouts / boxes were emitted for a single frame. Those +//! are what the wall time is a function of, and — unlike microseconds — they are exactly +//! reproducible, so they're the number to quote when comparing an optimization across machines. +//! +//! Always compare runs at the same `-Doptimize`. cmark and freetype compile at the app's +//! optimize level, so a Debug run measures unoptimized C and is several times slower than what +//! ships. + +const std = @import("std"); +const dvui = @import("dvui"); +const markdown = @import("markdown"); +const render_ast = markdown.render_ast; + +/// Documents to render. These are the repo's own, wired in as anonymous imports from +/// `build/app.zig` rather than checked in as fixtures — `PLUGINS.md` is the document that +/// prompted this benchmark (single-digit fps in Debug), and the smaller ones separate costs +/// that scale with document size from those that don't. +const sample_huge = @embedFile("sample_huge"); // docs/PLUGINS.md +/// Same size as `sample_huge` but shaped completely differently: very long paragraphs (single +/// blocks of several thousand characters) and several tables. It is the document that stayed slow +/// after block virtualization, which is exactly why it is in here. +const sample_prose = @embedFile("sample_prose"); // docs/PLUGIN_MANIFEST_PLAN.md +const sample_medium = @embedFile("sample_medium"); // CLAUDE.md +const sample_small = @embedFile("sample_small"); // docs/MODULARIZATION_RELEASE_NOTES.md + +/// Live document + preview state for the frame function, which `dvui.App.frameFunction` +/// requires to take no arguments. +var doc: []const u8 = ""; +var preview: markdown.Preview = .{}; +var gpa: std.mem.Allocator = undefined; +/// Width the preview is given this frame, or null for the whole window. Driven per frame by the +/// resize case — the preview pane really does change width every frame while a panel animates +/// open, and that used to invalidate every cached block height at once. +var forced_width: ?f32 = null; +var profile_blocks: bool = false; +var frame_times: [10]i128 = @splat(0); +var open_samples: std.ArrayListUnmanaged(render_ast.BlockSample) = .empty; + +fn frame() !dvui.App.Result { + var b = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = if (forced_width == null) .both else .vertical, + .min_size_content = if (forced_width) |w| dvui.Size{ .w = w } else null, + .max_size_content = if (forced_width) |w| dvui.Options.MaxSize.width(w) else null, + }); + defer b.deinit(); + + // No `document_path`: that disables wikilink resolution, which needs a `Host` this harness + // has no reason to stand up. It is also what the store's README pane passes, so this is a + // path the app really takes rather than one invented for the benchmark. + markdown.drawPreview(&preview, doc, gpa, .{ + .io = dvui.io, + .image_base_dir = ".", + .id_extra = 0, + }); + return .ok; +} + +/// Scrolls the way the user does — a real wheel event through dvui's own event routing. +/// Writing `ScrollInfo.viewport.y` directly instead puts the scroll container in a state its +/// own code never produces, which trips an overflow check inside dvui in ReleaseSafe. +fn sendScroll(ticks: f32) !void { + const cw = dvui.currentWindow(); + _ = try cw.addEventMouseMotion(.{ .pt = .{ .x = 400, .y = 300 } }); + _ = try cw.addEventMouseWheel(ticks, .vertical, null); +} + +fn nowNs() i128 { + return std.Io.Clock.boot.now(dvui.io).nanoseconds; +} + +const Case = struct { + /// Wheel ticks sent once before the timed frames, to park the viewport somewhere other than + /// the very top. + park_scroll_ticks: f32 = 0, + /// Lines scrolled per frame — 0 keeps the viewport still (the idle case, which is what the + /// app spends nearly all its time in). + scroll_lines_per_frame: f32 = 0, + /// Change the pane's width every frame, as a panel's open animation and a window-resize drag + /// both do. This is the case block-height caching is worst at, so it is the one to watch. + resizing: bool = false, + /// Off = the whole document is laid out every frame, which is what this renderer did before + /// `render_ast.renderTopLevel` learned to skip off-screen blocks. Kept as a row in the output + /// so the baseline is measured on the same machine and run as everything it is compared to. + virtualize: bool = true, +}; + +/// Runs timed frames after letting the preview settle, and reports µs/frame plus the render +/// counters for one frame. +fn run(label: []const u8, sample: []const u8, case: Case) !void { + doc = sample; + preview = .{}; + render_ast.virtualize_blocks = case.virtualize; + defer render_ast.virtualize_blocks = true; + + var t = try dvui.testing.init(.{ + .allocator = std.testing.allocator, + .window_size = .{ .w = 1200, .h = 800 }, + }); + defer { + // Preview first: it may own a running background parse worker holding the window + // pointer it wakes on completion, and `Preview.deinit` is what joins that worker. + // Destroying the window first left the worker refreshing freed memory. + preview.deinit(); + t.deinit(); + } + + // Warm up: the first frames parse the document, build the glyph atlas and settle every + // widget's min size, none of which recur. + // + // Parsing happens on a worker thread, so "run N frames" no longer implies the document + // exists yet — and a fixed count silently stopped being enough the moment frames got fast. + // In ReleaseFast the whole 15-frame warm-up finished in ~150us, the parse had not landed, + // and every scenario below then measured the "Loading preview…" placeholder: 11us/frame, + // 0us in renderDocument, and the counters left over from the *previous* document. Wait for + // the document to actually be there. + { + var spins: usize = 0; + while (preview.rs.blocks.len() == 0 and spins < 10_000) : (spins += 1) { + _ = try dvui.testing.step(frame); + } + if (preview.rs.blocks.len() == 0) return error.MarkdownPreviewNeverParsed; + } + for (0..15) |_| _ = try dvui.testing.step(frame); + + if (case.park_scroll_ticks != 0) { + try sendScroll(case.park_scroll_ticks); + for (0..5) |_| _ = try dvui.testing.step(frame); + } + + // Report the *minimum* of several rounds, not the mean: anything else running on the + // machine can only make a round slower, so the fastest round is the closest estimate of the + // work actually being measured. + const rounds: usize = 5; + const iters: usize = 30; + var best_ns: i128 = std.math.maxInt(i128); + var render_ns: u64 = 0; + var counters: render_ast.Stats = .{}; + for (0..rounds) |_| { + // Every round scrolls the same span, so the min across rounds compares like with like. + if (case.scroll_lines_per_frame != 0) { + try sendScroll(10_000); + _ = try dvui.testing.step(frame); + } + const t0 = nowNs(); + render_ast.stats.render_ns = 0; + for (0..iters) |i| { + if (case.scroll_lines_per_frame != 0) try sendScroll(-case.scroll_lines_per_frame * 20); + if (case.resizing) forced_width = 700 + @as(f32, @floatFromInt(i % 30)) * 15; + _ = try dvui.testing.step(frame); + } + forced_width = null; + const round_ns = nowNs() - t0; + if (round_ns < best_ns) { + best_ns = round_ns; + render_ns = render_ast.stats.render_ns / iters; + counters = render_ast.stats; + } + } + const per_frame_us: u64 = @intCast(@divTrunc(best_ns, iters * 1000)); + + if (profile_blocks) { + var samples: std.ArrayListUnmanaged(render_ast.BlockSample) = .empty; + defer samples.deinit(gpa); + render_ast.block_profile = &samples; + render_ast.block_profile_gpa = gpa; + _ = try dvui.testing.step(frame); + render_ast.block_profile = null; + std.mem.sort(render_ast.BlockSample, samples.items, {}, struct { + fn lt(_: void, a: render_ast.BlockSample, b: render_ast.BlockSample) bool { + return a.ns > b.ns; + } + }.lt); + for (samples.items[0..@min(6, samples.items.len)]) |worst| { + std.debug.print(" block {d:>3} {s:<14} {d:>6} us textlayouts={d} text={d}B\n", .{ + worst.index, worst.kind, worst.ns / 1000, worst.text_layouts, worst.add_text_bytes, + }); + } + } + + std.debug.print( + " {s:<30} {d:>6} us/frame ({d:>6} us in renderDocument) blocks={d} textlayouts={d} boxes={d} addText={d}/{d}B\n", + .{ + label, + per_frame_us, + render_ns / 1000, + counters.blocks, + counters.text_layouts, + counters.boxes, + counters.add_text_calls, + counters.add_text_bytes, + }, + ); +} + +/// What opening the file costs: the document is parsed, its blocks are placed for the first time, +/// and the preview settles — timed frame by frame, because this is the hitch the user actually +/// feels (and it lands while the preview panel is animating open, when frames are scarcest). +/// +/// The window is warmed on a *different* document first. dvui's glyph atlas is per-window and the +/// app's window is long-lived, so a cold atlas would charge this measurement for rasterizing every +/// glyph — hundreds of microseconds per block, none of which the real app pays when opening its +/// second markdown file. Warming makes the number mean "opening a document", not "starting fizzy". +fn runOpen(sample: []const u8) !void { + const rounds: usize = 5; + const frames: usize = 10; + var best_ns: i128 = std.math.maxInt(i128); + for (0..rounds) |_| { + var warm: markdown.Preview = .{}; + doc = sample_small; + preview = warm; + var t = try dvui.testing.init(.{ + .allocator = std.testing.allocator, + .window_size = .{ .w = 1200, .h = 800 }, + }); + for (0..20) |_| _ = try dvui.testing.step(frame); + warm = preview; + warm.deinit(); + + // Now open the document under test in that same, warm window. + doc = sample; + preview = .{}; + render_ast.stats.parse_ns = 0; + var samples: std.ArrayListUnmanaged(render_ast.BlockSample) = .empty; + defer samples.deinit(std.testing.allocator); + if (profile_blocks) { + render_ast.block_profile = &samples; + render_ast.block_profile_gpa = std.testing.allocator; + } + const t0 = nowNs(); + var per: [10]i128 = @splat(0); + var prev = t0; + for (0..frames) |i| { + _ = try dvui.testing.step(frame); + if (i == 0) render_ast.block_profile = null; + const now = nowNs(); + per[i] = @divTrunc(now - prev, 1000); + prev = now; + } + if (nowNs() - t0 < best_ns) { + best_ns = nowNs() - t0; + frame_times = per; + open_samples.clearRetainingCapacity(); + open_samples.appendSlice(std.testing.allocator, samples.items) catch {}; + } + render_ast.block_profile = null; + // Preview before window — see the note in `run`. + preview.deinit(); + t.deinit(); + } + + if (profile_blocks) { + std.mem.sort(render_ast.BlockSample, open_samples.items, {}, struct { + fn lt(_: void, a: render_ast.BlockSample, b: render_ast.BlockSample) bool { + return a.ns > b.ns; + } + }.lt); + var total: u64 = 0; + for (open_samples.items) |x| total += x.ns; + std.debug.print(" first frame: {d} top-level blocks, {d} us in them\n", .{ open_samples.items.len, total / 1000 }); + for (open_samples.items[0..@min(4, open_samples.items.len)]) |x| { + std.debug.print(" block {d:>3} {s:<14} {d:>6} us textlayouts={d} text={d}B\n", .{ x.index, x.kind, x.ns / 1000, x.text_layouts, x.add_text_bytes }); + } + } + std.debug.print(" {s:<30} {d:>6} us for the first {d} frames ({d} us parsing) each: {any}\n", .{ "opening the document", @divTrunc(best_ns, 1000), frames, render_ast.stats.parse_ns / 1000, frame_times }); +} + +test "bench: markdown preview frame cost" { + gpa = std.testing.allocator; + std.debug.print("\n== markdown preview frame cost — {s} ==\n", .{@tagName(@import("builtin").mode)}); + + const cases = [_]struct { name: []const u8, text: []const u8 }{ + .{ .name = "huge (docs/PLUGINS.md)", .text = sample_huge }, + .{ .name = "prose (docs/PLUGIN_MANIFEST_PLAN.md)", .text = sample_prose }, + .{ .name = "medium (CLAUDE.md)", .text = sample_medium }, + .{ .name = "small (release notes)", .text = sample_small }, + }; + + defer open_samples.deinit(std.testing.allocator); + for (cases) |c| { + std.debug.print(" {s}, {d} bytes\n", .{ c.name, c.text.len }); + profile_blocks = true; + try runOpen(c.text); + profile_blocks = false; + try run("no virtualization (baseline)", c.text, .{ .virtualize = false }); + profile_blocks = true; + try run("idle, top of document", c.text, .{}); + profile_blocks = false; + try run("idle, viewport mid-document", c.text, .{ .park_scroll_ticks = -4000 }); + try run("scrolling 3 lines/frame", c.text, .{ .scroll_lines_per_frame = 3 }); + try run("resizing the pane every frame", c.text, .{ .resizing = true }); + } +} diff --git a/tests/bench/bench_text.zig b/tests/bench/bench_text.zig index 563e96e3..c1e5e2d1 100644 --- a/tests/bench/bench_text.zig +++ b/tests/bench/bench_text.zig @@ -158,6 +158,53 @@ fn run(label: []const u8, sample: []const u8, cursor: usize) !void { std.debug.print(" {s:<34} {d:>6} us/frame\n", .{ label, per_frame_us }); } +/// A document whose only unusual feature is one pathologically long line — minified JS/CSS, a +/// one-line JSON blob, a generated data table. The short lines around it are there so the +/// viewport contains ordinary text too, the way it does in the editor. +/// +/// Built at runtime rather than embedded: the repo has no such file, and a checked-in fixture +/// of this size would be dead weight (`long_len` is the knob worth sweeping anyway). +fn buildLongLine(gpa: std.mem.Allocator, long_len: usize, trailing_lines: usize) ![]u8 { + var buf: std.ArrayListUnmanaged(u8) = .empty; + errdefer buf.deinit(gpa); + for (0..5) |i| try buf.print(gpa, "const short_{d} = {d};\n", .{ i, i }); + const unit = "const x = foo(bar, 1234); "; + while (buf.items.len < long_len) try buf.appendSlice(gpa, unit); + try buf.append(gpa, '\n'); + for (0..trailing_lines) |i| try buf.print(gpa, "const after_{d} = {d};\n", .{ i, i }); + return buf.toOwnedSlice(gpa); +} + +test "bench: long single line" { + const gpa = std.testing.allocator; + std.debug.print("\n== long single line — {s} ==\n", .{@tagName(@import("builtin").mode)}); + + for ([_]usize{ 2_000, 20_000, 200_000 }) |long_len| { + const sample = try buildLongLine(gpa, long_len, 200); + defer gpa.free(sample); + std.debug.print(" one line of {d} chars, {d} bytes total\n", .{ long_len, sample.len }); + + tree_sitter = true; + cache_layout = true; + typing = false; + scroll_lines_per_frame = 0; + park_scroll_ticks = 0; + try run("idle, long line on screen", sample, 0); + try run("idle, caret mid-long-line", sample, 100 + long_len / 2); + tree_sitter = false; + try run("idle, no highlighting", sample, 0); + tree_sitter = true; + + // Same long line with nothing after it — the minified-file shape. Worth its own case + // because it needs only one visible byte range to describe the frame, where a long line + // with text below it needs two with the line's off-screen middle between them, so this + // is the case that stays fast even if that gap handling regresses. + const trailing = try buildLongLine(gpa, long_len, 0); + defer gpa.free(trailing); + try run("idle, long line last in file", trailing, 0); + } +} + test "bench: text editor frame cost" { std.debug.print("\n== text editor frame cost — {s} ==\n", .{@tagName(@import("builtin").mode)}); diff --git a/tests/integration.zig b/tests/integration.zig index a568815a..2fc8cd16 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -9,13 +9,6 @@ //! The same step also runs `fizzy-sdk-tests` (rooted at `src/sdk/sdk.zig`) //! for SDK/dylib/settings coverage that needs dvui — see `build/app.zig`. //! -//! Pixel-art-specific coverage (`Internal.File`, `Layer`, `Packer`, -//! `Animation`, grid/pack/flood-fill regressions) moved out with the -//! pixi plugin extraction — pixi now ships from its own repo -//! (`fizzyedit/pixi`) and owns that coverage there. This target keeps -//! the headless dvui harness alive for future fizzy-shell-level -//! integration tests (workbench, text, image, menu/sidebar flows). -//! //! See `tests/README.md` for the overall layering. const std = @import("std"); @@ -439,7 +432,6 @@ test "switching to different content re-reveals" { try std.testing.expectEqual(@as(f32, 1), reveal_alpha); } - // -- center-provider cross-fade ----------------------------------------------------------------- // Swapping center providers can't be a fade-in: each provider paints its own pane (square and @@ -541,3 +533,677 @@ test "a center provider that disappears is not drawn for its own cross-fade" { try std.testing.expectEqual(@as(usize, 0), center_a_draws); try std.testing.expectEqual(@as(usize, 1), center_b_draws); } + +// -- markdown preview virtualization ------------------------------------------------------------ + +// The markdown preview lays out only the blocks near the viewport (`render_ast.renderTopLevel`), +// which is the difference between ~34ms and ~2.5ms per frame on docs/PLUGINS.md in Debug. The +// whole optimization rests on one claim: skipping a block changes nothing the user can see, +// because its wrapper still reports the height the block had when it was last drawn. +// +// So compare layout, not widget counts: every top-level block's height, and the scroll +// container's resulting virtual size, must come out the same whether the blocks were all laid +// out or only the on-screen ones were. If a remembered height ever drifted from the measured +// one, the document below it would shift and the scrollbar would lie — and that is exactly what +// these two numbers catch. (Comparing rendered pixels would be better still, but dvui's testing +// backend has no render targets, so `dvui.testing.capturePng` is unavailable here.) +const markdown = @import("markdown"); +const md_render_ast = markdown.render_ast; + +var md_preview: markdown.Preview = .{}; +var md_doc: []const u8 = ""; +const md_sample = @embedFile("markdown_sample"); +/// Table-heavy: one of its tables is 45KB on its own, which is what makes it the document that +/// exercises row culling inside a table rather than only block skipping around it. +const md_sample_tables = @embedFile("markdown_sample_tables"); + +fn markdownFrame() !dvui.App.Result { + var b = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both }); + defer b.deinit(); + markdown.drawPreview(&md_preview, md_doc, std.testing.allocator, .{ + .io = dvui.io, + .image_base_dir = ".", + .id_extra = 0, + }); + return .ok; +} + +/// Steps until the layout has stopped moving: every block height settled, and no off-screen table +/// row still owed a measuring pass. Takes a while by design — the preview re-measures only a few +/// off-screen blocks and a few KB of table text per frame (`render_ast.resettle_budget`, +/// `render_ast.table_measure_bytes`), and the first real width arrives on frame two, when the +/// scroll viewport is known. +/// +/// `pending_measure` is part of the condition and not just a nicety: a table block stops being +/// re-measured as soon as it has a height to stand on, long before its off-screen rows have been +/// measured, so waiting on block heights alone stops while the table is still hundreds of points +/// short of its real size. +/// +/// "Settled" here means every block has stopped wanting a re-measure — `.settled` proper, or +/// `.deferred` (an off-screen table whose height can only be answered by scrolling to it). See +/// `block_heights.Height.State`. +fn markdownSettle() !void { + for (0..600) |_| { + _ = try dvui.testing.step(markdownFrame); + var all = md_preview.rs.blocks.heights.items.len > 0; + for (md_preview.rs.blocks.heights.items) |e| { + if (e.wantsMeasure()) all = false; + } + if (all and md_render_ast.stats.pending_measure == 0) return; + } + // `MarkdownPreviewNeverSettled` on its own says nothing about *why*, and the answer is + // always the same shape: which blocks are still owed a measure, and in what state. Printing + // the tally is what turned "the layout never settles" into "164 of 185 blocks were never + // measured once, because the resettle budget was gated on being near the viewport". + var counts = [_]usize{0} ** 4; + for (md_preview.rs.blocks.heights.items) |e| counts[@intFromEnum(e.state)] += 1; + std.debug.print( + "\nnever settled: blocks={d} estimated={d} measured={d} settled={d} deferred={d} pending_measure={d}\n", + .{ md_preview.rs.blocks.heights.items.len, counts[0], counts[1], counts[2], counts[3], md_render_ast.stats.pending_measure }, + ); + var shown: usize = 0; + for (md_preview.rs.blocks.heights.items, 0..) |e, i| { + if (!e.wantsMeasure()) continue; + if (shown >= 10) break; + shown += 1; + std.debug.print(" block {d}: h={d:.2} state={s}\n", .{ i, e.h, @tagName(e.state) }); + } + return error.MarkdownPreviewNeverSettled; +} + +const MarkdownLayout = struct { + heights: []f32, + virtual_h: f32, + + fn deinit(self: MarkdownLayout, gpa: std.mem.Allocator) void { + gpa.free(self.heights); + } +}; + +/// Lays the document out scrolled `wheel_ticks` from the top and reports the resulting geometry. +fn markdownLayout(gpa: std.mem.Allocator, virtualize: bool, wheel_ticks: f32) !MarkdownLayout { + md_render_ast.virtualize_blocks = virtualize; + // Window first, so its `defer` runs *last*. `Preview.deinit` is what joins a background + // parse worker, and that worker holds the window pointer it wakes on completion — tearing + // the window down first left it refreshing freed memory. + var t = try dvui.testing.init(.{ .allocator = gpa, .window_size = .{ .w = 900, .h = 700 } }); + defer t.deinit(); + + md_preview = .{}; + defer md_preview.deinit(); + + try markdownSettle(); + + if (wheel_ticks != 0) { + const cw = dvui.currentWindow(); + _ = try cw.addEventMouseMotion(.{ .pt = .{ .x = 400, .y = 300 } }); + _ = try cw.addEventMouseWheel(wheel_ticks, .vertical, null); + try markdownSettle(); + } + + const heights = try gpa.alloc(f32, md_preview.rs.blocks.heights.items.len); + for (md_preview.rs.blocks.heights.items, heights) |entry, *out| out.* = entry.h; + return .{ .heights = heights, .virtual_h = md_preview.scroll.virtual_size.h }; +} + +test "markdown preview: skipping off-screen blocks lays the document out identically" { + const gpa = std.testing.allocator; + defer md_render_ast.virtualize_blocks = true; + + // Top, a screen or so down, and far enough that most of the document is behind the viewport. + for ([_][]const u8{ md_sample, md_sample_tables }) |sample| for ([_]f32{ 0, -1200, -6000 }) |ticks| { + md_doc = sample; + const full = try markdownLayout(gpa, false, ticks); + defer full.deinit(gpa); + const virtualized = try markdownLayout(gpa, true, ticks); + defer virtualized.deinit(gpa); + + try std.testing.expect(full.heights.len > 30); // the samples really are long documents + try std.testing.expectEqualSlices(f32, full.heights, virtualized.heights); + // …and the scroll container's total is exactly those blocks plus the column's padding. + // Deliberately *not* compared against the full render's total: drawing every block lets + // each table's grid — a scroll container in its own right — ask the scroll area for more + // room than the block actually occupies, which is why that number comes out ~20% larger + // than the document really is. + var sum: f32 = 0; + for (virtualized.heights) |h| sum += h; + try std.testing.expectApproxEqAbs(sum + 16, virtualized.virtual_h, 0.01); + }; +} + +/// Steps until the document has been parsed and placed. The parse runs on a worker thread, so a +/// fixed number of frames guarantees nothing about whether there is a document yet. +fn markdownAwaitParse() !void { + for (0..600) |_| { + _ = try dvui.testing.step(markdownFrame); + if (md_preview.rs.blocks.len() > 0) return; + } + return error.MarkdownPreviewNeverParsed; +} + +/// Scrolls by `ticks` and runs a fixed number of frames *without* waiting for settle — the point +/// is what the reader experiences mid-scroll, not the steady state they eventually reach. +fn markdownScroll(ticks: f32, frames: usize) !void { + const cw = dvui.currentWindow(); + _ = try cw.addEventMouseMotion(.{ .pt = .{ .x = 400, .y = 300 } }); + _ = try cw.addEventMouseWheel(ticks, .vertical, null); + for (0..frames) |_| _ = try dvui.testing.step(markdownFrame); +} + +// The user-visible complaint these two encode: on docs/PLUGIN_MANIFEST_PLAN.md, scrolling about +// three quarters of the way down went unstable — the document jumped under the reader and the +// scrollbar jumped with it, and scrolling back up landed near the top of the document instead of +// where they had been. +// +// Both symptoms are the same defect seen from two ends: the document's total height was mostly +// low-biased *estimates* (blocks far from the viewport were never measured, so their guesses +// stood in for real heights), and an absolute `viewport.y` measured against a total that grows as +// you scroll into it cannot mean the same thing from one frame to the next. + +test "markdown preview: the document's height stops moving once settled" { + const gpa = std.testing.allocator; + md_doc = md_sample_tables; + // Window first, so its `defer` runs *last*. `Preview.deinit` is what joins a background + // parse worker, and that worker holds the window pointer it wakes on completion — tearing + // the window down first left it refreshing freed memory. + var t = try dvui.testing.init(.{ .allocator = gpa, .window_size = .{ .w = 900, .h = 700 } }); + defer t.deinit(); + + md_preview = .{}; + defer md_preview.deinit(); + try markdownSettle(); + + // Every block measured, so no block may still be standing on a guess. An `.estimated` block + // here is a block whose height the scrollbar is lying about. + for (md_preview.rs.blocks.heights.items, 0..) |e, i| { + if (e.state == .estimated) { + std.debug.print("block {d} never measured (h={d:.2})\n", .{ i, e.h }); + return error.BlockLeftAtEstimate; + } + } + + // Scrolling a settled document must not change how tall it is. When it does, every scroll + // position below the change means something different than it did the frame before — which + // is exactly what "the scrollbar jumps while I scroll" is. + const before = md_preview.scroll.virtual_size.h; + try markdownScroll(-4000, 30); + try std.testing.expectApproxEqAbs(before, md_preview.scroll.virtual_size.h, 1.0); + try markdownScroll(-4000, 30); + try std.testing.expectApproxEqAbs(before, md_preview.scroll.virtual_size.h, 1.0); +} + +test "markdown preview: scrolling deep and back returns to the same place" { + const gpa = std.testing.allocator; + md_doc = md_sample_tables; + // Window first, so its `defer` runs *last*. `Preview.deinit` is what joins a background + // parse worker, and that worker holds the window pointer it wakes on completion — tearing + // the window down first left it refreshing freed memory. + var t = try dvui.testing.init(.{ .allocator = gpa, .window_size = .{ .w = 900, .h = 700 } }); + defer t.deinit(); + + md_preview = .{}; + defer md_preview.deinit(); + try markdownSettle(); + + // Three quarters of the way down — the region the instability was reported in, and on this + // document the one holding the 45KB table. + const max_scroll = md_preview.scroll.virtual_size.h - md_preview.scroll.viewport.h; + try std.testing.expect(max_scroll > 2000); // it really is a long document + md_preview.scroll.scrollToOffset(.vertical, max_scroll * 0.75); + for (0..30) |_| _ = try dvui.testing.step(markdownFrame); + + const parked = md_preview.scroll.viewport.y; + // The scroll must actually have taken effect — see the note in the rapid-scrolling test. + try std.testing.expect(parked > max_scroll * 0.5); + // A settled document must not drift while merely being looked at. + for (0..30) |_| _ = try dvui.testing.step(markdownFrame); + try std.testing.expectApproxEqAbs(parked, md_preview.scroll.viewport.y, 1.0); + + // Down a screen and back up the same amount: a round trip must be a no-op. It was not — the + // heights discovered on the way down changed what the offset meant on the way back. + try markdownScroll(-1500, 20); + try markdownScroll(1500, 20); + try std.testing.expectApproxEqAbs(parked, md_preview.scroll.viewport.y, 2.0); +} + +// The case the anchor exists for. Every other test here holds the geometry still, which is +// precisely the condition under which the old absolute-offset scheme also looked fine. +// +// Here the reader parks three quarters of the way down and *then* the column reflows under them. +// Every height above them changes, so the pixel offset they were sitting at now points somewhere +// else entirely. Holding position through that is the whole reason scroll state is a source line +// rather than a number of pixels. +test "markdown preview: the reader holds position when the column reflows" { + const gpa = std.testing.allocator; + md_doc = md_sample_tables; + // Window first, so its `defer` runs *last*. `Preview.deinit` is what joins a background + // parse worker, and that worker holds the window pointer it wakes on completion — tearing + // the window down first left it refreshing freed memory. + var t = try dvui.testing.init(.{ .allocator = gpa, .window_size = .{ .w = 900, .h = 700 } }); + defer t.deinit(); + + md_preview = .{}; + defer md_preview.deinit(); + + try markdownSettle(); + + // Park deep in the document. Growth *below* the reader moves nothing, so a reader near the + // top would sit still even with no anchoring at all — the position has to be far enough down + // that a reflow changes a lot of height above them. + const max_before = md_preview.scroll.virtual_size.h - md_preview.scroll.viewport.h; + try std.testing.expect(max_before > 2000); + md_preview.scroll.scrollToOffset(.vertical, max_before * 0.75); + for (0..5) |_| _ = try dvui.testing.step(markdownFrame); + try std.testing.expect(md_preview.scroll.viewport.y > max_before * 0.5); + + const anchor = md_preview.anchor orelse return error.NoAnchor; + try std.testing.expect(!anchor.at_end); + const height_before = md_preview.scroll.virtual_size.h; + + // Narrow the window hard: every wrapped block reflows taller, including everything above the + // reader. This is a sash drag, and it is the cleanest way to move a lot of height at once. + // The testing backend reports its size from these fields, so writing them *is* a resize. + t.backend.size = .{ .w = 450, .h = 700 }; + t.backend.size_pixels = .{ .w = 900, .h = 1400 }; + // One explicit frame before settling. `dvui.testing.step` ends with `Window.begin`, which is + // what re-reads the backend size — so on the first step the frame still runs at the *old* + // width, and `markdownSettle` would see an already-settled layout and return immediately, + // before the resize had changed anything. + _ = try dvui.testing.step(markdownFrame); + try markdownSettle(); + + // The document really did change size underneath them — otherwise this proves nothing. + const height_after = md_preview.scroll.virtual_size.h; + try std.testing.expect(@abs(height_after - height_before) > 500); + + // ...and they are still on the same source line, at the same offset into it. An absolute + // pixel offset could not survive this: the content that used to be at that offset is now + // hundreds of points further down. + const now = md_preview.anchor.?; + try std.testing.expectEqual(anchor.line, now.line); + try std.testing.expectApproxEqAbs(anchor.offset_px, now.offset_px, 2.0); +} + +// The symptom that outlasted the anchor: scrolling past the end of the big table, and into the +// next one, snapped and jumped. +// +// The anchor holds a reader against a *block*, so it cannot help when the block itself changes +// size — and a table's measured height was a function of where the reader was scrolled. Its rows +// are culled to what is on screen, and a row that had never been measured stood in with a +// placeholder, so the grid reported a different total depending on which rows happened to be real +// that frame. Every such change moved everything below it. +// +// The invariant that has to hold, and what this asserts: **a block's height is a function of the +// document, not of the scroll position.** +test "markdown preview: block heights do not depend on where the reader is" { + const gpa = std.testing.allocator; + md_doc = md_sample_tables; + var t = try dvui.testing.init(.{ .allocator = gpa, .window_size = .{ .w = 900, .h = 700 } }); + defer t.deinit(); + md_preview = .{}; + defer md_preview.deinit(); + + try markdownSettle(); + const base = try gpa.alloc(f32, md_preview.rs.blocks.heights.items.len); + defer gpa.free(base); + for (md_preview.rs.blocks.heights.items, base) |e, *o| o.* = e.h; + const total_base = md_preview.scroll.virtual_size.h; + try std.testing.expect(total_base > 10_000); // the sample really is a long document + + md_preview.scroll.scrollToOffset(.vertical, 0); + try markdownSettle(); + + // Wheel all the way down in steps, two frames each — no settling between them, which is what + // a real scroll looks like and what the earlier tests here never exercised. + var step_i: usize = 0; + while (step_i < 90) : (step_i += 1) { + try markdownScroll(-300, 2); + for (md_preview.rs.blocks.heights.items, base, 0..) |e, b, bi| { + if (@abs(e.h - b) > 1.0) { + std.debug.print( + "\nblock {d} changed height while scrolling: {d:.1} -> {d:.1} (state {s}) at y={d:.1}\n", + .{ bi, b, e.h, @tagName(e.state), md_preview.scroll.viewport.y }, + ); + return error.BlockHeightDependsOnScroll; + } + } + } + + // ...and the document is the same size at the bottom as it was at the top. + try std.testing.expectApproxEqAbs(total_base, md_preview.scroll.virtual_size.h, 1.0); +} + +// Rapidly scrolling up and down through the tables made the preview jump wildly, and it survived +// both the anchor and the "block heights do not depend on scroll position" fix. Two distinct +// causes, neither visible to a test that scrolls gently in one direction: +// +// 1. A table's *emitted* height was still unstable even when its recorded height was not. The +// scroll container builds `virtual_size` from widgets, not from the height table, so refusing +// to record a bad measurement did not stop it reaching the scrollbar. Block 5 emitted 46pt on +// one frame and 29,995pt on another against a real ~6,132. +// 2. The anchor was re-derived every frame, so any single frame of bad geometry became the +// reader's stored position permanently. +test "markdown preview: rapid scrolling up and down does not move the document" { + const gpa = std.testing.allocator; + md_doc = md_sample_tables; + var t = try dvui.testing.init(.{ .allocator = gpa, .window_size = .{ .w = 900, .h = 700 } }); + defer t.deinit(); + md_preview = .{}; + defer md_preview.deinit(); + + try markdownSettle(); + const max = md_preview.scroll.virtual_size.h - md_preview.scroll.viewport.h; + try std.testing.expect(max > 5000); + + // Park inside the big table. + md_preview.scroll.scrollToOffset(.vertical, max * 0.6); + try markdownSettle(); + const parked = md_preview.scroll.viewport.y; + // Guard against the test silently running at the top: an anchor that overwrote the offset + // between frames used to discard `scrollToOffset` entirely, which made several tests here + // pass while asserting nothing. + try std.testing.expect(parked > max * 0.5); + + const total = md_preview.scroll.virtual_size.h; + + // Thrash: one frame per direction change, which is what outruns every budget in the renderer. + var round: usize = 0; + while (round < 12) : (round += 1) { + try markdownScroll(-2000, 1); + try markdownScroll(2000, 1); + try std.testing.expectApproxEqAbs(parked, md_preview.scroll.viewport.y, 1.0); + try std.testing.expectApproxEqAbs(total, md_preview.scroll.virtual_size.h, 1.0); + } +} + +// Dragging the split-pane sash: every cached height is invalidated on every frame of the drag, +// which is the harshest thing that happens to this renderer. Two things must hold at once, and +// they pull against each other — the document has to actually reflow, and the reader must not +// move while it does. +// +// Getting the second one by freezing everything is not a pass: an earlier version pinned table +// blocks so hard they could never relearn their height (a pinned block measures exactly its pin, +// so it agrees with itself forever), and the document silently stopped reflowing. Hence the +// explicit assertion that the total really did change. +test "markdown preview: the reader holds position while the sash is dragged" { + const gpa = std.testing.allocator; + md_doc = md_sample_tables; + var t = try dvui.testing.init(.{ .allocator = gpa, .window_size = .{ .w = 900, .h = 700 } }); + defer t.deinit(); + md_preview = .{}; + defer md_preview.deinit(); + + try markdownSettle(); + const max = md_preview.scroll.virtual_size.h - md_preview.scroll.viewport.h; + md_preview.scroll.scrollToOffset(.vertical, max * 0.6); + try markdownSettle(); + + const start = md_preview.anchor orelse return error.NoAnchor; + try std.testing.expect(md_preview.scroll.viewport.y > max * 0.5); // really did park + const total_before = md_preview.scroll.virtual_size.h; + + // 900 -> 500 in 10pt steps, one frame each: a drag, not a jump. + var w: f32 = 900; + var i: usize = 0; + while (i < 40) : (i += 1) { + w -= 10; + t.backend.size = .{ .w = w, .h = 700 }; + t.backend.size_pixels = .{ .w = w * 2, .h = 1400 }; + _ = try dvui.testing.step(markdownFrame); + + // Checked every frame, not just at the end: the failure this guards against was the + // reader creeping a little on each frame of the drag. + const now = md_preview.anchor orelse return error.NoAnchor; + try std.testing.expectEqual(start.line, now.line); + try std.testing.expectApproxEqAbs(start.offset_px, now.offset_px, 2.0); + + // The pane must still be *drawing* while it is dragged. Skipping work during a resize is + // the obvious way to make one fast, and an over-eager version of exactly that (zeroing + // the table's on-screen row budget along with its off-screen one) made every visible row + // cull itself, so the table rendered blank for the whole drag while the timings looked + // excellent. Cheap frames that draw nothing are not the goal. + try std.testing.expect(md_render_ast.stats.add_text_bytes > 500); + } + + // The column really did narrow, and the document really did get taller for it. + try std.testing.expect(md_preview.rs.blocks.layout_width < 550); + try std.testing.expect(md_preview.scroll.virtual_size.h > total_before + 500); +} + +// The workflow this preview actually exists for: typing in the editor with the preview beside it. +// Every keystroke re-parses the document, and a re-parse used to throw the whole height table +// away — all 50 blocks fell back to estimates, the total collapsed from 20,093 to 8,886, and the +// reader was thrown hundreds of points for several frames. Once per character. +// +// Two things keep it still now, and both are needed: heights are keyed by block source so an edit +// only invalidates the block it touched, and the anchor identifies its block by source hash so an +// insertion above the reader does not renumber them out from under it. +test "markdown preview: an edit does not move the reader or lose the layout" { + const gpa = std.testing.allocator; + // The same document with one line inserted near the top — what typing looks like from here. + const edited = try std.mem.concat(gpa, u8, &.{ md_sample_tables[0..200], "\nnew line\n", md_sample_tables[200..] }); + defer gpa.free(edited); + + md_doc = md_sample_tables; + var t = try dvui.testing.init(.{ .allocator = gpa, .window_size = .{ .w = 900, .h = 700 } }); + defer t.deinit(); + md_preview = .{}; + defer md_preview.deinit(); + + try markdownSettle(); + const max = md_preview.scroll.virtual_size.h - md_preview.scroll.viewport.h; + md_preview.scroll.scrollToOffset(.vertical, max * 0.6); + try markdownSettle(); + + const y_before = md_preview.scroll.viewport.y; + const total_before = md_preview.scroll.virtual_size.h; + try std.testing.expect(y_before > max * 0.5); // really did park + + md_doc = edited; + _ = try dvui.testing.step(markdownFrame); + + // On the very first frame after the edit, most of the layout must survive. Blocks the edit did + // not touch keep their measured heights, so the document does not momentarily believe it is + // half its real size. + try std.testing.expect(md_preview.scroll.virtual_size.h > total_before * 0.8); + var kept: usize = 0; + for (md_preview.rs.blocks.heights.items) |e| { + if (e.state != .estimated) kept += 1; + } + try std.testing.expect(kept > md_preview.rs.blocks.heights.items.len / 2); + + // And the reader ends up exactly where they were, despite every line below the insertion + // having been renumbered. + try markdownSettle(); + try std.testing.expectApproxEqAbs(y_before, md_preview.scroll.viewport.y, 2.0); +} + +// Before a block has ever been laid out, its height is a guess from its source — and until the +// warm-up sweep finishes, the scrollbar is the sum of those guesses. The guess used to ignore what +// kind of block it was: an image is one line of source and hundreds of points tall, a table row is +// a line of source and a line *plus* cell padding, a heading is a line in a much larger font. All +// the errors pointed the same way, and docs/PLUGIN_MANIFEST_PLAN.md estimated at 24% of its real +// length — a scrollbar claiming the document was a quarter of its true size. +// +// A band, not a number: these are guesses and are meant to be. What matters is that they are the +// right order of magnitude, and that a future change to the estimator cannot quietly undo that. +test "markdown preview: the estimated document length is in the right ballpark" { + const gpa = std.testing.allocator; + for ([_][]const u8{ md_sample, md_sample_tables }) |sample| { + md_doc = sample; + var t = try dvui.testing.init(.{ .allocator = gpa, .window_size = .{ .w = 900, .h = 700 } }); + defer t.deinit(); + md_preview = .{}; + defer md_preview.deinit(); + + try markdownAwaitParse(); + const m = md_render_ast.currentMetricsForTest(); + const w = md_preview.rs.blocks.layout_width; + var est: f32 = 0; + for (0..md_preview.rs.blocks.len()) |i| est += md_preview.rs.blocks.estimate(i, m, w) orelse 0; + + try markdownSettle(); + var real: f32 = 0; + for (md_preview.rs.blocks.heights.items) |e| real += e.h; + + try std.testing.expect(real > 10_000); // these really are long documents + const ratio = est / real; + if (ratio < 0.7 or ratio > 1.4) { + std.debug.print("\nestimated length {d:.0} vs real {d:.0} ({d:.0}%)\n", .{ est, real, ratio * 100 }); + return error.EstimateOutOfBand; + } + } +} + +// Scrolling the whole document down and back up, checking every step that the reader went where +// they asked and nowhere else. This is the shape of the bug reports that kept coming back — "it +// jumps to the top", "it jumps to the bottom" — and none of the earlier tests could see it, +// because they all parked somewhere and thrashed locally instead of traversing. +// +// The last one it caught: anchoring by block source hash, where the hash identifies *text* and +// documents repeat themselves. docs/PLUGIN_MANIFEST_PLAN.md has seven top-level blocks sharing a +// single hash, so an anchor on any of them resolved to whichever copy came first and the reader +// was thrown to the top of the document. +test "markdown preview: scrolling through the document never jumps past where it was asked" { + const gpa = std.testing.allocator; + // Both samples: PLUGINS.md is the longer one (185 blocks) and has its own tables. Running + // this only on the table-heavy sample missed it entirely. + for ([_][]const u8{ md_sample, md_sample_tables }) |sample| { + md_doc = sample; + var t = try dvui.testing.init(.{ .allocator = gpa, .window_size = .{ .w = 900, .h = 700 } }); + defer t.deinit(); + md_preview = .{}; + defer md_preview.deinit(); + + try markdownSettle(); + md_preview.scroll.scrollToOffset(.vertical, 0); + try markdownSettle(); + + // Small steps on purpose. A coarse traversal steps straight over the short blocks — a rule, a + // one-line paragraph — and those are exactly the ones a document repeats, so a coarse sweep + // never anchors on one and never sees the bug that repetition causes. + const step_px: f32 = 100; + // Generous: one step of scrolling, plus room for the document's total to still be settling. + const tolerance: f32 = step_px + 250; + + var down: usize = 0; + while (down < 220) : (down += 1) { + const before = md_preview.scroll.viewport.y; + try markdownScroll(-step_px, 2); + const after = md_preview.scroll.viewport.y; + if (after < before - 1 or after > before + tolerance) { + std.debug.print("\nscrolling down: y {d:.1} -> {d:.1} (asked for +{d:.0})\n", .{ before, after, step_px }); + return error.ScrollJumped; + } + } + try std.testing.expect(md_preview.scroll.viewport.y > 5000); // it really did travel + + var up: usize = 0; + while (up < 260) : (up += 1) { + const before = md_preview.scroll.viewport.y; + try markdownScroll(step_px, 2); + const after = md_preview.scroll.viewport.y; + if (after > before + 1 or after < before - tolerance) { + std.debug.print("\nscrolling up: y {d:.1} -> {d:.1} (asked for -{d:.0})\n", .{ before, after, step_px }); + return error.ScrollJumped; + } + } + try std.testing.expectApproxEqAbs(@as(f32, 0), md_preview.scroll.viewport.y, 1.0); + } +} + + +// The preview must stop asking for frames. `dvui.Window.end` returns 0 while a refresh is pending +// ("render again immediately") and null when there is nothing to do — so a preview that keeps +// returning 0 is an app that never sleeps, burning battery behind an idle window. +// +// This is not hypothetical: the renderer asks for another frame whenever any block or table row is +// still owed a measuring pass, and "keep asking until it converges" is not a termination argument. +// A table whose cells never agree with the column width they are laid out in never converges, and +// the preview then holds the whole app awake for as long as the document is open. +fn markdownReachesIdle() !bool { + // Generous: the warm-up sweep legitimately wants a few hundred frames on a long document. + for (0..900) |_| { + const wait = try dvui.testing.step(markdownFrame); + if (wait == null or wait.? > 0) return true; + } + return false; +} + +test "markdown preview: stops asking for frames so the app can sleep" { + const gpa = std.testing.allocator; + for ([_][]const u8{ md_sample, md_sample_tables }) |sample| { + md_doc = sample; + var t = try dvui.testing.init(.{ .allocator = gpa, .window_size = .{ .w = 900, .h = 700 } }); + defer t.deinit(); + md_preview = .{}; + defer md_preview.deinit(); + + try markdownAwaitParse(); + if (!try markdownReachesIdle()) { + var counts = [_]usize{0} ** 4; + for (md_preview.rs.blocks.heights.items) |e| counts[@intFromEnum(e.state)] += 1; + std.debug.print( + "\nnever idle: estimated={d} measured={d} settled={d} deferred={d} pending_measure={d}\n", + .{ counts[0], counts[1], counts[2], counts[3], md_render_ast.stats.pending_measure }, + ); + return error.PreviewNeverStopsRequestingFrames; + } + + // ...and it must still be idle after scrolling into the tables and back, which is where + // the never-settling measurements live. + try markdownScroll(-8000, 4); + if (!try markdownReachesIdle()) return error.PreviewNeverStopsRequestingFramesAfterScroll; + try markdownScroll(8000, 4); + if (!try markdownReachesIdle()) return error.PreviewNeverStopsRequestingFramesAfterScrollBack; + } +} + + +// Typing, one character at a time, with the preview open beside the editor. This is the single +// most common thing anyone does with this preview, and every keystroke re-parses the document. +// +// The per-edit test above inserts one line and checks the reader comes back. That is not the same +// as *never leaving*: a jump that lasts one frame and corrects itself is still a jump the reader +// sees, once per character. +var md_edit_buf: std.ArrayListUnmanaged(u8) = .empty; + +test "markdown preview: typing does not move the preview" { + const gpa = std.testing.allocator; + md_edit_buf.clearRetainingCapacity(); + defer md_edit_buf.deinit(gpa); + try md_edit_buf.appendSlice(gpa, md_sample); + md_doc = md_edit_buf.items; + + var t = try dvui.testing.init(.{ .allocator = gpa, .window_size = .{ .w = 900, .h = 700 } }); + defer t.deinit(); + md_preview = .{}; + defer md_preview.deinit(); + + try markdownSettle(); + const max = md_preview.scroll.virtual_size.h - md_preview.scroll.viewport.h; + // Deep, so that every table in the document is *above* the reader. A table's height changing + // below them moves nothing; the whole question is what happens to content above. + md_preview.scroll.scrollToOffset(.vertical, max * 0.9); + try markdownSettle(); + + const parked = md_preview.scroll.viewport.y; + try std.testing.expect(parked > max * 0.8); + + // Type into a paragraph near the top — above the reader, so any height it gains moves + // everything they are looking at. + var typed: usize = 0; + while (typed < 12) : (typed += 1) { + try md_edit_buf.insert(gpa, 300, 'x'); + md_doc = md_edit_buf.items; + // A couple of frames per keystroke, which is what a typist actually gives it. + for (0..2) |_| _ = try dvui.testing.step(markdownFrame); + if (@abs(md_preview.scroll.viewport.y - parked) > 3) { + std.debug.print( + "\nkeystroke {d}: preview moved {d:.1} -> {d:.1} (total {d:.0})\n", + .{ typed, parked, md_preview.scroll.viewport.y, md_preview.scroll.virtual_size.h }, + ); + return error.PreviewMovedWhileTyping; + } + } +}