diff --git a/build/app.zig b/build/app.zig index dc0c84ac..86a88082 100644 --- a/build/app.zig +++ b/build/app.zig @@ -367,6 +367,7 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil .{ "fizzy-layout-anchor-tests", "src/core/math/layout_anchor.zig" }, .{ "fizzy-window-layout-tests", "src/backend/window_layout.zig" }, .{ "fizzy-plugin-store-tests", "src/backend/plugin_store/store.zig" }, + .{ "fizzy-paths-tests", "src/core/paths.zig" }, .{ "fizzy-lsp-protocol-tests", "src/core/lsp/Protocol.zig" }, .{ "fizzy-lsp-uri-tests", "src/core/lsp/UriUtil.zig" }, .{ "fizzy-settings-plugins-zon-tests", "src/editor/SettingsPluginsZon.zig" }, @@ -374,6 +375,13 @@ 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 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. + .{ "fizzy-textcore-tests", "src/plugins/text/src/textcore/textcore.zig" }, + // Keybinding parse/resolve core. Deliberately dvui-free (see keymap.zig) — dvui's + // keybind map can't express chords and is keyed by bind name, not command. + .{ "fizzy-keymap-tests", "src/editor/keymap/keymap.zig" }, }) |entry| { try unit_test_artifacts.append(b.allocator, b.addTest(.{ .name = entry[0], @@ -481,7 +489,7 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil .icons = icons_test, .backend = dvui_testing_dep.module("testing"), }, workbench_opts, fizzy_test_module); - _ = text_plugin.addStaticModule(b, target, optimize, .{ + const text_module_test = text_plugin.addStaticModule(b, target, optimize, .{ .dvui = dvui_testing_dep.module("dvui_testing"), .core = core_module_test, .sdk = sdk_module_test, @@ -515,6 +523,15 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil integration_module.addImport("fizzy", fizzy_test_module); integration_module.addImport("dvui", dvui_testing_dep.module("dvui_testing")); + // The text plugin itself, so integration tests can drive its `TextEntryWidget` directly in + // a headless window. Its editing behavior splits in two: the *decisions* live in dvui-free + // `textcore/` and are unit-tested there, but applying them (buffer memmoves + selection + // arithmetic against a live `TextLayoutWidget`) only exists inside the widget, and that + // half needs real frames and real key/text events to exercise. Reuses the module already + // 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); + const integration_tests = b.addTest(.{ .name = "fizzy-integration-tests", .root_module = integration_module, @@ -535,6 +552,45 @@ pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.buil test_integration_step.dependOn(&b.addRunArtifact(integration_tests).step); check_integration_step.dependOn(&integration_tests.step); + // `zig build bench-text` — text editor frame-cost benchmark. Its own step, never wired into + // `test`/`test-all`: it prints timings instead of asserting, and the numbers are + // machine-dependent. Same headless harness as the integration tests, so it measures the + // real widget rather than a model of it. Compare runs only at equal `-Doptimize` — the C + // libraries inside dvui build at the app's optimize level (see the file's doc comment). + { + const bench_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("tests/bench/bench_text.zig"), + }); + bench_module.addImport("dvui", dvui_testing_dep.module("dvui_testing")); + bench_module.addImport("text", text_module_test); + // The tree-sitter Zig queries the benchmark highlights with, taken from dvui's examples + // instead of vendoring a copy into the repo. The real editor gets its queries from the + // external `zig` language plugin; what the benchmark times (query + per-capture chunk + // emission) doesn't depend on which of the two supplied them. + bench_module.addAnonymousImport("ts_zig_queries", .{ + .root_source_file = dvui_testing_dep.path("src/Examples/tree_sitter_zig_queries.scm"), + }); + // The documents it benchmarks are this repo's own sources — `@embedFile` can't reach + // outside its package, so they arrive the same way. + bench_module.addAnonymousImport("sample_large", .{ .root_source_file = b.path("src/editor/Editor.zig") }); + bench_module.addAnonymousImport("sample_small", .{ .root_source_file = b.path("src/App.zig") }); + + const bench_text = b.addTest(.{ .name = "fizzy-bench-text", .root_module = bench_module }); + bench_text.root_module.link_libcpp = !target_is_windows_msvc; + if (target.result.os.tag == .windows) { + bench_text.root_module.linkSystemLibrary("comctl32", .{}); + } + + const bench_step = b.step("bench-text", "Benchmark the text editor's per-frame draw cost (prints timings)"); + const run_bench = b.addRunArtifact(bench_text); + // Timings are the output — never serve a cached result, and don't let a parallel build + // step's CPU contention skew them. + 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/docs/PLUGINS.md b/docs/PLUGINS.md index cba9dc13..3ed1cd45 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -47,7 +47,7 @@ the first time; use it as reference after that. | `Plugin` | A plugin's identity + **vtable** of optional hooks. The shell calls these; a plugin implements only what it needs. | | `DocHandle` | Opaque handle to an open document: `{ ptr, id, owner: *Plugin }`. The shell stores these per tab and **routes every document operation to `owner`** — it never inspects `ptr`. | | `regions` | The contribution structs a plugin registers: `SidebarView`, `BottomView`, `CenterProvider`, `MenuContribution`, `Command`, `LanguageSupport`, … There is no settings region here — see `sdk.settings` below. | -| `sdk.settings` (`settings.zig`) | Comptime settings API: `sdk.settings.Schema(struct { … })` derives a persisted-values type + a `SettingsSchema` you register with the Host. The shell's settings pane draws it for you — no hand-rolled dvui settings section. | +| `sdk.settings` (`settings.zig`) | Comptime settings API: `sdk.settings.Schema(struct { … })` over `Value(T, .{ .description = … })` cells derives a persisted-values type + a `SettingsSchema` you register with the Host. The shell's settings pane draws it for you — no hand-rolled dvui settings section. | | `dylib` / `dvui_context` | The C-ABI entry contract + dvui-context injection used when a plugin is loaded as a runtime library. | **The shell owns no features.** Each frame it iterates the Host registries and draws whatever @@ -327,15 +327,22 @@ fallback when a loaded plugin has no fetchable `ICON.png` (e.g. a sideloaded dyl There is no `registerSettingsSection` and no author-written settings schema in `plugin.zig.zon`. Persisted settings are declared as a plain Zig struct (build-options-style, -comptime-derived), then registered from `register(host)`: +comptime-derived) whose every field is a self-describing `sdk.settings.Value` cell, then +registered from `register(host)`: ```zig const MySettings = sdk.settings.Schema(struct { - insert_spaces_on_tab: bool = true, - tab_size: enum(u8) { two = 2, four = 4, eight = 8 } = .four, - format_on_save: bool = false, + insert_spaces_on_tab: sdk.settings.Value(bool, .{ + .description = "Insert spaces instead of a tab character when pressing Tab.", + }) = .init(true), + tab_size: sdk.settings.Value(enum(u8) { two = 2, four = 4, eight = 8 }, .{ + .description = "How many columns a tab occupies.", + }) = .init(.four), + format_on_save: sdk.settings.Value(bool, .{ + .description = "Reformat the document each time it is saved.", + }) = .init(false), }); -var settings: MySettings.Value = .{}; +var settings: MySettings.Cells = .{}; pub fn register(host: *sdk.Host) !void { plugin.state = @ptrCast(&plugin_state); @@ -347,18 +354,41 @@ pub fn register(host: *sdk.Host) !void { .value = &settings, // shell draws shared controls }); } + +// Read a value with `.get()`, write one with `.set()`: +if (settings.format_on_save.get()) formatDocument(doc); ``` -`Schema(T)` comptime-walks `T`'s fields (`bool`/`int`/`float`/`enum`/`[]const u8`) into a -`Setting` table — each entry's `kind: Kind` is a `union(TypeTag)` carrying only the metadata that +**Every setting describes itself.** A cell's second (comptime) parameter is +`settings.Options{ description, name, min, max, step }`, and `description` has no default — a +setting declared without one is a compile error. The pane has a permanent place for it (see the +row shape below), so there is no shell-side table of strings that can drift from the plugin that +owns the setting. Metadata lives entirely in the *type*: `@sizeOf(Value(T, …)) == @sizeOf(T)`, +and only the payload is ever stored or persisted. + +The shell draws each setting as one group, VSCode-style: the name (derived from the field name — +`insert_spaces_on_tab` → `Insert spaces on tab`, or `Options.name` to override), the zon key +underneath in smaller dim mono, the wrapped description, and then the control at full width. +Booleans are the exception — their checkbox sits before the description on one line. Both the +name and the key are matched by the settings search, so users can find a row by either. + +`Schema(T)` comptime-walks `T`'s cells into a `Setting` table (`key`, `label`, `description`, +`kind`) — each entry's `kind: Kind` is a `union(TypeTag)` carrying only the metadata that payload type actually uses (`IntKind{min,max,choices}`, `FloatKind{min,max,step}`, `EnumKind{choices}`, -void for bool/string/color), rather than one flat struct with every type's bounds fields present -on every entry — and generates `Value` (= `T`), `load`/`store` (a zon round-trip through +void for bool/string/color/other), rather than one flat struct with every type's bounds fields +present on every entry — and generates `Cells` (= `T`), `Payloads` (the bare-value mirror that is +what actually round-trips through zon), `load`/`store` (through `Host.loadPluginSettings`/`storePluginSettings`), `applyZon` (parse a blob directly into the live value — the primitive `Access.applyBlob` wraps with a `settingsChanged` notification, used by external-change reconciliation, see below), and `register` (wires a `SettingsSchema` + typed `Access` vtable into the Host). +**Any type is a legal setting.** `bool`/int/float/`enum`/`[]const u8` get dedicated controls; +anything else `std.zon` can round-trip (a struct, an array, an optional) is `TypeTag.other` and +the pane draws it as its zon text in an editable entry — text that doesn't parse simply doesn't +commit. The on-disk shape is always the bare payload (`.tab_size = 8`), never the cell, so +`settings.zon` files written before cells existed keep loading unchanged. + Every plugin's block lives as a real, hand-editable nested ZON struct literal keyed by plugin id inside the shell's own `{config}/settings.zon`: @@ -382,7 +412,7 @@ scalars/enums. Don't free a string field yourself, and don't assign one directly persisted — go through the settings pane or `applyZon`, or you'll leak the schema's copy. Author fields live under `.settings` so they can never collide with the shell-reserved -`.enabled`. Only fields that differ from `T`'s own declared defaults are written; an all-default +`.enabled`. Only fields whose payload differs from the cell's declared default is written; an all-default value removes that plugin's `.settings` (and if also disabled, the whole `.plugins.` entry). `src/editor/SettingsPluginsZon.zig` locates/composes fields by source byte-span via the same `std.zig.Ast`/`ZonGen` machinery `std.zon.parse` uses, so nothing else in the file is @@ -525,6 +555,30 @@ focusing a pixi doc runs `"pixi.copy"`; a second editor answers the same shell a registering its own `".copy"`, `…transform`, etc. An action the owner didn't register is simply a no-op for its documents. +**Menu rows name a command, and get its chord for free.** A menu item is never told what +shortcut to display — it names the command it runs and the shell resolves the chord from the +live keymap, so a rebind in the Keyboard Shortcuts pane shows up in both menu bars immediately: + +```zig +// in-app menu (inside a `registerMenuSection` draw callback) +if (host.drawMenuItem("Grid Layout…", "pixi.gridLayout")) { … } + +// macOS NSMenu leaf +try host.registerNativeMenuItem(.{ …, .command = "pixi.gridLayout", .sf_symbol = "square.grid.3x3", .run = … }); +``` + +Both fields are optional; omit them for a row with no command behind it, which then simply +carries no accelerator. `drawMenuItem`'s second parameter used to be a dvui *bind name* — a +separate flat namespace plugin commands have no entry in — so it never resolved to anything. + +**Command palette flattening.** Document verbs that the shell forwards (`copy`, `paste`, `undo`, +`redo`, `deleteSelection`, `acceptEdit`, `cancelEdit`) appear **once** in the palette as the +Fizzy stub (`fizzy.copy`, …) — greyed when no active document offers that action. Plugin +implementations (`text.copy`, `pixi.copy`, …) stay registered for dispatch/menus/keybinds but are +hidden from the palette so the same title isn't listed N times. Verbs with no Fizzy stub +(*Transform*, *Grid Layout*) show only the active owner's command when present. Global commands +(`pixi.packProject`, `fizzy.save`, …) always appear as their own rows. + ### 3.5 Memory: one allocator, one arena - **`host.allocator`** (== `sdk.allocator()`) — the persistent heap allocator. Use it for anything @@ -588,6 +642,7 @@ Every hook is optional and independent — implement only what your plugin offer | `treeSitterHighlight(state, ext) ?TreeSitterHighlight` | Tree-sitter grammar, query, and capture→style table for syntax highlighting | | `previewPane(state, ext, bytes, id_extra, gpa) !void` | Draw a read-only preview pane (markdown render, JSON tree, …) | | `supportsPreview(state, ext) bool` | Optional gate; defaults to checking whether `previewPane` is populated | +| `documentOpened(state, ext, path, bytes) void` | Non-blocking; fired when text opens/reloads a document — LSP plugins use this to spawn the server and `didOpen` before first hover | | `hover(state, ext, path, bytes, byte_offset) ?HoverResult` | Non-blocking; hover text for the token at `byte_offset`. Called every frame the mouse dwells over a token — see §3.9 | | `gotoDefinition(state, ext, path, bytes, byte_offset) ?DefinitionLocation` | May block briefly; Ctrl/Cmd-click jump target | | `completion(state, ext, path, bytes, byte_offset) ?[]const CompletionItem` | Non-blocking; ghost-text + dropdown candidates at the cursor | @@ -596,8 +651,8 @@ Every hook is optional and independent — implement only what your plugin offer | `supportsFormat(state, ext) bool` | Non-blocking gate for a "Format Document" menu item | | `format(state, ext, path, bytes) ?[]const u8` | May block briefly; whole-document reformat on explicit user action | -The text editor calls `host.treeSitterHighlightFor(ext)` and `host.previewProviderFor(ext)` — -first registered provider that answers for the extension wins. When a preview provider exists, +The text editor calls `host.treeSitterHighlightFor(ext)`, `host.previewProviderFor(ext)`, and +`host.documentOpenedFor(ext, path, bytes)` (on open/reload). When a preview provider exists, `TextEditor` splits the tab into raw editor + preview panes. The `hover`/`gotoDefinition`/ `completion`/`signatureHelp`/`format` hooks follow the same first-registered-provider-wins lookup, keyed off `ext`. @@ -661,14 +716,16 @@ Then: `sdk.allocator()`/`sdk.host()` are valid; `Config` can't be a comptime default on the file-scope `var client: Client = .{};` every plugin declares for exactly that reason. 2. Wire `client.onFolderOpen` / `client.onFolderClose` / `client.deinit` into your `Plugin` - vtable's `onFolderOpen` / `onFolderClose` / `deinit` — the client spawns/restarts the - server against the project root on folder open and tears it down on close/unload. + vtable's `onFolderOpen` / `onFolderClose` / `deinit` — the client updates/restarts against + the project root on folder open (spawn itself is lazy / warm-up-driven) and tears down on + close/unload. 3. Register a `LanguageSupport` whose hooks are thin wrappers: gate on your extension list, - then delegate straight to the matching `client.*` method (`client.hover`, `.gotoDefinition`, - `.completion`, `.resolveCompletionDocumentation`, `.signatureHelp`, `.format`) — see - [`fizzyedit/zig`](https://github.com/fizzyedit/zig)'s `src/Lsp.zig` for the ~80-line - reference wrapper this pattern produces end to end, and its `plugin.zig` for wiring it into - `register(host)`. + then delegate straight to the matching `client.*` method. Implement `documentOpened` as + `client.warmUp(path, bytes)` so the server starts when a matching file is opened (not on + first hover). Other hooks map to `client.hover` / `.gotoDefinition` / `.completion` / + `.resolveCompletionDocumentation` / `.signatureHelp` / `.format` — see + [`fizzyedit/zig`](https://github.com/fizzyedit/zig)'s `src/Lsp.zig` for the reference + wrapper, and its `plugin.zig` for wiring it into `register(host)`. You do not need to handle JSON-RPC framing, threading, request/response id correlation, position-encoding negotiation, or server-initiated requests yourself — all of that is generic @@ -928,7 +985,7 @@ drop straight into the plugins directory, exactly like §2.6. | `src/sdk/DocHandle.zig` | Opaque document handle (`owner`-routed) | | `src/sdk/EditorAPI.zig` | Shell read/utility surface plugins reach back through | | `src/sdk/regions.zig` | Sidebar/bottom/center/menu/settings/command contribution structs | -| `src/sdk/language.zig` | `LanguageSupport` registry — hover/goto-definition/completion/signature-help/format/highlighting/preview hooks looked up by file extension | +| `src/sdk/language.zig` | `LanguageSupport` registry — documentOpened/hover/goto-definition/completion/signature-help/format/highlighting/preview hooks looked up by file extension | | `src/core/lsp/Client.zig` | Server-agnostic LSP client (JSON-RPC framing, caching, threading) shared by every language plugin — see §3.9 | | `src/sdk/dylib.zig`, `dvui_context.zig` | Runtime-library C entry contract + dvui injection | | `src/sdk/version.zig` | SDK version + ABI fingerprint CI lock | diff --git a/docs/PLUGIN_MANIFEST_PLAN.md b/docs/PLUGIN_MANIFEST_PLAN.md index 698d6ec9..10153836 100644 --- a/docs/PLUGIN_MANIFEST_PLAN.md +++ b/docs/PLUGIN_MANIFEST_PLAN.md @@ -36,6 +36,7 @@ | R13 — Shell fields persist non-default-only too | done | 2026-07-26 — R12's non-default-only rule applied to the *other* half of `settings.zon`: `Settings.serialize` now diffs every shell field against `const default_value: Settings = .{}` and emits only what differs (an untouched shell serializes to `.{}`, which `composeMergedText` already splices `.plugins` onto — covered by a new unit test). A field hand-written back at its default drops out on the next write, matching plugin blocks. One ownership consequence: with fields now routinely absent from disk, `std.zon.parse` fills them from the struct's declared defaults — and `theme`'s default is a comptime string literal, so the old `std.zon.parse.free(gpa, parsed)` calls would have handed a non-allocated pointer to the allocator. New `Settings.freeParsed` zeroes that field before the whole-struct free (`Allocator.free` early-returns on an empty slice) and is used at all three parse sites; `Settings`' process-lifetime `loaded` snapshot is gone with it, since `load` can now free its parse immediately after duping `theme`. No SDK/fingerprint change (shell-only). Verified: `zig build`/`test`/`check-web`, plus a live macOS run in an isolated `HOME`/`TMPDIR` sandbox — an all-defaults-spelled-out file migrated down to just `.content_opacity`, a hand-edited `.font_body_size` at its default was dropped on the reconcile-triggered rewrite while a non-default `.theme` survived, and a clean quit showed no invalid free. | | R14 — String settings ownership rule | done | 2026-07-26 — the plugin-side half of the same hazard R13 fixed for the shell, found while doing R13 and fixed before a plugin could hit it. `Schema(T).applyZon` used to `std.zon.parse.free` the whole previous value, so **any plugin with a `[]const u8` setting whose default is non-empty would have invalid-freed a string literal on its very first `load()`** — and again on every reconcile, since R12 omits default fields from disk and `std.zon.parse` fills those from the same literals. Now one explicit rule, stated once and used everywhere: *a string field's bytes are schema-owned (host allocator) unless the slice is exactly `T`'s declared default*, tested by pointer identity (`freeOwned`/`fieldIsOwned`). Persistence comparisons stay content-based, so an allocated copy equal to the default still prunes out of settings.zon. Consequences: new `Schema(T).deinit(value)` for plugins to call on teardown (no-op for scalar-only structs, documented in `docs/PLUGINS.md` §3.1.1); `Access.setString` implemented at last (dupe + release the old value under the same rule), which retires the "read-only until setString owns allocation policy" TODO in `PluginSettingsPane` — string settings now draw an editable text entry (commit on Enter; re-seeds from the live value while unfocused, so an abandoned edit reverts and an externally reconciled change shows up without a pane-side notification). No SDK boundary change — `Access` already had the member, fingerprint/`test-sdk-version` unchanged. Verified: `zig build`/`test-all`/`check-web`/`test-sdk-version`; new `Schema` unit tests under `test-integration` (`testing.allocator` catches both the invalid free and a leak) — note these live in the *integration* layer, `zig build test` alone does not run them; plus a live macOS sandbox run with a temporary string setting added to `text`: loaded from disk at register, hand-edited twice (allocated → allocated → back to the default), no crash, block correctly pruned when the value returned to its default. | | R13 follow-up — shutdown-write use-after-free | done | 2026-07-26 — R13's first real run panicked (`incorrect alignment` in `std.hash_map`) at quit. Pre-existing latent bug in `Editor.deinit`, surfaced by R13: `deinit` stops the watchers first (correct), but `stop()` *is* the watcher's teardown — `std.HashMapUnmanaged.deinit` leaves the maps `undefined` — and `deinit` then goes on to call `saveSettingsRaw` → `writeMergedSettings` → `notifyPathChanged` → `by_path.get` on that dead map. It only bites when the shutdown write actually happens (`writeMergedSettings` returns early on an unchanged hash), which is why it survived until R13 made "the composed text differs from what's on disk" the common case. Fix: `Editor.deinit` clears `document_watcher`/`settings_watcher` after stopping them (clearing is part of stopping, not tidiness — it also prevents `SettingsWatcher.stop`'s `config_folder` double-free), plus `DocumentWatcher.stop` now resets its maps to `.empty` instead of leaving them undefined, making a late query an empty-map miss rather than garbage. A/B verified in the macOS sandbox: same seeded config + edit-then-quit-inside-the-debounce sequence panics with the fix stashed and quits cleanly with it applied, writing the normalized file either way. | +| R15 — Self-describing setting cells + VSCode-shaped rows | done | 2026-07-28 — a settings struct field is no longer a bare `bool`/`u8`/enum but a `sdk.settings.Value(T, .{ .description = … }) = .init(default)` **cell**: payload type, default, and a **required** description, all comptime (metadata lives in the type, so `@sizeOf(Value(T, …)) == @sizeOf(T)` and only the payload is ever stored). Omitting the description is a compile error at the declaration site — the point being that the pane now has a permanent place for it, and no shell-side table of strings can drift from the plugin that owns the setting. `Setting` gained `description` and a real `label` (derived from the field name in sentence case, `insert_spaces_on_tab` → `Insert spaces on tab`, overridable via `Options.name`); `key` stays the field/zon name. **On-disk format is unchanged**: everything persistence touches goes through a new comptime `Plain(T)` mirror (built with 0.16's `@Struct`) that has the same field names but bare payload types and the cells' defaults, so `applyZon`/`diffSerialize`/`fullSerialize` still read and write `.{ .tab_size = 8 }` exactly as before — no migration, and R11 reconciliation / R12 diff-only persistence needed no changes. **Any type is now a legal setting**: `kindFor` no longer `@compileError`s on an unsupported type, it returns the new `TypeTag.other`, and `Access` gained `getZonText`/`setZonText` so the pane can draw such a value as editable zon text (a parse failure simply doesn't commit — the entry re-seeds from the live value). **UI** (`src/editor/SettingRow.zig`, new): one row drawer shared by shell and plugin settings so they cannot drift — bold name, dim mono key underneath, wrapped description, then the full-width control (VSCode's order), with booleans the one exception (plain `dvui.checkbox` *before* the description on one line, since a full-width control for two states is all frame and no information). The short-lived full-width toggle button in `core.dvui` was deleted — this replaced it. Search (`SettingsTree.scoreLeaf`) now scores the zon key alongside the label/path/keywords, and both name and key are highlight-rendered, so a row that survived on a key match can show why. Fizzy's own settings (`explorer/settings.zig`) grew required `key`/`description` fields (plus `inline_control`) and all 11 items were filled in, keeping shell and plugin rows identical. sdk **0.1.44** (fingerprint `0xf5802c523f9bbc82` — `Setting` gained a field, `Kind`/`TypeTag` gained `other`, `Access` gained two members). Verified: `zig build`, `zig build test`, `zig build test-sdk-version`, `zig build test-integration` (23 SDK tests, incl. new cases for label derivation, `Options` bound refinement, the `.other` zon round-trip, and a diff-serialize assertion that the on-disk shape has no `.v` in it — confirmed actually executing via `-Dtest-filter`), and the text plugin's standalone `cd src/plugins/text && zig build`. | | Old Phase 2 (sidecar enforcement) | **cancelled** | superseded by this revision | --- diff --git a/sdk/sdk_version.zig b/sdk/sdk_version.zig index 240a1424..f30f425e 100644 --- a/sdk/sdk_version.zig +++ b/sdk/sdk_version.zig @@ -5,5 +5,5 @@ const std = @import("std"); pub const sdk_version = std.SemanticVersion{ .major = 0, .minor = 1, - .patch = 43, + .patch = 44, }; diff --git a/src/backend/backend_native.zig b/src/backend/backend_native.zig index a6fcc7d1..d8e1e167 100644 --- a/src/backend/backend_native.zig +++ b/src/backend/backend_native.zig @@ -9,6 +9,8 @@ const win32 = @import("win32"); const singleton = @import("singleton.zig"); const window_layout = @import("window_layout.zig"); const Constants = @import("../editor/Constants.zig"); +const KeybindSettings = @import("../editor/KeybindSettings.zig"); +const menu_model = @import("../editor/menu_model.zig"); // AppKit geometry types for NSView frame/bounds (same layout as Foundation). const NSPoint = extern struct { x: f64, y: f64 }; @@ -432,23 +434,59 @@ const NSEventModifierFlagControl: c_ulong = 1 << 19; pub const DialogFileFilter = sdl3.SDL_DialogFileFilter; // macOS native menu bar (top bar): action ids match FizzyMenuTarget.m -pub const NativeMenuAction = enum(c_int) { - open_folder = 0, - open_files = 1, - save = 2, - copy = 3, - paste = 4, - undo = 5, - redo = 6, - toggle_explorer = 8, - show_dvui_demo = 9, - save_as = 10, - new_file = 11, - about = 13, - check_for_updates = 14, - report_bug = 15, - save_all = 16, -}; + +/// Every fixed menu-bar item, by the action it performs, kept so a rebind can push the new +/// chord onto the item. Without this the `NSMenu` key equivalent stays whatever it was built +/// with: `Keybinds.tick` deliberately skips these commands on macOS (the native menu already +/// ran them), so after rebinding, the new chord had nothing dispatching it and the old one kept +/// working. See `setNativeMenuShortcut`. +var native_menu_items: [menu_model.flat_commands.len]?objc.Object = @splat(null); + + + +/// Point a menu item at a different chord. `key` is the key-equivalent character (lowercase, +/// as AppKit expects — the shift modifier is carried in the mask, not the case); passing null +/// clears the shortcut, which is the right outcome for a chord AppKit can't express. +pub fn setNativeMenuShortcut(tag: usize, key: ?[]const u8, modifier_mask: c_ulong) void { + if (comptime builtin.os.tag != .macos) return; + if (tag >= native_menu_items.len) return; + applyKeyEquivalent(native_menu_items[tag] orelse return, key, modifier_mask); +} + +/// `setNativeMenuShortcut` for a plugin-contributed item, keyed by its index in +/// `Host.native_menu_items` — the same index `rebuildDynamicNativeMenus` stamps as the item's +/// tag. Silently does nothing when that item isn't currently in the bar (hidden, or its plugin +/// unloaded), which is the same shape as a stale tag above. +pub fn setDynamicNativeMenuShortcut(index: usize, key: ?[]const u8, modifier_mask: c_ulong) void { + if (comptime builtin.os.tag != .macos) return; + for (dynamic_leaf_items.items) |entry| { + if (entry.index != index) continue; + applyKeyEquivalent(entry.item, key, modifier_mask); + return; + } +} + +fn applyKeyEquivalent(item: objc.Object, key: ?[]const u8, modifier_mask: c_ulong) void { + const NSString = objc.getClass("NSString") orelse return; + + var buf: [8]u8 = undefined; + const text: [:0]const u8 = blk: { + const k = key orelse break :blk ""; + if (k.len >= buf.len) break :blk ""; + @memcpy(buf[0..k.len], k); + buf[k.len] = 0; + break :blk buf[0..k.len :0]; + }; + + const str = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{text.ptr}); + item.msgSend(void, "setKeyEquivalent:", .{str.value}); + item.msgSend(void, "setKeyEquivalentModifierMask:", .{if (key == null) @as(c_ulong, 0) else modifier_mask}); +} + +pub const modifier_command: c_ulong = NSEventModifierFlagCommand; +pub const modifier_shift: c_ulong = NSEventModifierFlagShift; +pub const modifier_option: c_ulong = NSEventModifierFlagOption; +pub const modifier_control: c_ulong = NSEventModifierFlagControl; // Queue a single pending native action id. // This may be written from an AppKit callback thread, so use an atomic. @@ -482,55 +520,77 @@ export fn FizzyNativeMenuGenericAction(tag: c_int) void { /// `Host`/`Editor` state — none of it touches `dvui.currentWindow()` — so it's safe to call /// from outside `Window.begin`/`end`, unlike e.g. the save/open dialog callbacks (see /// `pollPendingDialogResult`). -export fn FizzyNativeMenuActionEnabled(id: c_int) callconv(.c) bool { - if (id < 0 or id > @intFromEnum(NativeMenuAction.save_all)) return true; - const action: NativeMenuAction = @enumFromInt(id); - switch (action) { - .save => { - const doc = fizzy.editor.activeDoc() orelse return false; - return doc.owner.isDirty(doc) or !doc.owner.documentHasRecognizedSaveExtension(doc); - }, - .save_as => return fizzy.editor.activeDoc() != null, - .save_all => { - for (fizzy.editor.open_files.values()) |doc| { - if (doc.owner.isDirty(doc) and doc.owner.documentHasRecognizedSaveExtension(doc)) return true; - } - return false; - }, - // Always enabled: Copy/Paste no longer only target the active document (see - // `Editor.forwardKeybindToFocusedWidget`) — they also reach whichever widget currently - // holds dvui keyboard focus (Output Panel, a plugin search box, ...), which this - // validator can't see (it runs outside `Window.begin`/`end`, per the doc comment above). - // Disabling the item here on the active-document-only condition would prevent the key - // equivalent from ever reaching those other widgets. The underlying handlers already - // no-op safely when there's nothing to copy/paste. - .copy, .paste => return true, - .undo => { - const doc = fizzy.editor.activeDoc() orelse return false; - return doc.owner.canUndo(doc); - }, - .redo => { - const doc = fizzy.editor.activeDoc() orelse return false; - return doc.owner.canRedo(doc); - }, - else => return true, +/// True while the app must not act on key presses at all. AppKit matches an `NSMenu` key +/// equivalent and fires its action before the key ever reaches SDL, so the only way to stop +/// `cmd+o` from opening a folder picker while the settings pane is capturing a chord is to +/// report the menu items disabled — AppKit will not perform a disabled item's key equivalent. +export fn FizzyNativeMenuInputBlocked() callconv(.c) bool { + return KeybindSettings.isRecording(); +} + +export fn FizzyNativeMenuActionEnabled(tag: c_int) callconv(.c) bool { + if (KeybindSettings.isRecording()) return false; + if (tag < 0) return true; + const item = menu_model.byTag(@intCast(tag)) orelse return true; + // Copy/Paste stay enabled here even when the active document can't do them: a disabled + // NSMenuItem does not perform its key equivalent, and on macOS that is the only way the + // chord reaches the app at all, including the focused widgets that handle it themselves. + if (item.native_always_enabled) return true; + // `visible` items that aren't visible are shown greyed rather than removed — rebuilding the + // retained NSMenu on every state change isn't worth it for the same information. + if (item.visible) |f| { + if (!f(fizzy.editor)) return false; } + const enabled = item.enabled orelse return true; + return enabled(fizzy.editor); } -// Only referenced on macOS (from setupMacOSMenuBar). -const fizzy_get_selector = if (builtin.os.tag == .macos) struct { - extern fn FizzyGetSelector(name: [*c]const u8) ?*anyopaque; - fn get(name: [*c]const u8) ?*anyopaque { - return FizzyGetSelector(name); - } -}.get else struct { - fn get(_: [*c]const u8) ?*anyopaque { - return null; - } -}.get; +/// Current label for a model item, so state-dependent titles ("Show Explorer" / "Hide +/// Explorer") track the app. AppKit menus are retained state; validation runs just before a +/// menu displays, which is when this is called. The macOS View menu used to say "Show +/// Explorer" permanently, because its title was baked in at construction. +export fn FizzyNativeMenuItemTitle(tag: c_int) callconv(.c) ?[*:0]const u8 { + if (tag < 0) return null; + const item = menu_model.byTag(@intCast(tag)) orelse return null; + return switch (item.title) { + .static => null, // already correct; nothing to rewrite + .dynamic => |f| f(fizzy.editor).ptr, + }; +} + +/// The app menu's "About fizzy", which AppKit creates rather than the model. +export fn FizzyNativeMenuAboutAction() callconv(.c) void { + pending_native_menu_about.store(true, .release); +} +var pending_native_menu_about: std.atomic.Value(bool) = .init(false); + +/// A Recent Folders click. The index is into `editor.recents.folders`, newest last. +export fn FizzyNativeRecentFolderAction(index: c_int) callconv(.c) void { + if (index < 0) return; + pending_native_recent_folder.store(index, .release); +} +var pending_native_recent_folder: std.atomic.Value(c_int) = .init(-1); + +/// Returns and clears a pending Recent Folders selection. +pub fn pollPendingRecentFolder() ?usize { + const i = pending_native_recent_folder.swap(-1, .acq_rel); + if (i < 0) return null; + return @intCast(i); +} + +/// `FizzyGetSelector` from `FizzyMenuTarget.m` — turns a selector name into a SEL without +/// linking the Objective-C runtime here directly. +extern fn FizzyGetSelector(name: [*:0]const u8) ?*anyopaque; + +fn fizzy_get_selector(name: [*:0]const u8) ?*anyopaque { + return FizzyGetSelector(name); +} + +/// Returns and clears a pending app-menu About click. +pub fn pollPendingAbout() bool { + return pending_native_menu_about.swap(false, .acq_rel); +} -// macOS trackpad pinch-to-zoom. NSEventTypeMagnify bypasses SDL3 entirely (SDL2's gesture -// API was removed and never replaced), so an Obj-C local event monitor (see // `objc/FizzyTrackpadGesture.m`) calls back here for each magnification delta. We accumulate // a single multiplicative ratio that the canvas widget drains and applies per frame. // @@ -1245,9 +1305,16 @@ var native_file_menu: ?objc.Object = null; var native_edit_menu: ?objc.Object = null; var native_view_menu: ?objc.Object = null; var native_help_menu: ?objc.Object = null; +/// Top-level NSMenus, indexed like `menu_model.menu_bar`. +var native_submenus: [menu_model.menu_bar.len]?objc.Object = @splat(null); +/// The Recent Folders submenu and the item carrying it, rebuilt as the recents list changes. +var native_recent_folders_menu: ?objc.Object = null; +var native_recent_folders_item: ?objc.Object = null; const DynamicTopLevelMenu = struct { item: objc.Object, menu: objc.Object }; -const DynamicLeafItem = struct { parent_menu: objc.Object, item: objc.Object }; +/// `index` is the item's position in `Host.native_menu_items` — its `NSMenuItem` tag, and the +/// handle `setDynamicNativeMenuShortcut` restamps a rebound chord through. +const DynamicLeafItem = struct { parent_menu: objc.Object, item: objc.Object, index: usize }; /// Plugin-created top-level menus (main-menu items) from the previous rebuild, torn down /// at the start of the next one. @@ -1257,17 +1324,13 @@ var dynamic_top_level_menus: std.ArrayListUnmanaged(DynamicTopLevelMenu) = .empt var dynamic_leaf_items: std.ArrayListUnmanaged(DynamicLeafItem) = .empty; fn isBuiltinNativeMenuId(id: []const u8) bool { - return std.mem.eql(u8, id, "workbench.menu.file") or - std.mem.eql(u8, id, "shell.menu.edit") or - std.mem.eql(u8, id, "shell.menu.view") or - std.mem.eql(u8, id, "shell.menu.help"); + return menu_model.submenuFor(id) != null; } fn resolveBuiltinNativeMenu(id: []const u8) ?objc.Object { - if (std.mem.eql(u8, id, "workbench.menu.file")) return native_file_menu; - if (std.mem.eql(u8, id, "shell.menu.edit")) return native_edit_menu; - if (std.mem.eql(u8, id, "shell.menu.view")) return native_view_menu; - if (std.mem.eql(u8, id, "shell.menu.help")) return native_help_menu; + for (menu_model.menu_bar, 0..) |sub, i| { + if (menu_model.menuMatches(sub, id)) return native_submenus[i]; + } return null; } @@ -1297,6 +1360,7 @@ pub fn rebuildDynamicNativeMenus() void { const NSMenu = objc.getClass("NSMenu") orelse return; const NSMenuItem = objc.getClass("NSMenuItem") orelse return; const NSString = objc.getClass("NSString") orelse return; + const NSImage = objc.getClass("NSImage") orelse return; const empty = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"".ptr}); const generic_sel = fizzy_get_selector("genericMenuAction:") orelse return; @@ -1369,9 +1433,26 @@ pub fn rebuildDynamicNativeMenus() void { // Tag with the item's index in `host.native_menu_items`, resolved back on click // in `Editor.zig`'s `flushQueuedNativeMenuItems`. item.msgSend(void, "setTag:", .{@as(c_long, @intCast(idx))}); + if (ni.sf_symbol) |sym| { + if (fizzy.app.allocator.dupeZ(u8, sym)) |sym_z| { + defer fizzy.app.allocator.free(sym_z); + setMenuItemImage(item, NSImage, NSString, sym_z.ptr, title_z.ptr); + } else |_| {} + } - dynamic_leaf_items.append(fizzy.app.allocator, .{ .parent_menu = parent_menu, .item = item }) catch {}; + dynamic_leaf_items.append(fizzy.app.allocator, .{ + .parent_menu = parent_menu, + .item = item, + .index = idx, + }) catch {}; } + + // The items above are built with no key equivalent; their chords come from the keymap, and + // this is what puts them there. Both callers of this function (startup, and every plugin + // load/unload/hide-toggle) reach it *after* the keymap is rebuilt, so nothing else would — + // the fixed bar hits the same ordering hazard, which is why `setupMacOSMenuBar` ends with + // the same call. + fizzy.Editor.Keybinds.syncNativeMenuShortcuts(fizzy.editor); } /// Inserts a "File" menu into the macOS app menu bar (between Apple and Window). Safe to call multiple times; runs once. @@ -1393,173 +1474,84 @@ pub fn setupMacOSMenuBar() void { if (target.value == 0) return; native_menu_target = target; - const file_menu_title_str = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"File".ptr}); - const file_menu = NSMenu.msgSend(objc.Object, "alloc", .{}).msgSend(objc.Object, "initWithTitle:", .{file_menu_title_str.value}); - if (file_menu.value == 0) return; - native_file_menu = file_menu; - const empty = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"".ptr}); - const key_f = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"f".ptr}); - const key_n = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"n".ptr}); - const key_o = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"o".ptr}); - const key_s = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"s".ptr}); - const NSImage = objc.getClass("NSImage") orelse return; + const action_sel = fizzy_get_selector("menuAction:") orelse return; + + // Build every top-level menu from `menu_model`, the same tree `Menu.zig` draws. Each item's + // tag is its depth-first index among command items, which is all the C boundary needs: one + // integer that resolves back to a command id. The fourteen hand-written Objective-C + // forwarding methods and the `NativeMenuAction` enum they switched on existed only to carry + // that integer, and are gone. + var tag: c_long = 0; + inline for (&menu_model.menu_bar, 0..) |*sub, sub_index| { + const sub_title = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{sub.title.ptr}); + const menu = NSMenu.msgSend(objc.Object, "alloc", .{}).msgSend(objc.Object, "initWithTitle:", .{sub_title.value}); + if (menu.value != 0) { + native_submenus[sub_index] = menu; + + inline for (sub.items) |item| { + switch (item) { + .separator => menu.msgSend(void, "addItem:", .{NSMenuItem.msgSend(objc.Object, "separatorItem", .{}).value}), + + .command => |c| { + const item_title = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{c.title.resolveStatic().ptr}); + const mi = menu.msgSend(objc.Object, "addItemWithTitle:action:keyEquivalent:", .{ + item_title.value, + @intFromPtr(action_sel), + empty.value, + }); + if (mi.value != 0) { + mi.msgSend(void, "setTarget:", .{target.value}); + mi.msgSend(void, "setTag:", .{tag}); + if (c.sf_symbol) |sym| setMenuItemImage(mi, NSImage, NSString, sym, c.title.resolveStatic()); + native_menu_items[@intCast(tag)] = mi; + } + tag += 1; + }, + + // Populated later: recents aren't loaded when the bar is built, and plugin + // sections arrive as plugins register. Both get a placeholder submenu here + // so their position in the menu is fixed by the model rather than by + // whatever order the rebuilds happen to run in. + .recent_folders => { + const rf_title = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"Recent Folders".ptr}); + const rf_item = menu.msgSend(objc.Object, "addItemWithTitle:action:keyEquivalent:", .{ + rf_title.value, + @as(usize, 0), + empty.value, + }); + if (rf_item.value != 0) { + const rf_menu = NSMenu.msgSend(objc.Object, "alloc", .{}).msgSend(objc.Object, "initWithTitle:", .{rf_title.value}); + if (rf_menu.value != 0) { + rf_item.msgSend(void, "setSubmenu:", .{rf_menu.value}); + native_recent_folders_menu = rf_menu; + native_recent_folders_item = rf_item; + } + } + }, - // New — ⌘N - { - const new_title = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"New".ptr}); - const new_sel = fizzy_get_selector("newFile:") orelse return; - const new_item = file_menu.msgSend(objc.Object, "addItemWithTitle:action:keyEquivalent:", .{ - new_title.value, - new_sel, - key_n.value, - }); - if (new_item.value != 0) { - new_item.msgSend(void, "setTarget:", .{target.value}); - new_item.msgSend(void, "setKeyEquivalentModifierMask:", .{NSEventModifierFlagCommand}); - setMenuItemImage(new_item, NSImage, NSString, "doc.badge.plus", "New"); - } - } - // Open Folder — ⌘F, folder icon - { - const open_folder_title = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"Open Folder".ptr}); - const open_folder_sel = fizzy_get_selector("openFolder:") orelse return; - const open_folder_item = file_menu.msgSend(objc.Object, "addItemWithTitle:action:keyEquivalent:", .{ - open_folder_title.value, - open_folder_sel, - key_f.value, - }); - if (open_folder_item.value != 0) { - open_folder_item.msgSend(void, "setTarget:", .{target.value}); - open_folder_item.msgSend(void, "setKeyEquivalentModifierMask:", .{NSEventModifierFlagCommand}); - setMenuItemImage(open_folder_item, NSImage, NSString, "folder", "Open Folder"); - } - } - // Open Files — ⌘O, doc icon - { - const open_files_title = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"Open Files".ptr}); - const open_files_sel = fizzy_get_selector("openFiles:") orelse return; - const open_files_item = file_menu.msgSend(objc.Object, "addItemWithTitle:action:keyEquivalent:", .{ - open_files_title.value, - open_files_sel, - key_o.value, - }); - if (open_files_item.value != 0) { - open_files_item.msgSend(void, "setTarget:", .{target.value}); - open_files_item.msgSend(void, "setKeyEquivalentModifierMask:", .{NSEventModifierFlagCommand}); - setMenuItemImage(open_files_item, NSImage, NSString, "doc.on.doc", "Open Files"); - } - } - - const separator = NSMenuItem.msgSend(objc.Object, "separatorItem", .{}); - file_menu.msgSend(void, "addItem:", .{separator.value}); - - // Save — ⌘S - const save_title = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"Save".ptr}); - const save_sel = fizzy_get_selector("save:") orelse return; - const save_item = file_menu.msgSend(objc.Object, "addItemWithTitle:action:keyEquivalent:", .{ - save_title.value, - save_sel, - key_s.value, - }); - if (save_item.value != 0) { - save_item.msgSend(void, "setTarget:", .{target.value}); - save_item.msgSend(void, "setKeyEquivalentModifierMask:", .{NSEventModifierFlagCommand}); - save_item.msgSend(void, "setTag:", .{@as(c_long, @intFromEnum(NativeMenuAction.save))}); - } - - // Save As — ⇧⌘S - { - const save_as_title = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"Save As…".ptr}); - const save_as_sel = fizzy_get_selector("saveAs:") orelse return; - const save_as_item = file_menu.msgSend(objc.Object, "addItemWithTitle:action:keyEquivalent:", .{ - save_as_title.value, - save_as_sel, - key_s.value, - }); - if (save_as_item.value != 0) { - save_as_item.msgSend(void, "setTarget:", .{target.value}); - save_as_item.msgSend(void, "setKeyEquivalentModifierMask:", .{NSEventModifierFlagCommand | NSEventModifierFlagShift}); - save_as_item.msgSend(void, "setTag:", .{@as(c_long, @intFromEnum(NativeMenuAction.save_as))}); - setMenuItemImage(save_as_item, NSImage, NSString, "arrow.down.doc", "Save As"); - } - } - - // Save All — ⌥⌘S (Option-Command-S, matches the common convention used by Xcode etc.) - { - const save_all_title = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"Save All".ptr}); - const save_all_sel = fizzy_get_selector("saveAll:") orelse return; - const save_all_item = file_menu.msgSend(objc.Object, "addItemWithTitle:action:keyEquivalent:", .{ - save_all_title.value, - save_all_sel, - key_s.value, - }); - if (save_all_item.value != 0) { - save_all_item.msgSend(void, "setTarget:", .{target.value}); - save_all_item.msgSend(void, "setKeyEquivalentModifierMask:", .{NSEventModifierFlagCommand | NSEventModifierFlagOption}); - save_all_item.msgSend(void, "setTag:", .{@as(c_long, @intFromEnum(NativeMenuAction.save_all))}); - setMenuItemImage(save_all_item, NSImage, NSString, "square.and.arrow.down.on.square", "Save All"); - } - } - - const file_title = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"File".ptr}); - const file_item = NSMenuItem.msgSend(objc.Object, "alloc", .{}).msgSend(objc.Object, "initWithTitle:action:keyEquivalent:", .{ - file_title.value, - @as(usize, 0), - empty.value, - }); - if (file_item.value == 0) return; - file_item.msgSend(void, "setSubmenu:", .{file_menu.value}); - main_menu.msgSend(void, "insertItem:atIndex:", .{ file_item.value, @as(c_ulong, 1) }); - - // Edit menu — Copy, Paste, Undo, Redo (match DVUI menu). Plugin-specific verbs (e.g. pixi's - // Transform / Grid Layout) inject themselves via `NativeMenuItem`s parented to this menu's - // id (see `rebuildDynamicNativeMenus`) instead of being hardcoded here. - const key_c = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"c".ptr}); - const key_v = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"v".ptr}); - const key_z = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"z".ptr}); - const key_e = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"e".ptr}); - const key_m = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"m".ptr}); - - const edit_menu_title_str = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"Edit".ptr}); - const edit_menu = NSMenu.msgSend(objc.Object, "alloc", .{}).msgSend(objc.Object, "initWithTitle:", .{edit_menu_title_str.value}); - if (edit_menu.value != 0) { - native_edit_menu = edit_menu; - addNativeMenuItem(edit_menu, NSMenuItem, NSString, target, "Copy", "copy:", @intFromPtr(key_c.value), NSEventModifierFlagCommand, @intFromPtr(empty.value), .copy); - addNativeMenuItem(edit_menu, NSMenuItem, NSString, target, "Paste", "paste:", @intFromPtr(key_v.value), NSEventModifierFlagCommand, @intFromPtr(empty.value), .paste); - edit_menu.msgSend(void, "addItem:", .{NSMenuItem.msgSend(objc.Object, "separatorItem", .{}).value}); - addNativeMenuItem(edit_menu, NSMenuItem, NSString, target, "Undo", "undo:", @intFromPtr(key_z.value), NSEventModifierFlagCommand, @intFromPtr(empty.value), .undo); - addNativeMenuItem(edit_menu, NSMenuItem, NSString, target, "Redo", "redo:", @intFromPtr(key_z.value), NSEventModifierFlagCommand | NSEventModifierFlagShift, @intFromPtr(empty.value), .redo); - const edit_title = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"Edit".ptr}); - const edit_item = NSMenuItem.msgSend(objc.Object, "alloc", .{}).msgSend(objc.Object, "initWithTitle:action:keyEquivalent:", .{ - edit_title.value, - @as(usize, 0), - empty.value, - }); - if (edit_item.value != 0) { - edit_item.msgSend(void, "setSubmenu:", .{edit_menu.value}); - main_menu.msgSend(void, "insertItem:atIndex:", .{ edit_item.value, @as(c_ulong, 2) }); - } - } + .plugin_section, .submenu => {}, + } + } - // View menu — Show/Hide Explorer, Show DVUI Demo - const view_menu_title_str = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"View".ptr}); - const view_menu = NSMenu.msgSend(objc.Object, "alloc", .{}).msgSend(objc.Object, "initWithTitle:", .{view_menu_title_str.value}); - if (view_menu.value != 0) { - native_view_menu = view_menu; - addNativeMenuItem(view_menu, NSMenuItem, NSString, target, "Show Explorer", "toggleExplorer:", @intFromPtr(key_e.value), NSEventModifierFlagCommand, @intFromPtr(empty.value), .toggle_explorer); - view_menu.msgSend(void, "addItem:", .{NSMenuItem.msgSend(objc.Object, "separatorItem", .{}).value}); - addNativeMenuItem(view_menu, NSMenuItem, NSString, target, "Show DVUI Demo", "showDvuiDemo:", @intFromPtr(empty.value), 0, @intFromPtr(empty.value), .show_dvui_demo); - const view_title = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"View".ptr}); - const view_item = NSMenuItem.msgSend(objc.Object, "alloc", .{}).msgSend(objc.Object, "initWithTitle:action:keyEquivalent:", .{ - view_title.value, - @as(usize, 0), - empty.value, - }); - if (view_item.value != 0) { - view_item.msgSend(void, "setSubmenu:", .{view_menu.value}); - main_menu.msgSend(void, "insertItem:atIndex:", .{ view_item.value, @as(c_ulong, 3) }); + const bar_item = NSMenuItem.msgSend(objc.Object, "alloc", .{}).msgSend(objc.Object, "initWithTitle:action:keyEquivalent:", .{ + sub_title.value, + @as(usize, 0), + empty.value, + }); + if (bar_item.value != 0) { + bar_item.msgSend(void, "setSubmenu:", .{menu.value}); + if (comptime std.mem.eql(u8, sub.id, "fizzy.menu.help")) { + // Help goes last so the conventional order (App, File, Edit, View, …, + // Window, Help) survives, and AppKit wires in its search field. + main_menu.msgSend(void, "addItem:", .{bar_item.value}); + ns_app.msgSend(void, "setHelpMenu:", .{menu.value}); + native_help_item = bar_item; + } else { + main_menu.msgSend(void, "insertItem:atIndex:", .{ bar_item.value, @as(c_ulong, sub_index + 1) }); + } + } } } @@ -1602,51 +1594,63 @@ pub fn setupMacOSMenuBar() void { } } - // Help menu — Check for Updates… (matches the infobar fizzy button: opens the AboutFizzy dialog). - const help_menu_title_str = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"Help".ptr}); - const help_menu = NSMenu.msgSend(objc.Object, "alloc", .{}).msgSend(objc.Object, "initWithTitle:", .{help_menu_title_str.value}); - if (help_menu.value != 0) { - native_help_menu = help_menu; - addNativeMenuItem(help_menu, NSMenuItem, NSString, target, "Check for Updates…", "checkForUpdates:", @intFromPtr(empty.value), 0, @intFromPtr(empty.value), .check_for_updates); - help_menu.msgSend(void, "addItem:", .{NSMenuItem.msgSend(objc.Object, "separatorItem", .{}).value}); - - // Report Bug → opens the GitHub Issues page in the user's browser. - // Inlined (instead of using `addNativeMenuItem`) so we can attach an SF Symbol. - if (fizzy_get_selector("reportBug:")) |report_bug_sel| { - const bug_title = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"Report Bug…".ptr}); - const bug_item = help_menu.msgSend(objc.Object, "addItemWithTitle:action:keyEquivalent:", .{ - bug_title.value, - report_bug_sel, - empty.value, - }); - if (bug_item.value != 0) { - bug_item.msgSend(void, "setTarget:", .{target.value}); - setMenuItemImage(bug_item, NSImage, NSString, "ant.fill", "Report Bug"); - } - } + macos_menu_bar_set_up = true; - const help_item = NSMenuItem.msgSend(objc.Object, "alloc", .{}).msgSend(objc.Object, "initWithTitle:action:keyEquivalent:", .{ - help_menu_title_str.value, - @as(usize, 0), + // Add any plugin-contributed native menus/items already registered by this point + // (built-in static plugins register in `postInit`, which runs before this function). + rebuildDynamicNativeMenus(); + rebuildNativeRecentFolders(); + + // Items are built with no key equivalent; the chords come from the keymap. `buildKeymap` + // also stamps them, but the two run in either order depending on startup path — this ran + // first at boot, so every File/Edit shortcut was stamped onto items that did not exist yet + // and never restamped. The menus showed no chords, and because `nativeMenuOwnsChord` still + // told `dispatch` the native menu owned them, nothing handled those keys at all. + fizzy.Editor.Keybinds.syncNativeMenuShortcuts(fizzy.editor); +} + +/// Fill the Recent Folders submenu from the current recents list. +/// +/// AppKit menus are retained state, so unlike the dvui menu — which just re-reads the list every +/// frame — this has to be rebuilt whenever the list changes. Recent Folders had no macOS +/// representation at all before the model; it existed only in the dvui bar. +pub fn rebuildNativeRecentFolders() void { + if (comptime builtin.os.tag != .macos) return; + const menu = native_recent_folders_menu orelse return; + const target = native_menu_target orelse return; + const NSString = objc.getClass("NSString") orelse return; + const sel = fizzy_get_selector("recentFolderAction:") orelse return; + + menu.msgSend(void, "removeAllItems", .{}); + + const empty = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{"".ptr}); + const folders = fizzy.editor.recents.folders.items; + + // Newest first, matching the dvui menu's reverse walk. + var i: usize = folders.len; + while (i > 0) : (i -= 1) { + const folder = folders[i - 1]; + // `stringWithUTF8String:` needs a sentinel; recents are plain slices. + var buf: [1024]u8 = undefined; + if (folder.len >= buf.len) continue; + @memcpy(buf[0..folder.len], folder); + buf[folder.len] = 0; + + const title = NSString.msgSend(objc.Object, "stringWithUTF8String:", .{@as([*:0]const u8, @ptrCast(&buf))}); + const item = menu.msgSend(objc.Object, "addItemWithTitle:action:keyEquivalent:", .{ + title.value, + @intFromPtr(sel), empty.value, }); - if (help_item.value != 0) { - help_item.msgSend(void, "setSubmenu:", .{help_menu.value}); - // Append at the end so the conventional macOS order (App, File, Edit, View, …, Window, Help) is preserved. - main_menu.msgSend(void, "addItem:", .{help_item.value}); - // Tell AppKit this is the Help menu so the system search field is wired in. - ns_app.msgSend(void, "setHelpMenu:", .{help_menu.value}); - native_help_item = help_item; + if (item.value != 0) { + item.msgSend(void, "setTarget:", .{target.value}); + item.msgSend(void, "setTag:", .{@as(c_long, @intCast(i - 1))}); } } - // key_m was previously used by the now-removed nested Window submenu; keep the binding silent. - _ = key_m; - macos_menu_bar_set_up = true; - - // Add any plugin-contributed native menus/items already registered by this point - // (built-in static plugins register in `postInit`, which runs before this function). - rebuildDynamicNativeMenus(); + if (native_recent_folders_item) |it| { + it.msgSend(void, "setHidden:", .{folders.len == 0}); + } } /// Sets an SF Symbol image on a menu item (macOS 11+). No-op if the image cannot be created. @@ -1663,25 +1667,6 @@ fn setMenuItemImage(menu_item: objc.Object, NSImageClass: objc.Class, NSStringCl } } -fn addNativeMenuItem(menu: objc.Object, _: objc.Class, NSStringClass: objc.Class, target: objc.Object, title: [*:0]const u8, action_name: [*:0]const u8, key_equiv_value: usize, modifier_mask: c_ulong, empty_str: usize, action: NativeMenuAction) void { - const sel = fizzy_get_selector(action_name) orelse return; - const title_obj = NSStringClass.msgSend(objc.Object, "stringWithUTF8String:", .{title}); - const item = menu.msgSend(objc.Object, "addItemWithTitle:action:keyEquivalent:", .{ - title_obj.value, - @intFromPtr(sel), - if (key_equiv_value != 0) key_equiv_value else empty_str, - }); - if (item.value != 0) { - item.msgSend(void, "setTarget:", .{target.value}); - if (modifier_mask != 0) item.msgSend(void, "setKeyEquivalentModifierMask:", .{modifier_mask}); - // Tag mirrors the `NativeMenuAction` this item performs — `validateMenuItem:` (see - // `FizzyMenuTarget.m`) reads it back via `FizzyNativeMenuActionEnabled` to grey the - // item out when the action wouldn't currently do anything (no active document, empty - // selection, empty undo stack, …). - item.msgSend(void, "setTag:", .{@as(c_long, @intFromEnum(action))}); - } -} - fn addNativeMenuItemWithTarget(menu: objc.Object, _: objc.Class, NSStringClass: objc.Class, target: ?objc.Object, title: [*:0]const u8, action: *const anyopaque, key_equiv_value: usize, modifier_mask: c_ulong, empty_str: usize) void { const title_obj = NSStringClass.msgSend(objc.Object, "stringWithUTF8String:", .{title}); const item = menu.msgSend(objc.Object, "addItemWithTitle:action:keyEquivalent:", .{ @@ -1696,10 +1681,10 @@ fn addNativeMenuItemWithTarget(menu: objc.Object, _: objc.Class, NSStringClass: } /// Returns and clears a pending native menu action (macOS menu bar). Call once per frame; on non-macOS always returns null. -pub fn pollPendingNativeMenuAction() ?NativeMenuAction { +pub fn pollPendingNativeMenuAction() ?usize { const id = pending_native_menu_action_id.swap(-1, .acq_rel); - if (id < 0 or id > @intFromEnum(NativeMenuAction.save_all)) return null; - return @enumFromInt(id); + if (id < 0 or id >= menu_model.flat_commands.len) return null; + return @intCast(id); } /// Returns and clears a pending generic native menu item tag (plugin `NativeMenuItem`s). diff --git a/src/backend/backend_web.zig b/src/backend/backend_web.zig index 79f00cca..990a88c6 100644 --- a/src/backend/backend_web.zig +++ b/src/backend/backend_web.zig @@ -22,24 +22,6 @@ pub const DialogFileFilter = extern struct { /// Back-compat alias: a few internal callers still use the SDL-style name. pub const SDL_DialogFileFilter = DialogFileFilter; -pub const NativeMenuAction = enum(c_int) { - open_folder = 0, - open_files = 1, - save = 2, - copy = 3, - paste = 4, - undo = 5, - redo = 6, - toggle_explorer = 8, - show_dvui_demo = 9, - save_as = 10, - new_file = 11, - about = 13, - check_for_updates = 14, - report_bug = 15, - save_all = 16, -}; - pub const TitleBarButton = enum { minimize, maximize, close }; pub fn resetTitleBarHints() void {} @@ -126,7 +108,9 @@ pub fn takeTrackpadPinchRatio() f32 { return prev; } -pub fn pollPendingNativeMenuAction() ?NativeMenuAction { +/// Mirrors the native signature: a tag into `menu_model.flat_commands`. No native menu bar +/// on web, so nothing is ever pending. +pub fn pollPendingNativeMenuAction() ?usize { return null; } @@ -134,6 +118,26 @@ pub fn pollPendingGenericNativeMenuAction() ?usize { return null; } +/// No app menu on web, so there is never a pending About click. +pub fn pollPendingAbout() bool { + return false; +} + +/// No native Recent Folders submenu on web — the dvui menu handles the list directly. +pub fn pollPendingRecentFolder() ?usize { + return null; +} + +/// Chords live entirely in the keymap on web; there are no native menu items to stamp. +pub fn setNativeMenuShortcut(_: usize, _: ?[]const u8, _: c_ulong) void {} + +pub fn setDynamicNativeMenuShortcut(_: usize, _: ?[]const u8, _: c_ulong) void {} + +pub const modifier_command: c_ulong = 0; +pub const modifier_shift: c_ulong = 0; +pub const modifier_option: c_ulong = 0; +pub const modifier_control: c_ulong = 0; + /// Web's dialog callbacks run synchronously from `WebFileIo` within the frame already, so /// there's nothing to drain here. Kept symmetric with the native backend's deferred-dispatch /// queue (see `backend_native.pollPendingDialogResult`) so `Editor.tick` can call it unconditionally. @@ -149,6 +153,10 @@ pub fn pollPendingDialogResult() ?PendingDialogResult { /// `host.menus`/`host.menu_sections` directly, same as non-macOS native). pub fn rebuildDynamicNativeMenus() void {} +/// The dvui menu re-reads the recents list every frame, so there is no retained +/// native submenu to rebuild here (see `backend_native.rebuildNativeRecentFolders`). +pub fn rebuildNativeRecentFolders() void {} + pub fn showSimpleMessage(_: [:0]const u8, _: [:0]const u8) void {} pub fn showSaveFileDialog( diff --git a/src/backend/objc/FizzyMenuTarget.m b/src/backend/objc/FizzyMenuTarget.m index 89d87191..7061f253 100644 --- a/src/backend/objc/FizzyMenuTarget.m +++ b/src/backend/objc/FizzyMenuTarget.m @@ -1,64 +1,80 @@ #import #import -/* Called from Zig when a native menu item is chosen. Zig exports this and sets a pending action. */ -extern void FizzyNativeMenuAction(int id); +/* Called from Zig when a native menu item is chosen. The int is the item's tag: its depth-first + * index among `menu_model`'s command items. Zig resolves it back to a command id and runs it. + * This replaced fourteen one-line forwarding methods, one per `NativeMenuAction` variant — the + * enum and the methods existed only to carry this integer across the boundary. */ +extern void FizzyNativeMenuAction(int tag); /* Called from Zig for plugin-contributed native menu items (see `genericMenuAction:` below). * Zig looks the tag up against `host.native_menu_items`, resolved fresh at click time. */ extern void FizzyNativeMenuGenericAction(int tag); -/* Called from Zig's `validateMenuItem:` below with the clicked item's `tag` (a `NativeMenuAction` - * value, set on each fixed item by `addNativeMenuItem`/`setupMacOSMenuBar`) — returns whether - * that action currently does anything (an active document, a non-empty selection, …). */ -extern bool FizzyNativeMenuActionEnabled(int id); +/* Whether the model item with this tag is currently actionable (an active document, a non-empty + * selection, …). Backed by the same predicate the dvui menu greys its row with. */ +extern bool FizzyNativeMenuActionEnabled(int tag); + +/* Current title for the model item with this tag, or NULL to leave it alone. AppKit menus are + * retained state, so a label that depends on app state ("Show Explorer" / "Hide Explorer") has + * to be refreshed; validation runs just before a menu displays, which is exactly the moment. */ +extern const char *FizzyNativeMenuItemTitle(int tag); + +/* True while the app is capturing a key chord (the Keyboard Shortcuts settings pane). AppKit + * fires a menu item's key equivalent before the key reaches SDL, so blocking dvui events is not + * enough — every item has to report disabled, which stops the key equivalent from performing. */ +extern bool FizzyNativeMenuInputBlocked(void); + +/* Called from Zig when a Recent Folders item is chosen; the tag is an index into the recents. */ +extern void FizzyNativeRecentFolderAction(int index); + +/* The app menu's "About fizzy" item is created by AppKit, not from the model, so it has no tag + * to carry. It runs the same command as the Help menu's entry. */ +extern void FizzyNativeMenuAboutAction(void); @interface FizzyMenuTarget : NSObject -- (void)newFile:(id)sender; -- (void)openFolder:(id)sender; -- (void)openFiles:(id)sender; -- (void)save:(id)sender; -- (void)saveAs:(id)sender; -- (void)saveAll:(id)sender; -- (void)copy:(id)sender; -- (void)paste:(id)sender; -- (void)undo:(id)sender; -- (void)redo:(id)sender; -- (void)toggleExplorer:(id)sender; -- (void)showDvuiDemo:(id)sender; -- (void)about:(id)sender; -- (void)checkForUpdates:(id)sender; -- (void)reportBug:(id)sender; +- (void)menuAction:(id)sender; - (void)genericMenuAction:(id)sender; +- (void)recentFolderAction:(id)sender; +- (void)about:(id)sender; @end @implementation FizzyMenuTarget -- (void)newFile:(id)sender { (void)sender; FizzyNativeMenuAction(11); } -- (void)openFolder:(id)sender { (void)sender; FizzyNativeMenuAction(0); } -- (void)openFiles:(id)sender { (void)sender; FizzyNativeMenuAction(1); } -- (void)save:(id)sender { (void)sender; FizzyNativeMenuAction(2); } -- (void)saveAs:(id)sender { (void)sender; FizzyNativeMenuAction(10); } -- (void)saveAll:(id)sender { (void)sender; FizzyNativeMenuAction(16); } -- (void)copy:(id)sender { (void)sender; FizzyNativeMenuAction(3); } -- (void)paste:(id)sender { (void)sender; FizzyNativeMenuAction(4); } -- (void)undo:(id)sender { (void)sender; FizzyNativeMenuAction(5); } -- (void)redo:(id)sender { (void)sender; FizzyNativeMenuAction(6); } -- (void)toggleExplorer:(id)sender { (void)sender; FizzyNativeMenuAction(8); } -- (void)showDvuiDemo:(id)sender { (void)sender; FizzyNativeMenuAction(9); } -- (void)about:(id)sender { (void)sender; FizzyNativeMenuAction(13); } -- (void)checkForUpdates:(id)sender { (void)sender; FizzyNativeMenuAction(14); } -- (void)reportBug:(id)sender { (void)sender; FizzyNativeMenuAction(15); } +- (void)menuAction:(id)sender { + NSMenuItem *item = (NSMenuItem *)sender; + FizzyNativeMenuAction((int)[item tag]); +} - (void)genericMenuAction:(id)sender { NSMenuItem *item = (NSMenuItem *)sender; FizzyNativeMenuGenericAction((int)[item tag]); } +- (void)recentFolderAction:(id)sender { + NSMenuItem *item = (NSMenuItem *)sender; + FizzyNativeRecentFolderAction((int)[item tag]); +} +/* The app menu's "About fizzy" is created by AppKit, not by the model, so it keeps its own + * selector. It runs the same command as the Help menu's entry. */ +- (void)about:(id)sender { + (void)sender; + FizzyNativeMenuAboutAction(); +} - (BOOL)validateMenuItem:(NSMenuItem *)menuItem { - /* Plugin-contributed items (Transform, Grid Layout, …) share this same target but tag - * themselves with an index into `host.native_menu_items`, not a `NativeMenuAction` — greying - * those out isn't part of this validator. */ - if ([menuItem action] == @selector(genericMenuAction:)) { + /* Checked before anything else so plugin and recents items are blocked too. */ + if (FizzyNativeMenuInputBlocked()) { + return NO; + } + if ([menuItem action] == @selector(genericMenuAction:) || + [menuItem action] == @selector(recentFolderAction:) || + [menuItem action] == @selector(about:)) { return YES; } + if ([menuItem action] != @selector(menuAction:)) { + return YES; + } + const char *title = FizzyNativeMenuItemTitle((int)[menuItem tag]); + if (title != NULL && title[0] != '\0') { + [menuItem setTitle:[NSString stringWithUTF8String:title]]; + } return FizzyNativeMenuActionEnabled((int)[menuItem tag]) ? YES : NO; } @end diff --git a/src/backend/singleton_native.zig b/src/backend/singleton_native.zig index 7e7d6044..e7d93b58 100644 --- a/src/backend/singleton_native.zig +++ b/src/backend/singleton_native.zig @@ -285,10 +285,13 @@ pub fn freeResolvedArgv(gpa: std.mem.Allocator, argv: []const []const u8) void { gpa.free(argv); } +/// Normalizing (not just joining) matters for the common `fizzy .` invocation: a plain join +/// yields `/.`, which names the right directory but is a distinct string from `` +/// everywhere downstream — see `fizzy.paths.normalize`. fn resolveAbsolute(gpa: std.mem.Allocator, cwd: []const u8, path: []const u8) ![]u8 { - if (std.fs.path.isAbsolute(path)) return gpa.dupe(u8, path); + if (std.fs.path.isAbsolute(path)) return fizzy.paths.normalize(gpa, path); if (cwd.len == 0) return error.NoCwd; - return std.fs.path.join(gpa, &.{ cwd, path }); + return fizzy.paths.normalizeJoin(gpa, cwd, path); } fn pickUnixSocketDir(buf: *[std.fs.max_path_bytes]u8) []const u8 { diff --git a/src/core/core.zig b/src/core/core.zig index 0b05ec00..b374f62d 100644 --- a/src/core/core.zig +++ b/src/core/core.zig @@ -53,3 +53,6 @@ pub const lsp = @import("lsp/lsp.zig"); /// Shared fuzzy matcher (zf) behind every filter box in the app. See `fuzzy.zig` — note that /// lower scores are better. pub const fuzzy = @import("fuzzy.zig"); + +/// Fixed Fizzy accent colours — theme-independent. See `palette.zig`. +pub const palette = @import("palette.zig"); diff --git a/src/core/dvui.zig b/src/core/dvui.zig index 609c25a6..9891c037 100644 --- a/src/core/dvui.zig +++ b/src/core/dvui.zig @@ -44,6 +44,91 @@ pub fn treeRowIconOptions(over: dvui.Options) dvui.Options { return defaults.override(over); } +const fuzzy = @import("fuzzy.zig"); + +/// Draw `text` as a single-line label, tinting the bytes `query` matched with the theme +/// highlight colour — same treatment as the file-tree filter, settings search, and plugin store. +/// +/// Falls back to a plain `dvui.label` when the query is empty or nothing matched. `plain` +/// should match how the row was scored (`false` for paths, `true` for bare names/titles). +pub fn labelHighlighted( + src: std.builtin.SourceLocation, + text: []const u8, + query: *const fuzzy.Query, + plain: bool, + opts: dvui.Options, +) void { + const color = opts.color(.text); + if (query.isEmpty()) { + dvui.label(src, "{s}", .{text}, opts); + return; + } + + var buf: [fuzzy.highlight_buf_len]usize = undefined; + const hits = fuzzy.highlight(text, query, &buf, .{ .plain = plain }); + if (hits.len == 0) { + dvui.label(src, "{s}", .{text}, opts); + return; + } + + var tl = dvui.textLayout(src, .{ .break_lines = false }, opts.override(.{ .background = false })); + defer tl.deinit(); + + const matched = dvui.themeGet().color(.highlight, .fill); + var i: usize = 0; + var h: usize = 0; + while (i < text.len) { + if (h < hits.len and hits[h] == i) { + const start = i; + while (h < hits.len and hits[h] == i) : (h += 1) i += 1; + tl.addText(text[start..i], .{ .color_text = matched }); + } else { + const start = i; + i = if (h < hits.len) hits[h] else text.len; + tl.addText(text[start..i], .{ .color_text = color }); + } + } +} + +/// `labelHighlighted`'s body, for callers that already own a `TextLayoutWidget` — appends `text` +/// to `tl` with the bytes `query` matched tinted, everything else in `plain_color`. Used for the +/// settings tree's rows, where the caller controls wrapping and font. +pub fn addHighlightedText( + tl: *dvui.TextLayoutWidget, + text: []const u8, + query: *const fuzzy.Query, + plain: bool, + plain_color: dvui.Color, +) void { + if (query.isEmpty()) { + tl.addText(text, .{ .color_text = plain_color }); + return; + } + + var buf: [fuzzy.highlight_buf_len]usize = undefined; + const hits = fuzzy.highlight(text, query, &buf, .{ .plain = plain }); + if (hits.len == 0) { + tl.addText(text, .{ .color_text = plain_color }); + return; + } + + const matched = dvui.themeGet().color(.highlight, .fill); + var i: usize = 0; + var h: usize = 0; + while (i < text.len) { + if (h < hits.len and hits[h] == i) { + // Consume the whole contiguous run of matched bytes in one addText. + const start = i; + while (h < hits.len and hits[h] == i) : (h += 1) i += 1; + tl.addText(text[start..i], .{ .color_text = matched }); + } else { + const start = i; + i = if (h < hits.len) hits[h] else text.len; + tl.addText(text[start..i], .{ .color_text = plain_color }); + } + } +} + /// Reserve one tree-row glyph slot: a box of exactly `treeRowGlyphSize()`, into which the caller /// draws a caret, an icon, an image, or a letter. /// diff --git a/src/core/lsp/Client.zig b/src/core/lsp/Client.zig index 7cd57aa2..a683ca2e 100644 --- a/src/core/lsp/Client.zig +++ b/src/core/lsp/Client.zig @@ -7,15 +7,15 @@ //! extension-gate + `Config` and registers the result as an `sdk.LanguageSupport` provider. //! //! Threading model (read this before touching anything below): -//! - `hover`/`gotoDefinition` are called on the **draw thread**, every frame the mouse -//! dwells over a token (hover) or on a Ctrl/Cmd+click (gotoDefinition). `hover` must -//! never block — it only reads a cache and, on a miss, hands a *copy* of the input off to -//! a background worker. `gotoDefinition` is allowed to block briefly (a few hundred ms). +//! - `hover`/`gotoDefinition`/`warmUp` are called on the **draw/open thread**. `hover` and +//! `warmUp` must never block — they only read a cache / queue work and, on a miss, hand a +//! *copy* of the input off to a background worker. `gotoDefinition` is allowed to block +//! briefly (a few hundred ms). //! - The reader thread and dispatch-worker thread are the only places that block on the //! language server's I/O. Neither may call `dvui.*` — they only touch their own copied //! inputs, this struct's fields (behind `SpinLock`), and `self.config.allocator`, mirroring //! the discipline documented on `pixi`'s `PackJob.zig`. -//! - `bytes` passed into `hover`/`gotoDefinition` is a borrowed slice into the live, +//! - `bytes` passed into `hover`/`gotoDefinition`/`warmUp` is a borrowed slice into the live, //! mutable document buffer — copy it before handing it to the background queue. const std = @import("std"); const builtin = @import("builtin"); @@ -57,6 +57,11 @@ pub const Config = struct { /// `"gopls"`-keyed settings dict — while others (zls) simply ignore it. Caller-owned; must /// outlive every call to `onFolderOpen` (this client re-sends it on every server restart). initialization_options: ?std.json.Value = null, + /// Optional hook invoked on the startup thread immediately before the subprocess is + /// spawned. Language plugins use this to resolve the server binary and fill + /// `initialization_options` fields that need PATH resolution (e.g. zls's `zig_exe_path`) + /// without blocking the UI thread — a login-shell spawn can cost hundreds of ms. + beforeInitialize: ?*const fn () void = null, }; /// Hover text returned by `hover`. `text` is plain text or markdown, valid only for the @@ -112,9 +117,12 @@ pub const CompletionItem = struct { /// unlike `insert_text`. Distinct field because a ghost-text suffix like "else" reads as /// confusing/wrong when shown as a list row on its own. label: []const u8, - /// Suggestion text already trimmed to a pure suffix after what the user has typed — - /// ghost text only ever appears after the caret, it never re-shows characters already - /// visible before it. + /// The full text this candidate inserts, replacing `[replace_start, replace_end)` — *not* + /// trimmed against what the user has already typed, because matching is fuzzy (`arlst` + /// matches `ArrayList`), so the typed characters aren't generally a removable prefix. A + /// caller drawing inline ghost text is responsible for deciding whether a literal suffix + /// exists to preview (see `TextEntryWidget.completionGhost`); accepting a candidate is + /// always a wholesale replace of the range, which needs no trimming either way. insert_text: []const u8, /// Byte range in the document this replaces when accepted. Usually `[byte_offset, /// byte_offset)` (a pure insertion), but can start earlier when the underlying language @@ -211,7 +219,10 @@ const CacheEntry = struct { /// reference after launch permanently shows nothing, while a freshly typed identical /// reference elsewhere — a different cache key — hovers fine). Positive entries never /// expire: once zls has real text for a position, that answer isn't going stale the way - /// "zls hasn't looked yet" is. + /// "zls hasn't looked yet" is. The TTL itself is deliberately short (see the constant): + /// while an unconfirmed negative is live, `hover()` reports pending and the tooltip shows + /// "Just a moment...", so a multi-second wait feels like a hang even though no request is + /// in flight. cached_at: std.Io.Clock.Timestamp, /// Only meaningful when `text == null`. False means this negative hasn't been retried /// yet — per `cached_at`'s doc comment, a *single* negative answer is routinely just zls @@ -345,6 +356,12 @@ response_map: std.AutoHashMapUnmanaged(i64, *ResponseSlot) = .empty, open_docs_lock: SpinLock = .{}, open_docs: std.StringHashMapUnmanaged(DocState) = .empty, +/// Documents waiting for a `didOpen`/`didChange` once the server becomes ready — filled by +/// `warmUp` when called before/during startup so analysis can begin without waiting for the +/// first hover. Keyed by path; a later `warmUp` for the same path replaces the bytes. +pending_sync_lock: SpinLock = .{}, +pending_syncs: std.StringHashMapUnmanaged([]u8) = .empty, + hover_cache_lock: SpinLock = .{}, hover_cache: std.AutoHashMapUnmanaged(CacheKey, CacheEntry) = .empty, in_flight: std.AutoHashMapUnmanaged(CacheKey, void) = .empty, @@ -434,8 +451,12 @@ const hover_cache_limit = 256; /// How long a *negative* hover cache entry ("zls had no info here") stays trusted before a /// re-hover at the same position retries instead of reusing it — see `CacheEntry.cached_at`'s /// doc comment for why this needs to expire at all rather than caching forever like everything -/// else here. -const hover_negative_cache_ttl_ms: i64 = 5000; +/// else here. Kept short so a transient "still indexing" null from zls (common for first +/// touches into large deps like `dvui`) self-corrects in under a second rather than leaving +/// the tooltip stuck on "Just a moment..." for multi-second stretches — VSCode never surfaces +/// that wait because it simply doesn't show a loading placeholder; we do, so the retry has to +/// be snappy. Still long enough that a genuine empty answer isn't re-requested every frame. +const hover_negative_cache_ttl_ms: i64 = 750; const completion_cache_limit = 256; const signature_help_cache_limit = 256; const resolve_cache_limit = 256; @@ -444,7 +465,12 @@ const resolve_cache_limit = 256; /// not exhaustive. const completion_max_items = 50; const poll_interval_ms: u64 = 5; -const hover_timeout_ms: u64 = 2000; +/// Field-access hovers into large deps (`dvui.App.StartOptions`, stdlib paths, …) routinely +/// take well over the old 2s budget once zls has to walk a type chain — abandoning at 2s then +/// immediately re-queueing (and discarding the late reply) was the dominant "Just a moment +/// for 3–4s even when warm" path: the first attempt timed out just as zls was about to +/// answer, the retry paid most of that work again. VSCode keeps the request open; match that. +const hover_timeout_ms: u64 = 15_000; const definition_timeout_ms: u64 = 400; const completion_timeout_ms: u64 = 2000; /// How long a completion request waits, with nothing newer superseding it, before it's @@ -508,6 +534,20 @@ pub fn deinit(self: *Client) void { if (self.resolve_return_scratch) |t| gpa.free(t); } +/// Non-blocking. Kicks off language-server spawn if needed and ensures `path` is +/// `didOpen`/`didChange`-synced so the server can start analyzing before the first +/// hover/completion. Call from `LanguageSupport.documentOpened` (and anywhere else a +/// matching file becomes available). Safe on the draw/open path — same `dvui.io` capture +/// rules as `hover`/`ensureStarted`. +pub fn warmUp(self: *Client, path: []const u8, bytes: []const u8) void { + if (path.len == 0) return; + self.queuePendingSync(path, bytes); + if (!self.ensureStarted(path)) return; + // Already running — flush now so we don't wait for the dispatch loop's next 20ms tick. + // `syncDocument` is safe on this thread (same as `gotoDefinition`). + self.flushPendingSyncs(dvui.io); +} + /// Replaces `hover_return_scratch` with a fresh copy of `text`, freeing whatever was there — /// see the field's doc comment. Returns null (and leaves the scratch empty) if the copy /// itself fails, rather than falling back to the original unsafe-to-return reference. @@ -649,6 +689,12 @@ pub fn hover(self: *Client, path: []const u8, bytes: []const u8, byte_offset: us }; self.queue_lock.lock(); + // Latest hover position wins — anything still queued is for a token the mouse already + // left (common when gliding onto a dotted path like `dvui.App.StartOptions`, where each + // segment is its own cache key). Running those serially just delays the one the tooltip + // is actually waiting on. The job currently in `runHoverJob` (already dequeued) still + // finishes and caches; only not-yet-started work is dropped. + self.dropQueuedHoverJobsLocked(); self.queue.append(gpa, .{ .path = owned_path, .bytes = owned_bytes, .byte_offset = byte_offset, .key = key }) catch { self.queue_lock.unlock(); gpa.free(owned_path); @@ -686,7 +732,7 @@ pub fn gotoDefinition(self: *Client, path: []const u8, bytes: []const u8, byte_o return null; }; - const body = self.waitForSlot(io, req.id, req.slot, definition_timeout_ms) orelse { + const body = self.waitForSlot(io, req.id, req.slot, definition_timeout_ms, .never) orelse { dvui.log.warn("{s}: gotoDefinition: request (id={d}) timed out", .{ self.config.language_id, req.id }); return null; }; @@ -760,7 +806,7 @@ pub fn format(self: *Client, path: []const u8, bytes: []const u8) ?[]const u8 { return null; }; - const body = self.waitForSlot(io, req.id, req.slot, format_timeout_ms) orelse { + const body = self.waitForSlot(io, req.id, req.slot, format_timeout_ms, .never) orelse { dvui.log.warn("{s}: format: request (id={d}) timed out", .{ self.config.language_id, req.id }); return null; }; @@ -1022,8 +1068,8 @@ fn ensureStarted(self: *Client, doc_path: []const u8) bool { .not_started => { if (self.workspace_root == null) self.deriveFallbackRoot(doc_path); if (self.workspace_root == null) return false; - // Captured here, on the caller's thread (draw thread — `ensureStarted` is only - // ever called from `hover`/`gotoDefinition`/etc.), and handed off by value rather + // Captured here, on the caller's thread (draw/open thread — `ensureStarted` is + // called from `warmUp`/`hover`/`gotoDefinition`/etc.), and handed off by value rather // than read fresh from inside a background thread. `dvui.io` is a plain, // non-atomic global the draw thread also *writes* every frame // (`Editor.syncLoadedPluginDvuiContexts` → `dvui_context.inject`) — reading it @@ -1058,6 +1104,11 @@ fn startupThreadMain(self: *Client, io: std.Io) void { // thread to notice the state flipped to `.unavailable`. Called directly here (not via // `dispatchThreadMain`, see below) because that task is never spawned on this path — // `handshake` is what starts it, and it never got that far. + // + // Resolve server argv / init options *before* spawn — plugins (zls) may rewrite + // `command[0]` to an absolute path via a login-shell PATH lookup that must not run on + // the UI thread, and on Linux `spawnProcess`'s own resolver has no shell-env fallback. + if (self.config.beforeInitialize) |hook| hook(); self.spawnProcess(io) catch { self.state.store(.unavailable, .release); self.config.refresh(); @@ -1251,7 +1302,7 @@ fn handshake(self: *Client, io: std.Io) !void { // send as `null` for servers (like zls) that don't need it. .initializationOptions = self.config.initialization_options orelse .null, }); - const body = self.waitForSlot(io, req.id, req.slot, initialize_timeout_ms) orelse { + const body = self.waitForSlot(io, req.id, req.slot, initialize_timeout_ms, .never) orelse { return error.InitializeTimeout; }; defer gpa.free(body); @@ -1358,6 +1409,8 @@ fn shutdownProcess(self: *Client) void { self.open_docs.clearAndFree(gpa); self.open_docs_lock.unlock(); + self.clearPendingSyncs(gpa); + self.queue_lock.lock(); for (self.queue.items) |j| { gpa.free(j.path); @@ -1380,6 +1433,20 @@ fn clearInFlight(self: *Client, key: CacheKey) void { self.hover_cache_lock.unlock(); } +/// Drops every not-yet-started hover job. Caller must hold `queue_lock`. Used so only the +/// latest mouse position stays queued — see `hover` / `dispatchThreadMain`. Takes +/// `hover_cache_lock` per entry to clear `in_flight`; safe because nothing nests +/// `queue_lock` under `hover_cache_lock`. +fn dropQueuedHoverJobsLocked(self: *Client) void { + const gpa = self.config.allocator; + while (self.queue.items.len > 0) { + const stale = self.queue.pop().?; + gpa.free(stale.path); + gpa.free(stale.bytes); + self.clearInFlight(stale.key); + } +} + fn clearCompletionInFlight(self: *Client, key: CacheKey) void { self.completion_cache_lock.lock(); _ = self.completion_in_flight.remove(key); @@ -1529,10 +1596,21 @@ fn dispatchThreadMain(self: *Client, io: std.Io) void { startup_wake_done = true; self.config.refresh(); } + // Drain `warmUp` opens queued while the server was still starting — must run once + // we're ready so zls can begin analyzing before the first hover asks for anything. + if (self.state.load(.acquire) == .ready) { + self.flushPendingSyncs(io); + } self.checkPendingNegativeWake(io); self.queue_lock.lock(); - const job: ?HoverJob = if (self.queue.items.len > 0) self.queue.orderedRemove(0) else null; + // Prefer the most recently queued hover (see `hover`'s coalesce) — if several slipped + // in before this tick, older ones are tokens the mouse already left. + var job: ?HoverJob = null; + if (self.queue.items.len > 0) { + job = self.queue.pop(); + self.dropQueuedHoverJobsLocked(); + } self.queue_lock.unlock(); if (job) |j| { @@ -1606,14 +1684,16 @@ fn runHoverJob(self: *Client, io: std.Io, j: HoverJob) !void { .textDocument = .{ .uri = uri }, .position = pos, }); - const body = self.waitForSlot(io, req.id, req.slot, hover_timeout_ms) orelse { + const body = self.waitForSlot(io, req.id, req.slot, hover_timeout_ms, .abandon_if_hover_queued) orelse { // Deliberately not cached: no response at all is inconclusive (could be a slow // first parse of a huge file), unlike the definitive "no hover here" answers below. - // `in_flight` for this key is about to be cleared by the dispatch loop's `defer` - // (which also wakes the UI thread — see `dispatchThreadMain`), so the client is ready - // to retry the instant something re-polls `hover()`. The eventual real response (if - // zls answers after all, just late) has nowhere to land by then anyway: `waitForSlot` - // already destroyed this request's slot. + // Also covers "abandoned because a newer hover position was queued" — see + // `waitForSlot`'s `AbandonPolicy`. `in_flight` for this key is about to be cleared by + // the dispatch loop's `defer` (which also wakes the UI thread — see + // `dispatchThreadMain`), so the client is ready to retry the instant something + // re-polls `hover()`. The eventual real response (if zls answers after all, just + // late) has nowhere to land by then anyway: `waitForSlot` already destroyed this + // request's slot. return; }; defer gpa.free(body); @@ -1731,7 +1811,7 @@ fn runCompletionJob(self: *Client, io: std.Io, pc: PendingCompletion) !void { .textDocument = .{ .uri = uri }, .position = pos, }); - const body = self.waitForSlot(io, req.id, req.slot, completion_timeout_ms) orelse { + const body = self.waitForSlot(io, req.id, req.slot, completion_timeout_ms, .never) orelse { // Deliberately not cached: no response at all is inconclusive, same rationale as // hover's timeout case. The dispatch loop's `defer` wakes the UI thread regardless of // how this job ends — see `dispatchThreadMain`. @@ -1939,7 +2019,7 @@ fn runSignatureHelpJob(self: *Client, io: std.Io, ps: PendingSignatureHelp) !voi .textDocument = .{ .uri = uri }, .position = pos, }); - const body = self.waitForSlot(io, req.id, req.slot, signature_help_timeout_ms) orelse { + const body = self.waitForSlot(io, req.id, req.slot, signature_help_timeout_ms, .never) orelse { // Deliberately not cached: no response at all is inconclusive, same rationale as // hover's/completion's timeout case. The dispatch loop's `defer` wakes the UI thread // regardless of how this job ends — see `dispatchThreadMain`. @@ -2024,7 +2104,7 @@ fn runResolveJob(self: *Client, io: std.Io, rj: ResolveJob) !void { // `completionItem/resolve`'s params *are* the completion item itself — no wrapper object, // unlike every other request this client sends. const req = try self.sendRequest(io, "completionItem/resolve", item_parsed.value); - const body = self.waitForSlot(io, req.id, req.slot, completion_resolve_timeout_ms) orelse { + const body = self.waitForSlot(io, req.id, req.slot, completion_resolve_timeout_ms, .never) orelse { // Deliberately not cached: no response at all is inconclusive, same rationale as // hover's/completion's timeout case. The dispatch loop's `defer` wakes the UI thread // regardless of how this job ends — see `dispatchThreadMain`. @@ -2409,6 +2489,66 @@ fn applyTextEdits(gpa: std.mem.Allocator, bytes: []const u8, edits: []const std. // ---- document sync ------------------------------------------------------------------------ +fn queuePendingSync(self: *Client, path: []const u8, bytes: []const u8) void { + const gpa = self.config.allocator; + const owned_path = gpa.dupe(u8, path) catch return; + errdefer gpa.free(owned_path); + const owned_bytes = gpa.dupe(u8, bytes) catch { + gpa.free(owned_path); + return; + }; + + self.pending_sync_lock.lock(); + defer self.pending_sync_lock.unlock(); + const gop = self.pending_syncs.getOrPut(gpa, owned_path) catch { + gpa.free(owned_path); + gpa.free(owned_bytes); + return; + }; + if (gop.found_existing) { + gpa.free(owned_path); + gpa.free(gop.value_ptr.*); + gop.value_ptr.* = owned_bytes; + } else { + gop.value_ptr.* = owned_bytes; + } +} + +fn flushPendingSyncs(self: *Client, io: std.Io) void { + const gpa = self.config.allocator; + + self.pending_sync_lock.lock(); + var pending = self.pending_syncs; + self.pending_syncs = .empty; + self.pending_sync_lock.unlock(); + defer { + var it = pending.iterator(); + while (it.next()) |e| { + gpa.free(e.key_ptr.*); + gpa.free(e.value_ptr.*); + } + pending.deinit(gpa); + } + + var it = pending.iterator(); + while (it.next()) |e| { + self.syncDocument(io, e.key_ptr.*, e.value_ptr.*) catch |err| { + dvui.log.warn("{s}: warmUp syncDocument failed: {any}", .{ self.config.language_id, err }); + }; + } +} + +fn clearPendingSyncs(self: *Client, gpa: std.mem.Allocator) void { + self.pending_sync_lock.lock(); + defer self.pending_sync_lock.unlock(); + var it = self.pending_syncs.iterator(); + while (it.next()) |e| { + gpa.free(e.key_ptr.*); + gpa.free(e.value_ptr.*); + } + self.pending_syncs.clearAndFree(gpa); +} + fn syncDocument(self: *Client, io: std.Io, path: []const u8, bytes: []const u8) !void { const gpa = self.config.allocator; const hash = std.hash.Wyhash.hash(0, bytes); @@ -2585,11 +2725,26 @@ fn handleLogMessage(self: *Client, params: ?std.json.Value) void { /// Polls `slot.ready` until it's set or `timeout_ms` elapses. Either way, removes the slot /// from `response_map` and frees it — the caller owns the returned body (if any). -fn waitForSlot(self: *Client, io: std.Io, id: i64, slot: *ResponseSlot, timeout_ms: u64) ?[]u8 { +/// +/// `abandon`: hover jobs pass `.abandon_if_hover_queued` so a long in-flight request for a +/// token the mouse already left yields as soon as a newer position is queued, instead of +/// blocking the tooltip on work nobody is waiting for anymore. Other request kinds leave the +/// default (`.never`) — completions/signature-help already debounce, and definition/format +/// are one-shot user actions. +const AbandonPolicy = enum { never, abandon_if_hover_queued }; + +fn waitForSlot(self: *Client, io: std.Io, id: i64, slot: *ResponseSlot, timeout_ms: u64, abandon: AbandonPolicy) ?[]u8 { const gpa = self.config.allocator; var waited: u64 = 0; while (!slot.ready.load(.acquire)) { - if (self.shutdown.load(.acquire) or waited >= timeout_ms) { + var give_up = self.shutdown.load(.acquire) or waited >= timeout_ms; + if (!give_up and abandon == .abandon_if_hover_queued) { + self.queue_lock.lock(); + const newer_queued = self.queue.items.len > 0; + self.queue_lock.unlock(); + give_up = newer_queued; + } + if (give_up) { self.response_map_lock.lock(); _ = self.response_map.remove(id); self.response_map_lock.unlock(); diff --git a/src/core/palette.zig b/src/core/palette.zig new file mode 100644 index 00000000..3ce89106 --- /dev/null +++ b/src/core/palette.zig @@ -0,0 +1,51 @@ +//! The fixed Fizzy accent palette — independent of the active `dvui.Theme`. +//! +//! Used anywhere identity should stay stable across theme switches: rainbow bracket +//! nesting in the text editor, file-tree row / icon tints in the workbench, etc. Themes +//! still own chrome (fills, borders, selection); this table owns the "which of N accents" +//! question, so switching from Fizzy Dark to Adwaita Light doesn't reshuffle the file tree +//! or recolor every nested brace. +//! +//! Colours are chosen to read on both dark and light content fills — saturated enough to +//! tell apart at a glance, not so neon that they fight syntax highlighting. + +const dvui = @import("dvui"); + +/// Soft red, amber, gold, green, blue, purple, cyan — cycles via `at`. +pub const colors = [_]dvui.Color{ + .{ .r = 224, .g = 108, .b = 117, .a = 255 }, + .{ .r = 209, .g = 154, .b = 102, .a = 255 }, + .{ .r = 229, .g = 192, .b = 123, .a = 255 }, + .{ .r = 152, .g = 195, .b = 121, .a = 255 }, + .{ .r = 97, .g = 175, .b = 239, .a = 255 }, + .{ .r = 198, .g = 120, .b = 221, .a = 255 }, + .{ .r = 86, .g = 182, .b = 194, .a = 255 }, +}; + +/// `index` wraps — nesting depth 7 is the same colour as depth 0, file-tree row 7 the same +/// as row 0. Empty `colors` is impossible (the array is a compile-time constant), so the +/// modulo is always well-defined. +pub fn at(index: usize) dvui.Color { + return colors[index % colors.len]; +} + +/// Scrambled walk through `colors` for rainbow brackets — same accents as the file tree, but +/// not the same sequence. Indent-0 braces therefore don't double as "file-tree row 0", and the +/// eye reads editor nesting as its own scale rather than a mirror of the explorer. +/// +/// Must stay a permutation of `0 .. colors.len` (every colour still appears; no duplicates). +const bracket_order = [_]usize{ 4, 0, 6, 2, 5, 1, 3 }; + +comptime { + if (bracket_order.len != colors.len) @compileError("bracket_order must cover every palette colour"); + var seen = [_]bool{false} ** colors.len; + for (bracket_order) |i| { + if (i >= colors.len or seen[i]) @compileError("bracket_order must be a permutation of colors indices"); + seen[i] = true; + } +} + +/// Like `at`, but through `bracket_order` — used by the text editor's rainbow brackets only. +pub fn bracket(index: usize) dvui.Color { + return colors[bracket_order[index % bracket_order.len]]; +} diff --git a/src/core/paths.zig b/src/core/paths.zig index 1f09cabc..2b90b688 100644 --- a/src/core/paths.zig +++ b/src/core/paths.zig @@ -1,6 +1,27 @@ const std = @import("std"); const builtin = @import("builtin"); +/// Lexically canonical form of `path`: `.` / `..` components collapsed, duplicate and trailing +/// separators removed (`/` itself is preserved). Purely textual — no syscalls, no symlink +/// resolution — so it is safe to call on paths that don't exist yet and on any thread. +/// +/// Exists because a path that only *differs* lexically (`~/dev/fizzy/.` vs `~/dev/fizzy`, the +/// shape `fizzy .` produces by joining cwd with the literal argument) still names the same +/// directory to the OS while comparing unequal to everything that derives a key from it — +/// recents dedupe, and language servers, which are handed the project folder as a `rootUri` +/// and quietly fail to locate the build root when it carries a `.` component. Normalize at the +/// boundary (argv resolution, `setProjectFolder`, `openFilePath` / `docFromPath`, recents +/// load/append) so nothing downstream can ever see the odd spelling. +pub fn normalize(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + return std.fs.path.resolve(allocator, &.{path}); +} + +/// `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 { + return std.fs.path.resolve(allocator, &.{ base, path }); +} + /// The OS "local configuration" root — fizzy's own canonical mapping (formerly `known-folders` /// `.local_configuration`). **Single source of truth, shared by the runtime loader (`configRoot` /// below) and the build-time plugin installer (`plugin_sdk.zig`'s `fizzyPluginsDir`)** so a @@ -77,3 +98,61 @@ pub fn configFolderZ( buf[folder.len] = 0; return buf[0..folder.len :0]; } + +// Posix spellings only — `normalize` delegates to `std.fs.path.resolve`, which switches on the +// native OS, so these assertions are skipped when the test host is Windows (the Windows +// canonicalization rules, including drive-letter casing, are std's own tested territory). +const skip_posix_cases = @import("builtin").target.os.tag == .windows; + +test normalize { + if (skip_posix_cases) return error.SkipZigTest; + const ta = std.testing.allocator; + const cases = [_]struct { in: []const u8, want: []const u8 }{ + // The shape `fizzy .` used to produce: same directory, different string. + .{ .in = "/Users/me/dev/fizzy/.", .want = "/Users/me/dev/fizzy" }, + .{ .in = "/Users/me/dev/fizzy/./.", .want = "/Users/me/dev/fizzy" }, + .{ .in = "/Users/me/dev/fizzy/", .want = "/Users/me/dev/fizzy" }, + .{ .in = "/Users/me/dev/fizzy///", .want = "/Users/me/dev/fizzy" }, + .{ .in = "/Users/me/dev//fizzy", .want = "/Users/me/dev/fizzy" }, + .{ .in = "/Users/me/dev/pixi/../fizzy", .want = "/Users/me/dev/fizzy" }, + .{ .in = "/Users/me/dev/fizzy", .want = "/Users/me/dev/fizzy" }, + // File paths — same collapse `openFilePath` relies on so `a/./b.zig` can't + // double-open against an already-loaded `a/b.zig`. + .{ .in = "/Users/me/dev/fizzy/./src/editor/Editor.zig", .want = "/Users/me/dev/fizzy/src/editor/Editor.zig" }, + .{ .in = "/Users/me/dev/fizzy/src/../src/main.zig", .want = "/Users/me/dev/fizzy/src/main.zig" }, + // Root survives as the one path that keeps its separator. + .{ .in = "/", .want = "/" }, + .{ .in = "/.", .want = "/" }, + }; + for (cases) |c| { + const got = try normalize(ta, c.in); + defer ta.free(got); + std.testing.expectEqualStrings(c.want, got) catch |err| { + std.debug.print("normalize(\"{s}\")\n", .{c.in}); + return err; + }; + } +} + +test normalizeJoin { + if (skip_posix_cases) return error.SkipZigTest; + const ta = std.testing.allocator; + + // `fizzy .` from the project directory. + const dot = try normalizeJoin(ta, "/Users/me/dev/fizzy", "."); + defer ta.free(dot); + try std.testing.expectEqualStrings("/Users/me/dev/fizzy", dot); + + const rel = try normalizeJoin(ta, "/Users/me/dev", "fizzy/src/"); + defer ta.free(rel); + try std.testing.expectEqualStrings("/Users/me/dev/fizzy/src", rel); + + const up = try normalizeJoin(ta, "/Users/me/dev/fizzy", "../pixi"); + defer ta.free(up); + try std.testing.expectEqualStrings("/Users/me/dev/pixi", up); + + // An absolute argument ignores the base, as a shell would. + const abs = try normalizeJoin(ta, "/Users/me/dev", "/tmp/scratch/."); + defer ta.free(abs); + try std.testing.expectEqualStrings("/tmp/scratch", abs); +} diff --git a/src/core/widgets/FloatingWindowWidget.zig b/src/core/widgets/FloatingWindowWidget.zig index a9040d6b..2506e6f8 100644 --- a/src/core/widgets/FloatingWindowWidget.zig +++ b/src/core/widgets/FloatingWindowWidget.zig @@ -30,12 +30,55 @@ pub const Resize = enum { all, }; +/// Which edges hold still while `auto_size` animates the window to a new size. +/// +/// Each field is the fraction of the size change taken off the leading (top/left) edge, so 0.5 +/// splits it across both — a window that grows about its centre, which is what dialogs want and +/// what this widget has always done. 0 pins the leading edge, so the window grows down/right +/// from a fixed corner; 1 pins the trailing edge. +pub const SizeAnchor = struct { + x: f32 = 0.5, + y: f32 = 0.5, + + pub const center: SizeAnchor = .{}; + pub const top: SizeAnchor = .{ .y = 0 }; + pub const top_left: SizeAnchor = .{ .x = 0, .y = 0 }; +}; + +/// Axes `auto_size` may animate. A window whose width is chosen by the caller rather than by its +/// contents (the command palette is a fixed column) has to be able to opt one axis out, or the +/// caller pinning that axis every frame and auto-size pulling it toward the content min size +/// fight each other forever. +pub const AutoSizeAxes = enum { + both, + /// Height only. + vertical, + /// Width only. + horizontal, + + fn animatesWidth(self: AutoSizeAxes) bool { + return self != .vertical; + } + fn animatesHeight(self: AutoSizeAxes) bool { + return self != .horizontal; + } +}; + pub const InitOptions = struct { modal: bool = false, + /// Scrim opacity, defaulting to dvui's per-theme value. Set it to animate the dim in and + /// out with a window that opens or closes over time (see `CommandPalette`). Same field name + /// and meaning as upstream dvui's `FloatingWindowWidget`. + modal_alpha: ?u8 = null, rect: ?*Rect = null, center_on: ?Rect.Natural = null, open_flag: ?*bool = null, + /// Edges held still while `auto_size` animates to a new size. See `SizeAnchor`. + size_anchor: SizeAnchor = .center, + /// Axes `auto_size` is allowed to animate. See `AutoSizeAxes`. + auto_size_axes: AutoSizeAxes = .both, + /// Whether to allow resizing the window by dragging the edges/corners. resize: Resize = .all, process_events_in_deinit: bool = true, @@ -272,13 +315,13 @@ pub fn init(self: *FloatingWindowWidget, src: std.builtin.SourceLocation, init_o if (dvui.animationGet(self.wd.id, "_auto_height")) |*a| { diff_y = self.wd.rect.h - a.value(); self.wd.rect.h = a.value(); - self.wd.rect.y += diff_y / 2; + self.wd.rect.y += diff_y * self.init_options.size_anchor.y; } if (dvui.animationGet(self.wd.id, "_auto_width")) |*a| { diff_x = self.wd.rect.w - a.value(); self.wd.rect.w = a.value(); - self.wd.rect.x += diff_x / 2; + self.wd.rect.x += diff_x * self.init_options.size_anchor.x; } if (dvui.minSizeGet(self.wd.id)) |min_size| { @@ -290,7 +333,7 @@ pub fn init(self: *FloatingWindowWidget, src: std.builtin.SourceLocation, init_o const ms = Size.min(Size.max(min_size, self.options.min_sizeGet()), .cast(dvui.windowRect().size())); - if (ms.w != self.wd.rect.w) { + if (self.init_options.auto_size_axes.animatesWidth() and ms.w != self.wd.rect.w) { if (dvui.animationGet(self.wd.id, "_auto_width")) |a| { if (a.end_val != ms.w) { _ = dvui.currentWindow().animations.remove(self.wd.id.update("_auto_width")); @@ -311,7 +354,7 @@ pub fn init(self: *FloatingWindowWidget, src: std.builtin.SourceLocation, init_o } } - if (ms.h != self.wd.rect.h) { + if (self.init_options.auto_size_axes.animatesHeight() and ms.h != self.wd.rect.h) { if (dvui.animationGet(self.wd.id, "_auto_height")) |*a| { if (a.end_val != ms.h) { _ = dvui.currentWindow().animations.remove(self.wd.id.update("_auto_height")); @@ -451,7 +494,7 @@ pub fn drawBackground(self: *FloatingWindowWidget) void { if (self.init_options.modal and !dvui.firstFrame(self.data().id)) { // paint over everything below var col = self.options.color(.text); - col.a = if (dvui.themeGet().dark) 60 else 80; + col.a = self.init_options.modal_alpha orelse (if (dvui.themeGet().dark) 60 else 80); dvui.windowRectPixels().fill(.{}, .{ .color = col }); } @@ -709,6 +752,25 @@ pub fn stopAutoSizing(self: *FloatingWindowWidget) void { self.auto_size = false; } +/// Start the standard close animation, collapsing this window's height onto its `size_anchor` +/// edge and leaving x/y/w alone — the counterpart to an auto-sized open for a window that grows +/// from a pinned edge rather than from its centre. +/// +/// Cancels any in-flight auto-size animation first. `init` applies the auto-size animations +/// *after* the close ones, so a window closed while still growing would keep being driven by +/// `_auto_height` and never visibly close. +pub fn closeAnimateCollapse(self: *FloatingWindowWidget) void { + const cw = dvui.currentWindow(); + _ = cw.animations.remove(self.data().id.update("_auto_width")); + _ = cw.animations.remove(self.data().id.update("_auto_height")); + self.auto_size = false; + + var close_rect = self.data().rectScale().r; + close_rect.y += (close_rect.h - 1) * self.init_options.size_anchor.y; + close_rect.h = 1; + dvui.dataSet(null, self.data().id, "_close_rect", close_rect); +} + /// Request that the window center itself on its parent (or /// InitOptions.center_on). This takes effect next frame. pub fn autoPosition(self: *FloatingWindowWidget) void { diff --git a/src/editor/CommandPalette.zig b/src/editor/CommandPalette.zig new file mode 100644 index 00000000..4bb8f512 --- /dev/null +++ b/src/editor/CommandPalette.zig @@ -0,0 +1,741 @@ +//! VSCode-style Quick Open: a dropdown from the top centre of the window. +//! +//! Two modes, distinguished exactly the way VSCode does it — by what you type: +//! +//! - **Files** (default, `mod+p`): fuzzy search every file under the project folder, with the +//! same file-type icons the explorer uses. Selecting one opens it. +//! - **Commands** (`>` prefix, or `mod+shift+p` which just seeds the entry with `>`): fuzzy +//! search registered commands. Document verbs (Copy/Paste/…) are flattened to the shell +//! forwarder that would actually run — see `document_verbs` / `shouldShowCommand`. +//! +//! Mode is derived from the text rather than held as state, so backspacing over the `>` slides +//! straight back to file search, as it does in VSCode. + +const std = @import("std"); +const builtin = @import("builtin"); +const dvui = @import("dvui"); +const icons = @import("icons"); +const fizzy = @import("../fizzy.zig"); +const fuzzy = @import("core").fuzzy; +const wdvui = @import("core").dvui; +const keymap = @import("keymap/keymap.zig"); +const Keybinds = @import("Keybinds.zig"); + +const Editor = @import("Editor.zig"); +const CommandPalette = @This(); + +/// Same guards the explorer's own filter index uses — a palette must never be the reason +/// opening a folder hangs. +const max_index_files: usize = 20_000; +const max_index_depth: usize = 24; +/// Rows are virtualised only by truncation: past this many hits the ranking is meaningless +/// anyway, and the list is scrollable. +const max_rows: usize = 300; + +const width_max: f32 = 640; +const width_margin: f32 = 80; +const top_offset: f32 = 64; +const row_height: f32 = 28; + +/// Reveal timing. The query row is full-height from the first frame — only the suggestion list +/// grows — so the palette's top edge never moves and the bottom edge is what springs down. +/// Scrim fade timings. The panel's own geometry is animated by `FloatingWindowWidget`'s +/// auto-size (300ms `outBack`) and close (400ms `inBack`) machinery; these match those so the +/// dim lands with the panel. +const open_secs: f32 = 0.3; +const close_secs: f32 = 0.4; + +/// A frame's worth of time, clamped. `secondsSinceLastFrame` reports the *real* gap since the +/// previous frame, and fizzy sleeps when idle — so the frame that opens the palette typically +/// carries hundreds of ms of accumulated idle. Fed raw into the fade it consumed the whole +/// animation in one step, which is why the open never appeared while the close (always mid- +/// interaction, frames already flowing) looked fine. +fn frameDelta() f32 { + return @min(dvui.secondsSinceLastFrame(), 1.0 / 30.0); +} + +pub const Mode = enum { files, commands }; + +open: bool = false, +/// Reveal progress, 0 closed → 1 open. Drives the scrim fade and the open/close lifecycle; the +/// panel's own height is a separate, retargetable animation (see `list_h`). +anim: f32 = 0, + +/// Measured height of the suggestion rows — last frame's `ScrollInfo.virtual_size.h`, capped at +/// the ceiling. The scroll viewport is then sized to exactly this, which is the whole point of +/// measuring rather than computing `rows * row_height`: a viewport even a fraction of a pixel +/// under its content makes the scrollbar appear permanently (`virtual_size.h > viewport.h`). +/// Sizing the viewport from the same number the container measured makes them agree exactly. +list_content_h: f32 = 0, +/// Playing the outro. `open` stays true throughout so the palette keeps drawing — this is also +/// what lets an activated row hold its pressed highlight instead of vanishing on click. +closing: bool = false, +/// Row the user activated, kept highlighted through the outro. +activated: ?usize = null, +/// The floating window's own close flag. Watched rather than aliased to `open` so a close driven +/// from inside dvui still plays the outro instead of cutting the palette dead. +fw_open: bool = true, +/// True on the frame the palette opens, so the entry can claim keyboard focus exactly once. +just_opened: bool = false, +/// Remaining frames to re-assert entry focus after open (guards against a same-chord dialog +/// stealing focus before the entry exists). +focus_frames: u8 = 0, +/// Remaining frames to park the entry's cursor at the end of the seeded text. The entry keeps its +/// own selection across opens, so seeding `">"` would otherwise leave the cursor at offset 0 and +/// every typed character would land in front of the prefix. +tail_frames: u8 = 0, +/// Position/size fed to the modal floating window each frame. +fw_rect: dvui.Rect = .{}, +selected: usize = 0, +scroll_to_selected: bool = false, +/// Query text. Owned here rather than left in dvui's internal store so opening in command mode +/// can seed a `>` (dvui derives length from the first zero byte for a buffer-backed entry). +text_buf: [256]u8 = @splat(0), + +/// Absolute paths under the project folder, built on open. Owned. +index: std.ArrayList([]u8) = .empty, +/// Folder `index` was built for; empty when never built. +index_root: []u8 = &.{}, +/// Whether `index` reflects a completed walk of `index_root`. Tracked separately from +/// `index.items.len` because a legitimately empty result (unreadable folder, every entry +/// ignored) would otherwise re-walk the whole tree on every frame the palette is open. +index_built: bool = false, + +pub fn deinit(self: *CommandPalette, gpa: std.mem.Allocator) void { + self.freeIndex(gpa); + if (self.index_root.len > 0) gpa.free(self.index_root); + self.* = .{}; +} + +fn freeIndex(self: *CommandPalette, gpa: std.mem.Allocator) void { + for (self.index.items) |p| gpa.free(p); + self.index.clearRetainingCapacity(); +} + +fn queryText(self: *const CommandPalette) []const u8 { + const end = std.mem.indexOfScalar(u8, &self.text_buf, 0) orelse self.text_buf.len; + return self.text_buf[0..end]; +} + +fn setText(self: *CommandPalette, s: []const u8) void { + @memset(&self.text_buf, 0); + const n = @min(s.len, self.text_buf.len - 1); + @memcpy(self.text_buf[0..n], s[0..n]); +} + +pub fn show(self: *CommandPalette, mode: Mode) void { + self.open = true; + // Deliberately not resetting `anim`: re-opening mid-outro springs back from wherever the + // close got to rather than snapping shut and replaying from zero. + self.closing = false; + self.fw_open = true; + self.activated = null; + self.just_opened = true; + self.focus_frames = 3; + self.tail_frames = 3; + self.selected = 0; + self.scroll_to_selected = true; + self.setText(switch (mode) { + .files => "", + .commands => ">", + }); + dvui.refresh(null, @src(), null); +} + +/// Start the outro. The palette keeps drawing (and stays modal) until `finishClose`. +pub fn close(self: *CommandPalette) void { + if (!self.open or self.closing) return; + self.closing = true; + self.just_opened = false; + self.focus_frames = 0; + self.tail_frames = 0; + dvui.refresh(null, @src(), null); +} + +/// Whether the panel's height is still moving — either auto-sizing to a new content height or +/// collapsing on close. +fn animatingGeometry(self: *const CommandPalette, win: *fizzy.dvui.FloatingWindowWidget) bool { + if (self.closing) return true; + return dvui.animationGet(win.data().id, "_auto_height") != null; +} + +fn finishClose(self: *CommandPalette) void { + self.open = false; + self.closing = false; + self.anim = 0; + self.activated = null; + self.list_content_h = 0; + // The scrim fade and the widget's collapse are the same length but the collapse starts a + // frame later, so it can still have a frame to run when the palette stops drawing. Reset the + // height explicitly rather than leaving a partly-collapsed rect for the next open to spring + // from. + self.fw_rect.h = 1; + dvui.refresh(null, @src(), null); +} + +pub fn toggle(self: *CommandPalette, mode: Mode) void { + if (self.open and !self.closing) self.close() else self.show(mode); +} + +/// `>` selects command mode; everything after it is the query. +fn modeAndQuery(text: []const u8) struct { mode: Mode, query: []const u8 } { + if (text.len > 0 and text[0] == '>') { + return .{ .mode = .commands, .query = std.mem.trimStart(u8, text[1..], " ") }; + } + return .{ .mode = .files, .query = text }; +} + +// ---- file index --------------------------------------------------------------------------- + +fn ensureIndex(self: *CommandPalette, editor: *Editor) void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + const root = editor.folder orelse return; + if (self.index_built and std.mem.eql(u8, self.index_root, root)) return; + + const gpa = fizzy.app.allocator; + self.freeIndex(gpa); + if (!std.mem.eql(u8, self.index_root, root)) { + if (self.index_root.len > 0) gpa.free(self.index_root); + self.index_root = gpa.dupe(u8, root) catch &.{}; + } + self.indexDir(editor, root, 0); + self.index_built = std.mem.eql(u8, self.index_root, root); +} + +/// Depth-first walk that **prunes** ignored directories rather than filtering after the fact — +/// descending into `node_modules` and discarding the results afterwards is the slow thing. Uses +/// `Host.isPathIgnored`, the same rules the explorer tree uses, so the palette can never surface +/// a file the tree deliberately hides. +fn indexDir(self: *CommandPalette, editor: *Editor, directory: []const u8, depth: usize) void { + if (depth > max_index_depth or self.index.items.len >= max_index_files) return; + + const io = dvui.io; + const gpa = fizzy.app.allocator; + var dir = std.Io.Dir.cwd().openDir(io, directory, .{ + .access_sub_paths = true, + .iterate = true, + }) catch return; + defer dir.close(io); + + const root = editor.folder orelse return; + + var iter = dir.iterate(); + while (iter.next(io) catch null) |entry| { + if (self.index.items.len >= max_index_files) return; + + const abs_path = std.fs.path.join(gpa, &.{ directory, entry.name }) catch continue; + var keep = false; + defer if (!keep) gpa.free(abs_path); + + if (editor.host.isPathIgnored(root, abs_path, entry.name, entry.kind)) continue; + + switch (entry.kind) { + .file => { + self.index.append(gpa, abs_path) catch continue; + keep = true; + }, + .directory => self.indexDir(editor, abs_path, depth + 1), + else => {}, + } + } +} + +/// Invalidate the index — call when the project folder changes or the tree is known stale. +pub fn invalidate(self: *CommandPalette) void { + self.freeIndex(fizzy.app.allocator); + self.index_built = false; +} + +// ---- rows --------------------------------------------------------------------------------- + +const Row = union(Mode) { + files: []const u8, // absolute path + commands: struct { id: []const u8, title: []const u8, enabled: bool }, +}; + +fn collectFileRows(self: *CommandPalette, editor: *Editor, query: *const fuzzy.Query) []Row { + const arena = dvui.currentWindow().arena(); + const root = editor.folder orelse return &.{}; + + var hits: std.ArrayListUnmanaged(fuzzy.Ranked(usize)) = .empty; + for (self.index.items, 0..) |abs, i| { + const rel = std.fs.path.relativePosix(arena, ".", root, abs) catch continue; + // `plain = false`: these are real paths, so zf's basename weighting is what makes + // `filz` rank `src/files.zig` above a path that merely contains those letters. + const score = if (query.isEmpty()) @as(f64, 0) else (fuzzy.score(rel, query, .{ .plain = false }) orelse continue); + hits.append(arena, .{ .item = i, .score = score, .tie = rel.len }) catch break; + if (query.isEmpty() and hits.items.len >= max_rows) break; + } + fuzzy.sort(usize, hits.items); + + var rows: std.ArrayListUnmanaged(Row) = .empty; + for (hits.items) |h| { + if (rows.items.len >= max_rows) break; + rows.append(arena, .{ .files = self.index.items[h.item] }) catch break; + } + return rows.items; +} + +const document_verbs = Keybinds.document_verbs; +const commandActionSuffix = Keybinds.commandActionSuffix; + +/// Whether `id` should appear in the command palette given the current active document. +fn shouldShowCommand(editor: *Editor, id: []const u8) bool { + for (document_verbs) |v| { + if (v.fizzy_id) |fid| { + if (std.mem.eql(u8, id, fid)) return true; + } + } + + const suffix = commandActionSuffix(id); + for (document_verbs) |v| { + if (!std.mem.eql(u8, v.action, suffix)) continue; + // Plugin (or other) implementation of a document verb. + if (v.fizzy_id != null) return false; + const doc = editor.activeDoc() orelse return false; + var buf: [128]u8 = undefined; + const want = std.fmt.bufPrint(&buf, "{s}.{s}", .{ doc.owner.id, v.action }) catch return false; + return std.mem.eql(u8, id, want); + } + return true; +} + +fn collectCommandRows(editor: *Editor, query: *const fuzzy.Query) []Row { + const arena = dvui.currentWindow().arena(); + + var hits: std.ArrayListUnmanaged(fuzzy.Ranked(usize)) = .empty; + for (editor.host.commands.items, 0..) |c, i| { + if (!shouldShowCommand(editor, c.id)) continue; + // Match against the title *and* the id, so both "Save All" and "fizzy.saveAll" find it. + const score = if (query.isEmpty()) + @as(f64, 0) + else + (fuzzy.scoreBest(&.{ c.title, c.id }, query, .{ .plain = true }) orelse continue); + hits.append(arena, .{ .item = i, .score = score, .tie = c.title.len }) catch break; + } + fuzzy.sort(usize, hits.items); + + var rows: std.ArrayListUnmanaged(Row) = .empty; + for (hits.items) |h| { + if (rows.items.len >= max_rows) break; + const c = editor.host.commands.items[h.item]; + rows.append(arena, .{ .commands = .{ + .id = c.id, + .title = c.title, + .enabled = editor.host.commandEnabled(c.id), + } }) catch break; + } + return rows.items; +} + +/// Shortcut hint for a command, or null when it has none. +fn shortcutFor(editor: *Editor, id: []const u8) ?[]const u8 { + const arena = dvui.currentWindow().arena(); + const found = editor.keymap.bindingsFor(arena, id) catch return null; + if (found.len == 0) return null; + const platform: keymap.Platform = if (fizzy.platform.isMacOS()) .mac else .other; + return keymap.formatKeys(arena, found[0].stroke, platform) catch null; +} + +// ---- activation --------------------------------------------------------------------------- + +fn activate(self: *CommandPalette, editor: *Editor, rows: []const Row) void { + if (rows.len == 0) return; + const idx = @min(self.selected, rows.len - 1); + const row = rows[idx]; + // The work happens now, not when the outro ends — the palette animating away over a file + // that is already opening is the point; deferring it would just read as lag. `activated` is + // set only on a path that actually closes, so a disabled command can't leave a row stuck + // looking pressed. + switch (row) { + .files => |abs| { + self.activated = idx; + self.close(); + _ = editor.openFilePath(abs, editor.currentGroupingID()) catch { + dvui.log.err("palette: failed to open {s}", .{abs}); + }; + }, + .commands => |c| { + if (!c.enabled) return; + self.activated = idx; + self.close(); + editor.host.runCommand(c.id) catch |err| { + dvui.log.err("palette: command '{s}' failed: {s}", .{ c.id, @errorName(err) }); + }; + }, + } +} + +// ---- draw ---------------------------------------------------------------------------------- + +pub fn draw(self: *CommandPalette, editor: *Editor) void { + if (!self.open) return; + + // dvui closed the window from the inside (its own escape/close path). Turn that into an + // outro rather than letting the palette blink out. + if (!self.fw_open and !self.closing) { + self.fw_open = true; + self.close(); + } + + const win_rect = dvui.windowRect(); + const theme = dvui.themeGet(); + + const w = @min(width_max, @max(320, win_rect.w - width_margin * 2)); + const x = (win_rect.w - w) / 2; + // Half the window is the ceiling, not the fixed size — the panel is only as tall as its + // results and never fills the app. + const list_max_h = win_rect.h * 0.5; + + { // scrim fade + close lifecycle; the panel's geometry is the widget's job now + const dt = frameDelta(); + if (dvui.reduce_motion) { + self.anim = if (self.closing) 0 else 1; + } else if (self.closing) { + self.anim = @max(0, self.anim - dt / close_secs); + } else { + self.anim = @min(1, self.anim + dt / open_secs); + } + + // The scrim fade runs for exactly as long as the widget's close animation, so it is also + // the clock for when the palette stops drawing. + if (self.closing and self.anim <= 0) { + self.finishClose(); + return; + } + if (self.anim < 1 or self.closing) dvui.refresh(null, @src(), null); + } + + // Only x/y/w are ours. Height comes back from the widget's auto-size each frame through this + // same rect — overwriting it here would stomp the animation mid-flight. + self.fw_rect.x = x; + self.fw_rect.y = top_offset; + self.fw_rect.w = w; + if (self.fw_rect.h == 0) self.fw_rect.h = 1; + + // Same shell as Grid Layout / other dialogs: modal floating window focuses its subwindow + // (so the text entry can receive keys) and paints a black scrim via `color_text = .black`. + fizzy.dvui.modal_dim_titlebar = true; + // Scrim tracks the reveal, so the dim arrives and leaves with the panel instead of snapping + // to full black on frame one and popping off at the end of the outro. Same base values dvui + // picks per theme (60 dark / 80 light), scaled. + const dim_base: f32 = if (theme.dark) 60 else 80; + const dim_alpha: u8 = @intFromFloat(@round(dim_base * std.math.clamp(self.anim, 0, 1))); + + var win = fizzy.dvui.floatingWindow(@src(), .{ + .modal = true, + .modal_alpha = dim_alpha, + .open_flag = &self.fw_open, + .resize = .none, + .window_avoid = .none, + .rect = &self.fw_rect, + // The panel hangs from a fixed top edge and grows downward, and its width is chosen + // here rather than by its contents — so auto-size gets the height axis only, pinned + // at the top instead of the default grow-about-the-centre. + .size_anchor = .top, + .auto_size_axes = .vertical, + .process_events_in_deinit = true, + }, .{ + // Drives the modal dim fill (`options.color(.text)` + alpha) — must be black like dialogs, + // not theme text (which is light on dark themes and looked wrong). + .color_text = .black, + .color_fill = theme.color(.content, .fill).opacity(0.85), + .corners = dvui.CornerRect.all(8), + .padding = dvui.Rect.all(6), + .border = .all(0), + .box_shadow = .{ + .fade = 8, + .corners = .all(8), + .alpha = 0.25, + }, + }); + // `deinit` writes the live (animated) rect back into `fw_rect`, which is how the height + // carries to the next frame. Only the axes we own are restored. + defer { + win.deinit(); + self.fw_rect.x = x; + self.fw_rect.y = top_offset; + self.fw_rect.w = w; + } + // Grow/shrink to fit the real content min size, the same way dialogs do. While closing, hand + // over to the collapse animation instead — calling both would have them fight. + if (self.closing) win.closeAnimateCollapse() else win.autoSize(); + // Palette isn't draggable — an empty drag area keeps processEventsAfter from claiming + // pointer presses (which blocked row clicks) and from forcing the move (`arrow_all`) cursor. + win.dragAreaSet(.{}); + + // Content text must not inherit the black used for the scrim. + const text_color = theme.color(.window, .text); + + // The query drives everything below, so it has to be read before the rows are built. It's + // last frame's text at this point, which is exactly right: the entry hasn't processed this + // frame's keystrokes yet, and rebuilding the list a frame later would lag the selection. + const parsed = modeAndQuery(self.queryText()); + var query = fuzzy.Query.init(parsed.query); + + if (parsed.mode == .files) self.ensureIndex(editor); + const rows = switch (parsed.mode) { + .files => self.collectFileRows(editor, &query), + .commands => collectCommandRows(editor, &query), + }; + if (self.selected >= rows.len) self.selected = if (rows.len == 0) 0 else rows.len - 1; + + // Keyboard first: the text entry would otherwise swallow Up/Down (dvui's TextEntryWidget + // treats them as cursor motion) and Enter. Dead during the outro — the palette is on screen + // but no longer the thing you're driving. + if (!self.closing) self.handleKeys(editor, rows, win.data()); + + { // query row + var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .horizontal, + .color_text = text_color, + }); + defer hbox.deinit(); + + _ = dvui.icon( + @src(), + "palette-icon", + if (parsed.mode == .commands) icons.tvg.lucide.terminal else icons.tvg.lucide.search, + .{ .stroke_color = text_color }, + .{ .gravity_y = 0.5, .padding = dvui.Rect.all(4) }, + ); + + var entry = dvui.textEntry(@src(), .{ + .text = .{ .buffer = &self.text_buf }, + .placeholder = if (parsed.mode == .commands) "Run a command…" else "Search files by name…", + }, .{ + .expand = .horizontal, + .background = false, + .color_text = text_color, + .id_extra = 1, + }); + // FloatingWindow focuses its subwindow on first frame (size 0); claim the entry for a + // few frames after that so typing works without a click. + if (self.just_opened or self.focus_frames > 0) { + dvui.focusWidget(entry.data().id, win.data().id, null); + self.just_opened = false; + if (self.focus_frames > 0) self.focus_frames -= 1; + } + // Same window as the focus claim: park the cursor past the seeded prefix so the first + // keystroke appends instead of landing before the `>`. + if (self.tail_frames > 0) { + self.tail_frames -= 1; + entry.textLayout.selection.moveCursor(std.math.maxInt(usize), false); + } + entry.deinit(); + } + + if (rows.len == 0) { + // No scroll area, so the label's own min size is the panel's — auto-size shrinks to it. + self.list_content_h = 0; + dvui.label(@src(), "{s}", .{switch (parsed.mode) { + .files => if (editor.folder == null) "No folder open" else "No matching files", + .commands => "No matching commands", + }}, .{ + .expand = .horizontal, + .padding = dvui.Rect.all(8), + .color_text = text_color.opacity(0.6), + }); + } else { + // Viewport is last frame's measured content, capped. Below the cap it equals the content + // exactly, so no scrollbar; at the cap the content genuinely overflows and one appears. + const viewport_h = @min(list_max_h, @max(row_height, self.list_content_h)); + var scroll = dvui.scrollArea(@src(), .{ + // The panel spends its first frames animating to this height, during which the + // viewport really is shorter than the content. Hiding the bar until the geometry + // settles keeps it from flashing on every open and every resize. + .vertical_bar = if (self.animatingGeometry(win)) .hide else .auto, + }, .{ + .expand = .horizontal, + .min_size_content = .{ .w = 0, .h = viewport_h }, + .max_size_content = .height(viewport_h), + .background = false, + .color_text = text_color, + }); + // `si` lives in dvui's data store, not in the widget, so it outlives `deinit` — which is + // where `ScrollContainerWidget` finalises `virtual_size` from the rows just laid out. + const si = scroll.si; + + for (rows, 0..) |row, i| { + self.drawRow(editor, row, i, parsed.mode, &query, rows); + } + scroll.deinit(); + + self.list_content_h = si.virtual_size.h; + } + + // Click the scrim (outside the palette content) dismisses — modal owns all mouse targets. + if (self.closing) return; + const palette_r = win.data().rectScale().r; + for (dvui.events()) |*e| { + if (e.handled) continue; + if (e.evt != .mouse) continue; + const me = e.evt.mouse; + if (me.action != .press or !me.button.pointer()) continue; + if (palette_r.contains(me.p)) continue; + e.handle(@src(), win.data()); + self.close(); + return; + } +} + +fn drawRow( + self: *CommandPalette, + editor: *Editor, + row: Row, + i: usize, + mode: Mode, + query: *const fuzzy.Query, + rows: []const Row, +) void { + const theme = dvui.themeGet(); + + // Build the row first so we have a screen rect, then decide selection from mouse vs + // keyboard. Painting the highlight after means hover updates on the same frame. + var rb = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .id_extra = i, + .expand = .horizontal, + .min_size_content = .{ .w = 0, .h = row_height }, + .background = false, + .corners = dvui.CornerRect.all(4), + .padding = .{ .x = 6, .y = 2, .w = 6, .h = 2 }, + }); + defer rb.deinit(); + + const row_r = rb.data().borderRectScale().r; + const mouse_pt = dvui.currentWindow().mouse_pt; + const mouse_over = !self.closing and row_r.contains(mouse_pt) and dvui.clipGet().contains(mouse_pt); + + // Moving the mouse onto a row takes selection (same highlight as arrow keys). Stay put + // when the mouse is still so Up/Down keyboard nav isn't stolen by a resting cursor. + if (mouse_over and dvui.mouseTotalMotion().nonZero() and self.selected != i) { + self.selected = i; + self.scroll_to_selected = false; + } + + // Click to activate. Handled before the highlight is painted so the pressed fill lands on + // this very frame, and the row keeps drawing afterwards rather than returning early — it has + // to survive the outro to be seen at all. + if (!self.closing) { + for (dvui.events()) |*e| { + if (!dvui.eventMatchSimple(e, rb.data())) continue; + if (e.evt != .mouse) continue; + const me = e.evt.mouse; + if (me.action == .press and me.button.pointer()) { + e.handle(@src(), rb.data()); + self.selected = i; + self.activate(editor, rows); + break; + } + } + } + + // The activated row reads as pressed for the whole outro. Before this the palette closed on + // the same frame as the click, so the row you picked disappeared out from under the cursor + // with no acknowledgement that it was the one that ran. + const is_activated = if (self.activated) |a| a == i else false; + const is_selected = i == self.selected; + if (is_activated) { + row_r.fill(.all(4), .{ .color = theme.color(.control, .fill_press) }); + } else if (is_selected) { + row_r.fill(.all(4), .{ .color = theme.color(.control, .fill_hover) }); + } + + if (is_selected and self.scroll_to_selected) { + dvui.scrollTo(.{ .screen_rect = row_r }); + self.scroll_to_selected = false; + } + + if (mouse_over) { + dvui.cursorSet(.arrow); + } + + const text_color = theme.color(.window, .text); + + switch (row) { + .files => |abs| { + const ext = std.fs.path.extension(abs); + // Same fixed glyph slot as the file tree / tabs — `drawFileIcon` drawers use + // `expand = .ratio` and must not size against the whole palette row. + { + var icon_slot = wdvui.treeRowGlyph(@src(), .{ .gravity_y = 0.5, .margin = .{ .w = 4 } }); + defer icon_slot.deinit(); + if (!editor.host.drawFileIcon(ext, abs, text_color)) { + dvui.icon(@src(), "file", icons.tvg.lucide.file, .{ + .stroke_color = text_color, + }, wdvui.treeRowIconOptions(.{})); + } + } + // Basename is what users type most often; `.plain = false` still weights it like a + // path segment when the query hits directory letters that also appear in the name. + wdvui.labelHighlighted(@src(), std.fs.path.basename(abs), query, false, .{ + .gravity_y = 0.5, + .padding = .{ .x = 6, .y = 0, .w = 6, .h = 0 }, + .color_text = text_color, + .expand = .none, + }); + // Dimmed project-relative directory, VSCode-style — also highlight matches so a + // query like `src/` lights up the path rather than looking like a miss. + if (editor.folder) |root| { + const arena = dvui.currentWindow().arena(); + const dir = std.fs.path.dirname(abs) orelse root; + const rel = std.fs.path.relativePosix(arena, ".", root, dir) catch ""; + if (rel.len > 0) { + wdvui.labelHighlighted(@src(), rel, query, false, .{ + .gravity_y = 0.5, + .gravity_x = 0.0, + .color_text = text_color.opacity(0.5), + .expand = .none, + }); + } + } + }, + .commands => |c| { + const color = if (c.enabled) text_color else text_color.opacity(0.4); + wdvui.labelHighlighted(@src(), c.title, query, true, .{ + .gravity_y = 0.5, + .padding = .{ .x = 4, .y = 0, .w = 6, .h = 0 }, + .color_text = color, + .expand = .none, + }); + if (shortcutFor(editor, c.id)) |keys| { + dvui.label(@src(), "{s}", .{keys}, .{ + .gravity_y = 0.5, + .gravity_x = 1.0, + .expand = .horizontal, + .color_text = text_color.opacity(0.55), + }); + } + }, + } + _ = mode; +} + +fn handleKeys(self: *CommandPalette, editor: *Editor, rows: []const Row, wd: *const dvui.WidgetData) void { + for (dvui.events()) |*e| { + if (e.handled) continue; + if (e.evt != .key) continue; + const ke = e.evt.key; + if (ke.action != .down and ke.action != .repeat) continue; + + if (ke.matchBind("char_down")) { + e.handle(@src(), wd); + if (rows.len > 0) self.selected = (self.selected + 1) % rows.len; + self.scroll_to_selected = true; + } else if (ke.matchBind("char_up")) { + e.handle(@src(), wd); + if (rows.len > 0) { + self.selected = if (self.selected == 0) rows.len - 1 else self.selected - 1; + } + self.scroll_to_selected = true; + } else if (ke.code == .enter or ke.code == .kp_enter) { + e.handle(@src(), wd); + self.activate(editor, rows); + return; + } else if (ke.code == .escape) { + e.handle(@src(), wd); + self.close(); + return; + } + } +} diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 1ee34cb6..5bfcf954 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -27,6 +27,8 @@ pub const Settings = @import("Settings.zig"); pub const Dialogs = @import("dialogs/Dialogs.zig"); pub const Keybinds = @import("Keybinds.zig"); +const KeybindSettings = @import("KeybindSettings.zig"); +pub const menu_model = @import("menu_model.zig"); const workbench_mod = @import("workbench"); const text_mod = @import("text"); @@ -101,6 +103,19 @@ plugin_enabled_pending: std.StringArrayHashMapUnmanaged(bool) = .empty, /// them back or every widget's caret/clipboard handling silently dies after the first plugin /// load or unload. Keys are dvui's own static literals; only the map itself is owned here. dvui_default_keybinds: std.StringHashMapUnmanaged(dvui.enums.Keybind) = .empty, +/// Resolved keybinding table: chord -> command id. Rebuilt by `Keybinds.buildKeymap` +/// whenever `rebuildKeybinds` runs (plugin load/unload), so a plugin's binds never outlive +/// the image their strings live in. +keymap: @import("keymap/keymap.zig").Keymap = .{}, +/// Parsed `keybinds.zon`. Held because `keymap` borrows its command-id and owner-id strings — +/// it must outlive the keymap and is replaced wholesale on every rebuild. +keybinds_overrides: ?@import("keymap/keymap.zig").zon.File = null, +/// Cached `Keymap.conflicts()` result from the last rebuild — owned, freed on next rebuild. +keybind_conflicts: ?[]@import("keymap/keymap.zig").Conflict = null, +/// Which default keymap the shell starts from. +keybind_profile: Keybinds.Profile = .vscode, +/// VSCode-style Quick Open / command palette overlay. +command_palette: @import("CommandPalette.zig") = .{}, /// User plugins that failed to load this session, so the UI can tell the author what /// went wrong instead of failing silently into the log. Populated by `loadUserPlugins`; @@ -121,6 +136,15 @@ infobar: Infobar, /// The root folder that will be searched for files and a .fizproject file folder: ?[]const u8 = null, + +/// Whether a text-input widget held keyboard focus at the end of the last frame. +/// +/// `dvui.wantTextInput` is the cross-cutting signal — dvui's own `TextEntryWidget`, the text +/// plugin's fork, and any plugin entry all call it on frames they have focus — but it is reset +/// by `Window.begin` and filled in as widgets draw, so it can only be read as last frame's +/// answer. That is fine for deciding who owns a clipboard verb: focus doesn't change between +/// the keystroke and the frame that handles it. +text_input_focused: bool = false, /// From `.fizignore` (preferred) or `.gitignore` at the project root; used by the Files explorer. ignore: IgnoreRules = .{}, @@ -150,7 +174,8 @@ window_opacity: f32 = 1.0, /// target on the first frame" so there is no fade at launch. window_opacity_anim: f32 = -1.0, -pending_native_menu_actions: [16]fizzy.backend.NativeMenuAction = undefined, +/// Menu-bar clicks waiting for a safe point in the frame. Each is a `menu_model` tag. +pending_native_menu_actions: [16]usize = undefined, pending_native_menu_actions_len: u8 = 0, /// Same queue/flush shape as `pending_native_menu_actions`, but for the generic macOS @@ -1133,8 +1158,9 @@ fn setPluginEnabledPersisted(editor: *Editor, id: []const u8, enabled: bool) !vo /// Rebuild the whole window keybind map from scratch: shell binds + every *currently /// registered* plugin's `contributeKeybinds`. Used after a plugin is unregistered so its -/// binds (whose key strings live in the soon-to-be-`dlclose`d image) are dropped. -fn rebuildKeybinds(editor: *Editor) void { +/// binds (whose key strings live in the soon-to-be-`dlclose`d image) are dropped. Also +/// called after the Keyboard Shortcuts pane writes `keybinds.zon`. +pub fn rebuildKeybinds(editor: *Editor) void { if (comptime builtin.target.cpu.arch == .wasm32) return; const window = dvui.currentWindow(); window.keybinds.clearRetainingCapacity(); @@ -1148,6 +1174,9 @@ fn rebuildKeybinds(editor: *Editor) void { plugin.contributeKeybinds(window) catch |err| dvui.log.err("keybind rebuild ('{s}') failed: {s}", .{ plugin.id, @errorName(err) }); } + // Lift the finished bind map into the command keymap that `Keybinds.tick` dispatches from. + Keybinds.buildKeymap(editor) catch |err| + dvui.log.err("keymap rebuild failed: {s}", .{@errorName(err)}); } /// True if `plugin` owns any currently-dirty open document. @@ -1366,6 +1395,10 @@ pub fn uninstallPlugin(editor: *Editor, id: []const u8, force: bool) !void { pub fn postInit(editor: *Editor) !void { sdk.installRuntime(&fizzy.app.allocator, &editor.host, null); + // Shell commands must be registered against the Editor's *final* address — `init` returns + // an Editor by value, so a pointer taken there would dangle the moment it's moved. + try Keybinds.registerCommands(editor); + // Install the shell's read/utility surface so plugins reach shared shell state // (per-frame arena, project folder, content opacity, settings dirty-mark) through // the Host instead of importing the concrete Editor. @@ -1443,10 +1476,17 @@ pub fn postInit(editor: *Editor) !void { // Menu bar contributions (non-macOS in-app bar). The File/Edit draw bodies still live // in the shell's `Menu.zig`; a later step could move them into the workbench / pixel-art // plugins so those self-register. Order = bar order. - try editor.host.registerMenu(.{ .id = "workbench.menu.file", .title = "File", .draw = Menu.drawFileMenu }); - try editor.host.registerMenu(.{ .id = "shell.menu.edit", .title = "Edit", .draw = Menu.drawEditMenu }); - try editor.host.registerMenu(.{ .id = "shell.menu.view", .title = "View", .draw = Menu.drawViewMenu }); - try editor.host.registerMenu(.{ .id = "shell.menu.help", .title = "Help", .draw = Menu.drawHelpMenu }); + // One registration per `menu_model.menu_bar` entry, all pointing at the same generic + // renderer with the model node as `ctx`. There is no longer a per-menu draw function that + // could disagree with the macOS builder walking the same tree. + inline for (&menu_model.menu_bar) |*sub| { + try editor.host.registerMenu(.{ + .id = sub.id, + .title = sub.title, + .draw = Menu.drawModelMenu, + .ctx = @constCast(@ptrCast(sub)), + }); + } // Keybind contributions: each plugin registers its own binds into the window's // keybind map. The shell already registered its global/navigation/region binds @@ -1455,6 +1495,10 @@ pub fn postInit(editor: *Editor) !void { syncLoadedPluginDvuiContexts(editor); const window = dvui.currentWindow(); for (editor.host.plugins.items) |plugin| try plugin.contributeKeybinds(window); + // Startup's only pass over the finished bind map. `rebuildKeybinds` covers later plugin + // load/unload, but it never runs during boot — without this the keymap stays empty and no + // shell shortcut works until a plugin happens to be reloaded. + try Keybinds.buildKeymap(editor); // The workbench-api is the file explorer's programmatic surface and drives OS // file management (open/create/rename/delete/move on disk). The web build has @@ -1575,8 +1619,8 @@ fn shellLogLine(ctx: *anyopaque, level: std.log.Level, scope: []const u8, messag /// See `EditorAPI.VTable.drawMenuItem`'s doc comment for why this widget construction has to /// happen here (in the shell) rather than in the calling plugin. -fn shellDrawMenuItem(ctx: *anyopaque, title: []const u8, keybind_name: ?[]const u8) bool { - _ = ctx; +fn shellDrawMenuItem(ctx: *anyopaque, title: []const u8, command_id: ?[]const u8) bool { + const editor = shellCtx(ctx); _ = dvui.separator(@src(), .{ .expand = .horizontal }); var mi = dvui.menuItem(@src(), .{}, .{ .expand = .horizontal, @@ -1586,8 +1630,10 @@ fn shellDrawMenuItem(ctx: *anyopaque, title: []const u8, keybind_name: ?[]const }); defer mi.deinit(); const clicked = mi.activeRect() != null; - const kb: dvui.enums.Keybind = if (keybind_name) |name| - dvui.currentWindow().keybinds.get(name) orelse .{} + // Same resolution the shell's own menu rows use (`Menu.hotkeyFor`), so a plugin row and a + // shell row bound to the same chord can never disagree about what to display. + const kb: dvui.enums.Keybind = if (command_id) |id| + Keybinds.menuKeybindFor(editor, id) else .{}; fizzy.dvui.labelWithKeybind(title, kb, true, .{ .expand = .horizontal }, .{ .expand = .horizontal }); @@ -1870,10 +1916,30 @@ pub fn docPath(_: *Editor, doc: sdk.DocHandle) []const u8 { return doc.owner.documentPath(doc); } +/// Looks up an open document by path. Exact match first (the hot path — file-tree paint hits +/// this every frame with already-canonical abs paths); on miss, collapses `.` / `..` / +/// duplicate separators so a caller holding `a/./b.zig` still finds a doc stored as `a/b.zig` +/// (and vice versa for anything opened before `openFilePath` started normalizing). See +/// `fizzy.paths.normalize`. pub fn docFromPath(editor: *Editor, path: []const u8) ?sdk.DocHandle { for (editor.open_files.values()) |doc| { 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); + + for (editor.open_files.values()) |doc| { + const stored = editor.docPath(doc); + if (std.mem.eql(u8, stored, key)) return doc; + // Skip a second normalize when the stored spelling already matched `path` above, or + // 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; + 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; + } return null; } @@ -2525,6 +2591,20 @@ pub fn tick(editor: *Editor) !dvui.App.Result { } } + if (fizzy.backend.pollPendingAbout()) { + // The app menu's "About fizzy" is AppKit's own item, so it has no model tag. + editor.host.runCommand("fizzy.about") catch |err| { + dvui.log.err("about command failed: {s}", .{@errorName(err)}); + }; + } + if (fizzy.backend.pollPendingRecentFolder()) |i| { + if (i < editor.recents.folders.items.len) { + const folder = editor.recents.folders.items[i]; + editor.setProjectFolder(folder) catch |err| { + dvui.log.err("open recent folder failed: {s}", .{@errorName(err)}); + }; + } + } if (fizzy.backend.pollPendingNativeMenuAction()) |action| { editor.queueNativeMenuAction(action); } @@ -2959,10 +3039,17 @@ pub fn tick(editor: *Editor) !dvui.App.Result { } { // Plugin keybinds + per-frame overlays (e.g. pixel-art's radial menu) - for (editor.host.plugins.items) |plugin| { - plugin.tickKeybinds() catch |err| { - dvui.log.err("Plugin keybind tick failed: {s}", .{@errorName(err)}); - }; + // While the palette owns the keyboard, plugin ticks must not run — otherwise a + // plugin's `matchBind` (e.g. pixi Export on the same chord as Quick Open) steals + // focus before the palette entry can claim it. + // Recording a chord in settings blocks these for the same reason the palette does: + // the keys are being captured, not invoked. + if (!editor.command_palette.open and !KeybindSettings.isRecording()) { + for (editor.host.plugins.items) |plugin| { + plugin.tickKeybinds() catch |err| { + dvui.log.err("Plugin keybind tick failed: {s}", .{@errorName(err)}); + }; + } } Keybinds.tick() catch { dvui.log.err("Failed to tick hotkeys", .{}); @@ -2973,6 +3060,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result { dvui.log.err("Plugin overlay draw failed: {s}", .{@errorName(err)}); }; } + + editor.command_palette.draw(editor); } // Arms the launch update toast once the background check reports a newer @@ -3001,6 +3090,10 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // out and removes itself when the timer expires. editor.drawSaveToasts(); + // Every widget has drawn by now, so this is the frame's final answer about who holds + // keyboard focus. Read next frame by the clipboard commands. + editor.text_input_focused = dvui.currentWindow().textInputRequested() != null; + editor.saveSettingsGuarded() catch |err| { dvui.log.err("Failed to autosave settings ({s})", .{@errorName(err)}); }; @@ -3016,7 +3109,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { return .ok; } -fn queueNativeMenuAction(editor: *Editor, action: fizzy.backend.NativeMenuAction) void { +fn queueNativeMenuAction(editor: *Editor, action: usize) void { if (editor.pending_native_menu_actions_len >= editor.pending_native_menu_actions.len) { // If we ever overflow, drop the action rather than crashing. return; @@ -3066,111 +3159,21 @@ fn flushQueuedNativeMenuItems(editor: *Editor) void { } } -pub fn handleNativeMenuAction(editor: *Editor, action: fizzy.backend.NativeMenuAction) !void { - switch (action) { - .open_folder => { - if (comptime builtin.target.cpu.arch == .wasm32) { - Dialogs.WebFolderUnavailable.request(); - } else if (try dvui.dialogNativeFolderSelect(dvui.currentWindow().arena(), .{ .title = "Open Project Folder" })) |folder| { - try editor.setProjectFolder(folder); - } - }, - .open_files => { - if (comptime builtin.target.cpu.arch == .wasm32) { - fizzy.backend.showOpenFileDialog( - struct { - fn cb(_: ?[][:0]const u8) void {} - }.cb, - &.{}, - "", - null, - ); - } else if (try dvui.dialogNativeFileOpenMultiple(dvui.currentWindow().arena(), .{ - .title = "Open Files...", - })) |files| { - for (files) |file| { - _ = editor.openFilePath(file, editor.currentGroupingID()) catch { - std.log.err("Failed to open file: {s}", .{file}); - }; - } - } - }, - .save => { - editor.save() catch { - std.log.err("Failed to save", .{}); - }; - }, - .save_all => { - editor.saveAll() catch { - std.log.err("Failed to save all", .{}); - }; - }, - .new_file => { - editor.requestNewFileDialog(); - }, - .save_as => { - editor.requestSaveAs(); - }, - .copy => { - if (editor.activeDoc() != null) { - editor.copy() catch { - std.log.err("Failed to copy", .{}); - }; - } - // Also let whatever widget currently has keyboard focus handle it (Output Panel, - // a plugin search box, ...) — see `forwardKeybindToFocusedWidget`. Harmless no-op - // for a focused document editor, which already handled it above. - editor.forwardKeybindToFocusedWidget("copy") catch |err| { - dvui.log.err("Failed to forward copy to focused widget: {any}", .{err}); - }; - }, - .paste => { - if (editor.activeDoc() != null) { - editor.paste() catch { - std.log.err("Failed to paste", .{}); - }; - } - editor.forwardKeybindToFocusedWidget("paste") catch |err| { - dvui.log.err("Failed to forward paste to focused widget: {any}", .{err}); - }; - }, - .undo => { - if (editor.activeDoc()) |doc| { - doc.owner.undo(doc) catch { - std.log.err("Failed to undo", .{}); - }; - } - }, - .redo => { - if (editor.activeDoc()) |doc| { - doc.owner.redo(doc) catch { - std.log.err("Failed to redo", .{}); - }; - } - }, - .toggle_explorer => { - // Use .closed, not paned.split_ratio — split_ratio is only valid during draw - if (editor.explorer.closed) { - editor.explorer.open(); - } else { - editor.explorer.close(); - } - // Native menu does not go through SDL events; request a frame so the paned animates immediately. - dvui.refresh(null, @src(), dvui.currentWindow().data().id); - }, - .show_dvui_demo => { - dvui.Examples.show_demo_window = !dvui.Examples.show_demo_window; - }, - .about, .check_for_updates => { - // Mirror the infobar fizzy button: the About dialog displays version, current - // update status, and a Check-for-Updates / Install button. Both menu items land - // here so the macOS Help → "Check for Updates…" path is congruent with the in-app affordance. - Dialogs.AboutFizzy.request(); - }, - .report_bug => { - _ = dvui.openURL(.{ .url = "https://github.com/fizzyedit/fizzy/issues" }); - }, - } +/// Run the command a menu-bar item stands for. +/// +/// This used to be a switch that reimplemented every action a third time (`Menu.zig` had its +/// own inline copy, `Keybinds` had the command body), and the copies had drifted — the menu-bar +/// Open Folder went through `fizzy.backend` while the command went straight to +/// `dvui.dialogNative*`, which no-ops on web. The item now names a command and nothing else. +pub fn handleNativeMenuAction(editor: *Editor, tag: usize) !void { + const item = menu_model.byTag(tag) orelse { + dvui.log.err("native menu tag {d} is not a model item", .{tag}); + return; + }; + const id = item.id; + editor.host.runCommand(id) catch |err| { + dvui.log.err("native menu command '{s}' failed: {s}", .{ id, @errorName(err) }); + }; } pub fn setTitlebarColor(editor: *Editor) void { @@ -3359,14 +3362,24 @@ pub fn close(app: *App, editor: *Editor) void { } } -pub fn setProjectFolder(editor: *Editor, path: []const u8) !void { +/// The single choke point every folder open funnels through (CLI argv, menus, recents, the +/// SDK's `Host.setProjectFolder`), so `path` is canonicalized here once — plugins, recents and +/// anything deriving a key from `editor.folder` (a language server's `rootUri`, notably) then +/// can't disagree about how the same directory is spelled. See `fizzy.paths.normalize`. +pub fn setProjectFolder(editor: *Editor, path_in: []const u8) !void { + const path = try fizzy.paths.normalize(fizzy.app.allocator, path_in); + defer fizzy.app.allocator.free(path); + if (editor.folder) |folder| { editor.ignore.deinit(fizzy.app.allocator); for (editor.host.plugins.items) |plugin| plugin.onFolderClose(); fizzy.app.allocator.free(folder); } editor.folder = try fizzy.app.allocator.dupe(u8, path); + editor.command_palette.invalidate(); try editor.recents.appendFolder(try fizzy.app.allocator.dupe(u8, path)); + // The dvui menu re-reads recents every frame; the macOS submenu is retained state. + fizzy.backend.rebuildNativeRecentFolders(); if (editor.host.firstVisibleSidebarView()) |view| { editor.host.setActiveSidebarView(view.id); } @@ -3421,16 +3434,26 @@ pub fn clearFileTreeTabDragDropState(editor: *Editor) void { // multiple workspace `processTabDrag` calls in one frame do not race. } -pub fn openFilePath(editor: *Editor, path: []const u8, grouping: u64) !bool { - // Already open? Just focus it. - for (editor.open_files.values(), 0..) |doc, i| { - if (std.mem.eql(u8, editor.docPath(doc), path)) { +/// Choke point for every file open (CLI argv, file tree, palette, drag-drop, SDK +/// `Host.openFilePath`). Canonicalizes `path_in` once so `loading_jobs`, the document's stored +/// path, and later `docFromPath` lookups all agree — otherwise `foo/./bar.zig` and `foo/bar.zig` +/// would open as two documents. See `fizzy.paths.normalize`. +pub fn openFilePath(editor: *Editor, path_in: []const u8, grouping: u64) !bool { + const path = try fizzy.paths.normalize(fizzy.app.allocator, path_in); + defer fizzy.app.allocator.free(path); + + // Already open? Just focus it. (`docFromPath` also collapses lexical variants, so a doc + // opened under a pre-normalization spelling is still found.) + if (editor.docFromPath(path)) |doc| { + if (editor.open_files.getIndex(doc.id)) |i| { editor.setActiveFile(i); - return false; } + return false; } // Already loading? Mark this as the most-recent request so it gets focused on completion. + // Jobs always key on the canonical path (see `FileLoadJob.create` below), so a second open + // with a `.`-suffixed spelling collapses onto the in-flight one rather than spawning two. if (editor.loading_jobs.getKey(path)) |existing_key| { editor.last_load_request_path = existing_key; return false; @@ -3444,7 +3467,7 @@ pub fn openFilePath(editor: *Editor, path: []const u8, grouping: u64) !bool { return false; }; - // Spawn a worker. The job owns the path string we'll key the map by. + // Spawn a worker. The job owns the (already canonical) path string we'll key the map by. const io = dvui.io; const job = try FileLoadJob.create(fizzy.app.allocator, path, owner, grouping); errdefer job.destroy(io); @@ -3475,32 +3498,39 @@ pub fn openFilePath(editor: *Editor, path: []const u8, grouping: u64) !bool { return true; } -/// Synchronous open from browser file-picker bytes. Registers the document and returns its id. -pub fn openFileFromBytes(editor: *Editor, path: []u8, bytes: []const u8, grouping: u64) !u64 { +/// Synchronous open from browser file-picker bytes. Takes ownership of `path_in` and registers +/// the document under its canonical spelling (same contract as `openFilePath`). Returns its id. +pub fn openFileFromBytes(editor: *Editor, path_in: []u8, bytes: []const u8, grouping: u64) !u64 { + const path = blk: { + defer fizzy.app.allocator.free(path_in); + break :blk try fizzy.paths.normalize(fizzy.app.allocator, path_in); + }; + + // Freed on every exit path below except the success transfer into the plugin document + // (loaders dupe `path`). Cleared to null after that free so a later `errdefer` can't + // double-free if `insertOpenDoc` fails. + var path_owned: ?[]u8 = path; + errdefer if (path_owned) |p| fizzy.app.allocator.free(p); + if (editor.docFromPath(path)) |existing| { if (editor.open_files.getIndex(existing.id)) |idx| { editor.setActiveFile(idx); } - fizzy.app.allocator.free(path); return error.AlreadyOpen; } const owner = editor.host.pluginForExtension(std.fs.path.extension(path)) orelse { - fizzy.app.allocator.free(path); return error.InvalidExtension; }; const staging = try owner.allocDocumentBuffer(fizzy.app.allocator); defer fizzy.app.allocator.free(staging.backing); - const handled = owner.loadDocumentFromBytes(path, bytes, staging.buf.ptr) catch |err| { - fizzy.app.allocator.free(path); - return err; - }; - if (!handled) { - fizzy.app.allocator.free(path); - return error.InvalidFile; - } + const handled = try owner.loadDocumentFromBytes(path, bytes, staging.buf.ptr); + if (!handled) return error.InvalidFile; + + fizzy.app.allocator.free(path); + path_owned = null; owner.setDocumentGroupingOnBuffer(staging.buf.ptr, grouping); const id = owner.documentIdFromBuffer(staging.buf.ptr); @@ -3870,7 +3900,7 @@ pub fn paste(editor: *Editor) !void { /// box, dvui's own text-selection widgets — never sees the raw keystroke at all, unlike on /// Windows/Linux where the un-marked-handled key event still reaches it normally. Synthesizing /// the event here routes it through the same per-widget handling those platforms already use. -fn forwardKeybindToFocusedWidget(_: *Editor, name: []const u8) !void { +pub fn forwardKeybindToFocusedWidget(_: *Editor, name: []const u8) !void { const cw = dvui.currentWindow(); const kb = cw.keybinds.get(name) orelse return; const key = kb.key orelse return; @@ -3881,7 +3911,19 @@ fn forwardKeybindToFocusedWidget(_: *Editor, name: []const u8) !void { if (kb.alt orelse false) mod.combine(.lalt); if (kb.command orelse false) mod.combine(.lcommand); + // `addEventKey` writes `Window.modifiers` as *persistent* state, not per-event: whatever the + // last key event carried is what the window reports as currently held until another key + // event replaces it. Injecting a lone key-down therefore leaves the whole app believing + // cmd/ctrl is held down forever — which is why pasting from the menu left documents stuck in + // ctrl-hover mode, underlining words and treating clicks as go-to-definition. On the real + // key path this never happens: the user's own key-up follows and clears it. + // + // So: send the matching release. Anything tracking press/release pairs sees a complete one, + // and because the release carries no modifiers, `addEventKey` resets the window's held-key + // state through dvui's own path — no reaching into `cw.modifiers` from out here. `.none` is + // the honest value: a menu click means nothing is physically held. _ = try cw.addEventKey(.{ .code = key, .action = .down, .mod = mod }); + _ = try cw.addEventKey(.{ .code = key, .action = .up, .mod = .none }); } pub fn deleteSelectedContents(editor: *Editor) void { @@ -4278,6 +4320,17 @@ pub fn deinit(editor: *Editor) !void { editor.ignore.deinit(fizzy.app.allocator); + if (editor.keybind_conflicts) |c| { + fizzy.app.allocator.free(c); + editor.keybind_conflicts = null; + } + if (editor.keybinds_overrides) |*f| { + f.deinit(fizzy.app.allocator); + editor.keybinds_overrides = null; + } + KeybindSettings.deinit(fizzy.app.allocator); + editor.keymap.deinit(fizzy.app.allocator); + if (editor.folder) |folder| fizzy.app.allocator.free(folder); editor.arena.deinit(); } diff --git a/src/editor/KeybindSettings.zig b/src/editor/KeybindSettings.zig new file mode 100644 index 00000000..7aafc1d0 --- /dev/null +++ b/src/editor/KeybindSettings.zig @@ -0,0 +1,704 @@ +//! Keyboard Shortcuts settings pane — searchable via the settings tree under Fizzy. +//! +//! Lists every Host command with its current chord(s), lets the user click to record a new +//! binding (written to `keybinds.zon`), reset to default, and surfaces `Keymap.conflicts()`. +//! +//! Rows are laid out with `dvui.grid` (sortable headers), one grid per owner (Fizzy, or a +//! plugin), each inside a collapsible tree branch that starts closed. +//! +//! **This pane is part of the settings search.** `score` is the data pass the settings tree runs +//! before anything is drawn (see `SettingsTree`'s header comment); `draw` re-runs the same +//! `collectGroups` and renders only the commands that survived, with the matched characters of +//! each title tinted. A query that matches one keybind draws one branch with one row. +//! +//! **Column layout matches dvui's grid "fit window" demo:** every frame +//! `columnLayoutProportional` sizes columns to the grid's content width (Command flexes; +//! Shortcut/Reset stay fixed). No header resize handles — those are a separate demo mode +//! (`user_resizable`) and only mutate one column, which fights a fit-to-pane layout. +//! +//! **The grids never report a width to the explorer.** The explorer pane is a horizontally +//! scrolling area, so a child that asks for more width than the viewport makes the pane scroll — +//! and because a scroll container hands its children `max(virtual_size.w, viewport.w)`, a grid +//! sized from its parent's width would then ask for that new, larger width the next frame and +//! ratchet wider every frame. The grid is capped with `max_size_content = .width(0)` so it +//! contributes nothing to the pane's virtual width. +const std = @import("std"); +const builtin = @import("builtin"); +const dvui = @import("dvui"); +const icons = @import("icons"); +const core = @import("core"); +const fizzy = @import("../fizzy.zig"); +const keymap = @import("keymap/keymap.zig"); +const adapter = @import("keymap/dvui_adapter.zig"); +const Keybinds = @import("Keybinds.zig"); + +const wdvui = core.dvui; +const fuzzy = core.fuzzy; + +/// Command id currently waiting for a key press, or null when idle. Points into +/// `host.commands` (stable for the session while the plugin stays loaded). +var recording: ?[]const u8 = null; + +/// Shared column widths across every owner grid. +var col_widths: [3]f32 = .{ 0, 0, 0 }; + +/// Owners present in this set are expanded. Default (absent) is collapsed — the pane opens as a +/// short list of owners rather than a wall of tables. +var open_owners: std.AutoHashMapUnmanaged(u64, void) = .empty; + +/// Terms that name the *table itself* rather than any one command, so searching "shortcuts" +/// finds the whole list. Scored one term at a time rather than as a single sentence: against one +/// long string a query only has to be a scattered subsequence of it ("copy" picking up letters +/// from four different words), and every such accident would flood the pane with every command. +const table_keywords = [_][]const u8{ + "keybind", "keybinding", "keybindings", "keyboard", "shortcut", + "shortcuts", "hotkey", "hotkeys", "chord", "chords", + "keys", "remap", "bindings", +}; + +/// Whether a chord is being captured right now. +/// +/// While this is true the app has to stop treating keys as commands, on both of the paths that +/// can consume one before `pollRecording` ever sees it: +/// +/// - dvui events — `Keybinds.tick` and every plugin's `tickKeybinds` are skipped by the frame +/// loop, the same way they are while the command palette owns the keyboard. +/// - the macOS menu bar — AppKit matches an `NSMenu` key equivalent and fires its action +/// *before* the key ever reaches SDL, so no amount of dvui-side event handling can stop it. +/// `FizzyNativeMenuActionEnabled` reports every item disabled while recording, and AppKit +/// will not perform a disabled item's key equivalent. Without this, recording `cmd+o` opened +/// the folder picker instead of being captured. +pub fn isRecording() bool { + return recording != null; +} + +pub fn deinit(gpa: std.mem.Allocator) void { + open_owners.deinit(gpa); +} + +/// Proportional layout ratios for `columnLayoutProportional`: negative = flex share of leftover, +/// positive = fixed px. Command takes the remainder; Shortcut/Reset keep a comfortable width. +const col_ratios = [3]f32{ -1, 140, 64 }; + +// ---- match model -------------------------------------------------------------------------- + +const Row = struct { + cmd: fizzy.sdk.Host.Command, + keys: []const u8, + score: f64, + tie: usize, +}; + +const Group = struct { + owner: []const u8, + title: []const u8, + key: u64, + /// Best score among this group's rows — drives branch order while searching. Kept separate + /// from `owner_hit`: folding the two together made the *first* matching command lower + /// `score` below `floatMax`, which then read as "the owner matched" and let every later + /// command in the group through the filter. + score: f64, + /// Set when the query matched the owner's own name — that keeps all of its commands. + owner_hit: ?f64, + tie: usize, + rows: std.ArrayListUnmanaged(Row) = .empty, +}; + +fn ownerKey(owner: []const u8) u64 { + return std.hash.Wyhash.hash(0xb17d5, owner); +} + +fn isOwnerOpen(key: u64) bool { + return open_owners.contains(key); +} + +fn setOwnerOpen(key: u64, open: bool) void { + if (open) { + open_owners.put(fizzy.app.allocator, key, {}) catch {}; + } else { + _ = open_owners.remove(key); + } +} + +fn ownerPrefix(id: []const u8) []const u8 { + return if (std.mem.indexOfScalar(u8, id, '.')) |dot| id[0..dot] else id; +} + +fn ownerLabel(owner: []const u8) []const u8 { + if (std.mem.eql(u8, owner, "fizzy")) return "Fizzy"; + const editor = fizzy.editor; + if (editor.host.pluginById(owner)) |p| return p.display_name; + return owner; +} + +/// Group every command by owner, keeping only what `query` matched. Rebuilt from scratch (into +/// the frame arena) by both `score` and `draw` so the two can't disagree about what matched. +fn collectGroups( + arena: std.mem.Allocator, + query: *const fuzzy.Query, + platform: keymap.Platform, +) std.ArrayListUnmanaged(Group) { + const editor = fizzy.editor; + var groups: std.ArrayListUnmanaged(Group) = .empty; + + // A hit on the table's own name shows the whole list — but only as a *fallback*, applied + // below once it's clear no individual command matched. Applied per row instead, a query that + // matched three commands and also happened to brush a keyword would draw all of them. + const table_hit = fuzzy.scoreBest(&table_keywords, query, .{ .plain = true }); + var any_row = false; + + for (editor.host.commands.items, 0..) |c, ci| { + const owner = ownerPrefix(c.id); + const group = blk: { + for (groups.items) |*g| { + if (std.mem.eql(u8, g.owner, owner)) break :blk g; + } + const title = ownerLabel(owner); + // A hit on the owner's name ("text") shows all of that plugin's commands. + const owner_hit = fuzzy.scoreBest(&.{ title, owner }, query, .{ .plain = true }); + groups.append(arena, .{ + .owner = owner, + .title = title, + .key = ownerKey(owner), + .score = owner_hit orelse std.math.floatMax(f64), + .owner_hit = owner_hit, + .tie = groups.items.len, + }) catch continue; + break :blk &groups.items[groups.items.len - 1]; + }; + + const cmd_hit = fuzzy.scoreBest(&.{ c.title, c.id }, query, .{ .plain = true }); + const s = cmd_hit orelse group.owner_hit orelse continue; + + const keys = if (shortcutFor(editor, c.id, platform)) |sc| sc.keys else ""; + group.rows.append(arena, .{ .cmd = c, .keys = keys, .score = s, .tie = ci }) catch continue; + if (s < group.score) group.score = s; + any_row = true; + } + + // Nothing matched by name, but the query named the table itself ("shortcuts") — then the + // whole list is the answer. + if (!any_row) { + const s = table_hit orelse return .empty; + for (groups.items) |*g| { + g.score = s; + for (editor.host.commands.items, 0..) |c, ci| { + if (!std.mem.eql(u8, ownerPrefix(c.id), g.owner)) continue; + const keys = if (shortcutFor(editor, c.id, platform)) |sc| sc.keys else ""; + g.rows.append(arena, .{ .cmd = c, .keys = keys, .score = s, .tie = ci }) catch {}; + } + } + } + + // Drop owners whose commands all missed, then rank best-first while searching. With an empty + // query every score is 0 and registration order is the intended reading order. + var kept: std.ArrayListUnmanaged(Group) = .empty; + for (groups.items) |g| { + if (g.rows.items.len == 0) continue; + kept.append(arena, g) catch {}; + } + if (!query.isEmpty()) { + std.sort.block(Group, kept.items, {}, lowerGroup); + for (kept.items) |*g| std.sort.block(Row, g.rows.items, {}, lowerRow); + } + return kept; +} + +fn lowerGroup(_: void, a: Group, b: Group) bool { + if (a.score != b.score) return a.score < b.score; + return a.tie < b.tie; +} + +fn lowerRow(_: void, a: Row, b: Row) bool { + if (a.score != b.score) return a.score < b.score; + return a.tie < b.tie; +} + +/// Settings-tree search hook: the best score among the commands this pane would draw, or null +/// when nothing matches (the whole "Keyboard Shortcuts" row then disappears from the tree). +pub fn score(query: *const fuzzy.Query) ?f64 { + if (comptime builtin.target.cpu.arch == .wasm32) { + // No keybinds on web — the pane draws an explanatory line, so only match its own name. + return fuzzy.scoreBest(&table_keywords, query, .{ .plain = true }); + } + if (query.isEmpty()) return 0; + + const platform: keymap.Platform = if (fizzy.platform.isMacOS()) .mac else .other; + const groups = collectGroups(dvui.currentWindow().arena(), query, platform); + var best: ?f64 = null; + for (groups.items) |g| { + if (best == null or g.score < best.?) best = g.score; + } + return best; +} + +// ---- drawing ------------------------------------------------------------------------------ + +pub fn draw(query: *const fuzzy.Query) void { + if (comptime builtin.target.cpu.arch == .wasm32) { + dvui.label(@src(), "Keybindings are not available on the web build.", .{}, .{ + .color_text = dvui.themeGet().color(.window, .text).opacity(0.6), + }); + return; + } + + const editor = fizzy.editor; + const theme = dvui.themeGet(); + const platform: keymap.Platform = if (fizzy.platform.isMacOS()) .mac else .other; + const arena = dvui.currentWindow().arena(); + + drawConflicts(editor, platform, theme); + + // No banner: the row being recorded says so itself. A strip appearing above the tree pushed + // every row down the moment you clicked one, so the shortcut you were aiming at moved. + if (recording) |id| { + if (pollRecording(editor, id, platform)) { + recording = null; + } + } + + const searching = !query.isEmpty(); + const groups = collectGroups(arena, query, platform); + if (groups.items.len == 0) return; + + // Two trees, one per mode: a search force-expands branches and `TreeWidget` keeps expansion + // per widget id, so sharing one id space would bleed "expanded because searching" into the + // browsing tree's animation state (same split `SettingsTree` uses). + var tree = wdvui.TreeWidget.tree(@src(), .{}, .{ + .id_extra = @intFromBool(searching), + .expand = .horizontal, + .background = false, + }); + defer tree.deinit(); + + for (groups.items, 0..) |*group, gi| { + drawOwnerBranch(tree, editor, group, query, searching, gi, platform, theme); + } +} + +fn drawOwnerBranch( + tree: *wdvui.TreeWidget, + editor: *fizzy.Editor, + group: *const Group, + query: *const fuzzy.Query, + searching: bool, + id_extra: usize, + platform: keymap.Platform, + theme: dvui.Theme, +) void { + // While searching every branch is forced open and `open_owners` is left untouched, so + // clearing the query drops straight back to whatever the user had expanded. + const want_open = searching or isOwnerOpen(group.key); + + const b = tree.branch(@src(), .{ + .expanded = want_open, + .animation_duration = 450_000, + .animation_easing = dvui.easing.outBack, + }, .{ + .id_extra = id_extra, + .expand = .horizontal, + .color_fill_hover = theme.color(.control, .fill).opacity(0.5), + .color_fill_press = theme.color(.control, .fill_press), + .color_fill = .transparent, + .padding = dvui.Rect.all(1), + }); + defer b.deinit(); + + { + const icon_color = theme.color(.control, .fill); + { + var slot = wdvui.treeRowGlyph(@src(), .{}); + defer slot.deinit(); + _ = dvui.icon( + @src(), + "KeybindOwnerCaret", + if (b.expanded) icons.tvg.entypo.@"down-open" else icons.tvg.entypo.@"right-open", + .{ .fill_color = icon_color, .stroke_color = icon_color }, + wdvui.treeRowIconOptions(.{}), + ); + } + { + var slot = wdvui.treeRowGlyph(@src(), .{ .margin = .{ .w = 2 } }); + defer slot.deinit(); + _ = dvui.icon( + @src(), + "KeybindOwnerIcon", + icons.tvg.entypo.folder, + .{ .fill_color = icon_color, .stroke_color = icon_color }, + wdvui.treeRowIconOptions(.{}), + ); + } + // Same text colour and match-tinting as every other row in the pane. + wdvui.labelHighlighted(@src(), group.title, query, true, .{ + .gravity_y = 0.5, + .expand = .horizontal, + .font = dvui.Font.theme(.body), + .color_text = theme.color(.control, .text), + .margin = .all(0), + .padding = dvui.Rect.all(3), + }); + } + + if (b.expander(@src(), .{ .indent = 14 }, .{ + .expand = .horizontal, + .corners = .all(8), + })) { + drawOwnerGrid(editor, group, query, id_extra, platform, theme); + } + + if (!searching) setOwnerOpen(group.key, b.expanded); +} + +/// Solid dot, drawn rather than iconified — lucide's circles are stroked outlines, and a +/// recording indicator wants a filled disc. +fn recordingDot() void { + const size = dvui.Font.theme(.body).textHeight() * 0.55; + var b = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .min_size_content = .{ .w = size, .h = size }, + .expand = .none, + .gravity_y = 0.5, + .margin = .{ .x = 2, .w = 4 }, + .padding = dvui.Rect.all(0), + }); + defer b.deinit(); + + const r = b.data().borderRectScale().r; + r.fill(.all(r.h / 2), .{ .color = dvui.themeGet().color(.err, .fill) }); +} + +fn drawConflicts(editor: *fizzy.Editor, platform: keymap.Platform, theme: dvui.Theme) void { + const conflicts = editor.keybind_conflicts orelse return; + if (conflicts.len == 0) return; + + var box = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .horizontal, + .background = true, + .color_fill = theme.color(.err, .fill).opacity(0.25), + .corners = .all(6), + .padding = dvui.Rect.all(6), + .margin = .{ .h = 8 }, + }); + defer box.deinit(); + + dvui.label(@src(), "Conflicts", .{}, .{ + .font = dvui.Font.theme(.heading), + .expand = .horizontal, + }); + for (conflicts, 0..) |c, i| { + const keys = keymap.formatKeys(dvui.currentWindow().arena(), c.stroke, platform) catch "?"; + dvui.label(@src(), "{s}: {s} shadows {s}", .{ keys, c.winner, c.loser }, .{ + .id_extra = i, + .expand = .horizontal, + .color_text = theme.color(.window, .text).opacity(0.85), + }); + } +} + +/// Last-column heading with no trailing separator — `gridHeading(…, null, …)` still draws one, +/// which read as a tiny fourth column. +fn drawResetHeading(grid: *dvui.GridWidget, cell_style: dvui.GridWidget.CellStyle) void { + const cell_pos: dvui.GridWidget.Cell = .colRow(2, 0); + var cell = grid.headerCell(@src(), 2, cell_style.cellOptions(cell_pos)); + defer cell.deinit(); + + dvui.labelNoFmt(@src(), "Reset", .{}, cell_style.options(cell_pos).override(.{ + .expand = .horizontal, + .gravity_x = 0.5, + .gravity_y = 0.5, + .background = false, + .corners = .{}, + })); +} + +fn drawOwnerGrid( + editor: *fizzy.Editor, + group: *const Group, + query: *const fuzzy.Query, + id_extra: usize, + platform: keymap.Platform, + theme: dvui.Theme, +) void { + var grid = dvui.grid(@src(), .colWidths(&col_widths), .{ + .scroll_opts = .{ + // Vertical: none — the grid grows with its rows and the explorer pane scrolls. + // Horizontal: none — columns are fit to the grid width every frame (dvui "fit window"). + .horizontal = .none, + .vertical = .none, + .horizontal_bar = .hide, + .vertical_bar = .hide, + }, + .resize_rows = false, + }, .{ + .id_extra = id_extra, + .expand = .horizontal, + // Ask the pane for nothing: see this file's header comment. Height is left unbounded so + // the grid still reports its full row stack and grows to fit. + .max_size_content = .width(0), + .padding = .all(0), + .background = true, + .color_fill = theme.color(.window, .fill).opacity(0.25), + .corners = .all(4), + }); + defer grid.deinit(); + + // Same pattern as dvui's grid demo `.fit_window`: recompute widths from the grid's content + // rect every frame so the table tracks the pane. Positive ratios are fixed; `-1` flexes. + // + // `columnLayoutProportional` always subtracts `scrollbar_padding_defaults.w` (room for a + // vertical bar). We hide that bar, so add it back — otherwise a ~10px strip of grid + // background shows past the last column and the row/header fills don't reach the edge. + dvui.columnLayoutProportional( + &col_ratios, + &col_widths, + grid.data().contentRect().w + dvui.GridWidget.scrollbar_padding_defaults.w, + ); + + // Alphabetical by command until the user clicks a heading. `.unsorted` is only ever the + // grid's *initial* state — a click always leaves it ascending or descending, and that is + // what the grid persists — so this can't stomp a sort the user picked. + if (grid.sort_direction == .unsorted) grid.colSortSet(0, .ascending); + + const banded: dvui.GridWidget.CellStyle.Banded = .{ + .cell_opts = .{ + .padding = .{ .x = 6, .y = 2, .w = 4, .h = 2 }, + .background = true, + }, + .alt_cell_opts = .{ + .padding = .{ .x = 6, .y = 2, .w = 4, .h = 2 }, + .background = true, + .color_fill = theme.color(.control, .fill).opacity(0.22), + }, + }; + + const heading_style: dvui.GridWidget.CellStyle = .{ + .cell_opts = .{ + .padding = .{ .x = 6, .y = 2, .w = 4, .h = 2 }, + .background = true, + .color_fill = theme.color(.control, .fill).opacity(0.35), + }, + .opts = .{ + .expand = .horizontal, + .gravity_y = 0.5, + .font = dvui.Font.theme(.body).withWeight(.bold), + .color_text = theme.color(.window, .text).opacity(0.7), + }, + }; + + var plain_heading_style = heading_style; + plain_heading_style.opts.background = false; + + // Sortable headers with static separators only (fit-window mode — not user-resizable). + var sort_dir: dvui.GridWidget.SortDirection = .unsorted; + _ = dvui.gridHeadingSortable(@src(), grid, 0, "Command", &sort_dir, null, heading_style); + _ = dvui.gridHeadingSortable(@src(), grid, 1, "Shortcut", &sort_dir, null, heading_style); + drawResetHeading(grid, plain_heading_style); + + // Sorting reorders the rows this branch already filtered down to, so it composes with search. + const rows = dvui.currentWindow().arena().dupe(Row, group.rows.items) catch group.rows.items; + if (sort_dir != .unsorted) { + const sort_col = grid.sort_col_number; + const asc = sort_dir == .ascending; + std.sort.pdq(Row, rows, SortCtx{ .col = sort_col, .asc = asc }, SortCtx.lessThan); + } + + for (rows, 0..) |row, ri| { + drawCommandRow(grid, editor, row.cmd, query, ri, platform, theme, banded); + } +} + +const SortCtx = struct { + col: usize, + asc: bool, + + fn lessThan(ctx: SortCtx, a: Row, b: Row) bool { + const order: std.math.Order = switch (ctx.col) { + 0 => std.ascii.orderIgnoreCase(a.cmd.title, b.cmd.title), + 1 => std.mem.order(u8, a.keys, b.keys), + else => .eq, + }; + return if (ctx.asc) order == .lt else order == .gt; + } +}; + +fn drawCommandRow( + grid: *dvui.GridWidget, + editor: *fizzy.Editor, + c: fizzy.sdk.Host.Command, + query: *const fuzzy.Query, + row: usize, + platform: keymap.Platform, + theme: dvui.Theme, + banded: dvui.GridWidget.CellStyle.Banded, +) void { + const shortcut = shortcutFor(editor, c.id, platform); + const keys_text = if (shortcut) |s| s.keys else "—"; + const inherited = if (shortcut) |s| s.inherited else false; + const is_recording = if (recording) |r| std.mem.eql(u8, r, c.id) else false; + const has_override = Keybinds.hasUserOverride(editor, c.id); + + { + const cell_pos: dvui.GridWidget.Cell = .colRow(0, row); + var cell = grid.bodyCell(@src(), cell_pos, banded.cellOptions(cell_pos)); + defer cell.deinit(); + + var left = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .both, + .gravity_y = 0.5, + .margin = .all(0), + .padding = .all(0), + }); + defer left.deinit(); + wdvui.labelHighlighted(@src(), c.title, query, true, .{ + .expand = .horizontal, + .margin = .all(0), + .padding = .all(0), + }); + wdvui.labelHighlighted(@src(), c.id, query, true, .{ + .expand = .horizontal, + .margin = .all(0), + .padding = .all(0), + .color_text = theme.color(.window, .text).opacity(0.45), + }); + } + + { + const cell_pos: dvui.GridWidget.Cell = .colRow(1, row); + var cell = grid.bodyCell(@src(), cell_pos, banded.cellOptions(cell_pos)); + defer cell.deinit(); + + // Hand-built rather than `dvui.button` so the recording state can put a dot next to the + // label. Same widget id either way, so starting a recording doesn't reset the button's + // hover/press state. Column width is fixed — only the label text changes. + const clicked = blk: { + var bw: dvui.ButtonWidget = undefined; + bw.init(@src(), .{}, .{ + .expand = .horizontal, + .gravity_y = 0.5, + .margin = .{ .x = 0, .y = 1, .w = 0, .h = 1 }, + .padding = .{ .x = 4, .y = 2, .w = 4, .h = 2 }, + // Recording is now signalled here and nowhere else (there is no banner), so the + // row carries it: a fully-rounded red pill. The radius is deliberately far larger + // than the button — dvui clamps it to half the height, which is what makes the + // ends semicircular at any row height. + .corners = if (is_recording) .all(10_000_000) else null, + .color_fill = if (is_recording) theme.color(.err, .fill).opacity(0.18) else null, + }); + defer bw.deinit(); + bw.processEvents(); + bw.drawBackground(); + + { + var inner = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .both, + .margin = .all(0), + .padding = .all(0), + }); + defer inner.deinit(); + + if (is_recording) recordingDot(); + dvui.labelNoFmt(@src(), if (is_recording) "Recording…" else keys_text, .{}, .{ + .gravity_x = if (is_recording) 0.0 else 0.5, + .gravity_y = 0.5, + .expand = .horizontal, + .color_text = if (is_recording) + theme.color(.err, .fill) + else if (inherited) + theme.color(.control, .text).opacity(0.55) + else + null, + }); + } + + break :blk bw.clicked(); + }; + if (clicked) { + recording = if (is_recording) null else c.id; + } + } + + { + const cell_pos: dvui.GridWidget.Cell = .colRow(2, row); + var cell = grid.bodyCell(@src(), cell_pos, banded.cellOptions(cell_pos)); + defer cell.deinit(); + + // Always occupy the reset column so binding/unbinding never shifts column widths. + if (has_override) { + if (dvui.button(@src(), "Reset", .{}, .{ + .expand = .horizontal, + .gravity_y = 0.5, + .margin = .{ .x = 0, .y = 1, .w = 0, .h = 1 }, + .padding = .{ .x = 4, .y = 2, .w = 4, .h = 2 }, + })) { + Keybinds.clearUserBinding(editor, c.id) catch |err| { + dvui.log.err("clear keybind for '{s}' failed: {s}", .{ c.id, @errorName(err) }); + }; + if (recording) |r| { + if (std.mem.eql(u8, r, c.id)) recording = null; + } + } + } else { + _ = dvui.spacer(@src(), .{ + .expand = .horizontal, + .min_size_content = .{ .w = 0, .h = 1 }, + }); + } + } +} + +const Shortcut = struct { + keys: []const u8, + /// The chord belongs to a Fizzy forwarder this command is reached through, not to this + /// command. Drawn dimmed so it doesn't read as an override the Reset button could clear. + inherited: bool = false, +}; + +fn shortcutFor(editor: *fizzy.Editor, id: []const u8, platform: keymap.Platform) ?Shortcut { + if (directShortcut(editor, id, platform)) |keys| return .{ .keys = keys }; + + // A plugin's document verb (`pixi.copy`) is invoked through the Fizzy forwarder that owns + // the chord (`fizzy.copy` on `cmd+c`), so it has no binding of its own to find. Showing the + // forwarder's chord is the truth about what key runs this command; showing "—" implied it + // had no shortcut at all. + const source = Keybinds.inheritedChordSource(id) orelse return null; + const keys = directShortcut(editor, source, platform) orelse return null; + return .{ .keys = keys, .inherited = true }; +} + +fn directShortcut(editor: *fizzy.Editor, id: []const u8, platform: keymap.Platform) ?[]const u8 { + const arena = dvui.currentWindow().arena(); + const found = editor.keymap.bindingsFor(arena, id) catch return null; + if (found.len == 0) return null; + // Prefer the highest-source binding (user > plugin > profile > dvui). + var best = found[0]; + for (found[1..]) |b| { + if (@intFromEnum(b.source) >= @intFromEnum(best.source)) best = b; + } + return keymap.formatKeys(arena, best.stroke, platform) catch null; +} + +/// Returns true when recording finished (a chord was captured). +/// +/// Nothing is exempt: Esc binds like any other key rather than cancelling, so a command *can* be +/// put on it. The ways out are clicking the row again (which toggles recording off) and the row's +/// Reset button. The one thing still skipped is a bare modifier — those are waited on, since +/// every press of one is the start of a chord the user hasn't finished typing. +fn pollRecording(editor: *fizzy.Editor, command: []const u8, platform: keymap.Platform) bool { + for (dvui.events()) |*e| { + if (e.handled) continue; + if (e.evt != .key) continue; + const ke = e.evt.key; + if (ke.action != .down) continue; + + const chord = adapter.chordFrom(ke) orelse continue; + if (keymap.keyIsModifier(chord.key)) continue; + + e.handle(@src(), dvui.currentWindow().data()); + const keys = keymap.formatKeys(editor.host.allocator, .{ .first = chord }, platform) catch return true; + defer editor.host.allocator.free(keys); + Keybinds.setUserBinding(editor, command, keys) catch |err| { + dvui.log.err("set keybind for '{s}' failed: {s}", .{ command, @errorName(err) }); + }; + return true; + } + return false; +} diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index ccd4f63e..95728dd2 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -1,17 +1,46 @@ +//! Shell keybindings: the default bind table, the shell's own commands, and key dispatch. +//! +//! Keys used to be wired straight to `fizzy.editor.*` calls by a hardcoded if-chain, which meant +//! nothing was addressable by id and so nothing could be rebound. Now every shell action is a +//! registered `Command`, and `tick()` resolves a key event to a command id through +//! `keymap.Keymap` and runs it via the Host registry — the same registry plugin commands live +//! in, which is what makes a single rebindable table (and, later, a command palette) possible. +//! +//! **Migration shape.** dvui's `Window.keybinds` map is still the source of the *default* key +//! for each action: dvui seeds its own binds, `register()` below adds the shell's, and plugins +//! add theirs via `contributeKeybinds` — see `Editor.rebuildKeybinds`. `buildKeymap` then walks +//! that finished map and lifts every entry named in `command_binds` into a `Keymap` binding. +//! Keeping that direction means plugin-contributed binds (workbench's `save`, `open_folder`, …) +//! keep working untouched; they move to `Command.default_keys` in a later step. + const std = @import("std"); const builtin = @import("builtin"); const fizzy = @import("../fizzy.zig"); const dvui = @import("dvui"); +const sdk = @import("fizzy_sdk"); +const keymap = @import("keymap/keymap.zig"); +const adapter = @import("keymap/dvui_adapter.zig"); pub const Keybinds = @This(); +const Editor = @import("Editor.zig"); +const KeybindSettings = @import("KeybindSettings.zig"); +const menu_model = @import("menu_model.zig"); + /// Register the shell's own global / navigation / region binds. File-management /// binds and pixel-art editing binds are contributed by the workbench and /// pixel-art plugins (their `contributeKeybinds`), which `Editor.postInit` invokes /// after the plugins register. This runs during `Editor.init`, before postInit, so /// the shell binds land first; the split is disjoint, so no `putNoClobber` clashes. /// +/// **That disjointness is load-bearing, and plugins claim more names than this repo shows.** +/// pixi (an out-of-tree plugin) registers `undo`, `redo` and `delete_selection_contents` in its +/// own `contributeKeybinds`; adding any of them here panics at startup on every install that has +/// pixi, because `putNoClobber` asserts. A first-come-first-served hash map has no way to express +/// "shell default unless a plugin wants it" — that needs the layered `Command.default_keys` +/// resolution in step C, not another entry in this function. +/// /// Runtime mac detection — `builtin.os.tag.isDarwin()` is `false` for /// wasm32-freestanding, so macOS web users would otherwise get the Windows (Ctrl) /// bindings. `fizzy.platform.isMacOS()` reads DVUI's `navigator.platform`-derived @@ -47,115 +76,953 @@ pub fn register() !void { try window.keybinds.putNoClobber(window.gpa, "cancel", .{ .key = .escape }); } +// ---- shell commands --------------------------------------------------------------------------- + +/// `Host.runCommand` passes `owner.state` to `run`, so the shell needs *a* `Plugin` to hang its +/// commands off. This is that pseudo-plugin: never added to `host.plugins`, so no lifecycle hook +/// ever fires on it and `removeOwned` never touches its commands. The alternative — adding a +/// `state` field to `sdk.regions.Command` — would move the ABI fingerprint and force every +/// third-party plugin to be rebuilt, which isn't worth it for a pointer we can supply this way. +var shell_plugin: sdk.Plugin = .{ + .state = undefined, // set to the Editor in `registerCommands` + .vtable = &shell_vtable, + .id = "fizzy", + .display_name = "Fizzy", +}; + +const shell_vtable: sdk.Plugin.VTable = .{}; + +fn editorFromState(state: *anyopaque) *Editor { + return @ptrCast(@alignCast(state)); +} + +/// One shell action. +/// +/// Whether macOS also fires this through NSMenu — which matters because SDL delivers the same +/// key event and dispatching it here too would run the action twice — is no longer a field +/// here. It is read off `native_menu_bindings`, the menu itself, so the two can't disagree. +const ShellCommand = struct { + id: []const u8, + title: []const u8, + /// dvui keybind name this action's default key comes from, or null when it has no default. + bind: ?[]const u8, + run: *const fn (state: *anyopaque) anyerror!void, + isEnabled: ?*const fn (state: *anyopaque) bool = null, +}; + +const shell_commands = [_]ShellCommand{ + .{ .id = "fizzy.openFolder", .title = "Open Folder…", .bind = "open_folder", .run = cmdOpenFolder }, + .{ .id = "fizzy.openFiles", .title = "Open Files…", .bind = "open_files", .run = cmdOpenFiles }, + .{ .id = "fizzy.newFile", .title = "New File…", .bind = "new_file", .run = cmdNewFile }, + .{ .id = "fizzy.save", .title = "Save", .bind = "save", .run = cmdSave }, + .{ .id = "fizzy.saveAs", .title = "Save As…", .bind = "save_as", .run = cmdSaveAs }, + .{ .id = "fizzy.saveAll", .title = "Save All", .bind = "save_all", .run = cmdSaveAll }, + .{ .id = "fizzy.undo", .title = "Undo", .bind = "undo", .run = cmdUndo, .isEnabled = cmdUndoEnabled }, + .{ .id = "fizzy.redo", .title = "Redo", .bind = "redo", .run = cmdRedo, .isEnabled = cmdRedoEnabled }, + .{ .id = "fizzy.copy", .title = "Copy", .bind = "copy", .run = cmdCopy, .isEnabled = cmdCopyEnabled }, + .{ .id = "fizzy.paste", .title = "Paste", .bind = "paste", .run = cmdPaste, .isEnabled = cmdPasteEnabled }, + .{ .id = "fizzy.toggleExplorer", .title = "Toggle Explorer", .bind = "explorer", .run = cmdToggleExplorer }, + .{ .id = "fizzy.deleteSelection", .title = "Delete Selection", .bind = "delete_selection_contents", .run = cmdDeleteSelection, .isEnabled = cmdDeleteSelectionEnabled }, + .{ .id = "fizzy.accept", .title = "Accept", .bind = "activate", .run = cmdAccept, .isEnabled = cmdAcceptEnabled }, + .{ .id = "fizzy.cancel", .title = "Cancel", .bind = "cancel", .run = cmdCancel, .isEnabled = cmdCancelEnabled }, + // No dvui bind name — these are new, so their defaults come purely from the profile table. + .{ .id = "fizzy.quickOpen", .title = "Go to File…", .bind = null, .run = cmdQuickOpen }, + .{ .id = "fizzy.commandPalette", .title = "Show All Commands", .bind = null, .run = cmdCommandPalette }, + // Menu-bar-only actions. They had no command at all before Stage 1 — they existed solely as + // `NativeMenuAction` switch arms — so they were unreachable from the palette and unbindable. + .{ .id = "fizzy.showDvuiDemo", .title = "Show DVUI Demo", .bind = null, .run = cmdShowDvuiDemo }, + .{ .id = "fizzy.about", .title = "About Fizzy", .bind = null, .run = cmdAbout }, + .{ .id = "fizzy.reportBug", .title = "Report a Bug", .bind = null, .run = cmdReportBug }, +}; + +// Ids and bind names must both be unique: a duplicate id would make `Host.runCommand` +// dispatch to whichever was registered first, and a duplicate bind would silently give two +// commands the same key. Comptime because the table is comptime — a test would be strictly +// weaker than just refusing to compile. +comptime { + for (shell_commands, 0..) |a, i| { + for (shell_commands[i + 1 ..]) |b| { + if (std.mem.eql(u8, a.id, b.id)) { + @compileError("duplicate shell command id: " ++ a.id); + } + const a_bind = a.bind orelse continue; + const b_bind = b.bind orelse continue; + if (std.mem.eql(u8, a_bind, b_bind)) { + @compileError("shell commands '" ++ a.id ++ "' and '" ++ b.id ++ + "' both claim keybind '" ++ a_bind ++ "'"); + } + } + } +} + +// The canonical body for each shell action. Before Stage 1 of the menu unification these were +// implemented up to three times — here, inline in `Menu.zig`, and again in +// `Editor.handleNativeMenuAction` — and had already drifted: the menu-bar versions of Open +// Folder / Open Files went through `fizzy.backend`, which has a wasm implementation, while +// these went straight to `dvui.dialogNative*`, which silently no-ops on web. The backend route +// is the one that survives. + +fn cmdOpenFolder(_: *anyopaque) anyerror!void { + fizzy.backend.showOpenFolderDialog(Editor.Workspace.setProjectFolderCallback, null); +} + +fn cmdOpenFiles(_: *anyopaque) anyerror!void { + fizzy.backend.showOpenFileDialog(Editor.Workspace.openFilesCallback, &.{}, "", null); +} + +fn cmdNewFile(state: *anyopaque) anyerror!void { + editorFromState(state).requestNewFileDialog(); +} +fn cmdSave(state: *anyopaque) anyerror!void { + try editorFromState(state).save(); +} +fn cmdSaveAs(state: *anyopaque) anyerror!void { + editorFromState(state).requestSaveAs(); +} +fn cmdSaveAll(state: *anyopaque) anyerror!void { + try editorFromState(state).saveAll(); +} +fn cmdUndo(state: *anyopaque) anyerror!void { + try editorFromState(state).undo(); +} +fn cmdRedo(state: *anyopaque) anyerror!void { + try editorFromState(state).redo(); +} +/// Set while `tick` is running a command because of a live key event. That event is never +/// marked handled, so it goes on to reach the focused widget under its own steam — a clipboard +/// command that also synthesized one would make the widget act twice. False for menu clicks and +/// the command palette, where no such event exists. +var running_from_key_event: bool = false; + +/// Copy/Paste must reach exactly one target: the active document's editor, or some other +/// focused widget (Output Panel, a settings filter, a plugin search box) — never both. +/// +/// The discriminator is "does keyboard focus belong to the active document", which the shell +/// cannot answer on its own: `dvui.wantTextInput` (via `Editor.text_input_focused`) reports that +/// *a* text input has focus, not which document it belongs to. The document's owner does know, +/// and its command enablement is already the channel for owner-side answers — so the routing is +/// a plugin-side convention rather than any new SDK surface: +/// +/// > A document plugin reports `copy`/`paste` enabled only while its own editor holds keyboard +/// > focus (in-tree: `text`; out-of-tree: pixi). +/// +/// Hence the order below. An enabled document verb means focus is in that document, so it wins +/// outright. Otherwise a focused text input is some other widget's, and the synthetic event is +/// how it gets the chord. The last clause is the safety net for a document plugin that has *not* +/// adopted the convention (no `isEnabled`, or one that only tracks selection): it still gets the +/// verb, preserving the old behaviour rather than silently losing copy in that plugin. +/// +/// The forwarding stays conditional. On macOS `cmd+c` never arrives as an SDL key event — +/// AppKit matches the menu's key equivalent first — so the event must be synthesized. Elsewhere +/// the real event is still in flight (dispatch doesn't mark it handled) and synthesizing would +/// make the widget act twice. Menu clicks and palette invocations carry no key event anywhere, +/// so they always synthesize. +fn clipboardVerb(editor: *Editor, comptime bind: []const u8) anyerror!void { + if (editor.activeDocCommandEnabled(bind)) return runDocumentClipboardVerb(editor, bind); + + if (editor.text_input_focused) { + if (!running_from_key_event) try editor.forwardKeybindToFocusedWidget(bind); + return; + } + + if (editor.activeDoc() != null) try runDocumentClipboardVerb(editor, bind); +} + +fn runDocumentClipboardVerb(editor: *Editor, comptime bind: []const u8) anyerror!void { + if (comptime std.mem.eql(u8, bind, "copy")) { + try editor.copy(); + } else { + try editor.paste(); + } +} + +fn cmdCopy(state: *anyopaque) anyerror!void { + try clipboardVerb(editorFromState(state), "copy"); +} +fn cmdPaste(state: *anyopaque) anyerror!void { + try clipboardVerb(editorFromState(state), "paste"); +} +fn cmdDeleteSelection(state: *anyopaque) anyerror!void { + editorFromState(state).deleteSelectedContents(); +} +fn cmdAccept(state: *anyopaque) anyerror!void { + try editorFromState(state).accept(); +} +fn cmdCancel(state: *anyopaque) anyerror!void { + try editorFromState(state).cancel(); +} + +fn cmdUndoEnabled(state: *anyopaque) bool { + const doc = editorFromState(state).activeDoc() orelse return false; + return doc.owner.canUndo(doc); +} +fn cmdRedoEnabled(state: *anyopaque) bool { + const doc = editorFromState(state).activeDoc() orelse return false; + return doc.owner.canRedo(doc); +} +/// Enabled when *either* target of `clipboardVerb` can act: the active document, or a focused +/// text input that the synthetic event would reach. Gating on the document alone would grey out +/// Copy in the palette exactly when focus sits in a search box, which is when the fallback path +/// is the whole point. +fn cmdCopyEnabled(state: *anyopaque) bool { + const editor = editorFromState(state); + return editor.activeDocCommandEnabled("copy") or editor.text_input_focused; +} +fn cmdPasteEnabled(state: *anyopaque) bool { + const editor = editorFromState(state); + return editor.activeDocCommandEnabled("paste") or editor.text_input_focused; +} +fn cmdDeleteSelectionEnabled(state: *anyopaque) bool { + return editorFromState(state).activeDocCommandEnabled("deleteSelection"); +} +fn cmdAcceptEnabled(state: *anyopaque) bool { + return editorFromState(state).activeDocCommandEnabled("acceptEdit"); +} +fn cmdCancelEnabled(state: *anyopaque) bool { + return editorFromState(state).activeDocCommandEnabled("cancelEdit"); +} + +fn cmdQuickOpen(state: *anyopaque) anyerror!void { + editorFromState(state).command_palette.toggle(.files); +} +fn cmdCommandPalette(state: *anyopaque) anyerror!void { + editorFromState(state).command_palette.toggle(.commands); +} + +fn cmdToggleExplorer(state: *anyopaque) anyerror!void { + const editor = editorFromState(state); + // `.closed`, not `paned.split_ratio` — the latter is only valid during draw. + if (editor.explorer.closed) editor.explorer.open() else editor.explorer.close(); + // A native menu click doesn't arrive as an SDL event, so without this nothing requests the + // frame the paned needs to animate. + dvui.refresh(null, @src(), dvui.currentWindow().data().id); +} + +fn cmdShowDvuiDemo(_: *anyopaque) anyerror!void { + dvui.Examples.show_demo_window = !dvui.Examples.show_demo_window; +} + +/// About also carries the update status and the Check-for-Updates / Install button, which is +/// why Help → "Check for Updates…" is this same command. +fn cmdAbout(_: *anyopaque) anyerror!void { + Editor.Dialogs.AboutFizzy.request(); +} + +fn cmdReportBug(_: *anyopaque) anyerror!void { + _ = dvui.openURL(.{ .url = "https://github.com/fizzyedit/fizzy/issues" }); +} + +/// Register every shell action in the Host command registry. Called once during `Editor.init`. +pub fn registerCommands(editor: *Editor) !void { + shell_plugin.state = editor; + inline for (shell_commands) |c| { + try editor.host.registerCommand(.{ + .id = c.id, + .owner = &shell_plugin, + .title = c.title, + .run = c.run, + .isEnabled = c.isEnabled, + }); + } +} + +// ---- default profile ---------------------------------------------------------------------------- + +/// Which default keymap to start from. Only `vscode` ships today; the enum exists so adding +/// another is a data change rather than a structural one. +pub const Profile = enum { vscode }; + +/// A default binding, resolved per platform. `mod` is Command on macOS and Control elsewhere +/// (see `keymap.chord`), so most entries need only one spelling. +const DefaultBind = struct { + command: []const u8, + keys: []const u8, + /// Overrides `keys` on macOS when the platforms genuinely differ (redo, mostly). + keys_mac: ?[]const u8 = null, +}; + +/// VSCode-compatible shell defaults. +/// +/// These live here rather than in `dvui.Window.keybinds` on purpose. That map is a flat global +/// namespace claimed with `putNoClobber`, so a default the shell wants and a plugin also wants +/// is a startup panic, not a conflict — which is exactly how registering `undo` here crashed +/// every install with pixi. The keymap is layered instead: a plugin's binding and a shell +/// default can both exist, and `Keymap.best` picks by source. +/// +/// This also closes a real gap. `fizzy.undo` already routes through `activeDoc().owner.undo`, +/// so undo works for *any* document-owning plugin — but its only keyboard binding came from +/// pixi registering the name, meaning Ctrl+Z did nothing on an install without pixi. +const vscode_defaults = [_]DefaultBind{ + .{ .command = "fizzy.save", .keys = "mod+s" }, + .{ .command = "fizzy.saveAs", .keys = "mod+shift+s" }, + .{ .command = "fizzy.saveAll", .keys = "mod+alt+s" }, + .{ .command = "fizzy.newFile", .keys = "mod+n" }, + .{ .command = "fizzy.openFolder", .keys = "mod+f" }, + .{ .command = "fizzy.openFiles", .keys = "mod+o" }, + .{ .command = "fizzy.toggleExplorer", .keys = "mod+e" }, + .{ .command = "fizzy.undo", .keys = "mod+z" }, + // VSCode uses Ctrl+Y on Windows/Linux and Cmd+Shift+Z on macOS. + .{ .command = "fizzy.redo", .keys = "ctrl+y", .keys_mac = "mod+shift+z" }, + .{ .command = "fizzy.quickOpen", .keys = "mod+p" }, + .{ .command = "fizzy.commandPalette", .keys = "mod+shift+p" }, +}; + +/// C2-lite bridge: owner-scoped plugin defaults that still can't live on `Command.default_keys` +/// (ABI-gated). Added only when the Host command is registered. `owner_id` gates the binding +/// so shell profile chords (e.g. Quick Open on `mod+p`) win unless that plugin's document is +/// active. Retire these when C2 ships. +const plugin_owner_defaults = [_]struct { + command: []const u8, + owner_id: []const u8, + keys: []const u8, +}{ + .{ .command = "pixi.export", .owner_id = "pixi", .keys = "mod+p" }, +}; + +/// Document verbs: Fizzy owns the user-facing entry via `fizzy_id` (Copy, Paste, …), and a +/// plugin registers `"."` as the implementation that `runActiveDocCommand` +/// dispatches to. The chord is bound to the *Fizzy* command — `cmd+c` runs `fizzy.copy`, which +/// forwards to `pixi.copy` — so the plugin's own command genuinely has no binding of its own. +/// Both the palette (which hides the duplicate rows) and the Keyboard Shortcuts pane (which +/// shows the inherited chord) key off this table. +/// +/// Verbs with `fizzy_id == null` (Transform, Grid Layout, …) have no universal Fizzy entry; +/// there is nothing for them to inherit. +pub const DocumentVerb = struct { + action: []const u8, + fizzy_id: ?[]const u8, +}; + +pub const document_verbs = [_]DocumentVerb{ + .{ .action = "copy", .fizzy_id = "fizzy.copy" }, + .{ .action = "paste", .fizzy_id = "fizzy.paste" }, + .{ .action = "undo", .fizzy_id = "fizzy.undo" }, + .{ .action = "redo", .fizzy_id = "fizzy.redo" }, + .{ .action = "deleteSelection", .fizzy_id = "fizzy.deleteSelection" }, + .{ .action = "acceptEdit", .fizzy_id = "fizzy.accept" }, + .{ .action = "cancelEdit", .fizzy_id = "fizzy.cancel" }, + .{ .action = "transform", .fizzy_id = null }, + .{ .action = "gridLayout", .fizzy_id = null }, +}; + +pub fn commandActionSuffix(id: []const u8) []const u8 { + if (std.mem.lastIndexOfScalar(u8, id, '.')) |dot| return id[dot + 1 ..]; + return id; +} + +/// The Fizzy command whose chord actually reaches `id`, when `id` is a plugin's implementation +/// of a document verb and has no chord of its own. Null for shell commands and for verbs with +/// no universal Fizzy entry. +pub fn inheritedChordSource(id: []const u8) ?[]const u8 { + // A `fizzy.*` command is the source, never the inheritor. + if (std.mem.startsWith(u8, id, "fizzy.")) return null; + const suffix = commandActionSuffix(id); + for (document_verbs) |v| { + if (!std.mem.eql(u8, v.action, suffix)) continue; + return v.fizzy_id; + } + return null; +} + +fn defaultsFor(profile: Profile) []const DefaultBind { + return switch (profile) { + .vscode => &vscode_defaults, + }; +} + +// ---- keymap assembly -------------------------------------------------------------------------- + +/// Look up a shell command by the dvui bind name it defaults to. +fn shellCommandForBind(name: []const u8) ?ShellCommand { + inline for (shell_commands) |c| { + if (c.bind) |b| { + if (std.mem.eql(u8, b, name)) return c; + } + } + return null; +} + +/// The AppKit key-equivalent character for a key, or null for keys a plain `NSMenuItem` +/// shortcut can't express (function keys, arrows, keypad). Lowercase throughout: AppKit takes +/// shift from the modifier mask, and an uppercase character would demand shift on its own. +fn nsKeyEquivalent(key: keymap.Key) ?[]const u8 { + return switch (key) { + .a => "a", .b => "b", .c => "c", .d => "d", .e => "e", .f => "f", .g => "g", + .h => "h", .i => "i", .j => "j", .k => "k", .l => "l", .m => "m", .n => "n", + .o => "o", .p => "p", .q => "q", .r => "r", .s => "s", .t => "t", .u => "u", + .v => "v", .w => "w", .x => "x", .y => "y", .z => "z", + .zero => "0", .one => "1", .two => "2", .three => "3", .four => "4", + .five => "5", .six => "6", .seven => "7", .eight => "8", .nine => "9", + .grave => "`", .minus => "-", .equal => "=", .left_bracket => "[", + .right_bracket => "]", .backslash => "\\", .semicolon => ";", + .apostrophe => "'", .comma => ",", .period => ".", .slash => "/", + .space => " ", .tab => "\t", .enter => "\r", .backspace => "\u{8}", + else => null, + }; +} + +/// An AppKit key equivalent: the character plus the modifier mask to stamp on an `NSMenuItem`. +const NativeShortcut = struct { key: []const u8, mask: c_ulong }; + +/// The key equivalent for a command, or null when there is nothing AppKit can express — the +/// command is unbound, its chord is two-stroke, or its key has no key-equivalent character. +/// Null means *clear* the item's shortcut: a stale one would otherwise keep firing, and +/// `nativeMenuOwnsChord` (which asks the same question) would keep `dispatch` skipping the +/// command, leaving nothing to handle those keys at all. +/// +/// The command's *own* binding only — deliberately not `strokeForCommand`'s inherited-chord +/// fallback. An inherited chord is already stamped on the Fizzy item it belongs to (`cmd+c` on +/// Edit > Copy), and a second `NSMenuItem` claiming the same key equivalent is a coin flip over +/// which one AppKit fires. Inheritance is a display affordance; ownership is not inherited. +fn nativeShortcutFor(editor: *Editor, command_id: []const u8) ?NativeShortcut { + const binding = bestBinding(editor, command_id) orelse return null; + if (shadowedByHigherLayer(editor, binding, command_id)) return null; + const stroke = binding.stroke; + if (stroke.second != null) return null; + + const chord = stroke.first; + const key = nsKeyEquivalent(chord.key) orelse return null; + + var mask: c_ulong = 0; + if (chord.mods.command) mask |= fizzy.backend.modifier_command; + if (chord.mods.ctrl) mask |= fizzy.backend.modifier_control; + if (chord.mods.alt) mask |= fizzy.backend.modifier_option; + if (chord.mods.shift) mask |= fizzy.backend.modifier_shift; + return .{ .key = key, .mask = mask }; +} + +/// Whether a higher keymap layer has taken `command_id`'s chord away from it — the question +/// `nativeShortcutFor` asks before stamping an `NSMenuItem`. +pub fn chordShadowed(editor: *Editor, command_id: []const u8) bool { + const binding = bestBinding(editor, command_id) orelse return false; + return shadowedByHigherLayer(editor, binding, command_id); +} + +/// Whether another binding outranks `binding` on the same chord, by the same rule +/// `Keymap.conflicts` reports a shadowing pair with (source layer, then `when` specificity). +/// +/// AppKit has no such notion: two `NSMenuItem`s holding the same key equivalent are a coin flip +/// over which one fires, and the loser is whichever the menu happens to list first — not what +/// the keymap resolves. So the shadowed command's item gives the chord up entirely. Binding +/// `cmd+f` to Format Document, which the shell's profile hands to Open Folder, has to end with +/// `cmd+f` formatting: the menu, `Keybinds.tick` and AppKit all agree on the winner, and Open +/// Folder shows no chord because it no longer has one. +fn shadowedByHigherLayer(editor: *Editor, binding: keymap.Binding, command_id: []const u8) bool { + for (editor.keymap.bindings.items) |other| { + const other_cmd = other.command orelse continue; + if (std.mem.eql(u8, other_cmd, command_id)) continue; + if (!other.stroke.eql(binding.stroke)) continue; + if (!other.when.eql(binding.when)) continue; + // Owner-scoped bindings (pixi's `mod+p` Export vs the shell's Quick Open) fork on the + // active document rather than shadowing outright — either can be the right answer, so + // neither gives up its chord here. + const same_owner = if (binding.owner_id == null and other.owner_id == null) + true + else if (binding.owner_id) |b| (if (other.owner_id) |o| std.mem.eql(u8, b, o) else false) else false; + if (!same_owner) continue; + + const mine = (@as(u16, @intFromEnum(binding.source)) << 8) | binding.when.weight(); + const theirs = (@as(u16, @intFromEnum(other.source)) << 8) | other.when.weight(); + if (theirs > mine) return true; + } + return false; +} + +/// Push each native menu item's chord from the keymap onto its `NSMenuItem`, so a rebind takes +/// effect immediately — and, just as importantly, the old chord stops working. The fixed items +/// *are* the dispatch path for their commands on macOS, not just a label. +/// +/// Two passes, one rule. The fixed bar walks `menu_model.flat_commands`, whose index is the +/// item's tag; the plugin-contributed leaves walk `host.native_menu_items`, whose index is +/// theirs (`backend_native.rebuildDynamicNativeMenus` tags them the same way). Neither needs a +/// command↔item table: both name a command, and the chord comes from the keymap. +pub fn syncNativeMenuShortcuts(editor: *Editor) void { + if (comptime builtin.os.tag != .macos) return; + + for (menu_model.flat_commands, 0..) |item, tag| { + if (nativeShortcutFor(editor, item.id)) |s| { + fizzy.backend.setNativeMenuShortcut(tag, s.key, s.mask); + } else { + fizzy.backend.setNativeMenuShortcut(tag, null, 0); + } + } + + // Plugin items (`Host.registerNativeMenuItem`). Before this they were built with an empty + // key equivalent and never restamped, so a plugin action with a perfectly good chord — the + // text plugin's Format Document, pixi's Transform / Grid Layout — showed none in the macOS + // Edit menu no matter what the user bound it to. + for (editor.host.native_menu_items.items, 0..) |ni, index| { + const command_id = ni.command orelse { + fizzy.backend.setDynamicNativeMenuShortcut(index, null, 0); + continue; + }; + if (nativeShortcutFor(editor, command_id)) |s| { + fizzy.backend.setDynamicNativeMenuShortcut(index, s.key, s.mask); + } else { + fizzy.backend.setDynamicNativeMenuShortcut(index, null, 0); + } + } +} + +/// The chord a menu row should show beside `command_id`, resolved from the keymap — the same +/// place `tick` dispatches from, so what the menu advertises is what actually runs. +/// +/// Falls back to the Fizzy command this one inherits its chord from (`inheritedChordSource`): +/// a plugin's implementation of a document verb (`pixi.copy`) genuinely has no binding of its +/// own — `cmd+c` is bound to `fizzy.copy`, which forwards — so showing nothing would be wrong. +/// +/// A binding another layer has taken over (`shadowedByHigherLayer`) shows nothing: the chord is +/// no longer this command's, in the menu or anywhere else, and advertising it would promise a +/// key that runs something else. +pub fn strokeForCommand(editor: *Editor, command_id: []const u8) ?keymap.Stroke { + if (bestBinding(editor, command_id)) |b| { + return if (shadowedByHigherLayer(editor, b, command_id)) null else b.stroke; + } + if (inheritedChordSource(command_id)) |source| { + if (bestBinding(editor, source)) |b| return b.stroke; + } + return null; +} + +/// `strokeForCommand` in the shape the in-app menu rows want. An empty `Keybind` means "draw +/// nothing", which is also what a two-stroke chord gets: `dvui.enums.Keybind` is one key plus +/// modifier flags, with nowhere to put a second stroke. +pub fn menuKeybindFor(editor: *Editor, command_id: []const u8) dvui.enums.Keybind { + const stroke = strokeForCommand(editor, command_id) orelse return .{}; + if (stroke.second != null) return .{}; + return adapter.toKeybind(stroke.first); +} + +/// Highest-precedence binding for `command` (user > plugin > profile > dvui), or null. +fn bestBinding(editor: *Editor, command: []const u8) ?keymap.Binding { + var best: ?keymap.Binding = null; + for (editor.keymap.bindings.items) |b| { + const cmd = b.command orelse continue; + if (!std.mem.eql(u8, cmd, command)) continue; + if (best) |cur| { + if (@intFromEnum(b.source) >= @intFromEnum(cur.source)) best = b; + } else best = b; + } + return best; +} + +/// Whether a macOS menu item currently owns this command's chord, so `dispatch` doesn't run it +/// a second time. False once the chord is one AppKit can't express as a key equivalent (a +/// two-stroke chord, a function key), in which case `dispatch` has to handle it after all — +/// otherwise nothing would. +/// +/// Asks about plugin-contributed items too, not just the fixed bar: now that they carry key +/// equivalents, AppKit runs them, and a command reachable from both paths would fire twice. +/// This is the exact condition `syncNativeMenuShortcuts` stamps, so the two cannot disagree. +pub fn nativeMenuOwnsChord(editor: *Editor, id: []const u8) bool { + if (comptime builtin.os.tag != .macos) return false; + if (!menu_model.contains(id) and !nativeMenuItemFor(editor, id)) return false; + return nativeShortcutFor(editor, id) != null; +} + +/// Whether a visible plugin `NativeMenuItem` names `command_id`. +fn nativeMenuItemFor(editor: *Editor, command_id: []const u8) bool { + for (editor.host.native_menu_items.items) |ni| { + if (ni.hidden) continue; + const cmd = ni.command orelse continue; + if (std.mem.eql(u8, cmd, command_id)) return true; + } + return false; +} + +pub fn isNativeMenuCommandOnMacOS(id: []const u8) bool { + return menu_model.contains(id); +} + +/// Rebuild `editor.keymap` from the finished `dvui.Window.keybinds` map. Called at the end of +/// `Editor.rebuildKeybinds`, so it sees dvui's defaults, the shell's binds, and every loaded +/// plugin's contributions in one pass. +pub fn buildKeymap(editor: *Editor) !void { + const gpa = editor.host.allocator; + const window = dvui.currentWindow(); + + editor.keymap.deinit(gpa); + editor.keymap = .{}; + + if (editor.keybind_conflicts) |prev| { + gpa.free(prev); + editor.keybind_conflicts = null; + } + + // Layer 1 (lowest): whatever ended up in dvui's bind map — dvui's own defaults, the shell's + // `register()`, and every loaded plugin's `contributeKeybinds`. + var it = window.keybinds.iterator(); + while (it.next()) |kv| { + const cmd = shellCommandForBind(kv.key_ptr.*) orelse continue; + // Modifier-only binds ("shift", "zoom", "ctrl/cmd") have no key and can't be a chord. + const chord = adapter.fromKeybind(kv.value_ptr.*) orelse continue; + try editor.keymap.add(gpa, .{ + .stroke = .{ .first = chord }, + .command = cmd.id, + .source = .dvui, + }); + } + + // Layer 2: the shell's default profile. + const platform: keymap.Platform = if (fizzy.platform.isMacOS()) .mac else .other; + for (defaultsFor(editor.keybind_profile)) |d| { + const text = if (platform == .mac) (d.keys_mac orelse d.keys) else d.keys; + const stroke = keymap.parseKeys(text, platform) catch |err| { + dvui.log.err("default keybind '{s}' for '{s}' is invalid: {s}", .{ text, d.command, @errorName(err) }); + continue; + }; + try editor.keymap.add(gpa, .{ .stroke = stroke, .command = d.command, .source = .profile }); + } + + // Layer 2b: owner-scoped plugin defaults (C2-lite). Higher source than profile so they win + // when their owner is active; `owner_id` keeps them inert otherwise. + for (plugin_owner_defaults) |d| { + if (editor.host.command(d.command) == null) continue; + const stroke = keymap.parseKeys(d.keys, platform) catch |err| { + dvui.log.err("plugin keybind '{s}' for '{s}' is invalid: {s}", .{ d.keys, d.command, @errorName(err) }); + continue; + }; + try editor.keymap.add(gpa, .{ + .stroke = stroke, + .command = d.command, + .source = .plugin, + .owner_id = d.owner_id, + }); + } + + // Layer 3 (highest): the user's own `keybinds.zon`. + try loadUserOverrides(editor); + + // Mirror user overrides back onto the built-in bind names dvui's widgets and the menus read. + projectUserOverrides(editor); + + // Push the resolved chords onto the macOS menu bar. These items *are* the dispatch path for + // their commands, so a rebind that doesn't reach them takes effect nowhere. + syncNativeMenuShortcuts(editor); + + // Cache conflicts for the Keyboard Shortcuts settings pane. + editor.keybind_conflicts = editor.keymap.conflicts(gpa) catch |err| blk: { + dvui.log.err("keybind conflicts() failed: {s}", .{@errorName(err)}); + break :blk null; + }; +} + +/// Read and apply `/keybinds.zon`. A missing file is the normal case — defaults are +/// never written out, so a user who has rebound nothing has no file at all. +fn loadUserOverrides(editor: *Editor) !void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + const gpa = editor.host.allocator; + + if (editor.keybinds_overrides) |*f| { + f.deinit(gpa); + editor.keybinds_overrides = null; + } + + const path = try std.fs.path.join(gpa, &.{ editor.config_folder, "keybinds.zon" }); + defer gpa.free(path); + + const text = std.Io.Dir.cwd().readFileAllocOptions( + dvui.io, + path, + gpa, + .limited(1024 * 1024), + .of(u8), + 0, + ) catch |err| switch (err) { + error.FileNotFound => return, + else => { + dvui.log.err("keybinds.zon read failed: {s}", .{@errorName(err)}); + return; + }, + }; + defer gpa.free(text); + + const platform: keymap.Platform = if (fizzy.platform.isMacOS()) .mac else .other; + var file = try keymap.zon.parse(gpa, text, platform); + errdefer file.deinit(gpa); + + for (file.diagnostics) |d| { + dvui.log.err("keybinds.zon:{d}:{d}: {s}", .{ d.line, d.column, d.message }); + } + + const view = try file.toBindings(gpa, .user); + defer gpa.free(view); + for (view) |b| try editor.keymap.add(gpa, b); + + // The keymap borrows this File's strings, so it has to outlive the keymap. + editor.keybinds_overrides = file; +} + +// ---- projection back into dvui's bind map ------------------------------------------------------- + +/// Reserved command prefix for rebinding a **built-in editing bind** — the names dvui's own +/// widgets match on (`char_left`, `word_right_select`, `line_start`, …). Writing +/// `.{ .keys = "alt+b", .command = "bind.word_left" }` in keybinds.zon moves that motion. +/// +/// This exists because cursor motion cannot go through the command registry without becoming +/// worse. `TextEntryWidget` resolves motion *in-frame* against the byte buffer (see +/// `textcore/movement.zig`); routing it through `host.runCommand` would have to hand the result +/// back via `Document.pending_sel`, reintroducing exactly the one-frame lag that made navigation +/// feel fragile in the first place. So the widget keeps matching bind names, and the keymap +/// rewrites what those names mean. +pub const bind_override_prefix = "bind."; + +/// The dvui bind name a shell command's key is mirrored onto, so a rebind also moves the +/// built-in bind dvui's own widgets match on. The menus no longer go through this — they ask +/// the keymap directly (`menuKeybindFor`), which answers for every command rather than only the +/// ones that happen to have a dvui bind name. +fn shellBindForCommand(id: []const u8) ?[]const u8 { + inline for (shell_commands) |c| { + if (std.mem.eql(u8, c.id, id)) return c.bind; + } + return null; +} + +/// Push user-layer bindings back into `dvui.Window.keybinds`. +/// +/// Only names **already present** in the map are updated. That's both a sanity check (you can +/// only rebind something that exists) and a lifetime guarantee: `StringHashMap.put` keeps the +/// existing key allocation, so the map never ends up holding a pointer into a `keybinds.zon` +/// parse that a later rebuild frees. +fn projectUserOverrides(editor: *Editor) void { + const window = dvui.currentWindow(); + + for (editor.keymap.bindings.items) |b| { + if (b.source != .user) continue; + const command = b.command orelse continue; + + const name = if (std.mem.startsWith(u8, command, bind_override_prefix)) + command[bind_override_prefix.len..] + else + shellBindForCommand(command) orelse continue; + + if (b.stroke.second != null) { + // `dvui.enums.Keybind` is one key plus modifier flags; there is nowhere to put the + // second stroke. Chords still work for real commands, just not for built-in binds. + dvui.log.err( + "keybinds.zon: '{s}' cannot be bound to a chord (built-in binds are single-stroke)", + .{command}, + ); + continue; + } + + if (!window.keybinds.contains(name)) { + dvui.log.err("keybinds.zon: unknown built-in bind '{s}'", .{name}); + continue; + } + window.keybinds.put(window.gpa, name, adapter.toKeybind(b.stroke.first)) catch |err| { + dvui.log.err("keybind projection for '{s}' failed: {s}", .{ name, @errorName(err) }); + }; + } +} + +// ---- dispatch --------------------------------------------------------------------------------- + +/// Context flags for `when` matching. Only what the shell can answer today; grows as bindings +/// need finer gates. +fn currentContext(editor: *Editor) keymap.When { + return .{ + .editor_focused = editor.activeDoc() != null, + .explorer_focused = !editor.explorer.closed, + .modal_open = editor.command_palette.open, + // Declared by `keymap.When` since it was written but never filled in, so any `when` + // clause mentioning text input could not match. `dvui.wantTextInput` is the signal. + .text_input_focused = editor.text_input_focused, + }; +} + +fn activeOwnerId(editor: *Editor) ?[]const u8 { + const doc = editor.activeDoc() orelse return null; + return doc.owner.id; +} + // These keybinds are available regardless of the currently focused widget. // Any binds that need to be consumed by a specific widget do not need to trigger here. pub fn tick() !void { + const editor = fizzy.editor; + // While the palette is open it owns the keyboard entirely — otherwise Escape would also run + // `fizzy.cancel`, and a typed character could trip a single-key binding. + if (editor.command_palette.open) return; + // Likewise while the settings pane is capturing a chord: the whole point is that the keys + // being pressed are data, not commands. + if (KeybindSettings.isRecording()) return; + const ctx = currentContext(editor); + const active_owner = activeOwnerId(editor); + for (dvui.events()) |e| { if (e.handled) continue; switch (e.evt) { .key => |ke| { - // macOS: NSMenu key equivalents already call `FizzyNativeMenuAction` (see Editor.flushQueuedNativeMenuActions). - // SDL still delivers the same key events, so handling them here too would run the action twice. - if (builtin.os.tag != .macos) { - if (ke.matchBind("open_folder") and ke.action == .down) { - if (try dvui.dialogNativeFolderSelect(dvui.currentWindow().arena(), .{ - .title = "Open Project Folder", - })) |folder| { - try fizzy.editor.setProjectFolder(folder); - } - } - - if (ke.matchBind("open_files") and ke.action == .down) { - if (try dvui.dialogNativeFileOpenMultiple( - dvui.currentWindow().arena(), - .{ .title = "Open Files..." }, - )) |files| { - for (files) |file| { - _ = fizzy.editor.openFilePath(file, fizzy.editor.currentGroupingID()) catch { - std.log.err("Failed to open file: {s}", .{file}); - }; - } - } - } - } + if (ke.action != .down and ke.action != .repeat) continue; - if (ke.matchBind("save_as") and ke.action == .down) { - fizzy.editor.requestSaveAs(); - } + const chord = adapter.chordFrom(ke) orelse continue; + switch (editor.keymap.resolve(chord, ctx, active_owner)) { + .none => {}, + // `pending` (first half of a chord) and `unbound` both *claim* the key, and + // ought to mark the event handled so it doesn't also reach a widget. Neither + // can occur yet — every binding built by `buildKeymap` is single-stroke and + // none are unbinds — and `Event.handle` needs a `*WidgetData` the shell has + // no sensible value for. Left as a no-op deliberately: this stays behaviour- + // identical to the if-chain it replaced, which never marked events handled + // either. Revisit when chords or user unbinds actually land (step C/D). + .pending, .unbound => {}, + .command => |id| { + // macOS delivers these twice — once as an NSMenu key equivalent (which + // already ran the action) and once as an SDL key event. Let the native + // menu own them there. + if (nativeMenuOwnsChord(editor, id)) continue; - if (ke.matchBind("save_all") and ke.action == .down) { - fizzy.editor.saveAll() catch { - std.log.err("Failed to save all", .{}); - }; - } + // Repeat only makes sense for the actions that were previously wired to + // accept it; everything else fires once per press. + if (ke.action == .repeat and + !std.mem.eql(u8, id, "fizzy.undo") and + !std.mem.eql(u8, id, "fizzy.redo")) continue; - if (ke.matchBind("delete_selection_contents")) { - if (ke.action == .down) { - fizzy.editor.deleteSelectedContents(); - } + // The event that triggered this is still live and unmarked, so it will + // go on to reach whatever widget has focus by itself. Clipboard + // commands need to know that, or they synthesize a second one. + running_from_key_event = true; + defer running_from_key_event = false; + editor.host.runCommand(id) catch |err| { + dvui.log.err("command '{s}' failed: {s}", .{ id, @errorName(err) }); + }; + }, } + }, + else => {}, + } + } +} - if (builtin.os.tag != .macos) { - if (ke.matchBind("explorer") and ke.action == .down) { - if (fizzy.editor.explorer.closed) { - fizzy.editor.explorer.open(); - } else { - fizzy.editor.explorer.close(); - } - } - } +// ---- user overrides (Keyboard Shortcuts pane) ----------------------------------------------- - if (ke.matchBind("activate") and ke.action == .down) { - fizzy.editor.accept() catch { - std.log.err("Failed to accept", .{}); - }; - } +fn ownerIdForCommand(command: []const u8) ?[]const u8 { + if (std.mem.startsWith(u8, command, "fizzy.")) return null; + if (std.mem.startsWith(u8, command, bind_override_prefix)) return null; + const dot = std.mem.indexOfScalar(u8, command, '.') orelse return null; + return command[0..dot]; +} - if (ke.matchBind("cancel") and ke.action == .down) { - fizzy.editor.cancel() catch { - std.log.err("Failed to cancel", .{}); - }; - } +fn keybindsPath(editor: *Editor, gpa: std.mem.Allocator) ![]u8 { + return try std.fs.path.join(gpa, &.{ editor.config_folder, "keybinds.zon" }); +} - if (builtin.os.tag != .macos) { - if (ke.matchBind("undo") and (ke.action == .down or ke.action == .repeat)) { - fizzy.editor.undo() catch { - std.log.err("Failed to undo", .{}); - }; - } +/// Rewrite `keybinds.zon` from `bindings`, then rebuild the live keymap. +fn writeAndReload(editor: *Editor, bindings: []const keymap.zon.OwnedBinding) !void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + const gpa = editor.host.allocator; + const path = try keybindsPath(editor, gpa); + defer gpa.free(path); - if (ke.matchBind("copy") and ke.action == .down) { - fizzy.editor.copy() catch { - std.log.err("Failed to copy", .{}); - }; - } + const text = try keymap.zon.format(gpa, bindings); + defer gpa.free(text); - if (ke.matchBind("paste") and ke.action == .down) { - fizzy.editor.paste() catch { - std.log.err("Failed to paste", .{}); - }; - } + if (bindings.len == 0) { + std.Io.Dir.cwd().deleteFile(dvui.io, path) catch |err| switch (err) { + error.FileNotFound => {}, + else => return err, + }; + } else { + try std.Io.Dir.cwd().writeFile(dvui.io, .{ .sub_path = path, .data = text }); + } - if (ke.matchBind("redo") and (ke.action == .down or ke.action == .repeat)) { - fizzy.editor.redo() catch { - std.log.err("Failed to redo", .{}); - }; - } + editor.rebuildKeybinds(); +} - if (ke.matchBind("save") and ke.action == .down) { - fizzy.editor.save() catch { - std.log.err("Failed to save", .{}); - }; - } +fn collectCurrentOverrides(editor: *Editor, gpa: std.mem.Allocator) !std.ArrayList(keymap.zon.OwnedBinding) { + var out: std.ArrayList(keymap.zon.OwnedBinding) = .empty; + errdefer { + for (out.items) |*b| b.deinit(gpa); + out.deinit(gpa); + } - if (ke.matchBind("new_file") and ke.action == .down) { - fizzy.editor.requestNewFileDialog(); - } + if (editor.keybinds_overrides) |file| { + for (file.bindings) |b| { + try out.append(gpa, .{ + .keys = try gpa.dupe(u8, b.keys), + .stroke = b.stroke, + .command = if (b.command) |c| try gpa.dupe(u8, c) else null, + .when = b.when, + .when_text = if (b.when_text) |w| try gpa.dupe(u8, w) else null, + .owner_id = if (b.owner_id) |o| try gpa.dupe(u8, o) else null, + }); + } + } + return out; +} - } - }, - else => {}, +/// Set (or replace) the user override for `command`. `keys` is VSCode grammar (`mod+p`). +pub fn setUserBinding(editor: *Editor, command: []const u8, keys: []const u8) !void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + const gpa = editor.host.allocator; + const platform: keymap.Platform = if (fizzy.platform.isMacOS()) .mac else .other; + const stroke = try keymap.parseKeys(keys, platform); + + var list = try collectCurrentOverrides(editor, gpa); + defer { + for (list.items) |*b| b.deinit(gpa); + list.deinit(gpa); + } + + // Drop any prior override for this command (including unbinds). + var i: usize = 0; + while (i < list.items.len) { + if (list.items[i].command) |c| { + if (std.mem.eql(u8, c, command)) { + var removed = list.orderedRemove(i); + removed.deinit(gpa); + continue; + } + } + i += 1; + } + + const owner = ownerIdForCommand(command); + try list.append(gpa, .{ + .keys = try gpa.dupe(u8, keys), + .stroke = stroke, + .command = try gpa.dupe(u8, command), + .owner_id = if (owner) |o| try gpa.dupe(u8, o) else null, + }); + + try writeAndReload(editor, list.items); +} + +/// Remove the user override for `command`, restoring the profile/plugin default. +pub fn clearUserBinding(editor: *Editor, command: []const u8) !void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + const gpa = editor.host.allocator; + + var list = try collectCurrentOverrides(editor, gpa); + defer { + for (list.items) |*b| b.deinit(gpa); + list.deinit(gpa); + } + + var i: usize = 0; + var changed = false; + while (i < list.items.len) { + if (list.items[i].command) |c| { + if (std.mem.eql(u8, c, command)) { + var removed = list.orderedRemove(i); + removed.deinit(gpa); + changed = true; + continue; + } } + i += 1; + } + if (!changed) return; + try writeAndReload(editor, list.items); +} + +/// True when `command` has a user-layer override in the live keymap. +pub fn hasUserOverride(editor: *Editor, command: []const u8) bool { + for (editor.keymap.bindings.items) |b| { + if (b.source != .user) continue; + const c = b.command orelse continue; + if (std.mem.eql(u8, c, command)) return true; } + return false; } diff --git a/src/editor/Menu.zig b/src/editor/Menu.zig index 4166743b..bd8c9522 100644 --- a/src/editor/Menu.zig +++ b/src/editor/Menu.zig @@ -5,6 +5,7 @@ const Constants = @import("Constants.zig"); const Editor = fizzy.Editor; const settings = fizzy.settings; const builtin = @import("builtin"); +const model = @import("menu_model.zig"); pub var mouse_distance: f32 = std.math.floatMax(f32); @@ -37,10 +38,26 @@ pub fn draw() !dvui.App.Result { } /// File menu (workbench contribution). -pub fn drawFileMenu(_: ?*anyopaque) anyerror!void { - if (menuItem(@src(), "File", .{ .submenu = true }, .{ +/// Run the command a menu item stands for. +/// +/// Every item in both menu bars names a command and does nothing else. Before this, each item's +/// action was written out here *and* in the macOS menu path *and* as the command body in +/// `Keybinds` — three copies that had already drifted apart. +fn run(id: []const u8) void { + fizzy.editor.host.runCommand(id) catch |err| { + dvui.log.err("menu command '{s}' failed: {s}", .{ id, @errorName(err) }); + }; +} + +/// Draw one top-level menu from `menu_model`. Registered once per `menu_model.menu_bar` entry +/// with the `Submenu` itself as `ctx`, so there is no per-menu function here to fall out of step +/// with the macOS builder walking the same tree. +pub fn drawModelMenu(ctx: ?*anyopaque) anyerror!void { + const sub: *const model.Submenu = @ptrCast(@alignCast(ctx orelse return)); + const editor = fizzy.editor; + + if (menuItem(@src(), sub.title, .{ .submenu = true }, .{ .expand = .horizontal, - //.color_accent = dvui.themeGet().color(.window, .fill), .color_text = dvui.themeGet().color(.control, .text), })) |r| { var animator = dvui.animate(@src(), .{ @@ -54,300 +71,103 @@ pub fn drawFileMenu(_: ?*anyopaque) anyerror!void { var fw = dvui.floatingMenu(@src(), .{ .from = r }, .{}); defer fw.deinit(); - if (menuItemWithHotkey(@src(), "Open Folder", dvui.currentWindow().keybinds.get("open_folder") orelse .{}, true, .{}, .{ - .expand = .horizontal, - //.style = .control, - }) != null) { - // Use the backend abstraction (native = OS dialog, web = file input element - // or "folders unavailable" toast) instead of `dvui.dialogNativeFolderSelect`, - // which has no implementation on wasm and would silently no-op the menu. - fizzy.backend.showOpenFolderDialog(Editor.Workspace.setProjectFolderCallback, null); - fw.close(); + for (sub.items, 0..) |item, i| { + try drawModelItem(editor, item, i, fw); } + } +} - if (menuItemWithHotkey(@src(), "New File…", dvui.currentWindow().keybinds.get("new_file") orelse .{}, true, .{}, .{ - .expand = .horizontal, - }) != null) { - fizzy.editor.requestNewFileDialog(); - fw.close(); - } +fn drawModelItem( + editor: *Editor, + item: model.Item, + id_extra: usize, + fw: *dvui.FloatingMenuWidget, +) !void { + switch (item) { + .separator => _ = dvui.separator(@src(), .{ .expand = .horizontal, .id_extra = id_extra }), - if (menuItemWithHotkey(@src(), "Open Files", dvui.currentWindow().keybinds.get("open_files") orelse .{}, true, .{}, .{ - .expand = .horizontal, - //.style = .control, - }) != null) { - // Same reason as "Open Folder" above: route through the backend so the web - // build actually pops the file picker. The same callback the homepage uses - // handles the open-file plumbing on both platforms. - fizzy.backend.showOpenFileDialog( - Editor.Workspace.openFilesCallback, - &.{}, - "", - null, - ); - fw.close(); - } + .plugin_section => |parent| try drawMenuSections(parent), - _ = dvui.separator(@src(), .{ .expand = .horizontal }); + .recent_folders => try drawRecentFolders(editor, id_extra), - if (menuItemWithChevron( - @src(), - "Recent Folders", - .{ .submenu = true }, - .{ + .submenu => |nested| { + // No nested submenus in the bar today; the model allows them, so handle rather + // than silently drop. + if (menuItemWithChevron(@src(), nested.title, .{ .submenu = true }, .{ .expand = .horizontal, + .id_extra = id_extra, .color_text = dvui.themeGet().color(.window, .text), - //.style = .control, - }, - )) |recents_item| { - var recents_anim = dvui.animate(@src(), .{ - .kind = .alpha, - .duration = 250_000, - }, .{ - .expand = .both, - }); - defer recents_anim.deinit(); - - var recents_fw = dvui.floatingMenu(@src(), .{ .from = recents_item }, .{}); - defer recents_fw.deinit(); - - var vert_box = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .none, - }); - defer vert_box.deinit(); - - var i: usize = fizzy.editor.recents.folders.items.len; - while (i > 0) : (i -= 1) { - const folder = fizzy.editor.recents.folders.items[i - 1]; - if (menuItem(@src(), folder, .{}, .{ - .expand = .horizontal, - .font = dvui.Font.theme(.mono), - .id_extra = i, - .margin = dvui.Rect.all(1), - .padding = dvui.Rect.all(2), - })) |_| { - try fizzy.editor.setProjectFolder(folder); + })) |r| { + var nested_fw = dvui.floatingMenu(@src(), .{ .from = r }, .{}); + defer nested_fw.deinit(); + for (nested.items, 0..) |nested_item, j| { + try drawModelItem(editor, nested_item, j, nested_fw); } } - } - - _ = dvui.separator(@src(), .{ .expand = .horizontal }); - - if (menuItemWithHotkey(@src(), "Save", dvui.currentWindow().keybinds.get("save") orelse .{}, if (fizzy.editor.activeDoc()) |doc| - (doc.owner.isDirty(doc) or !doc.owner.documentHasRecognizedSaveExtension(doc)) - else - false, .{}, .{ - .expand = .horizontal, - .color_text = dvui.themeGet().color(.window, .text), - }) != null) { - fizzy.editor.save() catch { - std.log.err("Failed to save", .{}); - }; - fw.close(); - } - - if (menuItemWithHotkey(@src(), "Save As…", dvui.currentWindow().keybinds.get("save_as") orelse .{}, fizzy.editor.activeDoc() != null, .{}, .{ - .expand = .horizontal, - .color_text = dvui.themeGet().color(.window, .text), - }) != null) { - fizzy.editor.requestSaveAs(); - fw.close(); - } - - // Save All is enabled whenever any open file is dirty with a recognized - // extension. Worker queue handles them serially; UI stays responsive. - const any_dirty = blk: { - for (fizzy.editor.open_files.values()) |doc| { - if (doc.owner.isDirty(doc) and doc.owner.documentHasRecognizedSaveExtension(doc)) break :blk true; - } - break :blk false; - }; - if (menuItemWithHotkey(@src(), "Save All", dvui.currentWindow().keybinds.get("save_all") orelse .{}, any_dirty, .{}, .{ - .expand = .horizontal, - .color_text = dvui.themeGet().color(.window, .text), - }) != null) { - fizzy.editor.saveAll() catch { - std.log.err("Failed to save all", .{}); - }; - fw.close(); - } - } -} - -/// Edit menu. Undo/Redo/Save are vtable-guaranteed for any document-owning plugin; Copy/Paste/ -/// Transform/Grid Layout are Commands, so each is only shown when the active document's owner -/// actually registered it (see `activeDocHasCommand`) rather than rendered permanently disabled. -pub fn drawEditMenu(_: ?*anyopaque) anyerror!void { - if (menuItem( - @src(), - "Edit", - .{ .submenu = true }, - .{ - .expand = .horizontal, - .color_text = dvui.themeGet().color(.control, .text), }, - )) |r| { - var animator = dvui.animate(@src(), .{ - .kind = .alpha, - .duration = 250_000, - }, .{ - .expand = .both, - }); - defer animator.deinit(); - var fw = dvui.floatingMenu(@src(), .{ .from = r }, .{}); - defer fw.deinit(); - - if (fizzy.editor.activeDocHasCommand("copy")) { - if (menuItemWithHotkey( - @src(), - "Copy", - dvui.currentWindow().keybinds.get("copy") orelse .{}, - fizzy.editor.activeDocCommandEnabled("copy"), - .{}, - .{ .expand = .horizontal }, - ) != null) { - fizzy.editor.copy() catch { - std.log.err("Failed to copy", .{}); - }; - fw.close(); + .command => |c| { + if (c.visible) |f| { + if (!f(editor)) return; } - } + const enabled = if (c.enabled) |f| f(editor) else true; + const hotkey = hotkeyFor(editor, c.id); - if (fizzy.editor.activeDocHasCommand("paste")) { - if (menuItemWithHotkey( - @src(), - "Paste", - dvui.currentWindow().keybinds.get("paste") orelse .{}, - fizzy.editor.activeDocCommandEnabled("paste"), - .{}, - .{ .expand = .horizontal }, - ) != null) { - fizzy.editor.paste() catch { - std.log.err("Failed to paste", .{}); - }; + if (menuItemWithHotkey(@src(), c.title.resolve(editor), hotkey, enabled, .{}, .{ + .expand = .horizontal, + .id_extra = id_extra, + .color_text = dvui.themeGet().color(.window, .text), + }) != null) { + run(c.id); fw.close(); } - } - - if (fizzy.editor.activeDocHasCommand("copy") or fizzy.editor.activeDocHasCommand("paste")) { - _ = dvui.separator(@src(), .{ .expand = .horizontal }); - } - - if (menuItemWithHotkey( - @src(), - "Undo", - dvui.currentWindow().keybinds.get("undo") orelse .{}, - if (fizzy.editor.activeDoc()) |doc| doc.owner.canUndo(doc) else false, - .{}, - .{ .expand = .horizontal }, - ) != null) { - if (fizzy.editor.activeDoc()) |doc| { - doc.owner.undo(doc) catch { - std.log.err("Failed to undo", .{}); - }; - } - } - - if (menuItemWithHotkey( - @src(), - "Redo", - dvui.currentWindow().keybinds.get("redo") orelse .{}, - if (fizzy.editor.activeDoc()) |doc| doc.owner.canRedo(doc) else false, - .{}, - .{ .expand = .horizontal }, - ) != null) { - if (fizzy.editor.activeDoc()) |doc| { - doc.owner.redo(doc) catch { - std.log.err("Failed to redo", .{}); - }; - } - } - - // Transform / Grid Layout are pixel-art-specific, not shell concepts — pixi injects - // them itself via a `MenuSectionContribution` parented to "shell.menu.edit" below. - try drawMenuSections("shell.menu.edit"); + }, } } -/// View menu (shell built-in). -pub fn drawViewMenu(_: ?*anyopaque) anyerror!void { - if (menuItem(@src(), "View", .{ .submenu = true }, .{ - .expand = .horizontal, - .color_text = dvui.themeGet().color(.control, .text), - })) |r| { - var animator = dvui.animate(@src(), .{ - .kind = .alpha, - .duration = 250_000, - }, .{ - .expand = .both, - }); - defer animator.deinit(); - - var fw = dvui.floatingMenu(@src(), .{ .from = r }, .{}); - defer fw.deinit(); - - if (menuItemWithHotkey( - @src(), - if (fizzy.editor.explorer.paned.split_ratio.* == 0.0) "Show Explorer" else "Hide Explorer", - dvui.currentWindow().keybinds.get("explorer") orelse .{}, - true, - .{}, - .{ - .expand = .horizontal, - }, - ) != null) { - if (fizzy.editor.explorer.paned.split_ratio.* == 0.0) { - fizzy.editor.explorer.open(); - } else { - fizzy.editor.explorer.close(); - } - - fw.close(); - } - - try drawMenuSections("shell.menu.view"); - - _ = dvui.separator(@src(), .{ .expand = .horizontal }); - - if (menuItem(@src(), "Show DVUI Demo", .{}, .{ .expand = .horizontal }) != null) { - dvui.Examples.show_demo_window = !dvui.Examples.show_demo_window; - fw.close(); - } - } +/// The chord shown beside a row, straight from the keymap `Keybinds.tick` dispatches out of. +/// +/// This used to go via the command's dvui *bind name* (`dvui.Window.keybinds`), which only +/// worked for the subset of commands that have one. Anything bound purely through the keymap — +/// a command with no dvui bind (`fizzy.quickOpen`), or any plugin command the user gave a chord +/// in the Keyboard Shortcuts pane — resolved to nothing and drew a blank accelerator, even +/// though the chord worked. Asking the keymap directly is one lookup for every command. +fn hotkeyFor(editor: *Editor, command_id: []const u8) dvui.enums.Keybind { + return fizzy.Editor.Keybinds.menuKeybindFor(editor, command_id); } -/// Help menu (shell built-in). Matches the macOS native Help menu so the two -/// menubars stay congruent. -pub fn drawHelpMenu(_: ?*anyopaque) anyerror!void { - if (menuItem(@src(), "Help", .{ .submenu = true }, .{ +fn drawRecentFolders(editor: *Editor, id_extra: usize) !void { + if (editor.recents.folders.items.len == 0) return; + + if (menuItemWithChevron(@src(), "Recent Folders", .{ .submenu = true }, .{ .expand = .horizontal, - .color_text = dvui.themeGet().color(.control, .text), - })) |r| { - var animator = dvui.animate(@src(), .{ + .id_extra = id_extra, + .color_text = dvui.themeGet().color(.window, .text), + })) |recents_item| { + var recents_anim = dvui.animate(@src(), .{ .kind = .alpha, .duration = 250_000, - }, .{ - .expand = .both, - }); - defer animator.deinit(); - - var fw = dvui.floatingMenu(@src(), .{ .from = r }, .{}); - defer fw.deinit(); + }, .{ .expand = .both }); + defer recents_anim.deinit(); - if (menuItem(@src(), "Check for Updates…", .{}, .{ .expand = .horizontal }) != null) { - // The AboutFizzy dialog hosts the actual update check + install controls. - // macOS routes "About fizzy" to the same dialog via the native Help menu; - // here we only expose the update entry to avoid duplicating it. - fizzy.Editor.Dialogs.AboutFizzy.request(); - fw.close(); - } + var recents_fw = dvui.floatingMenu(@src(), .{ .from = recents_item }, .{}); + defer recents_fw.deinit(); - _ = dvui.separator(@src(), .{ .expand = .horizontal }); + var vert_box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .none }); + defer vert_box.deinit(); - if (menuItem(@src(), "Report Bug…", .{}, .{ .expand = .horizontal }) != null) { - _ = dvui.openURL(.{ .url = "https://github.com/fizzyedit/fizzy/issues" }); - fw.close(); + var i: usize = editor.recents.folders.items.len; + while (i > 0) : (i -= 1) { + const folder = editor.recents.folders.items[i - 1]; + if (menuItem(@src(), folder, .{}, .{ + .expand = .horizontal, + .font = dvui.Font.theme(.mono), + .id_extra = i, + .margin = dvui.Rect.all(1), + .padding = dvui.Rect.all(2), + })) |_| { + try editor.setProjectFolder(folder); + } } } } diff --git a/src/editor/PluginSettingsPane.zig b/src/editor/PluginSettingsPane.zig index 9071a4a9..bd414c07 100644 --- a/src/editor/PluginSettingsPane.zig +++ b/src/editor/PluginSettingsPane.zig @@ -20,9 +20,19 @@ pub fn drawField(schema: *const settings.SettingsSchema, field: settings.Setting const value = schema.value; switch (field.kind) { + // Drawn by `SettingRow.beginInlineControl` on the description's line, so it is + // deliberately label-less and non-expanding — the description *is* its label. .bool => { var b = access.getBool(value, field_index); - if (dvui.checkbox(@src(), &b, null, .{ .id_extra = id_extra, .expand = .none })) { + if (dvui.checkbox(@src(), &b, null, .{ + .id_extra = id_extra, + .expand = .none, + .gravity_y = 0.5, + // Horizontal margin keeps the box off the row's left edge and off the description + // that follows it on the same line. + .margin = .{ .x = 4, .w = 4 }, + .padding = .{ .x = 2, .w = 4, .y = 2, .h = 2 }, + })) { access.setBool(value, field_index, b); access.persist(value, schema.owner); } @@ -96,9 +106,33 @@ pub fn drawField(schema: *const settings.SettingsSchema, field: settings.Setting .color => { dvui.label(@src(), "(color picker TBD)", .{}, .{ .id_extra = id_extra }); }, + .other => try drawZon(schema, field_index, id_extra), } } +/// Fallback control for a setting whose type the pane has no dedicated widget for (a struct, an +/// array, an optional…): the value as zon text, edited in place. Same commit-on-Enter, +/// re-seed-when-unfocused contract as `drawString` — plus a rejected parse simply doesn't +/// commit, so the next unfocused frame reverts the entry to the live value. +fn drawZon(schema: *const settings.SettingsSchema, field_index: usize, id_extra: usize) !void { + const access = schema.access; + const value = schema.value; + const current = access.getZonText(value, field_index, dvui.currentWindow().arena()); + + var entry: dvui.TextEntryWidget = undefined; + entry.init(@src(), .{}, .{ .id_extra = id_extra, .expand = .horizontal }); + const focused = dvui.focusedWidgetId() == entry.data().id; + if (!focused and !std.mem.eql(u8, entry.getText(), current)) entry.textSet(current, false); + entry.processEvents(); + entry.draw(); + if (entry.enter_pressed and !std.mem.eql(u8, entry.getText(), current)) { + if (access.setZonText(value, field_index, entry.getText())) { + access.persist(value, schema.owner); + } + } + entry.deinit(); +} + /// Text entry for a `[]const u8` setting. Committed on Enter only — the entry re-seeds itself /// from the live value whenever it isn't focused, so an abandoned edit reverts on blur and an /// external change (a hand-edit reconciled by `SettingsWatcher`) shows up here without the pane diff --git a/src/editor/Recents.zig b/src/editor/Recents.zig index ef3862e1..addde38d 100644 --- a/src/editor/Recents.zig +++ b/src/editor/Recents.zig @@ -28,6 +28,14 @@ fn trimTrailingPathSeparators(path: []const u8) []const u8 { return path[0..end]; } +/// Everything stored in `folders` / `last_*_folder` is canonical (`fizzy.paths.normalize`), so +/// `/foo` and `/foo/.` are one entry rather than two rows pointing at the same directory. +/// Applied on load as well as on append: files written before this normalization existed can +/// still hold the odd spellings, and those must collapse instead of surviving forever. +fn canonicalize(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + return fizzy.paths.normalize(allocator, trimTrailingPathSeparators(path)); +} + pub fn load(allocator: std.mem.Allocator, path: []const u8) !Recents { var folders = std.array_list.Managed([]const u8).init(allocator); @@ -44,29 +52,29 @@ pub fn load(allocator: std.mem.Allocator, path: []const u8) !Recents { var dd = d; dd.close(dvui.io); - const canon = trimTrailingPathSeparators(folder); + const canon = canonicalize(allocator, folder) catch continue; - var found = false; - for (folders.items) |existing| { - if (std.mem.eql(u8, trimTrailingPathSeparators(existing), canon)) { - found = true; + // Duplicates collapse onto the *later* row — the list is ordered + // oldest-first, so the last spelling of a directory is the recent one. + for (folders.items, 0..) |existing, i| { + if (std.mem.eql(u8, existing, canon)) { + allocator.free(folders.orderedRemove(i)); break; } } - if (found) continue; - try folders.append(try allocator.dupe(u8, canon)); + try folders.append(canon); } else |_| {} } return .{ .folders = folders, .last_open_folder = if (disk.last_open_folder.len > 0) - try allocator.dupe(u8, trimTrailingPathSeparators(disk.last_open_folder)) + try canonicalize(allocator, disk.last_open_folder) else null, .last_save_folder = if (disk.last_save_folder.len > 0) - try allocator.dupe(u8, trimTrailingPathSeparators(disk.last_save_folder)) + try canonicalize(allocator, disk.last_save_folder) else null, }; @@ -78,23 +86,28 @@ pub fn load(allocator: std.mem.Allocator, path: []const u8) !Recents { }; } +/// `path` may be spelled however the caller got it; stored entries are canonical, so the key is +/// canonicalized before comparing (falling back to a trailing-separator trim if that allocation +/// fails, which at worst misses a match and adds a duplicate row). pub fn indexOfFolder(recents: *Recents, path: []const u8) ?usize { if (recents.folders.items.len == 0) return null; - const key = trimTrailingPathSeparators(path); + const canon_key = canonicalize(fizzy.app.allocator, path) catch null; + defer if (canon_key) |k| fizzy.app.allocator.free(k); + const key: []const u8 = canon_key orelse trimTrailingPathSeparators(path); + for (recents.folders.items, 0..) |folder, i| { - if (std.mem.eql(u8, trimTrailingPathSeparators(folder), key)) + if (std.mem.eql(u8, folder, key)) return i; } return null; } +/// Takes ownership of `path`. pub fn appendFolder(recents: *Recents, path: []const u8) !void { const canon_owned = dup: { - const t = trimTrailingPathSeparators(path); - const duped = try fizzy.app.allocator.dupe(u8, t); - fizzy.app.allocator.free(path); - break :dup duped; + defer fizzy.app.allocator.free(path); + break :dup try canonicalize(fizzy.app.allocator, path); }; if (recents.indexOfFolder(canon_owned)) |index| { diff --git a/src/editor/SettingRow.zig b/src/editor/SettingRow.zig new file mode 100644 index 00000000..07cfc839 --- /dev/null +++ b/src/editor/SettingRow.zig @@ -0,0 +1,102 @@ +//! One setting's chrome, drawn identically for the shell's own settings and every plugin's. +//! +//! A setting reads as a small self-contained group, VSCode-style: +//! +//! Insert spaces on tab ← name (`header`), sentence case, bold +//! insert_spaces_on_tab ← the key it has in settings.zon, dim and smaller +//! Insert spaces instead of a… ← description (`description`), wrapped +//! [▁▁▁▁▁ control ▁▁▁▁▁] ← the control, full width +//! +//! Booleans are the one exception: their checkbox sits *before* the description on one line +//! (`beginInlineControl`), because a full-width control for two states is all frame and no +//! information. +//! +//! Name and key are both search-highlighted — the settings search matches either, so a row that +//! survived on a key match has to be able to show why. +//! +//! `SettingsTree` owns *which* rows appear and in what order; this file owns what one row looks +//! like. Control bodies live in `PluginSettingsPane` (plugin settings) and +//! `explorer/settings.zig` (the shell's own). +const std = @import("std"); +const dvui = @import("dvui"); +const core = @import("core"); + +const fuzzy = core.fuzzy; +const wdvui = core.dvui; + +/// Horizontal inset shared by every line in a row, so name/key/description/control all start on +/// the same left edge. +const inset: f32 = 3; + +/// Name + key. Call once per setting, before the description and control. +pub fn header(name: []const u8, key: []const u8, query: *const fuzzy.Query) void { + { + var tl = dvui.textLayout(@src(), .{ .break_lines = false }, .{ + .background = false, + .expand = .horizontal, + .margin = dvui.Rect.all(0), + .padding = .{ .x = inset, .w = inset, .h = 1 }, + .font = dvui.Font.theme(.body).withWeight(.bold), + }); + wdvui.addHighlightedText(tl, name, query, true, dvui.themeGet().color(.control, .text)); + tl.deinit(); + } + + // The key is what the user types in settings.zon, so it gets the mono face — and enough + // dimming that the pane reads as a list of names, not a list of identifiers. + var tl = dvui.textLayout(@src(), .{ .break_lines = false }, .{ + .background = false, + .expand = .horizontal, + .margin = dvui.Rect.all(0), + .padding = .{ .x = inset, .w = inset, .h = 2 }, + .font = dvui.Font.theme(.mono), + }); + wdvui.addHighlightedText(tl, key, query, true, mutedText(0.55)); + tl.deinit(); +} + +/// The wrapped description, drawn above the control (VSCode's order). +/// +/// No `gravity_y`: inside the row's vertical box a non-zero vertical gravity is measured against +/// the *whole* box's spare height, which slides the description up over the key line. +pub fn description(text: []const u8) void { + drawDescription(text, .{ + .padding = .{ .x = inset, .w = inset, .h = 3 }, + }); +} + +/// The same description on a checkbox's line — vertically centred against the checkbox, and +/// inset far enough to clear it. +pub fn descriptionInline(text: []const u8) void { + drawDescription(text, .{ + .gravity_y = 0.5, + .padding = .{ .w = inset, .h = 1 }, + }); +} + +fn drawDescription(text: []const u8, over: dvui.Options) void { + const defaults: dvui.Options = .{ + .background = false, + .expand = .horizontal, + .margin = dvui.Rect.all(0), + .font = dvui.Font.theme(.body), + }; + var tl = dvui.textLayout(@src(), .{ .break_lines = true }, defaults.override(over)); + tl.addText(text, .{ .color_text = mutedText(0.8) }); + tl.deinit(); +} + +/// Row for a control that shares its line with the description — the checkbox goes in first, the +/// description wraps in whatever width is left. Caller deinits. +pub fn beginInlineControl() *dvui.BoxWidget { + return dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .horizontal, + .background = false, + .margin = .{ .x = inset - 1, .y = 1, .h = 2 }, + .padding = dvui.Rect.all(0), + }); +} + +fn mutedText(alpha: f32) dvui.Color { + return dvui.themeGet().color(.control, .text).opacity(alpha); +} diff --git a/src/editor/SettingsTree.zig b/src/editor/SettingsTree.zig index b7b73562..bf4834cb 100644 --- a/src/editor/SettingsTree.zig +++ b/src/editor/SettingsTree.zig @@ -6,6 +6,7 @@ //! ▾ Fizzy ← the shell's own settings (`explorer/settings.zig`'s `groups`) //! ▾ Appearance //! ▾ Input +//! ▾ Keyboard Shortcuts //! ▾ Debugging //! ▾ Text ← one branch per plugin that registered a settings schema //! ▾ Pixi @@ -25,6 +26,7 @@ const assets = @import("assets"); const core = @import("core"); const fizzy = @import("../fizzy.zig"); const PluginSettingsPane = @import("PluginSettingsPane.zig"); +const SettingRow = @import("SettingRow.zig"); const PluginStore = @import("PluginStore.zig"); const shell_settings = @import("explorer/settings.zig"); @@ -77,6 +79,12 @@ const Leaf = struct { /// Index into the owning source (`Group.items` or `SettingsSchema.fields`). index: usize, label: []const u8, + /// The name this setting has in `settings.zon` — drawn under the label and matched by the + /// search, so someone who only knows the zon key can still find the row. + key: []const u8 = "", + /// Only read for a `settings.Search` leaf, whose own drawer needs it above its table; other + /// leaves take theirs straight from the item/field. + description: []const u8 = "", score: f64, tie: usize, }; @@ -107,16 +115,22 @@ const Branch = struct { /// The path form is why this uses `plain = false`: zf then treats the last `/` segment as a /// filename and weights it above the ancestors, so typing `theme` ranks the Theme row above a /// plugin whose *branch* happens to contain the letters. -fn scoreLeaf(query: *const fuzzy.Query, path: []const u8, label: []const u8, keywords: []const u8) ?f64 { +fn scoreLeaf(query: *const fuzzy.Query, path: []const u8, label: []const u8, key: []const u8, keywords: []const u8) ?f64 { if (query.isEmpty()) return 0; const by_path = fuzzy.score(path, query, .{ .plain = false }); const by_label = fuzzy.score(label, query, .{ .plain = true }); + // The zon key is drawn on the row, so a key hit is as legitimate as a label hit — typing + // `insert_spaces_on_tab` (or just `spaces`) has to find the row either way. + const by_key = if (key.len > 0) fuzzy.score(key, query, .{ .plain = true }) else null; const by_keywords = if (keywords.len > 0) fuzzy.score(keywords, query, .{ .plain = true }) else null; var best: ?f64 = by_path; if (by_label) |s| if (best == null or s < best.?) { best = s; }; + if (by_key) |s| if (best == null or s < best.?) { + best = s; + }; // A keyword-only hit ("dark" → Theme) is a weaker signal than a visible-text hit, so it is // penalised rather than allowed to outrank a row the user can actually read the match on. if (by_keywords) |s| if (best == null or s + 1.0 < best.?) { @@ -158,9 +172,20 @@ fn collect(arena: std.mem.Allocator, query: *const fuzzy.Query) std.ArrayListUnm .key = branchKey(std.fmt.allocPrint(arena, "{s}/{s}", .{ shell_branch_title, group.title }) catch group.title), }; for (group.items, 0..) |item, ii| { - const path = std.fmt.allocPrint(arena, "{s}/{s}/{s}", .{ shell_branch_title, group.title, item.label }) catch item.label; - const s = scoreLeaf(query, path, item.label, item.keywords) orelse continue; - child.leaves.append(arena, .{ .index = ii, .label = item.label, .score = s, .tie = ii }) catch continue; + // An item that owns a searchable list scores itself: its rows, not its label, are + // what the query is really matching against (`settings.Search`). + const s = if (item.search) |sr| sr.score(query) orelse continue else blk: { + const path = std.fmt.allocPrint(arena, "{s}/{s}/{s}", .{ shell_branch_title, group.title, item.label }) catch item.label; + break :blk scoreLeaf(query, path, item.label, item.key, item.keywords) orelse continue; + }; + child.leaves.append(arena, .{ + .index = ii, + .label = item.label, + .key = item.key, + .description = item.description, + .score = s, + .tie = ii, + }) catch continue; if (s < child.score) child.score = s; } if (child.leaves.items.len == 0) continue; @@ -192,11 +217,18 @@ fn collect(arena: std.mem.Allocator, query: *const fuzzy.Query) std.ArrayListUnm for (schema.fields, 0..) |field, fi| { const path = std.fmt.allocPrint(arena, "{s}/{s}", .{ title, field.label }) catch field.label; - const leaf_hit = scoreLeaf(query, path, field.label, schema.owner.id); + const leaf_hit = scoreLeaf(query, path, field.label, field.key, schema.owner.id); // A hit on the plugin itself keeps all of its settings visible — you searched for the // plugin, so you want its panel, not a filtered subset of it. const s = leaf_hit orelse (branch_hit orelse continue); - branch.leaves.append(arena, .{ .index = fi, .label = field.label, .score = s, .tie = fi }) catch continue; + branch.leaves.append(arena, .{ + .index = fi, + .label = field.label, + .key = field.key, + .description = field.description, + .score = s, + .tie = fi, + }) catch continue; if (s < branch.score) branch.score = s; } if (branch.leaves.items.len == 0) continue; @@ -230,7 +262,25 @@ fn collect(arena: std.mem.Allocator, query: *const fuzzy.Query) std.ArrayListUnm // ---- drawing ------------------------------------------------------------------------------ pub fn draw() !void { - var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal }); + // Cap the pane at the explorer's viewport width. + // + // Two things depend on this. The explorer scrolls horizontally (long file paths need it), so + // it sizes itself to the widest child min size — and `TextLayoutWidget` documents that with + // `break_lines = true` its min *width* is still the width the text would need **unwrapped**. + // Left uncapped, every description therefore both widened the pane and, having been handed + // that width, never wrapped. Clamping the pane's own reported min size (`max_size_content` + // is what `minSizeSetAndRefresh` clamps against) stops descriptions from driving the width, + // which in turn gives them a bounded width to wrap inside. + const viewport_w = fizzy.editor.explorer.scroll_info.viewport.w; + const right_gap: f32 = 20; // clear of the pane's right edge (scrollbar / clip) + + var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .horizontal, + .margin = .{ .w = right_gap }, + // Zero on the very first frame, before the scroll area has measured itself — no cap that + // frame, then it settles. + .max_size_content = if (viewport_w > right_gap) .width(viewport_w - right_gap) else null, + }); defer vbox.deinit(); const query_text = drawSearchRow(); @@ -275,7 +325,7 @@ const RowStyle = enum { root, category }; /// Search row — deliberately the same shape as the file tree's filter (`workbench/src/files.zig`) /// so the two read as the same control. fn drawSearchRow() []const u8 { - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .margin = .{ .w = 10 } }); + var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); defer hbox.deinit(); dvui.icon( @@ -365,29 +415,53 @@ fn drawLeaves(branch: *const Branch, query: *const fuzzy.Query) !void { .id_extra = leaf.index, .expand = .horizontal, .background = false, - .margin = .{ .y = 2, .h = 2 }, + // A setting's name, key, description and control read as one group, so the gap + // *between* settings has to be clearly larger than any gap inside one. Split evenly + // top/bottom rather than loaded onto the bottom: dvui margins don't collapse, so two + // adjacent rows still sum to the same separation, but the first row no longer sits + // flush under its category header and the last no longer trails a lone wide gap. + .margin = .{ .y = 6, .h = 6 }, }); defer row.deinit(); - { - var tl = dvui.textLayout(@src(), .{ .break_lines = false }, .{ - .background = false, - .expand = .horizontal, - .margin = dvui.Rect.all(0), - .padding = dvui.Rect.all(3), - .font = dvui.Font.theme(.body), - }); - addHighlighted(tl, leaf.label, query); - tl.deinit(); - } - - if (branch.group) |group| { - group.items[leaf.index].draw(); + // A `settings.Search` item draws its own rows (each with its own name), so the leaf's own + // name/key header above them would just be a second, redundant heading — but its + // description still belongs at the top, explaining the table that follows. + const search_item: ?shell_settings.Search = + if (branch.group) |group| group.items[leaf.index].search else null; + + if (search_item == null) SettingRow.header(leaf.label, leaf.key, query); + + if (search_item) |sr| { + SettingRow.description(leaf.description); + sr.draw(query); + } else if (branch.group) |group| { + const item = group.items[leaf.index]; + if (item.inline_control) { + var inline_box = SettingRow.beginInlineControl(); + defer inline_box.deinit(); + if (item.draw) |drawFn| drawFn(); + SettingRow.descriptionInline(item.description); + } else { + SettingRow.description(item.description); + if (item.draw) |drawFn| drawFn(); + } } else if (branch.schema) |schema| { // Same hashing the old pane used: a plugin id that shows up in more than one section // must not collide on a widget id. const id_extra = hashId(schema.owner.id) +% leaf.index +% 1; - try PluginSettingsPane.drawField(schema, schema.fields[leaf.index], leaf.index, id_extra); + const field = schema.fields[leaf.index]; + // A checkbox is the one control that shares its line with the description — see + // `SettingRow`. + if (field.kind == .bool) { + var inline_box = SettingRow.beginInlineControl(); + defer inline_box.deinit(); + try PluginSettingsPane.drawField(schema, field, leaf.index, id_extra); + SettingRow.descriptionInline(field.description); + } else { + SettingRow.description(field.description); + try PluginSettingsPane.drawField(schema, field, leaf.index, id_extra); + } } } } @@ -403,9 +477,14 @@ fn drawFailure(f: fizzy.Editor.FailedPlugin) void { else std.fmt.bufPrint(&buf, "Failed to load: {s}", .{f.reason}) catch f.reason; - var tl = dvui.textLayout(@src(), .{}, .{ + // Wrapped, and with the same insets as a real setting row: an ABI-mismatch reason is a long + // sentence, and a single unwrapped line of it would widen the whole pane (see `draw`). + var tl = dvui.textLayout(@src(), .{ .break_lines = true }, .{ .expand = .horizontal, .background = false, + .margin = dvui.Rect.all(0), + .padding = .{ .x = 3, .w = 3, .y = 1, .h = 3 }, + .font = dvui.Font.theme(.body), }); tl.addText(text, .{ .color_text = dvui.themeGet().color(.err, .text) }); tl.deinit(); @@ -456,10 +535,14 @@ fn drawRow(b: *wdvui.TreeWidget.Branch, branch: *const Branch, query: *const fuz /// Always drawn inside a `treeRowGlyph` slot, so nothing here manages its own size. fn drawIdentityIcon(branch: *const Branch, style: RowStyle, color: dvui.Color) void { if (style == .category) { + // Each shell category names its own glyph (`Group.icon`) — a palette for Appearance, a + // bug for Debugging — so the row says what it configures. A folder would only say + // "there are more rows under here", which the caret already does. + const glyph = if (branch.group) |g| g.icon else icons.tvg.entypo.folder; _ = dvui.icon( @src(), "CategoryIcon", - icons.tvg.entypo.folder, + glyph, .{ .fill_color = color, .stroke_color = color }, wdvui.treeRowIconOptions(.{}), ); diff --git a/src/editor/explorer/IgnoreRules.zig b/src/editor/explorer/IgnoreRules.zig index 50649970..99e9126b 100644 --- a/src/editor/explorer/IgnoreRules.zig +++ b/src/editor/explorer/IgnoreRules.zig @@ -69,7 +69,20 @@ pub fn load(gpa: std.mem.Allocator, project_root_abs: []const u8) !IgnoreRules { return out; } +/// Version-control metadata directories, ignored whether or not the project lists them. +/// A VCS never ignores its own store — `.gitignore` has no reason to mention `.git` — so +/// anything walking the project from the ignore rules alone descends straight into it. In +/// this repo that is 8k files for `.git` and 52k for `.jj`: the file index went from ~9k +/// entries to ~60k, and one keystroke in the explorer filter drew a row for each. +const always_ignored_dirs = [_][]const u8{ ".git", ".jj", ".hg", ".svn" }; + pub fn isIgnored(self: *const IgnoreRules, project_root_abs: []const u8, abs_path: []const u8, entry_name: []const u8, kind: std.Io.File.Kind) bool { + if (kind == .directory) { + for (always_ignored_dirs) |d| { + if (std.mem.eql(u8, entry_name, d)) return true; + } + } + if (self.lines.items.len == 0) return false; const rel_unix = relPathUnix(project_root_abs, abs_path); diff --git a/src/editor/explorer/settings.zig b/src/editor/explorer/settings.zig index 23d9e03a..8365be84 100644 --- a/src/editor/explorer/settings.zig +++ b/src/editor/explorer/settings.zig @@ -15,48 +15,165 @@ const builtin = @import("builtin"); const std = @import("std"); const fizzy = @import("../../fizzy.zig"); +const icons = @import("icons"); const dvui = @import("dvui"); +const core = @import("core"); const Editor = fizzy.Editor; +const KeybindSettings = @import("../KeybindSettings.zig"); -/// One row in the settings tree: a labelled control the user can search for. +const fuzzy = core.fuzzy; + +/// An item that is a searchable list in its own right rather than one labelled control. +/// +/// The keybind table is the only one: it holds hundreds of rows, so matching it as a single leaf +/// labelled "Bindings" would be useless. `score` is run during `SettingsTree`'s data pass (it +/// returns the best score among the rows the item *would* draw, or null to drop the row from the +/// tree entirely) and `draw` then renders only what matched, highlighting it itself. +pub const Search = struct { + score: *const fn (query: *const fuzzy.Query) ?f64, + draw: *const fn (query: *const fuzzy.Query) void, +}; + +/// One row in the settings tree: a named, described control the user can search for. +/// +/// `label`/`key`/`description` are the same three things a plugin's settings cell carries +/// (`sdk.settings.Setting`), and `SettingRow` draws them identically — a shell setting and a +/// plugin setting must be indistinguishable in the pane. pub const Item = struct { + /// One of the two names the search matches on. Not drawn for a `search` item — those label + /// their own rows. label: []const u8, + /// The field's name in `settings.zon`, drawn under `label` and matched by the search. Kept in + /// sync with `Editor.Settings` by hand; `search` items that aren't a single field name it + /// after the section they configure. + key: []const u8, + /// What the setting does. Required, exactly as it is for plugin settings. + description: []const u8, /// Extra search terms that never appear on screen — synonyms and the words a user is likely /// to type instead of the label ("dark" for Theme, "trackpad" for the control scheme). keywords: []const u8 = "", - draw: *const fn () void, + /// The control body. Exactly one of this and `search` is set. + draw: ?*const fn () void = null, + search: ?Search = null, + /// Set for a control small enough to share the description's line rather than take a full + /// width of its own — checkboxes, and nothing else so far. + inline_control: bool = false, }; /// A category branch under "Fizzy". pub const Group = struct { title: []const u8, + /// TVG bytes for the branch's glyph — what the category *is*, not a generic folder. Drawn by + /// `SettingsTree.drawIdentityIcon`. + icon: []const u8, items: []const Item, }; pub const groups = [_]Group{ .{ .title = "Appearance", + .icon = icons.tvg.lucide.palette, .items = &.{ - .{ .label = "Theme", .keywords = "color colour dark light scheme", .draw = drawTheme }, - .{ .label = "Body Font Size", .keywords = "text size", .draw = drawBodyFontSize }, - .{ .label = "Heading Font Size", .keywords = "text size", .draw = drawHeadingFontSize }, - .{ .label = "Title Font Size", .keywords = "text size", .draw = drawTitleFontSize }, - .{ .label = "Monospace Font Size", .keywords = "text size code mono", .draw = drawMonoFontSize }, - .{ .label = "Window Opacity", .keywords = "transparency alpha blur", .draw = drawWindowOpacity }, - .{ .label = "Content Opacity", .keywords = "transparency alpha", .draw = drawContentOpacity }, + .{ + .label = "Theme", + .key = "theme", + .description = "Colour theme used across the whole interface.", + .keywords = "color colour dark light scheme", + .draw = drawTheme, + }, + .{ + .label = "Body font size", + .key = "font_body_size", + .description = "Size of ordinary interface text — labels, tree rows, menus.", + .keywords = "text size", + .draw = drawBodyFontSize, + }, + .{ + .label = "Heading font size", + .key = "font_heading_size", + .description = "Size of section headings, such as the roots of this settings tree.", + .keywords = "text size", + .draw = drawHeadingFontSize, + }, + .{ + .label = "Title font size", + .key = "font_title_size", + .description = "Size of window and dialog titles.", + .keywords = "text size", + .draw = drawTitleFontSize, + }, + .{ + .label = "Monospace font size", + .key = "font_mono_size", + .description = "Size of fixed-width text: code, file paths, and setting keys.", + .keywords = "text size code mono", + .draw = drawMonoFontSize, + }, + .{ + .label = "Window opacity", + .key = "window_opacity_dark", + .description = "How opaque the window background is behind the interface. " ++ + "Dark and light themes are remembered separately.", + .keywords = "transparency alpha blur", + .draw = drawWindowOpacity, + }, + .{ + .label = "Content opacity", + .key = "content_opacity", + .description = "How opaque panels drawn over the window background are.", + .keywords = "transparency alpha", + .draw = drawContentOpacity, + }, }, }, .{ .title = "Input", + .icon = icons.tvg.lucide.mouse, + .items = &.{ + .{ + .label = "Context menu hold", + .key = "hold_menu_duration_ms", + .description = "How long a press has to be held before it opens a context menu, " ++ + "in milliseconds.", + .keywords = "right click long press duration delay", + .draw = drawHoldMenuDuration, + }, + .{ + .label = "Canvas control scheme", + .key = "input_scheme", + .description = "Which zoom and pan gestures canvases expect. Auto follows the " ++ + "pointing device currently in use.", + .keywords = "mouse trackpad pan zoom scroll", + .draw = drawInputScheme, + }, + }, + }, + .{ + .title = "Keyboard Shortcuts", + .icon = icons.tvg.lucide.keyboard, .items = &.{ - .{ .label = "Context Menu Hold", .keywords = "right click long press duration delay", .draw = drawHoldMenuDuration }, - .{ .label = "Canvas Control Scheme", .keywords = "mouse trackpad pan zoom scroll", .draw = drawInputScheme }, + .{ + .label = "Bindings", + .key = "keybinds", + .description = "Every command and the keys that run it. Click a binding to " ++ + "record a new one.", + .keywords = "keybind shortcut hotkey chord keyboard remap keys", + // Every command is individually searchable — see `KeybindSettings`. + .search = .{ .score = KeybindSettings.score, .draw = KeybindSettings.draw }, + }, }, }, .{ .title = "Debugging", + .icon = icons.tvg.lucide.bug, .items = &.{ - .{ .label = "Frame Rate", .keywords = "fps performance diagnostics", .draw = drawFps }, + .{ + .label = "Frame rate", + .key = "fps", + .description = "Frames per second this window is currently drawing. Read-only.", + .keywords = "fps performance diagnostics", + .draw = drawFps, + }, }, }, }; diff --git a/src/editor/keymap/Key.zig b/src/editor/keymap/Key.zig new file mode 100644 index 00000000..8958a334 --- /dev/null +++ b/src/editor/keymap/Key.zig @@ -0,0 +1,152 @@ +//! Physical key identity, plus the VSCode-compatible spellings used in `keybinds.zon`. +//! +//! Deliberately **not** `dvui.enums.Key`, so the whole `keymap/` tree stays dvui-free and +//! unit-testable from the app build (see `keymap.zig`). The tag names are kept identical to +//! dvui's so the adapter that does import dvui (`keymap_dvui.zig`) can convert by name and +//! `comptime`-assert that no tag has drifted, rather than hand-maintaining a 118-arm switch. + +const std = @import("std"); + +pub const Key = enum { + a, b, c, d, e, f, g, h, i, j, k, l, m, + n, o, p, q, r, s, t, u, v, w, x, y, z, + + zero, one, two, three, four, five, six, seven, eight, nine, + + f1, f2, f3, f4, f5, f6, f7, f8, f9, f10, f11, f12, + f13, f14, f15, f16, f17, f18, f19, f20, f21, f22, f23, f24, f25, + + kp_divide, kp_multiply, kp_subtract, kp_add, + kp_0, kp_1, kp_2, kp_3, kp_4, kp_5, kp_6, kp_7, kp_8, kp_9, + kp_decimal, kp_equal, kp_enter, + + enter, escape, tab, + left_shift, right_shift, left_control, right_control, + left_alt, right_alt, left_command, right_command, + menu, num_lock, caps_lock, print, scroll_lock, pause, + delete, home, end, page_up, page_down, insert, + left, right, up, down, + backspace, space, + minus, equal, left_bracket, right_bracket, backslash, + semicolon, apostrophe, comma, period, slash, grave, +}; + +/// Modifier keys are never bindable on their own — a binding whose key is one of these can only +/// ever fire together with itself, which is never what anyone means. +pub fn isModifier(k: Key) bool { + return switch (k) { + .left_shift, .right_shift, .left_control, .right_control, + .left_alt, .right_alt, .left_command, .right_command => true, + else => false, + }; +} + +/// Accepted spellings → key. Primary spelling first; aliases follow. VSCode's own names are +/// used wherever they exist so its documentation and any pasted `keybindings.json` line reads +/// the same here. +const Spelling = struct { text: []const u8, key: Key }; + +const spellings = [_]Spelling{ + // Punctuation, by the character itself (how VSCode writes them). + .{ .text = "`", .key = .grave }, .{ .text = "-", .key = .minus }, + .{ .text = "=", .key = .equal }, .{ .text = "[", .key = .left_bracket }, + .{ .text = "]", .key = .right_bracket }, .{ .text = "\\", .key = .backslash }, + .{ .text = ";", .key = .semicolon }, .{ .text = "'", .key = .apostrophe }, + .{ .text = ",", .key = .comma }, .{ .text = ".", .key = .period }, + .{ .text = "/", .key = .slash }, + + // Named keys. + .{ .text = "left", .key = .left }, .{ .text = "right", .key = .right }, + .{ .text = "up", .key = .up }, .{ .text = "down", .key = .down }, + .{ .text = "pageup", .key = .page_up }, .{ .text = "pagedown", .key = .page_down }, + .{ .text = "page_up", .key = .page_up }, .{ .text = "page_down", .key = .page_down }, + .{ .text = "home", .key = .home }, .{ .text = "end", .key = .end }, + .{ .text = "tab", .key = .tab }, .{ .text = "enter", .key = .enter }, + .{ .text = "return", .key = .enter }, .{ .text = "escape", .key = .escape }, + .{ .text = "esc", .key = .escape }, .{ .text = "space", .key = .space }, + .{ .text = "backspace", .key = .backspace }, .{ .text = "delete", .key = .delete }, + .{ .text = "del", .key = .delete }, .{ .text = "insert", .key = .insert }, + .{ .text = "capslock", .key = .caps_lock }, .{ .text = "numlock", .key = .num_lock }, + .{ .text = "scrolllock", .key = .scroll_lock }, .{ .text = "pausebreak", .key = .pause }, + .{ .text = "pause", .key = .pause }, .{ .text = "printscreen", .key = .print }, + .{ .text = "menu", .key = .menu }, + + // Digit row — spelled as digits, not as the tag names. + .{ .text = "0", .key = .zero }, .{ .text = "1", .key = .one }, + .{ .text = "2", .key = .two }, .{ .text = "3", .key = .three }, + .{ .text = "4", .key = .four }, .{ .text = "5", .key = .five }, + .{ .text = "6", .key = .six }, .{ .text = "7", .key = .seven }, + .{ .text = "8", .key = .eight }, .{ .text = "9", .key = .nine }, + + // Numpad, VSCode spelling. + .{ .text = "numpad0", .key = .kp_0 }, .{ .text = "numpad1", .key = .kp_1 }, + .{ .text = "numpad2", .key = .kp_2 }, .{ .text = "numpad3", .key = .kp_3 }, + .{ .text = "numpad4", .key = .kp_4 }, .{ .text = "numpad5", .key = .kp_5 }, + .{ .text = "numpad6", .key = .kp_6 }, .{ .text = "numpad7", .key = .kp_7 }, + .{ .text = "numpad8", .key = .kp_8 }, .{ .text = "numpad9", .key = .kp_9 }, + .{ .text = "numpad_multiply", .key = .kp_multiply }, + .{ .text = "numpad_add", .key = .kp_add }, + .{ .text = "numpad_subtract", .key = .kp_subtract }, + .{ .text = "numpad_divide", .key = .kp_divide }, + .{ .text = "numpad_decimal", .key = .kp_decimal }, + .{ .text = "numpad_enter", .key = .kp_enter }, + .{ .text = "numpad_equal", .key = .kp_equal }, +}; + +/// Parse a key token (already lowercased and trimmed). Letters `a`-`z` and `f1`-`f25` fall +/// through to the tag names, so they need no table entry. +pub fn fromSpelling(token: []const u8) ?Key { + if (token.len == 0) return null; + for (spellings) |s| { + if (std.mem.eql(u8, s.text, token)) return s.key; + } + // `a`..`z` and `f1`..`f25` are spelled exactly like their tags. + if (std.meta.stringToEnum(Key, token)) |k| { + // Guard against a config binding a bare modifier, or reaching a tag name that has a + // different canonical spelling above (e.g. "grave" rather than "`"). + if (isModifier(k)) return null; + return k; + } + return null; +} + +/// Canonical spelling, for writing `keybinds.zon` back out and for rendering shortcut hints. +pub fn toSpelling(k: Key) []const u8 { + for (spellings) |s| { + if (s.key == k) return s.text; + } + return @tagName(k); +} + +const t = std.testing; + +test "letters and function keys parse from their tag names" { + try t.expectEqual(Key.a, fromSpelling("a").?); + try t.expectEqual(Key.z, fromSpelling("z").?); + try t.expectEqual(Key.f12, fromSpelling("f12").?); +} + +test "VSCode punctuation and named-key spellings" { + try t.expectEqual(Key.grave, fromSpelling("`").?); + try t.expectEqual(Key.slash, fromSpelling("/").?); + try t.expectEqual(Key.page_up, fromSpelling("pageup").?); + try t.expectEqual(Key.escape, fromSpelling("esc").?); + try t.expectEqual(Key.enter, fromSpelling("enter").?); + try t.expectEqual(Key.kp_5, fromSpelling("numpad5").?); + try t.expectEqual(Key.zero, fromSpelling("0").?); +} + +test "bare modifiers and junk are rejected" { + try t.expectEqual(@as(?Key, null), fromSpelling("left_shift")); + try t.expectEqual(@as(?Key, null), fromSpelling("ctrl")); + try t.expectEqual(@as(?Key, null), fromSpelling("")); + try t.expectEqual(@as(?Key, null), fromSpelling("nonsense")); +} + +test "every spelling round-trips through its canonical form" { + for (std.enums.values(Key)) |k| { + if (isModifier(k)) continue; + const canonical = toSpelling(k); + try t.expectEqual(k, fromSpelling(canonical).?); + } +} diff --git a/src/editor/keymap/chord.zig b/src/editor/keymap/chord.zig new file mode 100644 index 00000000..7fd84235 --- /dev/null +++ b/src/editor/keymap/chord.zig @@ -0,0 +1,263 @@ +//! Parsing and formatting of VSCode-style key strings: `ctrl+shift+p`, `mod+k mod+c`, `alt+up`. +//! +//! Two-stroke chords are supported from the start, because dvui's keybind map physically cannot +//! express them (`dvui.enums.Keybind` is one key plus modifier flags) and retrofitting a chord +//! into a lookup-by-name design later would mean rewriting the dispatch rather than extending it. + +const std = @import("std"); +const Key = @import("Key.zig").Key; +const keyFromSpelling = @import("Key.zig").fromSpelling; +const keyToSpelling = @import("Key.zig").toSpelling; + +/// Which physical modifier `mod+` resolves to. Passed in rather than detected so both branches +/// are testable without a window — and because fizzy already has to distinguish these at runtime +/// (`fizzy.platform.isMacOS()` differs from `builtin.os.tag` on wasm). +pub const Platform = enum { mac, other }; + +pub const Mods = packed struct { + ctrl: bool = false, + shift: bool = false, + alt: bool = false, + command: bool = false, + + pub fn eql(a: Mods, b: Mods) bool { + return a.ctrl == b.ctrl and a.shift == b.shift and a.alt == b.alt and a.command == b.command; + } + + pub fn isEmpty(self: Mods) bool { + return !self.ctrl and !self.shift and !self.alt and !self.command; + } +}; + +pub const Chord = struct { + key: Key, + mods: Mods = .{}, + + pub fn eql(a: Chord, b: Chord) bool { + return a.key == b.key and a.mods.eql(b.mods); + } +}; + +pub const Stroke = struct { + first: Chord, + /// Second half of a chord, or null for an ordinary single-stroke binding. + second: ?Chord = null, + + pub fn isChord(self: Stroke) bool { + return self.second != null; + } + + pub fn eql(a: Stroke, b: Stroke) bool { + if (!a.first.eql(b.first)) return false; + if (a.second == null and b.second == null) return true; + if (a.second == null or b.second == null) return false; + return a.second.?.eql(b.second.?); + } +}; + +pub const ParseError = error{ + Empty, + /// More than two strokes — VSCode allows two, and so do we. + TooManyStrokes, + /// A stroke with modifiers but no key, e.g. "ctrl+". + MissingKey, + UnknownModifier, + UnknownKey, + /// The key was a bare modifier, e.g. "ctrl+shift". + ModifierAsKey, +}; + +fn parseModifier(token: []const u8, platform: Platform, mods: *Mods) ParseError!void { + // `mod` is the primary accelerator: Command on macOS, Control everywhere else. Default + // profile tables use it so one entry serves both platforms; a user who writes `ctrl` + // explicitly gets a literal Control even on a Mac. + if (std.mem.eql(u8, token, "mod") or std.mem.eql(u8, token, "primary")) { + switch (platform) { + .mac => mods.command = true, + .other => mods.ctrl = true, + } + return; + } + if (std.mem.eql(u8, token, "ctrl") or std.mem.eql(u8, token, "control")) { + mods.ctrl = true; + } else if (std.mem.eql(u8, token, "shift")) { + mods.shift = true; + } else if (std.mem.eql(u8, token, "alt") or std.mem.eql(u8, token, "option")) { + mods.alt = true; + } else if (std.mem.eql(u8, token, "cmd") or std.mem.eql(u8, token, "command") or + std.mem.eql(u8, token, "meta") or std.mem.eql(u8, token, "super") or + std.mem.eql(u8, token, "win")) + { + mods.command = true; + } else return error.UnknownModifier; +} + +fn parseChord(text: []const u8, platform: Platform, buf: []u8) ParseError!Chord { + if (text.len == 0) return error.Empty; + + var mods: Mods = .{}; + var rest = text; + var key_token: []const u8 = text; + + // Split on '+', treating the final segment as the key. `+` is never itself a key name + // (VSCode spells that key `=` / `shift+=`), so a trailing '+' is a missing key, not a plus. + while (std.mem.indexOfScalar(u8, rest, '+')) |idx| { + const token = rest[0..idx]; + if (token.len == 0) return error.MissingKey; + const lowered = lower(token, buf); + try parseModifier(lowered, platform, &mods); + rest = rest[idx + 1 ..]; + key_token = rest; + } + if (key_token.len == 0) return error.MissingKey; + + const lowered_key = lower(key_token, buf); + const key = keyFromSpelling(lowered_key) orelse { + // Distinguish "you named a modifier where a key goes" from "no idea what that is" — + // the former is a common mistake and deserves its own diagnostic. + var probe: Mods = .{}; + if (parseModifier(lowered_key, platform, &probe)) |_| { + return error.ModifierAsKey; + } else |_| {} + return error.UnknownKey; + }; + return .{ .key = key, .mods = mods }; +} + +fn lower(s: []const u8, buf: []u8) []const u8 { + if (s.len > buf.len) return s; + for (s, 0..) |c, i| buf[i] = std.ascii.toLower(c); + return buf[0..s.len]; +} + +/// Parse `"ctrl+k ctrl+c"` into a `Stroke`. Whitespace separates the two halves of a chord. +pub fn parseKeys(text: []const u8, platform: Platform) ParseError!Stroke { + const trimmed = std.mem.trim(u8, text, " \t"); + if (trimmed.len == 0) return error.Empty; + + var buf: [64]u8 = undefined; + var it = std.mem.tokenizeAny(u8, trimmed, " \t"); + + const first_text = it.next() orelse return error.Empty; + const first = try parseChord(first_text, platform, &buf); + + const second_text = it.next(); + const second: ?Chord = if (second_text) |st| try parseChord(st, platform, &buf) else null; + + if (it.next() != null) return error.TooManyStrokes; + return .{ .first = first, .second = second }; +} + +fn writeChord(c: Chord, w: *std.Io.Writer, platform: Platform) !void { + // Canonical modifier order, matching VSCode's own output. + if (c.mods.ctrl) try w.writeAll("ctrl+"); + if (c.mods.shift) try w.writeAll("shift+"); + if (c.mods.alt) try w.writeAll("alt+"); + if (c.mods.command) try w.writeAll(if (platform == .mac) "cmd+" else "win+"); + try w.writeAll(keyToSpelling(c.key)); +} + +/// Format back to a key string. Round-trips through `parseKeys` for the same platform. +pub fn formatKeys(gpa: std.mem.Allocator, s: Stroke, platform: Platform) ![]u8 { + var out: std.Io.Writer.Allocating = .init(gpa); + errdefer out.deinit(); + try writeChord(s.first, &out.writer, platform); + if (s.second) |sec| { + try out.writer.writeByte(' '); + try writeChord(sec, &out.writer, platform); + } + return out.toOwnedSlice(); +} + +// -- tests -------------------------------------------------------------------------------------- + +const t = std.testing; + +test "single chord with modifiers" { + const s = try parseKeys("ctrl+shift+p", .other); + try t.expectEqual(Key.p, s.first.key); + try t.expect(s.first.mods.ctrl and s.first.mods.shift); + try t.expect(!s.first.mods.alt and !s.first.mods.command); + try t.expect(!s.isChord()); +} + +test "mod resolves per platform" { + const mac = try parseKeys("mod+s", .mac); + try t.expect(mac.first.mods.command and !mac.first.mods.ctrl); + + const other = try parseKeys("mod+s", .other); + try t.expect(other.first.mods.ctrl and !other.first.mods.command); +} + +test "explicit ctrl stays literal even on macOS" { + const s = try parseKeys("ctrl+s", .mac); + try t.expect(s.first.mods.ctrl and !s.first.mods.command); +} + +test "two-stroke chords" { + const s = try parseKeys("ctrl+k ctrl+c", .other); + try t.expect(s.isChord()); + try t.expectEqual(Key.k, s.first.key); + try t.expectEqual(Key.c, s.second.?.key); + try t.expect(s.second.?.mods.ctrl); +} + +test "case and surrounding whitespace are insensitive" { + const a = try parseKeys(" Ctrl+Shift+P ", .other); + const b = try parseKeys("ctrl+shift+p", .other); + try t.expect(a.eql(b)); +} + +test "modifier aliases" { + try t.expect((try parseKeys("cmd+a", .other)).first.mods.command); + try t.expect((try parseKeys("option+a", .other)).first.mods.alt); + try t.expect((try parseKeys("control+a", .other)).first.mods.ctrl); + try t.expect((try parseKeys("meta+a", .other)).first.mods.command); +} + +test "punctuation keys" { + try t.expectEqual(Key.slash, (try parseKeys("ctrl+/", .other)).first.key); + try t.expectEqual(Key.grave, (try parseKeys("ctrl+`", .other)).first.key); + try t.expectEqual(Key.backslash, (try parseKeys("ctrl+\\", .other)).first.key); + try t.expectEqual(Key.equal, (try parseKeys("ctrl+=", .other)).first.key); +} + +test "errors are specific" { + try t.expectError(error.Empty, parseKeys("", .other)); + try t.expectError(error.Empty, parseKeys(" ", .other)); + try t.expectError(error.MissingKey, parseKeys("ctrl+", .other)); + try t.expectError(error.UnknownModifier, parseKeys("hyper+a", .other)); + try t.expectError(error.UnknownKey, parseKeys("ctrl+nope", .other)); + try t.expectError(error.ModifierAsKey, parseKeys("ctrl+shift", .other)); + try t.expectError(error.TooManyStrokes, parseKeys("ctrl+a ctrl+b ctrl+c", .other)); +} + +test "format round-trips" { + const cases = [_][]const u8{ + "ctrl+shift+p", + "alt+up", + "ctrl+/", + "f12", + "escape", + "ctrl+k ctrl+c", + "ctrl+shift+alt+pageup", + }; + for (cases) |c| { + const parsed = try parseKeys(c, .other); + const formatted = try formatKeys(t.allocator, parsed, .other); + defer t.allocator.free(formatted); + try t.expectEqualStrings(c, formatted); + // And the formatted form parses back to the same stroke. + try t.expect(parsed.eql(try parseKeys(formatted, .other))); + } +} + +test "every key formats to something that parses back" { + for (std.enums.values(Key)) |k| { + if (@import("Key.zig").isModifier(k)) continue; + const s: Stroke = .{ .first = .{ .key = k, .mods = .{ .ctrl = true, .alt = true } } }; + const text = try formatKeys(t.allocator, s, .other); + defer t.allocator.free(text); + try t.expect(s.eql(try parseKeys(text, .other))); + } +} diff --git a/src/editor/keymap/dvui_adapter.zig b/src/editor/keymap/dvui_adapter.zig new file mode 100644 index 00000000..fb76b87f --- /dev/null +++ b/src/editor/keymap/dvui_adapter.zig @@ -0,0 +1,88 @@ +//! The one file in `keymap/` that imports dvui. +//! +//! Everything else in this tree is deliberately dvui-free so it can be unit-tested from the app +//! build (see `keymap.zig`). This file is the boundary: it converts dvui key events *in*, and +//! projects single-stroke bindings back *out* into `dvui.Window.keybinds`. +//! +//! That projection is not optional. `TextEntryWidget` calls `ke.matchBind("char_left")` and +//! `Menu.zig` calls `cw.keybinds.get("save")` to render accelerators — both read the dvui map +//! directly. Chords have no representation there at all (`dvui.enums.Keybind` is one key plus +//! modifier flags), so they exist only in the resolver. + +const std = @import("std"); +const dvui = @import("dvui"); +const keymap = @import("keymap.zig"); + +const Key = keymap.Key; +const Chord = keymap.Chord; +const Mods = keymap.Mods; + +// `keymap.Key`'s tags are intentionally spelled exactly like `dvui.enums.Key`'s so conversion is +// by name. This catches drift at compile time instead of silently dropping a key at runtime. +comptime { + for (std.enums.values(Key)) |k| { + if (!@hasField(dvui.enums.Key, @tagName(k))) { + @compileError("keymap.Key." ++ @tagName(k) ++ + " has no dvui.enums.Key counterpart — dvui's key enum has changed"); + } + } +} + +pub fn toDvuiKey(k: Key) dvui.enums.Key { + return switch (k) { + inline else => |tag| @field(dvui.enums.Key, @tagName(tag)), + }; +} + +/// Null for a dvui key we don't model (nothing today, but dvui may add keys before we do). +pub fn fromDvuiKey(k: dvui.enums.Key) ?Key { + return switch (k) { + inline else => |tag| if (@hasField(Key, @tagName(tag))) + @field(Key, @tagName(tag)) + else + null, + }; +} + +pub fn modsFrom(mod: dvui.enums.Mod) Mods { + return .{ + .ctrl = mod.control(), + .shift = mod.shift(), + .alt = mod.alt(), + .command = mod.command(), + }; +} + +/// Chord for a key event, or null if the key isn't one we model. +pub fn chordFrom(ke: dvui.Event.Key) ?Chord { + const key = fromDvuiKey(ke.code) orelse return null; + return .{ .key = key, .mods = modsFrom(ke.mod) }; +} + +/// Lower a chord to a dvui keybind. Every modifier is stated explicitly (rather than left null) +/// so a projected bind matches only the exact combination — `matchKeyBind` treats a null field +/// as "don't care", which would make `ctrl+s` also fire on `ctrl+shift+s`. +pub fn toKeybind(c: Chord) dvui.enums.Keybind { + return .{ + .key = toDvuiKey(c.key), + .control = c.mods.ctrl, + .shift = c.mods.shift, + .alt = c.mods.alt, + .command = c.mods.command, + }; +} + +/// Lift a dvui keybind into a chord. Null for modifier-only binds (`"ctrl/cmd"`, `"shift"`, +/// `"zoom"`), which have no key and exist purely to be tested with `mod.matchBind`. +pub fn fromKeybind(kb: dvui.enums.Keybind) ?Chord { + const key = fromDvuiKey(kb.key orelse return null) orelse return null; + return .{ + .key = key, + .mods = .{ + .ctrl = kb.control orelse false, + .shift = kb.shift orelse false, + .alt = kb.alt orelse false, + .command = kb.command orelse false, + }, + }; +} diff --git a/src/editor/keymap/keymap.zig b/src/editor/keymap/keymap.zig new file mode 100644 index 00000000..beb62372 --- /dev/null +++ b/src/editor/keymap/keymap.zig @@ -0,0 +1,667 @@ +//! Keybinding resolution: chord → command id. +//! +//! **Invariant: nothing under `keymap/` may import dvui.** dvui's keybind map is a +//! `name → Keybind` lookup where `Keybind` is a single key plus modifier flags — it cannot hold +//! a chord, and it is matched by *bind name* rather than by command, which is why today's +//! `Keybinds.tick()` is a hardcoded if-chain wired straight to `fizzy.editor.*` calls with +//! nothing addressable by a config file. This module owns the real table; the dvui-facing +//! adapter converts events in and projects single-stroke bindings back out so menus and dvui's +//! own widgets keep working. +//! +//! Staying dvui-free also puts this in the `addTest`-per-pure-root list in `build/app.zig`. +//! +//! This file is the single test root for the tree — Zig collects `test` blocks only from an +//! artifact's root module, and relative imports are part of that same module. + +const std = @import("std"); + +pub const Key = @import("Key.zig").Key; +pub const keyIsModifier = @import("Key.zig").isModifier; +const chord_mod = @import("chord.zig"); +pub const zon = @import("zon.zig"); + +pub const Platform = chord_mod.Platform; +pub const Mods = chord_mod.Mods; +pub const Chord = chord_mod.Chord; +pub const Stroke = chord_mod.Stroke; +pub const parseKeys = chord_mod.parseKeys; +pub const formatKeys = chord_mod.formatKeys; +pub const ParseError = chord_mod.ParseError; + +const Allocator = std.mem.Allocator; + +/// Context gate — VSCode's `when` clauses, reduced to a fixed set. Deliberately a small closed +/// set rather than an expression language: a binding either applies in a context or it doesn't, +/// and every entry here has to be *supplied* by the shell each frame, so an open-ended grammar +/// would just be a way to write conditions nothing ever satisfies. +/// +/// Empty (`.{}`) means "applies anywhere", which is the common case. +pub const When = packed struct { + editor_focused: bool = false, + text_input_focused: bool = false, + explorer_focused: bool = false, + panel_focused: bool = false, + completion_visible: bool = false, + modal_open: bool = false, + + pub fn isAny(self: When) bool { + return !self.editor_focused and !self.text_input_focused and + !self.explorer_focused and !self.panel_focused and + !self.completion_visible and !self.modal_open; + } + + /// Does a binding requiring `self` apply in the live context `ctx`? Every flag the binding + /// asks for must be present; flags it doesn't ask about are ignored. + pub fn matches(self: When, ctx: When) bool { + if (self.editor_focused and !ctx.editor_focused) return false; + if (self.text_input_focused and !ctx.text_input_focused) return false; + if (self.explorer_focused and !ctx.explorer_focused) return false; + if (self.panel_focused and !ctx.panel_focused) return false; + if (self.completion_visible and !ctx.completion_visible) return false; + if (self.modal_open and !ctx.modal_open) return false; + return true; + } + + /// How many flags a binding constrains — the specificity used to break ties, so a + /// context-specific binding always wins over a global one on the same chord. + pub fn weight(self: When) u8 { + var n: u8 = 0; + inline for (@typeInfo(When).@"struct".fields) |f| { + if (@field(self, f.name)) n += 1; + } + return n; + } + + pub fn eql(a: When, b: When) bool { + return @as(u6, @bitCast(a)) == @as(u6, @bitCast(b)); + } + + /// Parse a `when` string. Unknown names are *not* an error — they're reported so a config + /// written against a newer fizzy round-trips instead of being silently dropped. + pub fn parse(text: []const u8, unknown: ?*std.ArrayList([]const u8), gpa: ?Allocator) !When { + var out: When = .{}; + var it = std.mem.tokenizeAny(u8, text, " \t&&,"); + while (it.next()) |raw| { + const tok = std.mem.trim(u8, raw, " \t"); + if (tok.len == 0) continue; + var matched = false; + inline for (@typeInfo(When).@"struct".fields) |f| { + if (eqlCamelOrSnake(tok, f.name)) { + @field(out, f.name) = true; + matched = true; + } + } + if (!matched) { + if (unknown) |list| try list.append(gpa.?, tok); + } + } + return out; + } +}; + +/// `completionVisible` and `completion_visible` both name the same flag — the ZON file leans +/// snake_case, VSCode docs use camelCase, and there's no value in making people care. +fn eqlCamelOrSnake(input: []const u8, snake: []const u8) bool { + if (std.ascii.eqlIgnoreCase(input, snake)) return true; + var i: usize = 0; + var j: usize = 0; + while (i < input.len and j < snake.len) { + if (snake[j] == '_') { + j += 1; + if (i >= input.len) return false; + if (std.ascii.toLower(input[i]) != std.ascii.toLower(snake[j])) return false; + i += 1; + j += 1; + continue; + } + if (std.ascii.toLower(input[i]) != std.ascii.toLower(snake[j])) return false; + i += 1; + j += 1; + } + return i == input.len and j == snake.len; +} + +/// Which layer a binding came from. Later sources override earlier ones on the same chord. +pub const Source = enum(u8) { + dvui = 0, + profile = 1, + plugin = 2, + user = 3, +}; + +pub const Binding = struct { + stroke: Stroke, + /// Command id to run, or null to *unbind* this chord — how a user turns off a default + /// without having to know what it was bound to. + command: ?[]const u8, + when: When = .{}, + source: Source = .profile, + /// Owning plugin id, for grouping in the UI and for dropping a plugin's binds on unload. + owner_id: ?[]const u8 = null, +}; + +pub const Resolution = union(enum) { + /// No binding; the event should fall through to whatever else wants it. + none, + /// First half of a chord matched — swallow the key and wait for the second stroke. + pending, + command: []const u8, + /// An explicit unbind matched. Distinct from `.none` because the key *was* claimed: the + /// point of unbinding is to stop a lower layer from firing, not to fall through to it. + unbound, +}; + +pub const Conflict = struct { + stroke: Stroke, + when: When, + winner: []const u8, + loser: []const u8, +}; + +pub const Keymap = struct { + bindings: std.ArrayList(Binding) = .empty, + /// Half-entered chord, if any. + pending: ?Chord = null, + + pub fn deinit(self: *Keymap, gpa: Allocator) void { + self.bindings.deinit(gpa); + self.* = .{}; + } + + pub fn add(self: *Keymap, gpa: Allocator, b: Binding) !void { + try self.bindings.append(gpa, b); + } + + /// Cancel a half-entered chord — call on focus loss or Escape, so a stray `ctrl+k` doesn't + /// silently eat the next keystroke minutes later. + pub fn cancelPending(self: *Keymap) void { + self.pending = null; + } + + /// Best binding for `stroke` in context `ctx`: highest `source`, then most specific `when`. + /// Bindings with `owner_id` only apply when that id matches `active_owner` (decision 1B: + /// active document owner wins on shared chords; otherwise the shell/profile binding fires). + fn best(self: Keymap, stroke: Stroke, ctx: When, active_owner: ?[]const u8) ?Binding { + var winner: ?Binding = null; + for (self.bindings.items) |b| { + if (!b.stroke.eql(stroke)) continue; + if (!b.when.matches(ctx)) continue; + if (b.owner_id) |oid| { + const ao = active_owner orelse continue; + if (!std.mem.eql(u8, oid, ao)) continue; + } + const w = winner orelse { + winner = b; + continue; + }; + const b_rank = (@as(u16, @intFromEnum(b.source)) << 8) | b.when.weight(); + const w_rank = (@as(u16, @intFromEnum(w.source)) << 8) | w.when.weight(); + // Owner-scoped bindings beat otherwise equal-rank global ones — that's the whole + // point of active-owner conflict resolution (pixi Export vs shell Quick Open). + const b_owner_boost: u16 = if (b.owner_id != null) 1 else 0; + const w_owner_boost: u16 = if (w.owner_id != null) 1 else 0; + const b_total = (b_rank << 1) | b_owner_boost; + const w_total = (w_rank << 1) | w_owner_boost; + // >= so a later entry at equal rank wins: within one layer, last one loaded wins. + if (b_total >= w_total) winner = b; + } + return winner; + } + + /// Is `c` the opening stroke of some chord that could still apply here? + fn opensChord(self: Keymap, c: Chord, ctx: When, active_owner: ?[]const u8) bool { + for (self.bindings.items) |b| { + if (b.stroke.second == null) continue; + if (b.command == null) continue; + if (!b.stroke.first.eql(c)) continue; + if (!b.when.matches(ctx)) continue; + if (b.owner_id) |oid| { + const ao = active_owner orelse continue; + if (!std.mem.eql(u8, oid, ao)) continue; + } + return true; + } + return false; + } + + /// Feed one key press. Stateful: consecutive calls complete a chord. + /// `active_owner` is the focused document's plugin id, or null when none. + pub fn resolve(self: *Keymap, c: Chord, ctx: When, active_owner: ?[]const u8) Resolution { + // Modifier keys alone never resolve, and must not cancel a pending chord — otherwise + // pressing Ctrl for the second half of `ctrl+k ctrl+c` would abort the chord. + if (keyIsModifier(c.key)) return .none; + + if (self.pending) |first| { + self.pending = null; + const full: Stroke = .{ .first = first, .second = c }; + if (self.best(full, ctx, active_owner)) |b| { + return if (b.command) |cmd| .{ .command = cmd } else .unbound; + } + // A chord was started but the second stroke matched nothing: swallow it rather than + // letting a half-typed `ctrl+k x` fire whatever `x` happens to be bound to. + return .unbound; + } + + // A single-stroke binding beats an unstarted chord on the same opening key, unless the + // single-stroke binding is a lower-priority layer. + const single: Stroke = .{ .first = c }; + const single_match = self.best(single, ctx, active_owner); + + if (self.opensChord(c, ctx, active_owner)) { + if (single_match) |b| { + if (b.source == .user) { + return if (b.command) |cmd| .{ .command = cmd } else .unbound; + } + } + self.pending = c; + return .pending; + } + + if (single_match) |b| { + return if (b.command) |cmd| .{ .command = cmd } else .unbound; + } + return .none; + } + + /// Every binding currently mapped to `command` — for rendering shortcut hints and the + /// Keyboard Shortcuts pane. + pub fn bindingsFor(self: Keymap, gpa: Allocator, command: []const u8) ![]Binding { + var out: std.ArrayList(Binding) = .empty; + errdefer out.deinit(gpa); + for (self.bindings.items) |b| { + const cmd = b.command orelse continue; + if (std.mem.eql(u8, cmd, command)) try out.append(gpa, b); + } + return out.toOwnedSlice(gpa); + } + + /// Bindings that share a chord but don't always fire together. Includes classic layer + /// shadowing (user beats profile) and active-owner forks (shell Quick Open vs pixi Export + /// on `mod+p`) so the settings pane can warn about them. + /// + /// Collapses before reporting: + /// 1. Duplicate entries for the same command (e.g. `fizzy.openFolder` from both the dvui + /// `open_folder` bind and the profile default) keep only the highest-ranked claim. + /// 2. Within one owner bucket (all-global, or one plugin), only *adjacent* ranks are + /// reported — if A shadows B and B shadows C, "A shadows C" is omitted because C is + /// already dead under B. + /// 3. Owner-scoped claims fork against the top global on that chord (context-dependent). + pub fn conflicts(self: Keymap, gpa: Allocator) ![]Conflict { + const Claim = struct { + stroke: Stroke, + when: When, + command: []const u8, + owner_id: ?[]const u8, + rank: u16, + index: usize, + }; + + var claims: std.ArrayList(Claim) = .empty; + defer claims.deinit(gpa); + + for (self.bindings.items, 0..) |b, i| { + const cmd = b.command orelse continue; + const rank: u16 = (@as(u16, @intFromEnum(b.source)) << 8) | b.when.weight(); + for (claims.items) |*c| { + if (!c.stroke.eql(b.stroke) or !c.when.eql(b.when)) continue; + if (!std.mem.eql(u8, c.command, cmd)) continue; + // Later equal-rank wins — same tie-break `best()` uses. + if (rank >= c.rank) { + c.rank = rank; + c.owner_id = b.owner_id; + c.index = i; + } + break; + } else { + try claims.append(gpa, .{ + .stroke = b.stroke, + .when = b.when, + .command = cmd, + .owner_id = b.owner_id, + .rank = rank, + .index = i, + }); + } + } + + var out: std.ArrayList(Conflict) = .empty; + errdefer out.deinit(gpa); + + var seen_group = try gpa.alloc(bool, claims.items.len); + defer gpa.free(seen_group); + @memset(seen_group, false); + + const byRankDesc = struct { + fn less(cs: []Claim, a: usize, b: usize) bool { + const ca = cs[a]; + const cb = cs[b]; + if (ca.rank != cb.rank) return ca.rank > cb.rank; + return ca.index > cb.index; + } + }.less; + + for (claims.items, 0..) |seed, si| { + if (seen_group[si]) continue; + + var group: std.ArrayList(usize) = .empty; + defer group.deinit(gpa); + for (claims.items, 0..) |c, ci| { + if (!c.stroke.eql(seed.stroke) or !c.when.eql(seed.when)) continue; + seen_group[ci] = true; + try group.append(gpa, ci); + } + + // Bucket by owner_id (null = global). Chain within a bucket; owner buckets fork + // against the global bucket's top claim. + var bucket_keys: std.ArrayList(?[]const u8) = .empty; + defer bucket_keys.deinit(gpa); + var buckets: std.ArrayList(std.ArrayList(usize)) = .empty; + defer { + for (buckets.items) |*bkt| bkt.deinit(gpa); + buckets.deinit(gpa); + } + + for (group.items) |ci| { + const key = claims.items[ci].owner_id; + const bkt_i = for (bucket_keys.items, 0..) |k, bi| { + if (k == null and key == null) break bi; + if (k != null and key != null and std.mem.eql(u8, k.?, key.?)) break bi; + } else null; + if (bkt_i) |bi| { + try buckets.items[bi].append(gpa, ci); + } else { + try bucket_keys.append(gpa, key); + var bkt: std.ArrayList(usize) = .empty; + try bkt.append(gpa, ci); + try buckets.append(gpa, bkt); + } + } + + var global_top: ?[]const u8 = null; + for (bucket_keys.items, 0..) |key, bi| { + const bkt = &buckets.items[bi]; + std.mem.sort(usize, bkt.items, claims.items, byRankDesc); + + // Adjacent shadow links only. + var i: usize = 0; + while (i + 1 < bkt.items.len) : (i += 1) { + const w = claims.items[bkt.items[i]]; + const l = claims.items[bkt.items[i + 1]]; + try out.append(gpa, .{ + .stroke = seed.stroke, + .when = seed.when, + .winner = w.command, + .loser = l.command, + }); + } + + if (key == null and bkt.items.len > 0) { + global_top = claims.items[bkt.items[0]].command; + } + } + + // Owner-scoped top vs global top — context-dependent, either can win. + if (global_top) |gt| { + for (bucket_keys.items, 0..) |key, bi| { + if (key == null) continue; + const bkt = buckets.items[bi]; + if (bkt.items.len == 0) continue; + const owner_cmd = claims.items[bkt.items[0]].command; + try out.append(gpa, .{ + .stroke = seed.stroke, + .when = seed.when, + .winner = owner_cmd, + .loser = gt, + }); + } + } + } + + return out.toOwnedSlice(gpa); + } +}; + +// -- tests ---------------------------------------------------------------------------------------- + +const t = std.testing; + +fn km(gpa: Allocator, entries: []const Binding) !Keymap { + var k: Keymap = .{}; + for (entries) |b| try k.add(gpa, b); + return k; +} + +fn keys(s: []const u8) Stroke { + return parseKeys(s, .other) catch unreachable; +} + +test "single-stroke resolves to its command" { + const a = t.allocator; + var k = try km(a, &.{ + .{ .stroke = keys("ctrl+s"), .command = "fizzy.save" }, + }); + defer k.deinit(a); + + const r = k.resolve(keys("ctrl+s").first, .{}, null); + try t.expectEqualStrings("fizzy.save", r.command); + try t.expect(k.resolve(keys("ctrl+q").first, .{}, null) == .none); +} + +test "chord needs both strokes" { + const a = t.allocator; + var k = try km(a, &.{ + .{ .stroke = keys("ctrl+k ctrl+c"), .command = "text.addLineComment" }, + }); + defer k.deinit(a); + + try t.expect(k.resolve(keys("ctrl+k").first, .{}, null) == .pending); + const r = k.resolve(keys("ctrl+c").first, .{}, null); + try t.expectEqualStrings("text.addLineComment", r.command); + try t.expectEqual(@as(?Chord, null), k.pending); +} + +test "an unmatched second stroke is swallowed, not misfired" { + const a = t.allocator; + var k = try km(a, &.{ + .{ .stroke = keys("ctrl+k ctrl+c"), .command = "text.addLineComment" }, + .{ .stroke = keys("ctrl+x"), .command = "fizzy.cut" }, + }); + defer k.deinit(a); + + try t.expect(k.resolve(keys("ctrl+k").first, .{}, null) == .pending); + // `ctrl+x` must NOT run cut here — it was typed as the tail of an abandoned chord. + try t.expect(k.resolve(keys("ctrl+x").first, .{}, null) == .unbound); + // ...and afterwards it works normally again. + try t.expectEqualStrings("fizzy.cut", k.resolve(keys("ctrl+x").first, .{}, null).command); +} + +test "pressing a modifier does not cancel a pending chord" { + const a = t.allocator; + var k = try km(a, &.{ + .{ .stroke = keys("ctrl+k ctrl+c"), .command = "text.addLineComment" }, + }); + defer k.deinit(a); + + try t.expect(k.resolve(keys("ctrl+k").first, .{}, null) == .pending); + try t.expect(k.resolve(.{ .key = .left_control }, .{}, null) == .none); + try t.expect(k.pending != null); + try t.expectEqualStrings("text.addLineComment", k.resolve(keys("ctrl+c").first, .{}, null).command); +} + +test "user layer overrides profile layer" { + const a = t.allocator; + var k = try km(a, &.{ + .{ .stroke = keys("ctrl+b"), .command = "fizzy.toggleSidebar", .source = .profile }, + .{ .stroke = keys("ctrl+b"), .command = "text.buildProject", .source = .user }, + }); + defer k.deinit(a); + try t.expectEqualStrings("text.buildProject", k.resolve(keys("ctrl+b").first, .{}, null).command); +} + +test "null command unbinds and claims the key" { + const a = t.allocator; + var k = try km(a, &.{ + .{ .stroke = keys("ctrl+b"), .command = "fizzy.toggleSidebar", .source = .profile }, + .{ .stroke = keys("ctrl+b"), .command = null, .source = .user }, + }); + defer k.deinit(a); + // Not `.none` — the whole point is to stop the profile binding firing. + try t.expect(k.resolve(keys("ctrl+b").first, .{}, null) == .unbound); +} + +test "more specific when wins at equal source" { + const a = t.allocator; + var k = try km(a, &.{ + .{ .stroke = keys("escape"), .command = "fizzy.cancel" }, + .{ .stroke = keys("escape"), .command = "text.dismissCompletion", .when = .{ .completion_visible = true } }, + }); + defer k.deinit(a); + + try t.expectEqualStrings("fizzy.cancel", k.resolve(keys("escape").first, .{}, null).command); + try t.expectEqualStrings( + "text.dismissCompletion", + k.resolve(keys("escape").first, .{ .completion_visible = true }, null).command, + ); +} + +test "a binding whose context is unmet does not match" { + const a = t.allocator; + var k = try km(a, &.{ + .{ .stroke = keys("ctrl+/"), .command = "text.toggleLineComment", .when = .{ .editor_focused = true } }, + }); + defer k.deinit(a); + try t.expect(k.resolve(keys("ctrl+/").first, .{}, null) == .none); + try t.expectEqualStrings( + "text.toggleLineComment", + k.resolve(keys("ctrl+/").first, .{ .editor_focused = true }, null).command, + ); +} + +test "cancelPending drops a half-entered chord" { + const a = t.allocator; + var k = try km(a, &.{ + .{ .stroke = keys("ctrl+k ctrl+c"), .command = "text.addLineComment" }, + .{ .stroke = keys("ctrl+c"), .command = "fizzy.copy" }, + }); + defer k.deinit(a); + + try t.expect(k.resolve(keys("ctrl+k").first, .{}, null) == .pending); + k.cancelPending(); + try t.expectEqualStrings("fizzy.copy", k.resolve(keys("ctrl+c").first, .{}, null).command); +} + +test "a user single-stroke binding beats a profile chord on the same opening key" { + const a = t.allocator; + var k = try km(a, &.{ + .{ .stroke = keys("ctrl+k ctrl+c"), .command = "text.addLineComment", .source = .profile }, + .{ .stroke = keys("ctrl+k"), .command = "fizzy.killLine", .source = .user }, + }); + defer k.deinit(a); + try t.expectEqualStrings("fizzy.killLine", k.resolve(keys("ctrl+k").first, .{}, null).command); +} + +test "when parsing accepts camelCase and snake_case, and reports unknowns" { + const a = t.allocator; + var unknown: std.ArrayList([]const u8) = .empty; + defer unknown.deinit(a); + + const w = try When.parse("editorFocused && completion_visible && someFutureThing", &unknown, a); + try t.expect(w.editor_focused and w.completion_visible); + try t.expect(!w.panel_focused); + try t.expectEqual(@as(usize, 1), unknown.items.len); + try t.expectEqualStrings("someFutureThing", unknown.items[0]); +} + +test "bindingsFor lists every chord for a command" { + const a = t.allocator; + var k = try km(a, &.{ + .{ .stroke = keys("ctrl+s"), .command = "fizzy.save" }, + .{ .stroke = keys("ctrl+k ctrl+s"), .command = "fizzy.save" }, + .{ .stroke = keys("ctrl+q"), .command = "fizzy.quit" }, + }); + defer k.deinit(a); + + const found = try k.bindingsFor(a, "fizzy.save"); + defer a.free(found); + try t.expectEqual(@as(usize, 2), found.len); +} + +test "conflicts reports the shadowed binding" { + const a = t.allocator; + var k = try km(a, &.{ + .{ .stroke = keys("ctrl+b"), .command = "fizzy.toggleSidebar", .source = .profile }, + .{ .stroke = keys("ctrl+b"), .command = "text.buildProject", .source = .user }, + .{ .stroke = keys("ctrl+s"), .command = "fizzy.save", .source = .profile }, + }); + defer k.deinit(a); + + const c = try k.conflicts(a); + defer a.free(c); + try t.expectEqual(@as(usize, 1), c.len); + try t.expectEqualStrings("text.buildProject", c[0].winner); + try t.expectEqualStrings("fizzy.toggleSidebar", c[0].loser); +} + +test "conflicts dedupes when the same command is bound twice on a chord" { + // Mirrors `fizzy.openFolder`: lifted from dvui's `open_folder` bind *and* listed in the + // shell profile. A user rebind of another command onto that chord must yield one row, not + // one per duplicate loser entry. + const a = t.allocator; + var k = try km(a, &.{ + .{ .stroke = keys("ctrl+f"), .command = "fizzy.openFolder", .source = .dvui }, + .{ .stroke = keys("ctrl+f"), .command = "fizzy.openFolder", .source = .profile }, + .{ .stroke = keys("ctrl+f"), .command = "text.format", .source = .user }, + }); + defer k.deinit(a); + + const c = try k.conflicts(a); + defer a.free(c); + try t.expectEqual(@as(usize, 1), c.len); + try t.expectEqualStrings("text.format", c[0].winner); + try t.expectEqualStrings("fizzy.openFolder", c[0].loser); +} + +test "conflicts reports only adjacent ranks in a shadow chain" { + // format > quickOpen > openFolder: omit the transitive "format shadows openFolder". + const a = t.allocator; + var k = try km(a, &.{ + .{ .stroke = keys("ctrl+f"), .command = "fizzy.openFolder", .source = .dvui }, + .{ .stroke = keys("ctrl+f"), .command = "fizzy.openFolder", .source = .profile }, + .{ .stroke = keys("ctrl+f"), .command = "fizzy.quickOpen", .source = .plugin }, + .{ .stroke = keys("ctrl+f"), .command = "text.format", .source = .user }, + }); + defer k.deinit(a); + + const c = try k.conflicts(a); + defer a.free(c); + try t.expectEqual(@as(usize, 2), c.len); + try t.expectEqualStrings("text.format", c[0].winner); + try t.expectEqualStrings("fizzy.quickOpen", c[0].loser); + try t.expectEqualStrings("fizzy.quickOpen", c[1].winner); + try t.expectEqualStrings("fizzy.openFolder", c[1].loser); +} + +test "owner-scoped binding only fires when that owner is active" { + const a = t.allocator; + var k = try km(a, &.{ + .{ .stroke = keys("ctrl+p"), .command = "fizzy.quickOpen", .source = .profile }, + .{ .stroke = keys("ctrl+p"), .command = "pixi.export", .source = .plugin, .owner_id = "pixi" }, + }); + defer k.deinit(a); + + try t.expectEqualStrings("fizzy.quickOpen", k.resolve(keys("ctrl+p").first, .{}, null).command); + try t.expectEqualStrings("pixi.export", k.resolve(keys("ctrl+p").first, .{}, "pixi").command); + try t.expectEqualStrings("fizzy.quickOpen", k.resolve(keys("ctrl+p").first, .{}, "text").command); + + const c = try k.conflicts(a); + defer a.free(c); + try t.expectEqual(@as(usize, 1), c.len); + try t.expectEqualStrings("pixi.export", c[0].winner); + try t.expectEqualStrings("fizzy.quickOpen", c[0].loser); +} + +test { + _ = @import("Key.zig"); + _ = chord_mod; + _ = zon; +} diff --git a/src/editor/keymap/zon.zig b/src/editor/keymap/zon.zig new file mode 100644 index 00000000..5b8e1e75 --- /dev/null +++ b/src/editor/keymap/zon.zig @@ -0,0 +1,604 @@ +//! Reading and writing `keybinds.zon` — the user's keybinding overrides. +//! +//! Only overrides live on disk. Defaults stay in code, so a user who has never rebound anything +//! has no `keybinds.zon` at all (same principle as R12's "omit all-default blocks" in +//! settings.zon). +//! +//! ```zon +//! .{ +//! .fizzy = .{ +//! .{ .keys = "ctrl+shift+e", .command = "fizzy.toggleExplorer" }, +//! .{ .keys = "ctrl+b", .command = null }, // unbind +//! }, +//! .plugins = .{ +//! .text = .{ +//! .{ .keys = "ctrl+k ctrl+c", .command = "text.addLineComment" }, +//! .{ .keys = "escape", .command = "text.dismissCompletion", +//! .when = "completionVisible" }, +//! }, +//! }, +//! } +//! ``` +//! +//! Parsing goes through `std.zig.Ast` + `Zoir` rather than `std.zon.parse`, for the same reason +//! `SettingsPluginsZon.zig` does: the plugin ids under `.plugins` are **dynamic field names**, +//! which a typed parse can't express. +//! +//! **Parsing never fails on bad content.** A malformed entry produces a `Diagnostic` (with a +//! line/column) and is skipped, so one typo can't cost a user every other binding they have. The +//! settings pane surfaces the diagnostics. + +const std = @import("std"); +const chord_mod = @import("chord.zig"); +const keymap = @import("keymap.zig"); + +const Allocator = std.mem.Allocator; +const Stroke = chord_mod.Stroke; +const Platform = chord_mod.Platform; +const When = keymap.When; + +pub const OwnedBinding = struct { + /// Verbatim key text as written by the user, so a round-trip through parse+format doesn't + /// rewrite `Ctrl+S` into `ctrl+s` behind their back. + keys: []u8, + stroke: Stroke, + /// null = an explicit unbind. + command: ?[]u8, + when: When = .{}, + when_text: ?[]u8 = null, + /// Plugin id, or null for the `.fizzy` block. + owner_id: ?[]u8 = null, + + pub fn deinit(self: *OwnedBinding, gpa: Allocator) void { + gpa.free(self.keys); + if (self.command) |c| gpa.free(c); + if (self.when_text) |w| gpa.free(w); + if (self.owner_id) |o| gpa.free(o); + self.* = undefined; + } +}; + +pub const Diagnostic = struct { + /// 1-based, for showing next to the offending line. + line: u32 = 0, + column: u32 = 0, + message: []u8, + + fn deinit(self: *Diagnostic, gpa: Allocator) void { + gpa.free(self.message); + self.* = undefined; + } +}; + +pub const File = struct { + bindings: []OwnedBinding = &.{}, + diagnostics: []Diagnostic = &.{}, + + pub fn deinit(self: *File, gpa: Allocator) void { + for (self.bindings) |*b| b.deinit(gpa); + gpa.free(self.bindings); + for (self.diagnostics) |*d| d.deinit(gpa); + gpa.free(self.diagnostics); + self.* = .{}; + } + + /// Borrowed view for feeding into a `Keymap`. The returned slice borrows this `File`'s + /// strings, so it must not outlive it. + pub fn toBindings(self: File, gpa: Allocator, source: keymap.Source) ![]keymap.Binding { + const out = try gpa.alloc(keymap.Binding, self.bindings.len); + for (self.bindings, out) |b, *o| { + o.* = .{ + .stroke = b.stroke, + .command = b.command, + .when = b.when, + .source = source, + .owner_id = b.owner_id, + }; + } + return out; + } +}; + +const Parsed = struct { ast: std.zig.Ast, zoir: std.zig.Zoir }; + +fn parseZon(gpa: Allocator, source: [:0]const u8) ?Parsed { + var ast = std.zig.Ast.parse(gpa, source, .zon) catch return null; + errdefer ast.deinit(gpa); + var zoir = std.zig.ZonGen.generate(gpa, ast, .{ .parse_str_lits = true }) catch { + ast.deinit(gpa); + return null; + }; + if (zoir.hasCompileErrors()) { + zoir.deinit(gpa); + ast.deinit(gpa); + return null; + } + return .{ .ast = ast, .zoir = zoir }; +} + +fn findField( + zoir: std.zig.Zoir, + container: std.zig.Zoir.Node.Index, + name: []const u8, +) ?std.zig.Zoir.Node.Index { + const s = switch (container.get(zoir)) { + .struct_literal => |sl| sl, + else => return null, + }; + for (s.names, 0..) |field_name, i| { + if (std.mem.eql(u8, field_name.get(zoir), name)) return s.vals.at(@intCast(i)); + } + return null; +} + +fn stringField( + zoir: std.zig.Zoir, + container: std.zig.Zoir.Node.Index, + name: []const u8, +) ?[]const u8 { + const node = findField(zoir, container, name) orelse return null; + return switch (node.get(zoir)) { + .string_literal => |s| s, + else => null, + }; +} + +/// 1-based line/column of a node's first token. +fn locationOf(ast: std.zig.Ast, zoir: std.zig.Zoir, node: std.zig.Zoir.Node.Index) struct { line: u32, column: u32 } { + const tok = ast.firstToken(node.getAstNode(zoir)); + const start = ast.tokenStart(tok); + var line: u32 = 1; + var col: u32 = 1; + for (ast.source[0..start]) |c| { + if (c == '\n') { + line += 1; + col = 1; + } else col += 1; + } + return .{ .line = line, .column = col }; +} + +const Collector = struct { + gpa: Allocator, + bindings: std.ArrayList(OwnedBinding) = .empty, + diagnostics: std.ArrayList(Diagnostic) = .empty, + + fn diag(self: *Collector, line: u32, column: u32, comptime fmt: []const u8, args: anytype) void { + const msg = std.fmt.allocPrint(self.gpa, fmt, args) catch return; + self.diagnostics.append(self.gpa, .{ .line = line, .column = column, .message = msg }) catch { + self.gpa.free(msg); + }; + } +}; + +fn collectBlock( + c: *Collector, + ast: std.zig.Ast, + zoir: std.zig.Zoir, + block: std.zig.Zoir.Node.Index, + owner_id: ?[]const u8, + platform: Platform, +) Allocator.Error!void { + const range = switch (block.get(zoir)) { + .array_literal => |r| r, + .empty_literal => return, + else => { + const loc = locationOf(ast, zoir, block); + c.diag(loc.line, loc.column, "expected a list of bindings, e.g. .{{ .{{ .keys = \"ctrl+s\", .command = \"fizzy.save\" }} }}", .{}); + return; + }, + }; + + var i: u32 = 0; + while (i < range.len) : (i += 1) { + const entry = range.at(i); + const loc = locationOf(ast, zoir, entry); + + switch (entry.get(zoir)) { + .struct_literal => {}, + else => { + c.diag(loc.line, loc.column, "binding must be a struct, e.g. .{{ .keys = \"ctrl+s\", .command = \"fizzy.save\" }}", .{}); + continue; + }, + } + + const keys_text = stringField(zoir, entry, "keys") orelse { + c.diag(loc.line, loc.column, "binding is missing a `.keys` string", .{}); + continue; + }; + + const stroke = chord_mod.parseKeys(keys_text, platform) catch |err| { + c.diag(loc.line, loc.column, "cannot parse keys \"{s}\": {s}", .{ keys_text, @errorName(err) }); + continue; + }; + + // `.command` must be present, but may be `null` to mean "unbind". + const command_node = findField(zoir, entry, "command") orelse { + c.diag(loc.line, loc.column, "binding is missing `.command` (use `.command = null` to unbind)", .{}); + continue; + }; + const command: ?[]const u8 = switch (command_node.get(zoir)) { + .string_literal => |s| s, + .null => null, + else => { + c.diag(loc.line, loc.column, "`.command` must be a string or null", .{}); + continue; + }, + }; + if (command) |cmd| { + if (cmd.len == 0) { + c.diag(loc.line, loc.column, "`.command` is empty (use `.command = null` to unbind)", .{}); + continue; + } + } + + var when: When = .{}; + var when_text: ?[]u8 = null; + errdefer if (when_text) |w| c.gpa.free(w); + if (findField(zoir, entry, "when")) |when_node| { + switch (when_node.get(zoir)) { + .string_literal => |s| { + var unknown: std.ArrayList([]const u8) = .empty; + defer unknown.deinit(c.gpa); + when = When.parse(s, &unknown, c.gpa) catch .{}; + for (unknown.items) |u| { + // Not fatal: a config written against a newer fizzy must round-trip. + c.diag(loc.line, loc.column, "unknown `when` context \"{s}\" (binding kept, context ignored)", .{u}); + } + when_text = try c.gpa.dupe(u8, s); + }, + .null => {}, + else => c.diag(loc.line, loc.column, "`.when` must be a string", .{}), + } + } + + const keys_copy = try c.gpa.dupe(u8, keys_text); + errdefer c.gpa.free(keys_copy); + const command_copy: ?[]u8 = if (command) |cmd| try c.gpa.dupe(u8, cmd) else null; + errdefer if (command_copy) |cc| c.gpa.free(cc); + const owner_copy: ?[]u8 = if (owner_id) |o| try c.gpa.dupe(u8, o) else null; + errdefer if (owner_copy) |oc| c.gpa.free(oc); + + try c.bindings.append(c.gpa, .{ + .keys = keys_copy, + .stroke = stroke, + .command = command_copy, + .when = when, + .when_text = when_text, + .owner_id = owner_copy, + }); + when_text = null; // ownership moved + } +} + +/// Parse a `keybinds.zon` source. Only allocation failure is an error — everything else lands in +/// `File.diagnostics`. +pub fn parse(gpa: Allocator, source: [:0]const u8, platform: Platform) Allocator.Error!File { + var c: Collector = .{ .gpa = gpa }; + errdefer { + for (c.bindings.items) |*b| b.deinit(gpa); + c.bindings.deinit(gpa); + for (c.diagnostics.items) |*d| d.deinit(gpa); + c.diagnostics.deinit(gpa); + } + + var parsed = parseZon(gpa, source) orelse { + c.diag(0, 0, "keybinds.zon is not valid ZON; no overrides loaded", .{}); + return .{ + .bindings = try c.bindings.toOwnedSlice(gpa), + .diagnostics = try c.diagnostics.toOwnedSlice(gpa), + }; + }; + defer { + parsed.zoir.deinit(gpa); + parsed.ast.deinit(gpa); + } + + // Fizzy-owned overrides. `.shell` is still accepted so existing keybinds.zon files keep + // loading after the command-id rename; writes always use `.fizzy`. + if (findField(parsed.zoir, .root, "fizzy")) |fizzy_block| { + try collectBlock(&c, parsed.ast, parsed.zoir, fizzy_block, null, platform); + } else if (findField(parsed.zoir, .root, "shell")) |shell_block| { + try collectBlock(&c, parsed.ast, parsed.zoir, shell_block, null, platform); + } + + if (findField(parsed.zoir, .root, "plugins")) |plugins_node| { + switch (plugins_node.get(parsed.zoir)) { + .struct_literal => |sl| { + for (sl.names, 0..) |id_str, i| { + const id = id_str.get(parsed.zoir); + try collectBlock(&c, parsed.ast, parsed.zoir, sl.vals.at(@intCast(i)), id, platform); + } + }, + .empty_literal => {}, + else => { + const loc = locationOf(parsed.ast, parsed.zoir, plugins_node); + c.diag(loc.line, loc.column, "`.plugins` must be a struct keyed by plugin id", .{}); + }, + } + } + + // `shell.*` command ids were renamed to `fizzy.*` ("shell"/"fizzy" are the same length). + for (c.bindings.items) |*b| { + if (b.command) |cmd| migrateLegacyShellCommandId(cmd); + } + + return .{ + .bindings = try c.bindings.toOwnedSlice(gpa), + .diagnostics = try c.diagnostics.toOwnedSlice(gpa), + }; +} + +/// In-place: `shell.save` → `fizzy.save`. Both prefixes are 5 chars + `.`. +fn migrateLegacyShellCommandId(cmd: []u8) void { + if (cmd.len >= 6 and std.mem.eql(u8, cmd[0..6], "shell.")) { + @memcpy(cmd[0..5], "fizzy"); + } +} + +// -- writing --------------------------------------------------------------------------------------- + +fn writeBinding(w: *std.Io.Writer, b: OwnedBinding, indent: []const u8) !void { + try w.print("{s}.{{ .keys = \"{f}\", ", .{ indent, std.zig.fmtString(b.keys) }); + if (b.command) |cmd| { + try w.print(".command = \"{f}\"", .{std.zig.fmtString(cmd)}); + } else { + try w.writeAll(".command = null"); + } + if (b.when_text) |wt| { + if (wt.len > 0) try w.print(", .when = \"{f}\"", .{std.zig.fmtString(wt)}); + } + try w.writeAll(" },\n"); +} + +/// Serialize overrides back to `keybinds.zon` text. Bindings are grouped by `owner_id`, matching +/// settings.zon's `.plugins.` shape so an uninstall can drop one plugin's block wholesale. +pub fn format(gpa: Allocator, bindings: []const OwnedBinding) ![]u8 { + var out: std.Io.Writer.Allocating = .init(gpa); + errdefer out.deinit(); + const w = &out.writer; + + try w.writeAll(".{\n"); + + var wrote_fizzy = false; + for (bindings) |b| { + if (b.owner_id != null) continue; + if (!wrote_fizzy) { + try w.writeAll(" .fizzy = .{\n"); + wrote_fizzy = true; + } + try writeBinding(w, b, " "); + } + if (wrote_fizzy) try w.writeAll(" },\n"); + + // Distinct plugin ids, in first-seen order — stable output, no allocation for a sort. + var wrote_plugins = false; + for (bindings, 0..) |b, i| { + const id = b.owner_id orelse continue; + var seen_earlier = false; + for (bindings[0..i]) |prev| { + if (prev.owner_id) |pid| { + if (std.mem.eql(u8, pid, id)) { + seen_earlier = true; + break; + } + } + } + if (seen_earlier) continue; + + if (!wrote_plugins) { + try w.writeAll(" .plugins = .{\n"); + wrote_plugins = true; + } + try w.print(" .{f} = .{{\n", .{std.zig.fmtId(id)}); + for (bindings) |b2| { + const bid = b2.owner_id orelse continue; + if (!std.mem.eql(u8, bid, id)) continue; + try writeBinding(w, b2, " "); + } + try w.writeAll(" },\n"); + } + if (wrote_plugins) try w.writeAll(" },\n"); + + try w.writeAll("}\n"); + return out.toOwnedSlice(); +} + +// -- tests ----------------------------------------------------------------------------------------- + +const t = std.testing; + +test "parses fizzy and plugin blocks" { + const a = t.allocator; + const src = + \\.{ + \\ .fizzy = .{ + \\ .{ .keys = "ctrl+shift+e", .command = "fizzy.toggleExplorer" }, + \\ .{ .keys = "ctrl+b", .command = null }, + \\ }, + \\ .plugins = .{ + \\ .text = .{ + \\ .{ .keys = "ctrl+k ctrl+c", .command = "text.addLineComment" }, + \\ }, + \\ .workbench = .{ + \\ .{ .keys = "ctrl+\\", .command = "workbench.splitRight" }, + \\ }, + \\ }, + \\} + ; + var f = try parse(a, src, .other); + defer f.deinit(a); + + try t.expectEqual(@as(usize, 0), f.diagnostics.len); + try t.expectEqual(@as(usize, 4), f.bindings.len); + + try t.expectEqual(@as(?[]u8, null), f.bindings[0].owner_id); + try t.expectEqualStrings("fizzy.toggleExplorer", f.bindings[0].command.?); + try t.expectEqual(@as(?[]u8, null), f.bindings[1].command); // unbind + try t.expectEqualStrings("text", f.bindings[2].owner_id.?); + try t.expect(f.bindings[2].stroke.isChord()); + try t.expectEqualStrings("workbench", f.bindings[3].owner_id.?); + try t.expectEqual(keymap.Key.backslash, f.bindings[3].stroke.first.key); +} + +test "when clauses parse" { + const a = t.allocator; + const src = + \\.{ + \\ .plugins = .{ + \\ .text = .{ + \\ .{ .keys = "escape", .command = "text.dismissCompletion", .when = "completionVisible" }, + \\ }, + \\ }, + \\} + ; + var f = try parse(a, src, .other); + defer f.deinit(a); + try t.expectEqual(@as(usize, 0), f.diagnostics.len); + try t.expect(f.bindings[0].when.completion_visible); + try t.expectEqualStrings("completionVisible", f.bindings[0].when_text.?); +} + +test "an unknown when context is kept, with a diagnostic" { + const a = t.allocator; + const src = + \\.{ .fizzy = .{ .{ .keys = "f5", .command = "fizzy.run", .when = "someFutureThing" } } } + ; + var f = try parse(a, src, .other); + defer f.deinit(a); + try t.expectEqual(@as(usize, 1), f.bindings.len); // kept + try t.expectEqual(@as(usize, 1), f.diagnostics.len); +} + +test "one bad entry does not discard the others" { + const a = t.allocator; + const src = + \\.{ + \\ .fizzy = .{ + \\ .{ .keys = "ctrl+s", .command = "fizzy.save" }, + \\ .{ .keys = "ctrl+nope", .command = "fizzy.broken" }, + \\ .{ .command = "fizzy.nokeys" }, + \\ .{ .keys = "ctrl+q" }, + \\ .{ .keys = "ctrl+w", .command = "fizzy.close" }, + \\ }, + \\} + ; + var f = try parse(a, src, .other); + defer f.deinit(a); + + try t.expectEqual(@as(usize, 2), f.bindings.len); + try t.expectEqualStrings("fizzy.save", f.bindings[0].command.?); + try t.expectEqualStrings("fizzy.close", f.bindings[1].command.?); + try t.expectEqual(@as(usize, 3), f.diagnostics.len); + // Diagnostics carry a usable location. + try t.expectEqual(@as(u32, 4), f.diagnostics[0].line); +} + +test "malformed ZON yields a diagnostic, not a crash" { + const a = t.allocator; + var f = try parse(a, ".{ this is not zon ", .other); + defer f.deinit(a); + try t.expectEqual(@as(usize, 0), f.bindings.len); + try t.expectEqual(@as(usize, 1), f.diagnostics.len); +} + +test "empty and absent blocks are fine" { + const a = t.allocator; + for ([_][:0]const u8{ ".{}", ".{ .fizzy = .{} }", ".{ .shell = .{} }", ".{ .plugins = .{} }" }) |src| { + var f = try parse(a, src, .other); + defer f.deinit(a); + try t.expectEqual(@as(usize, 0), f.bindings.len); + try t.expectEqual(@as(usize, 0), f.diagnostics.len); + } +} + +test "mod resolves per platform at parse time" { + const a = t.allocator; + const src = ".{ .fizzy = .{ .{ .keys = \"mod+s\", .command = \"fizzy.save\" } } }"; + + var mac = try parse(a, src, .mac); + defer mac.deinit(a); + try t.expect(mac.bindings[0].stroke.first.mods.command); + + var other = try parse(a, src, .other); + defer other.deinit(a); + try t.expect(other.bindings[0].stroke.first.mods.ctrl); +} + +test "format round-trips through parse" { + const a = t.allocator; + const src = + \\.{ + \\ .fizzy = .{ + \\ .{ .keys = "ctrl+shift+e", .command = "fizzy.toggleExplorer" }, + \\ .{ .keys = "ctrl+b", .command = null }, + \\ }, + \\ .plugins = .{ + \\ .text = .{ + \\ .{ .keys = "ctrl+k ctrl+c", .command = "text.addLineComment" }, + \\ .{ .keys = "escape", .command = "text.dismiss", .when = "completionVisible" }, + \\ }, + \\ }, + \\} + ; + var first = try parse(a, src, .other); + defer first.deinit(a); + + const written = try format(a, first.bindings); + defer a.free(written); + const written_z = try a.dupeZ(u8, written); + defer a.free(written_z); + + var second = try parse(a, written_z, .other); + defer second.deinit(a); + + try t.expectEqual(@as(usize, 0), second.diagnostics.len); + try t.expectEqual(first.bindings.len, second.bindings.len); + for (first.bindings, second.bindings) |x, y| { + try t.expectEqualStrings(x.keys, y.keys); + try t.expect(x.stroke.eql(y.stroke)); + try t.expect(x.when.eql(y.when)); + if (x.command) |xc| try t.expectEqualStrings(xc, y.command.?) else try t.expect(y.command == null); + if (x.owner_id) |xo| try t.expectEqualStrings(xo, y.owner_id.?) else try t.expect(y.owner_id == null); + } +} + +test "backslash keys survive a write/read cycle" { + const a = t.allocator; + var f = try parse(a, ".{ .fizzy = .{ .{ .keys = \"ctrl+\\\\\", .command = \"fizzy.split\" } } }", .other); + defer f.deinit(a); + try t.expectEqual(keymap.Key.backslash, f.bindings[0].stroke.first.key); + + const written = try format(a, f.bindings); + defer a.free(written); + const written_z = try a.dupeZ(u8, written); + defer a.free(written_z); + var again = try parse(a, written_z, .other); + defer again.deinit(a); + try t.expectEqual(keymap.Key.backslash, again.bindings[0].stroke.first.key); +} + +test "legacy .shell block and shell.* command ids still load" { + const a = t.allocator; + var f = try parse(a, ".{ .shell = .{ .{ .keys = \"ctrl+s\", .command = \"shell.save\" } } }", .other); + defer f.deinit(a); + try t.expectEqual(@as(usize, 0), f.diagnostics.len); + try t.expectEqualStrings("fizzy.save", f.bindings[0].command.?); +} + +test "toBindings feeds a Keymap" { + const a = t.allocator; + var f = try parse(a, ".{ .fizzy = .{ .{ .keys = \"ctrl+s\", .command = \"fizzy.save\" } } }", .other); + defer f.deinit(a); + + const view = try f.toBindings(a, .user); + defer a.free(view); + + var k: keymap.Keymap = .{}; + defer k.deinit(a); + for (view) |b| try k.add(a, b); + + const r = k.resolve((try chord_mod.parseKeys("ctrl+s", .other)).first, .{}, null); + try t.expectEqualStrings("fizzy.save", r.command); + try t.expectEqual(keymap.Source.user, view[0].source); +} diff --git a/src/editor/menu_model.zig b/src/editor/menu_model.zig new file mode 100644 index 00000000..1f8c9256 --- /dev/null +++ b/src/editor/menu_model.zig @@ -0,0 +1,286 @@ +//! The menu bar, once. +//! +//! Both menu bars are rendered from this one tree: `Menu.zig` draws it with dvui, and +//! `backend_native.buildMenuBar` builds the macOS `NSMenu` from it. Neither owns a list of +//! items, so an entry added here appears in both by construction — which is the whole point. +//! Before this, every item was written out in both places (plus an `NSMenuItem` selector, an +//! `NativeMenuAction` variant, and an Objective-C forwarding method), and the two had already +//! drifted: the macOS View menu said "Show Explorer" even when the explorer was open, and +//! Recent Folders existed only in the dvui menu. +//! +//! An item names a **command** and nothing else — see `Keybinds.shell_commands`. Titles, +//! enablement and shortcuts are either declared here once or derived from the command, never +//! restated per platform. + +const std = @import("std"); +const dvui = @import("dvui"); +const fizzy = @import("../fizzy.zig"); + +const Editor = @import("Editor.zig"); + +/// A label that depends on state ("Show Explorer" / "Hide Explorer"). +pub const Title = union(enum) { + // Null-terminated: these strings are handed straight to `NSString stringWithUTF8String:`. + static: [:0]const u8, + dynamic: *const fn (*Editor) [:0]const u8, + + pub fn resolve(self: Title, editor: *Editor) [:0]const u8 { + return switch (self) { + .static => |s| s, + .dynamic => |f| f(editor), + }; + } + + /// Title to stamp on a menu item at build time, before there is an `Editor` to ask. A + /// dynamic title gets refreshed by `FizzyNativeMenuItemTitle` each time the menu opens, so + /// this placeholder is only ever on screen if that path fails. + pub fn resolveStatic(self: Title) [:0]const u8 { + return switch (self) { + .static => |s| s, + .dynamic => "", + }; + } +}; + +pub const CommandItem = struct { + /// Registered command id. `Menu.zig` and the menu-bar click path both just run this. + id: []const u8, + title: Title, + /// SF Symbol name for the macOS item. dvui rows don't draw icons today. + sf_symbol: ?[:0]const u8 = null, + /// Hidden entirely when false. The dvui menu is immediate-mode so it simply skips the row; + /// AppKit menus are retained, so the native side disables the item instead of rebuilding + /// the whole bar every frame. + visible: ?*const fn (*Editor) bool = null, + /// Greyed out when false. Null means always enabled. + enabled: ?*const fn (*Editor) bool = null, + /// Keep the macOS item enabled even when `enabled` says otherwise. + /// + /// A disabled `NSMenuItem` does not perform its key equivalent, and on macOS that key + /// equivalent is the *only* way `cmd+c` reaches the app at all — AppKit consumes it before + /// SDL sees it. Greying Copy out because the active document can't copy would therefore also + /// stop Copy from reaching a focused search box or the Output Panel, which handle it + /// themselves. The dvui menu has no such constraint and greys them normally. + native_always_enabled: bool = false, +}; + +pub const Submenu = struct { + /// Host `MenuContribution` id. Namespaced `fizzy.` like every other shell-owned id — the + /// commands (`fizzy.copy`), the pseudo-plugin (`.id = "fizzy"`), all of it. These were + /// `shell.menu.*` (and, for File, `workbench.menu.file`, a leftover from when the File menu + /// really was a workbench contribution), which meant the same owner went by three names. + id: []const u8, + /// Ids this menu used to have. Plugins target a menu by `parent_menu_id`, and that is a + /// published contract — `sdk/regions.zig` documents the old spellings and shipped plugins + /// use them — so renaming without accepting the old names would silently drop a + /// third-party plugin's menu section with no error anywhere. + aliases: []const []const u8 = &.{}, + title: [:0]const u8, + items: []const Item, +}; + +pub const Item = union(enum) { + command: CommandItem, + separator, + submenu: Submenu, + /// The recents list, filled at draw time from `editor.recents`. + recent_folders, + /// Plugin-contributed section parented to this menu id (e.g. "fizzy.menu.edit"). + plugin_section: []const u8, +}; + +// ---- predicates ------------------------------------------------------------------------------- +// +// One copy each. These used to exist twice over: as inline expressions in `Menu.zig` and again +// as arms of `FizzyNativeMenuActionEnabled`, whose own doc comment admitted it "mirrors the exact +// greying conditions `Menu.zig` already computes". + +fn hasActiveDoc(editor: *Editor) bool { + return editor.activeDoc() != null; +} + +fn canSave(editor: *Editor) bool { + const doc = editor.activeDoc() orelse return false; + return doc.owner.isDirty(doc) or !doc.owner.documentHasRecognizedSaveExtension(doc); +} + +fn canSaveAll(editor: *Editor) bool { + for (editor.open_files.values()) |doc| { + if (doc.owner.isDirty(doc) and doc.owner.documentHasRecognizedSaveExtension(doc)) return true; + } + return false; +} + +fn canUndo(editor: *Editor) bool { + const doc = editor.activeDoc() orelse return false; + return doc.owner.canUndo(doc); +} + +fn canRedo(editor: *Editor) bool { + const doc = editor.activeDoc() orelse return false; + return doc.owner.canRedo(doc); +} + +fn hasCopy(editor: *Editor) bool { + return editor.activeDocHasCommand("copy"); +} + +fn hasPaste(editor: *Editor) bool { + return editor.activeDocHasCommand("paste"); +} + +/// Mirrors `Keybinds.cmdCopyEnabled`/`cmdPasteEnabled`: the document owner reports its verb +/// enabled only while its own editor holds focus, so a focused non-document text input is the +/// other case where Edit > Copy/Paste still does something (`Keybinds.clipboardVerb`). +fn copyEnabled(editor: *Editor) bool { + return editor.activeDocCommandEnabled("copy") or editor.text_input_focused; +} + +fn pasteEnabled(editor: *Editor) bool { + return editor.activeDocCommandEnabled("paste") or editor.text_input_focused; +} + +fn explorerTitle(editor: *Editor) [:0]const u8 { + return if (editor.explorer.closed) "Show Explorer" else "Hide Explorer"; +} + +// ---- the menu bar ----------------------------------------------------------------------------- + +const file_items = [_]Item{ + .{ .command = .{ .id = "fizzy.newFile", .title = .{ .static = "New File…" }, .sf_symbol = "doc.badge.plus" } }, + .{ .command = .{ .id = "fizzy.openFolder", .title = .{ .static = "Open Folder" }, .sf_symbol = "folder" } }, + // Not "doc.on.doc": that is the system's Copy glyph, which the Edit menu below uses. + .{ .command = .{ .id = "fizzy.openFiles", .title = .{ .static = "Open Files" }, .sf_symbol = "doc.text" } }, + .separator, + .recent_folders, + .separator, + .{ .command = .{ .id = "fizzy.save", .title = .{ .static = "Save" }, .sf_symbol = "square.and.arrow.down", .enabled = canSave } }, + .{ .command = .{ .id = "fizzy.saveAs", .title = .{ .static = "Save As…" }, .sf_symbol = "arrow.down.doc", .enabled = hasActiveDoc } }, + .{ .command = .{ .id = "fizzy.saveAll", .title = .{ .static = "Save All" }, .sf_symbol = "square.and.arrow.down.on.square", .enabled = canSaveAll } }, +}; + +// The four SF Symbols below are the ones AppKit's own Edit menus use, so a fizzy Edit menu sits +// next to Finder's and TextEdit's without looking foreign. +const edit_items = [_]Item{ + .{ .command = .{ + .id = "fizzy.copy", + .title = .{ .static = "Copy" }, + .sf_symbol = "doc.on.doc", + .visible = hasCopy, + .enabled = copyEnabled, + .native_always_enabled = true, + } }, + .{ .command = .{ + .id = "fizzy.paste", + .title = .{ .static = "Paste" }, + .sf_symbol = "doc.on.clipboard", + .visible = hasPaste, + .enabled = pasteEnabled, + .native_always_enabled = true, + } }, + .separator, + .{ .command = .{ .id = "fizzy.undo", .title = .{ .static = "Undo" }, .sf_symbol = "arrow.uturn.backward", .enabled = canUndo } }, + .{ .command = .{ .id = "fizzy.redo", .title = .{ .static = "Redo" }, .sf_symbol = "arrow.uturn.forward", .enabled = canRedo } }, + // Transform / Grid Layout are pixel-art concepts, not shell ones — pixi parents its own + // section here rather than the shell knowing about them. + .{ .plugin_section = "fizzy.menu.edit" }, +}; + +const view_items = [_]Item{ + .{ .command = .{ .id = "fizzy.toggleExplorer", .title = .{ .dynamic = explorerTitle } } }, + .{ .plugin_section = "fizzy.menu.view" }, + .separator, + .{ .command = .{ .id = "fizzy.showDvuiDemo", .title = .{ .static = "Show DVUI Demo" } } }, +}; + +const help_items = [_]Item{ + // The About dialog hosts the update check and install controls, which is why this and the + // macOS app menu's "About fizzy" are the same command. + .{ .command = .{ .id = "fizzy.about", .title = .{ .static = "Check for Updates…" } } }, + .separator, + .{ .command = .{ .id = "fizzy.reportBug", .title = .{ .static = "Report Bug…" }, .sf_symbol = "ant.fill" } }, +}; + +pub const menu_bar = [_]Submenu{ + .{ .id = "fizzy.menu.file", .aliases = &.{"workbench.menu.file"}, .title = "File", .items = &file_items }, + .{ .id = "fizzy.menu.edit", .aliases = &.{"shell.menu.edit"}, .title = "Edit", .items = &edit_items }, + .{ .id = "fizzy.menu.view", .aliases = &.{"shell.menu.view"}, .title = "View", .items = &view_items }, + .{ .id = "fizzy.menu.help", .aliases = &.{"shell.menu.help"}, .title = "Help", .items = &help_items }, +}; + +/// Whether a plugin's `parent_menu_id` refers to `sub`, under its current id or a legacy one. +pub fn menuMatches(sub: Submenu, parent_menu_id: []const u8) bool { + if (std.mem.eql(u8, sub.id, parent_menu_id)) return true; + for (sub.aliases) |a| { + if (std.mem.eql(u8, a, parent_menu_id)) return true; + } + return false; +} + +/// The menu a plugin's `parent_menu_id` targets, or null. +pub fn submenuFor(parent_menu_id: []const u8) ?Submenu { + for (menu_bar) |sub| { + if (menuMatches(sub, parent_menu_id)) return sub; + } + return null; +} + +// ---- flat command index ----------------------------------------------------------------------- +// +// The macOS side needs one integer per item to carry across the C boundary (an `NSMenuItem` tag), +// which is what `NativeMenuAction` and its fourteen Objective-C forwarding methods used to be. +// A depth-first index into this tree serves the same purpose and costs nothing to maintain: a new +// menu item is one line above, not a line here plus an enum variant plus a selector plus a method. + +fn countCommands(items: []const Item) usize { + var n: usize = 0; + for (items) |it| { + switch (it) { + .command => n += 1, + .submenu => |s| n += countCommands(s.items), + else => {}, + } + } + return n; +} + +fn appendCommands(out: []CommandItem, at: *usize, items: []const Item) void { + for (items) |it| { + switch (it) { + .command => |c| { + out[at.*] = c; + at.* += 1; + }, + .submenu => |s| appendCommands(out, at, s.items), + else => {}, + } + } +} + +/// Every command item, depth-first in menu order. The index *is* the macOS tag. +pub const flat_commands: []const CommandItem = blk: { + var total: usize = 0; + for (menu_bar) |m| total += countCommands(m.items); + + var out: [total]CommandItem = undefined; + var at: usize = 0; + for (menu_bar) |m| appendCommands(&out, &at, m.items); + const frozen = out; + break :blk &frozen; +}; + +/// The command item a macOS tag refers to, or null if the tag is stale. +pub fn byTag(tag: usize) ?CommandItem { + if (tag >= flat_commands.len) return null; + return flat_commands[tag]; +} + +/// Whether `id` appears in the menu bar at all. On macOS this answers "does an `NSMenuItem` key +/// equivalent already dispatch this command", which `Keybinds.dispatch` needs so it doesn't run +/// the action a second time. +pub fn contains(id: []const u8) bool { + for (flat_commands) |c| { + if (std.mem.eql(u8, c.id, id)) return true; + } + return false; +} diff --git a/src/plugins/text/plugin.zig b/src/plugins/text/plugin.zig index 135f4132..fbcfb10a 100644 --- a/src/plugins/text/plugin.zig +++ b/src/plugins/text/plugin.zig @@ -18,6 +18,12 @@ pub const plugin_options = @import("fizzy_plugin_options"); /// `Editor.isBundledPluginId`) read instead of retyping the string. pub const plugin_id = plugin_options.id; +/// The editing widget, re-exported so `tests/integration.zig` can drive it against dvui's +/// headless testing backend (a file belongs to exactly one module, so the tests reach it +/// through this plugin's module rather than rooting their own at the widget). Nothing in the +/// app imports it this way — `TextEditor.zig` uses the relative path directly. +pub const TextEntryWidget = @import("src/widgets/TextEntryWidget.zig"); + var plugin: sdk.Plugin = .{ .state = undefined, .vtable = &vtable, @@ -99,6 +105,7 @@ pub fn register(host: *sdk.Host) !void { .owner = &plugin, .title = "Paste", .run = cmdPaste, + .isEnabled = cmdPasteEnabled, }); try host.registerCommand(.{ .id = sdk.Plugin.commandId("text", "format"), @@ -113,15 +120,19 @@ pub fn register(host: *sdk.Host) !void { // "Edit" menu (in-app + native) rather than showing a permanently-greyed generic verb. try host.registerMenuSection(.{ .id = "text.menu.edit_section", - .parent_menu_id = "shell.menu.edit", + .parent_menu_id = "fizzy.menu.edit", .owner = &plugin, .draw = drawEditMenuSection, }); try host.registerNativeMenuItem(.{ .id = "text.native.format", .owner = &plugin, - .parent_menu_id = "shell.menu.edit", + .parent_menu_id = "fizzy.menu.edit", .title = "Format Document", + // Naming the command is what gets this item's chord onto the macOS menu — and keeps it + // there across a rebind. `run` is still the click path. + .command = sdk.Plugin.commandId("text", "format"), + .sf_symbol = "text.alignleft", .run = nativeFormat, }); } @@ -223,6 +234,9 @@ fn registerOpenDocument(state: *anyopaque, file: *anyopaque) anyerror!*anyopaque errdefer gpa.destroy(heap_doc); heap_doc.* = doc.*; try st.docs.put(gpa, doc.id, heap_doc); + // Kick language-server warmup (spawn + didOpen) before the first hover/completion so + // cold-start latency isn't paid on first use — see `LanguageSupport.VTable.documentOpened`. + sdk.host().documentOpenedFor(std.fs.path.extension(heap_doc.path), heap_doc.path, heap_doc.text.items); return heap_doc; } fn documentPtr(state: *anyopaque, id: u64) ?*anyopaque { @@ -258,7 +272,7 @@ fn setDocumentPath(_: *anyopaque, handle: DocHandle, path: []const u8) anyerror! } fn revealPosition(_: *anyopaque, handle: DocHandle, line: u32, character: u32) void { const doc = docFrom(handle) orelse return; - doc.pending_cursor = doc.byteOffsetForLineCharacter(line, character); + doc.pending_sel = .collapsed(doc.byteOffsetForLineCharacter(line, character)); doc.pending_scroll_line = line; } fn bindDocumentToPane(_: *anyopaque, _: DocHandle, _: dvui.Id, _: *anyopaque, _: bool) void { @@ -285,6 +299,7 @@ fn closeDocument(_: *anyopaque, handle: DocHandle) void { fn reloadDocument(_: *anyopaque, handle: DocHandle) anyerror!void { const doc = docFrom(handle) orelse return error.DocumentNotFound; try doc.reloadFromDisk(); + sdk.host().documentOpenedFor(std.fs.path.extension(doc.path), doc.path, doc.text.items); } fn isDirty(_: *anyopaque, handle: DocHandle) bool { return (docFrom(handle) orelse return false).isDirty(); @@ -292,7 +307,7 @@ fn isDirty(_: *anyopaque, handle: DocHandle) bool { fn saveDocument(state: *anyopaque, handle: DocHandle) anyerror!void { const doc = docFrom(handle) orelse return; const st: *State = @ptrCast(@alignCast(state)); - if (st.settings.format_on_save) formatDocument(doc); + if (st.settings.format_on_save.get()) formatDocument(doc); try doc.save(); } fn documentDefaultSaveAsFilename(_: *anyopaque, handle: DocHandle, allocator: std.mem.Allocator) anyerror![]const u8 { @@ -322,16 +337,26 @@ fn canRedoDocument(_: *anyopaque, handle: DocHandle) bool { } // ---- copy / paste commands ----------------------------------------------------- +// +// Both report enabled only while this editor holds keyboard focus. That is the shell's +// discriminator for routing a clipboard verb here versus to another focused text input — a +// search box, the Output Panel — which it otherwise has no way to tell apart (see +// `Keybinds.clipboardVerb` in the shell, and `Document.editor_focused`). Reporting enabled +// while focus is elsewhere would make Copy in that search box copy this document instead. fn cmdCopyEnabled(state: *anyopaque) bool { const doc = activeTextDoc(state) orelse return false; - return doc.sel_start != doc.sel_end; + return doc.editor_focused and doc.sel_start != doc.sel_end; } fn cmdCopy(state: *anyopaque) anyerror!void { const doc = activeTextDoc(state) orelse return; if (doc.sel_start == doc.sel_end) return; dvui.clipboardTextSet(doc.text.items[doc.sel_start..doc.sel_end]); } +fn cmdPasteEnabled(state: *anyopaque) bool { + const doc = activeTextDoc(state) orelse return false; + return doc.editor_focused; +} fn cmdPaste(state: *anyopaque) anyerror!void { const doc = activeTextDoc(state) orelse return; const clip = dvui.clipboardText(); @@ -370,7 +395,7 @@ fn formatDocument(doc: *Document) void { }; doc.sel_start = restore_cursor; doc.sel_end = restore_cursor; - doc.pending_cursor = restore_cursor; + doc.pending_sel = .collapsed(restore_cursor); } /// In-app "Edit" menu section (see `Host.registerMenuSection`) — only draws while the active @@ -384,7 +409,7 @@ fn drawEditMenuSection(ctx: ?*anyopaque) anyerror!void { _ = ctx; if (!cmdFormatEnabled(plugin.state)) return; - if (sdk.host().drawMenuItem("Format Document", "format")) { + if (sdk.host().drawMenuItem("Format Document", sdk.Plugin.commandId("text", "format"))) { sdk.host().runCommand(sdk.Plugin.commandId("text", "format")) catch |err| { dvui.log.err("text: format command failed: {any}", .{err}); }; diff --git a/src/plugins/text/src/Document.zig b/src/plugins/text/src/Document.zig index da176b41..f84c1e0e 100644 --- a/src/plugins/text/src/Document.zig +++ b/src/plugins/text/src/Document.zig @@ -5,7 +5,7 @@ const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); const sdk = @import("fizzy_sdk"); -const UndoStack = @import("UndoStack.zig"); +const tc = @import("textcore/textcore.zig"); const TextEntryWidget = @import("widgets/TextEntryWidget.zig"); const is_wasm = builtin.target.cpu.arch == .wasm32; @@ -37,9 +37,19 @@ unsaved: bool = false, /// widget instance — have somewhere durable to read "what's currently selected" from. sel_start: usize = 0, sel_end: usize = 0, -/// Byte offset the next `TextEditor.draw` should move the caret to, set by Paste/Undo/Redo -/// (which all edit `text` from outside the widget's own frame) and consumed once. -pending_cursor: ?usize = null, +/// Whether this document's `TextEntryWidget` held dvui keyboard focus as of the last draw, +/// mirrored alongside the selection above. The shell routes a clipboard verb to the active +/// document only while the document's owner reports that verb enabled, which is how it tells +/// "focus is in the editor" from "focus is in some other text input" (a search box, the Output +/// Panel) without knowing anything about widget ownership — see `Keybinds.clipboardVerb`. A +/// document that isn't drawn this frame (background tab) keeps its last value, which is +/// correct: it can't be the active document *and* undrawn. +editor_focused: bool = false, +/// Selection the next `TextEditor.draw` should install, set by Paste/Undo/Redo (which all +/// edit `text` from outside the widget's own frame) and consumed once. A full range rather +/// than a bare offset so undo can restore what *was* selected — undoing "type over a +/// selection" now re-selects the text it brought back. +pending_sel: ?tc.Range = null, /// Vertical scroll offset, mirrored from the editor's `ScrollInfo` after every draw and /// restored on the widget's next `dvui.firstFrame` (see `TextEditor.zig`). dvui garbage- /// collects a widget id's persisted per-frame data (including its scroll position) the very @@ -83,8 +93,8 @@ preview_split_ratio: f32 = 0.5, preview_collapsed: bool = false, preview_side: PreviewSide = .raw, -/// Undo/redo history — see `UndoStack` for the capture strategy. -history: UndoStack = .{}, +/// Undo/redo history — see `textcore.History` for the capture + grouping strategy. +history: tc.History = .{}, /// `history.topOpId()` as of the last successful save; the document is dirty exactly when /// the two differ. Deliberately an id, not `undo.items.len` — a length can return to its /// saved value via undo-then-new-edit while content differs from disk, but an id can't: @@ -216,6 +226,11 @@ pub fn isDirty(self: *const Document) bool { pub fn save(self: *Document) !void { if (comptime is_wasm) return error.Unsupported; try std.Io.Dir.cwd().writeFile(dvui.io, .{ .sub_path = self.path, .data = self.text.items }); + // Close the group *before* snapshotting the id: without this a save landing mid-typing-run + // would let the next keystroke merge into the same group, so one undo would jump straight + // past the state that was written to disk. VSCode pushes a stack element on save for the + // same reason. + self.history.closeGroup(); self.clean_op_id = self.history.topOpId(); } @@ -234,10 +249,10 @@ pub fn reloadFromDisk(self: *Document) !void { self.refreshLineCount(); self.sel_start = 0; self.sel_end = 0; - // Non-null `pending_cursor` is what tells `TextEditor.draw` to disable `cache_layout` + // Non-null `pending_sel` is what tells `TextEditor.draw` to disable `cache_layout` // for the next frame — required after a full buffer replace, otherwise dvui's text // layout cache (built against the previous contents) asserts / panics on draw. - self.pending_cursor = 0; + self.pending_sel = .collapsed(0); self.clearCompletionItems(); self.completion_anchor = null; self.completion_selected = 0; @@ -262,31 +277,32 @@ pub fn saveAs(self: *Document, new_path: []const u8) !void { /// edit outside any frame's `TextEntryWidget` instance. pub fn replaceRange(self: *Document, start: usize, end: usize, new: []const u8) !void { const gpa = sdk.allocator(); - try self.history.pushComplete(gpa, start, self.text.items[start..end], new); + const after: tc.Range = .collapsed(start + new.len); + try self.history.pushComplete(gpa, start, self.text.items[start..end], new, .init(start, end), after); try self.text.replaceRange(gpa, start, end - start, new); self.refreshLineCount(); - self.sel_start = start + new.len; - self.sel_end = self.sel_start; - self.pending_cursor = self.sel_start; + self.sel_start = after.head; + self.sel_end = after.head; + self.pending_sel = after; } /// Reverses the most recent edit, if any, and relocates the caret to it (applied on the next /// `TextEditor.draw` via `pending_cursor`). pub fn undo(self: *Document) void { const gpa = sdk.allocator(); - const cursor = self.history.applyUndo(gpa, &self.text) orelse return; + const sel = self.history.applyUndo(gpa, &self.text) orelse return; self.refreshLineCount(); - self.sel_start = cursor; - self.sel_end = cursor; - self.pending_cursor = cursor; + self.sel_start = @min(sel.start(), self.text.items.len); + self.sel_end = @min(sel.end(), self.text.items.len); + self.pending_sel = sel; } /// Re-applies the most recently undone edit, if any. pub fn redo(self: *Document) void { const gpa = sdk.allocator(); - const cursor = self.history.applyRedo(gpa, &self.text) orelse return; + const sel = self.history.applyRedo(gpa, &self.text) orelse return; self.refreshLineCount(); - self.sel_start = cursor; - self.sel_end = cursor; - self.pending_cursor = cursor; + self.sel_start = @min(sel.start(), self.text.items.len); + self.sel_end = @min(sel.end(), self.text.items.len); + self.pending_sel = sel; } diff --git a/src/plugins/text/src/Settings.zig b/src/plugins/text/src/Settings.zig index 84261f88..78ff752a 100644 --- a/src/plugins/text/src/Settings.zig +++ b/src/plugins/text/src/Settings.zig @@ -1,3 +1,9 @@ +//! The text plugin's user settings. Each field is a self-describing `sdk.settings.Value` cell: +//! payload type, default, and the description the shell shows under the setting's name. Read a +//! value with `.get()`. +const sdk = @import("fizzy_sdk"); +const settings = sdk.settings; + /// Discrete tab widths shown as a dropdown in the shell settings pane. pub const TabSize = enum(u8) { @"2" = 2, @@ -5,8 +11,25 @@ pub const TabSize = enum(u8) { @"8" = 8, }; -insert_spaces_on_tab: bool = true, -tab_size: TabSize = .@"4", +insert_spaces_on_tab: settings.Value(bool, .{ + .description = "Insert spaces instead of a tab character when pressing Tab.", +}) = .init(true), + +tab_size: settings.Value(TabSize, .{ + .description = "How many columns a tab occupies, and how many spaces one indent level inserts.", +}) = .init(.@"4"), + +/// Typing `{`, `(`, `[`, or a quote also inserts its closer (and typing the closer steps over +/// it, Backspace between an empty pair removes both, and typing an opener with text selected +/// wraps it) — VSCode's `editor.autoClosingBrackets`, exposed for the same reason it is there: +/// it's the one baseline editing nicety a sizable minority actively dislikes. +auto_close_brackets: settings.Value(bool, .{ + .description = "Typing an opening bracket or quote also inserts its closer. Typing the " ++ + "closer steps over it, and backspace between an empty pair removes both.", +}) = .init(true), + /// When true, `saveDocument` reformats the document (via the active `LanguageSupport.format` /// provider for its extension, if any) immediately before writing it to disk. -format_on_save: bool = false, +format_on_save: settings.Value(bool, .{ + .description = "Reformat the document with the language's formatter each time it is saved.", +}) = .init(false), diff --git a/src/plugins/text/src/TextEditor.zig b/src/plugins/text/src/TextEditor.zig index 33e153f8..adcdbfe8 100644 --- a/src/plugins/text/src/TextEditor.zig +++ b/src/plugins/text/src/TextEditor.zig @@ -8,6 +8,7 @@ const plugin_impl = @import("../plugin.zig"); const Document = @import("Document.zig"); const SyntaxHighlight = @import("SyntaxHighlight.zig"); const TextEntryWidget = @import("widgets/TextEntryWidget.zig"); +const tc = @import("textcore/textcore.zig"); const TooltipWidget = @import("widgets/TooltipWidget.zig"); const fuzzy = core.fuzzy; @@ -151,7 +152,7 @@ fn drawEditor(doc: *Document, ext: []const u8, id_extra: u64, gpa: std.mem.Alloc })); const gutter_rs = gutter_wd.borderRectScale(); - const out_of_band_edit = doc.pending_cursor != null; + const out_of_band_edit = doc.pending_sel != null; const tree_sitter_option = if (doc.text.items.len <= syntax_highlight_max_bytes) SyntaxHighlight.treeSitterOption(doc.path) else @@ -190,10 +191,17 @@ fn drawEditor(doc: *Document, ext: []const u8, id_extra: u64, gpa: std.mem.Alloc // setting below only picks *what* it inserts (spaces vs a literal tab), not whether // it does so at all. .tab_inserts_indent = true, - .tab_size = @intFromEnum(plugin_impl.statePtr().settings.tab_size), - .insert_spaces = plugin_impl.statePtr().settings.insert_spaces_on_tab, + .tab_size = @intFromEnum(plugin_impl.statePtr().settings.tab_size.get()), + .insert_spaces = plugin_impl.statePtr().settings.insert_spaces_on_tab.get(), // Same VSCode-style baseline as Tab above — not gated by a setting. .auto_indent_newline = true, + .auto_close_pairs = plugin_impl.statePtr().settings.auto_close_brackets.get(), + // Purely visual — it never changes the document — so it's baseline-on like + // `auto_indent_newline` rather than another setting to find and toggle. + .highlight_matching_bracket = true, + // Indent-level rainbow from `core.palette.bracket` — same-kind pairs match; kinds + // at the same indent take different slots (and a scrambled walk vs the file tree). + .rainbow_brackets = true, }, chromeless.override(.{ .expand = .both, .font = font, @@ -230,10 +238,16 @@ fn drawEditor(doc: *Document, ext: []const u8, id_extra: u64, gpa: std.mem.Alloc te.text_changed = true; } - if (doc.pending_cursor) |pos| { - const clamped = @min(pos, doc.text.items.len); - te.textLayout.selection.* = .{ .start = clamped, .cursor = clamped, .end = clamped }; - doc.pending_cursor = null; + if (doc.pending_sel) |r| { + const len = doc.text.items.len; + const head = @min(r.head, len); + const anchor = @min(r.anchor, len); + te.textLayout.selection.* = .{ + .start = @min(anchor, head), + .cursor = head, + .end = @max(anchor, head), + }; + doc.pending_sel = null; } te.processEvents(); @@ -270,6 +284,10 @@ fn drawEditor(doc: *Document, ext: []const u8, id_extra: u64, gpa: std.mem.Alloc doc.sel_start = te.textLayout.selection.start; doc.sel_end = te.textLayout.selection.end; + // Read before `te.deinit()` below, same as the selection: the shell's Copy/Paste routing + // asks the owner whether the verb is enabled, and this is the owner's answer to "is focus + // mine?" (see `Document.editor_focused`). + doc.editor_focused = dvui.focusedWidgetId() == te.data().id; const text_changed = te.text_changed; // `si` is a pointer into dvui's persistent per-widget-id data store (not a value owned by @@ -1615,17 +1633,17 @@ fn drawHighlightedCode(tl: *dvui.TextLayoutWidget, code: []const u8, ts: sdk.Tre fn docFromCtx(ctx: *anyopaque) *Document { return @ptrCast(@alignCast(ctx)); } -fn editNotifyBegin(ctx: *anyopaque) void { - docFromCtx(ctx).history.begin(); +fn editNotifyBegin(ctx: *anyopaque, sel_before: tc.Range) void { + docFromCtx(ctx).history.begin(sel_before); } fn editNotifyRemoved(ctx: *anyopaque, pos: usize, bytes: []const u8) void { - docFromCtx(ctx).history.noteRemoved(sdk.allocator(), pos, bytes); + docFromCtx(ctx).history.note(sdk.allocator(), pos, bytes, ""); } fn editNotifyInserted(ctx: *anyopaque, pos: usize, bytes: []const u8) void { - docFromCtx(ctx).history.noteInserted(sdk.allocator(), pos, bytes); + docFromCtx(ctx).history.note(sdk.allocator(), pos, "", bytes); } -fn editNotifyEnd(ctx: *anyopaque) void { - docFromCtx(ctx).history.end(sdk.allocator()); +fn editNotifyEnd(ctx: *anyopaque, sel_after: tc.Range) void { + docFromCtx(ctx).history.end(sdk.allocator(), sel_after); } const max_text_bytes: usize = 64 * 1024 * 1024; diff --git a/src/plugins/text/src/UndoStack.zig b/src/plugins/text/src/UndoStack.zig deleted file mode 100644 index 4a572f66..00000000 --- a/src/plugins/text/src/UndoStack.zig +++ /dev/null @@ -1,140 +0,0 @@ -//! Per-document undo/redo history for the text editor. -//! -//! Each edit is captured as `(pos, removed, inserted)` — the smallest slice of bytes that -//! changed and where — never a full-buffer snapshot, so history stays O(edit size) per -//! entry regardless of file size ("efficient"), and the stack itself is unbounded ("endless": -//! nothing pops off the bottom until the document closes). -//! -//! Two ways an edit gets recorded: -//! - `begin`/`noteRemoved`/`noteInserted`/`end`: used by `TextEntryWidget`'s `edit_notify` -//! hook, which fires once per widget mutation call (typing, paste-into-focused-widget, -//! backspace, delete) — `begin` opens a slot, 0-1 `noteRemoved` + 0-1 `noteInserted` fill -//! it in (both can fire in one call, e.g. typing over a selection replaces it), `end` -//! commits it as a single undo step. -//! - `pushComplete`: used by the `text.paste` command, which builds one full edit outside -//! the widget's own frame and has nothing to coalesce. -const std = @import("std"); - -const UndoStack = @This(); - -pub const EditOp = struct { - pos: usize = 0, - removed: []u8 = &.{}, - inserted: []u8 = &.{}, - id: u64 = 0, - - fn deinit(self: *EditOp, gpa: std.mem.Allocator) void { - gpa.free(self.removed); - gpa.free(self.inserted); - } -}; - -undo: std.ArrayListUnmanaged(EditOp) = .empty, -redo: std.ArrayListUnmanaged(EditOp) = .empty, -pending: ?EditOp = null, -next_id: u64 = 1, - -pub fn deinit(self: *UndoStack, gpa: std.mem.Allocator) void { - for (self.undo.items) |*op| op.deinit(gpa); - self.undo.deinit(gpa); - for (self.redo.items) |*op| op.deinit(gpa); - self.redo.deinit(gpa); - if (self.pending) |*op| op.deinit(gpa); -} - -pub fn canUndo(self: *const UndoStack) bool { - return self.undo.items.len > 0; -} - -pub fn canRedo(self: *const UndoStack) bool { - return self.redo.items.len > 0; -} - -/// The id of the entry currently at the top of `undo`, or 0 if empty. See `EditOp.id`. -pub fn topOpId(self: *const UndoStack) u64 { - return if (self.undo.items.len == 0) 0 else self.undo.items[self.undo.items.len - 1].id; -} - -pub fn begin(self: *UndoStack) void { - self.pending = .{}; -} - -pub fn noteRemoved(self: *UndoStack, gpa: std.mem.Allocator, pos: usize, bytes: []const u8) void { - const op = if (self.pending) |*p| p else return; - op.pos = pos; - if (op.removed.len != 0) gpa.free(op.removed); - op.removed = gpa.dupe(u8, bytes) catch blk: { - std.log.err("UndoStack.noteRemoved: dupe failed, dropping {d} bytes", .{bytes.len}); - break :blk &.{}; - }; -} - -pub fn noteInserted(self: *UndoStack, gpa: std.mem.Allocator, pos: usize, bytes: []const u8) void { - const op = if (self.pending) |*p| p else return; - if (op.removed.len == 0) op.pos = pos; - if (op.inserted.len != 0) gpa.free(op.inserted); - op.inserted = gpa.dupe(u8, bytes) catch blk: { - std.log.err("UndoStack.noteInserted: dupe failed, dropping {d} bytes", .{bytes.len}); - break :blk &.{}; - }; -} - -/// Commits the pending edit opened by `begin`. A no-op edit (nothing removed or inserted, -/// e.g. backspace at the start of the document) is discarded rather than pushed. -pub fn end(self: *UndoStack, gpa: std.mem.Allocator) void { - const op = self.pending orelse return; - self.pending = null; - self.commit(gpa, op); -} - -/// Records one complete edit built outside the begin/note/end flow (the `text.paste` command). -pub fn pushComplete(self: *UndoStack, gpa: std.mem.Allocator, pos: usize, removed: []const u8, inserted: []const u8) !void { - var op: EditOp = .{ .pos = pos, .removed = try gpa.dupe(u8, removed) }; - errdefer gpa.free(op.removed); - op.inserted = try gpa.dupe(u8, inserted); - self.commit(gpa, op); -} - -fn commit(self: *UndoStack, gpa: std.mem.Allocator, op_in: EditOp) void { - var op = op_in; - if (op.removed.len == 0 and op.inserted.len == 0) { - op.deinit(gpa); - return; - } - for (self.redo.items) |*r| r.deinit(gpa); - self.redo.clearRetainingCapacity(); - op.id = self.next_id; - self.next_id += 1; - self.undo.append(gpa, op) catch |err| { - std.log.err("UndoStack.commit: undo.append failed: {s}", .{@errorName(err)}); - op.deinit(gpa); - }; -} - -/// Reverses the most recent undo entry against `text`, moving it onto the redo stack. -/// Returns the byte offset to place the cursor at afterward, or `null` if there was nothing -/// to undo. -pub fn applyUndo(self: *UndoStack, gpa: std.mem.Allocator, text: *std.ArrayList(u8)) ?usize { - var op = self.undo.pop() orelse return null; - text.replaceRange(gpa, op.pos, op.inserted.len, op.removed) catch {}; - const cursor = op.pos + op.removed.len; - self.redo.append(gpa, op) catch |err| { - std.log.err("UndoStack.applyUndo: redo.append failed: {s}", .{@errorName(err)}); - op.deinit(gpa); - }; - return cursor; -} - -/// Re-applies the most recent redo entry against `text`, moving it back onto the undo stack. -/// Returns the byte offset to place the cursor at afterward, or `null` if there was nothing -/// to redo. -pub fn applyRedo(self: *UndoStack, gpa: std.mem.Allocator, text: *std.ArrayList(u8)) ?usize { - var op = self.redo.pop() orelse return null; - text.replaceRange(gpa, op.pos, op.removed.len, op.inserted) catch {}; - const cursor = op.pos + op.inserted.len; - self.undo.append(gpa, op) catch |err| { - std.log.err("UndoStack.applyRedo: undo.append failed: {s}", .{@errorName(err)}); - op.deinit(gpa); - }; - return cursor; -} diff --git a/src/plugins/text/src/textcore/History.zig b/src/plugins/text/src/textcore/History.zig new file mode 100644 index 00000000..9691313a --- /dev/null +++ b/src/plugins/text/src/textcore/History.zig @@ -0,0 +1,594 @@ +//! Undo/redo history with VSCode-compatible grouping. +//! +//! The old `UndoStack` pushed one entry per widget mutation — i.e. one per keystroke — so undo +//! walked back a character at a time. VSCode instead groups by **operation-type run**: a run of +//! ordinary typing is one undo step, a run of spaces is another, and switching between typing +//! and deleting starts a new one. It is *not* grouped by word, and there is no timer or +//! character-count threshold anywhere in that path. +//! +//! `EditKind` and `shouldBreakGroup` below are a direct port of VSCode's `EditOperationType`, +//! `getTypingOperation` and `shouldPushStackElementBetween` +//! (src/vs/editor/common/cursor/cursorTypeEditOperations.ts). The comments marked "VSCode:" quote +//! that source, including its worked examples. + +const std = @import("std"); +const Range = @import("Range.zig"); +const Transaction = @import("Transaction.zig"); + +const History = @This(); + +const Allocator = std.mem.Allocator; + +/// VSCode's `EditOperationType`. The numeric values don't matter to us, but the *set* does — +/// the grouping rule is defined entirely in terms of these categories. +pub const EditKind = enum { + /// Anything that isn't simple typing or deleting: paste, Enter, replace-selection, + /// a command-driven edit. Always breaks the group. + other, + deleting_left, + deleting_right, + typing_other, + typing_first_space, + typing_consecutive_space, + + fn isTyping(self: EditKind) bool { + return switch (self) { + .typing_other, .typing_first_space, .typing_consecutive_space => true, + else => false, + }; + } + + /// VSCode's `normalizeOperationType`: both space kinds collapse to one category, so a run + /// of spaces stays a single group. + fn normalized(self: EditKind) EditKind { + return switch (self) { + .typing_first_space, .typing_consecutive_space => .typing_first_space, + else => self, + }; + } +}; + +/// Classify one captured edit. `cursor_before` is the caret position before the edit, used only +/// to tell a backspace (removes behind the caret) from a forward delete. +pub fn classify( + removed: []const u8, + inserted: []const u8, + pos: usize, + cursor_before: usize, + prev: EditKind, +) EditKind { + // A replacement (typing over a selection) is never plain typing. + if (removed.len > 0 and inserted.len > 0) return .other; + + if (inserted.len > 0) { + // Multi-character insertions are pastes, snippet expansions, or Enter-with-indent — + // all "other", so they bracket cleanly in the undo history. + const seq_len = std.unicode.utf8ByteSequenceLength(inserted[0]) catch return .other; + if (seq_len != inserted.len) return .other; + // VSCode routes Enter through its own operation, which reports `Other` and therefore + // always opens a new undo step. Matching that means Enter is an undo boundary. + if (inserted[0] == '\n' or inserted[0] == '\r') return .other; + + if (inserted.len == 1 and inserted[0] == ' ') { + // VSCode's `getTypingOperation`. + return switch (prev) { + .typing_first_space, .typing_consecutive_space => .typing_consecutive_space, + else => .typing_first_space, + }; + } + return .typing_other; + } + + if (removed.len > 0) { + return if (pos < cursor_before) .deleting_left else .deleting_right; + } + return .other; +} + +/// VSCode's `shouldPushStackElementBetween`: true when `cur` must start a NEW undo step rather +/// than joining the group `prev` belongs to. +pub fn shouldBreakGroup(prev: EditKind, cur: EditKind) bool { + if (prev.isTyping() and !cur.isTyping()) { + // VSCode: "Always set an undo stop before non-type operations" + return true; + } + if (prev == .typing_first_space) { + // VSCode: `abc |d`: No undo stop + // `abc |d`: Undo stop + // One space keeps the run open; a second one closes it. This single rule is what makes + // undo *feel* word-granular without ever looking at word boundaries. + return false; + } + // VSCode: "Insert undo stop between different operation types" + return prev.normalized() != cur.normalized(); +} + +const Entry = struct { + txn: Transaction, + kind: EditKind, + /// Bumped on every mutation, including a merge into an existing entry. Dirty-tracking + /// compares this against `Document.clean_op_id`, so it must change whenever the buffer + /// changes — a merge that left the id alone would make a modified document look saved. + id: u64, + + fn deinit(self: *Entry, gpa: Allocator) void { + self.txn.deinit(gpa); + } +}; + +undo_stack: std.ArrayList(Entry) = .empty, +redo_stack: std.ArrayList(Entry) = .empty, +/// The in-progress edit opened by `begin`. +pending: ?struct { + txn: Transaction, + cursor_before: usize, +} = null, +/// False right after undo/redo/save/an out-of-band edit: the next captured edit must start a +/// fresh group rather than merging into whatever is on top. +group_open: bool = false, +next_id: u64 = 1, + +pub fn deinit(self: *History, gpa: Allocator) void { + for (self.undo_stack.items) |*e| e.deinit(gpa); + self.undo_stack.deinit(gpa); + for (self.redo_stack.items) |*e| e.deinit(gpa); + self.redo_stack.deinit(gpa); + if (self.pending) |*p| p.txn.deinit(gpa); + self.* = .{}; +} + +pub fn canUndo(self: History) bool { + return self.undo_stack.items.len > 0; +} + +pub fn canRedo(self: History) bool { + return self.redo_stack.items.len > 0; +} + +/// Id of the entry on top of the undo stack, or 0 when empty. See `Entry.id`. +pub fn topOpId(self: History) u64 { + const items = self.undo_stack.items; + return if (items.len == 0) 0 else items[items.len - 1].id; +} + +/// Close the current group so the next edit starts a new undo step. Called after undo, redo, +/// and on save — VSCode likewise pushes a stack element when a model is saved, without which a +/// save landing mid-group would make undo jump straight past the saved state. +pub fn closeGroup(self: *History) void { + self.group_open = false; +} + +// -- capture ------------------------------------------------------------------------------------ + +pub fn begin(self: *History, sel_before: Range) void { + self.pending = .{ + .txn = .{ .sel_before = sel_before, .sel_after = sel_before }, + .cursor_before = sel_before.head, + }; +} + +pub fn note( + self: *History, + gpa: Allocator, + pos: usize, + removed: []const u8, + inserted: []const u8, +) void { + const p = if (self.pending) |*p| p else return; + p.txn.append(gpa, pos, removed, inserted) catch |err| { + std.log.err("History.note: dropping {d}+{d} bytes: {s}", .{ + removed.len, inserted.len, @errorName(err), + }); + }; +} + +/// Commit the pending edit, merging it into the current group when VSCode's rule says to. +pub fn end(self: *History, gpa: Allocator, sel_after: Range) void { + var p = self.pending orelse return; + self.pending = null; + + if (p.txn.isEmpty()) { + p.txn.deinit(gpa); + return; + } + p.txn.sel_after = sel_after; + + // Classify from the net effect of the pending edit. + var removed_total: []const u8 = &.{}; + var inserted_total: []const u8 = &.{}; + var pos: usize = 0; + if (p.txn.edits.items.len > 0) { + const first = p.txn.edits.items[0]; + pos = first.pos; + removed_total = first.removed; + inserted_total = first.inserted; + // A single widget mutation reports at most one removal + one insertion; if it somehow + // reported more, that's compound and shouldn't be treated as plain typing. + if (p.txn.edits.items.len > 1) { + for (p.txn.edits.items[1..]) |e| { + if (e.removed.len > 0) removed_total = e.removed; + if (e.inserted.len > 0) inserted_total = e.inserted; + } + } + } + const prev_kind: EditKind = if (self.group_open and self.undo_stack.items.len > 0) + self.undo_stack.items[self.undo_stack.items.len - 1].kind + else + .other; + const kind = classify(removed_total, inserted_total, pos, p.cursor_before, prev_kind); + + self.clearRedo(gpa); + + const can_merge = self.group_open and + self.undo_stack.items.len > 0 and + !shouldBreakGroup(prev_kind, kind); + + if (can_merge) { + var top = &self.undo_stack.items[self.undo_stack.items.len - 1]; + // Move the pending edits onto the existing group; the group keeps its original + // `sel_before` so undo returns to where the whole run started. + for (p.txn.edits.items) |e| { + top.txn.edits.append(gpa, e) catch |err| { + std.log.err("History.end: merge failed: {s}", .{@errorName(err)}); + var owned = e; + owned.deinit(gpa); + continue; + }; + } + p.txn.edits.clearRetainingCapacity(); + p.txn.deinit(gpa); + top.txn.sel_after = sel_after; + top.kind = kind; + top.id = self.next_id; + self.next_id += 1; + } else { + self.undo_stack.append(gpa, .{ + .txn = p.txn, + .kind = kind, + .id = self.next_id, + }) catch |err| { + std.log.err("History.end: push failed: {s}", .{@errorName(err)}); + p.txn.deinit(gpa); + return; + }; + self.next_id += 1; + } + self.group_open = true; +} + +/// Record one complete edit built outside the begin/note/end flow (the paste command). Always +/// its own undo step. +pub fn pushComplete( + self: *History, + gpa: Allocator, + pos: usize, + removed: []const u8, + inserted: []const u8, + sel_before: Range, + sel_after: Range, +) !void { + var txn: Transaction = .{ .sel_before = sel_before, .sel_after = sel_after }; + errdefer txn.deinit(gpa); + try txn.append(gpa, pos, removed, inserted); + if (txn.isEmpty()) { + txn.deinit(gpa); + return; + } + self.clearRedo(gpa); + try self.undo_stack.append(gpa, .{ .txn = txn, .kind = .other, .id = self.next_id }); + self.next_id += 1; + self.group_open = false; +} + +fn clearRedo(self: *History, gpa: Allocator) void { + for (self.redo_stack.items) |*e| e.deinit(gpa); + self.redo_stack.clearRetainingCapacity(); +} + +// -- replay --------------------------------------------------------------------------------------- + +/// Undo one group. Returns the selection to restore, or null if there was nothing to undo. +pub fn applyUndo(self: *History, gpa: Allocator, text: *std.ArrayList(u8)) ?Range { + if (self.undo_stack.items.len == 0) return null; + var entry = self.undo_stack.pop().?; + entry.txn.applyInverse(gpa, text) catch |err| { + // Don't push the entry back: a desynced transaction will never become valid, and + // `fizzy.undo` accepts key-repeat — re-queueing would spam `EditOutOfRange` forever + // while the user holds ⌘Z. Drop it and let a later undo try the next group. + std.log.err("History.applyUndo failed: {s} (dropping corrupt undo entry)", .{@errorName(err)}); + entry.deinit(gpa); + self.group_open = false; + return null; + }; + const sel = entry.txn.sel_before; + self.redo_stack.append(gpa, entry) catch |err| { + std.log.err("History.applyUndo: redo push failed: {s}", .{@errorName(err)}); + entry.deinit(gpa); + }; + self.group_open = false; + return sel; +} + +pub fn applyRedo(self: *History, gpa: Allocator, text: *std.ArrayList(u8)) ?Range { + if (self.redo_stack.items.len == 0) return null; + var entry = self.redo_stack.pop().?; + entry.txn.applyForward(gpa, text) catch |err| { + std.log.err("History.applyRedo failed: {s} (dropping corrupt redo entry)", .{@errorName(err)}); + entry.deinit(gpa); + self.group_open = false; + return null; + }; + const sel = entry.txn.sel_after; + self.undo_stack.append(gpa, entry) catch |err| { + std.log.err("History.applyRedo: undo push failed: {s}", .{@errorName(err)}); + entry.deinit(gpa); + }; + self.group_open = false; + return sel; +} + +// -- tests --------------------------------------------------------------------------------------- + +const t = std.testing; + +test "VSCode grouping rule: table" { + // Typing runs merge. + try t.expect(!shouldBreakGroup(.typing_other, .typing_other)); + // Typing → deleting breaks ("always an undo stop before non-type operations"). + try t.expect(shouldBreakGroup(.typing_other, .deleting_left)); + // Deleting runs merge, but left/right are different runs. + try t.expect(!shouldBreakGroup(.deleting_left, .deleting_left)); + try t.expect(shouldBreakGroup(.deleting_left, .deleting_right)); + // `abc |d` — one space keeps the run open. + try t.expect(!shouldBreakGroup(.typing_first_space, .typing_other)); + // `abc |d` — a second space closes it. + try t.expect(shouldBreakGroup(.typing_consecutive_space, .typing_other)); + // Both space kinds are one category. + try t.expect(!shouldBreakGroup(.typing_first_space, .typing_consecutive_space)); + try t.expect(!shouldBreakGroup(.typing_consecutive_space, .typing_consecutive_space)); + // Anything after an `other` starts fresh. + try t.expect(shouldBreakGroup(.other, .typing_other)); +} + +test "classify" { + try t.expectEqual(EditKind.typing_other, classify("", "a", 0, 0, .other)); + try t.expectEqual(EditKind.typing_first_space, classify("", " ", 0, 0, .typing_other)); + try t.expectEqual(EditKind.typing_consecutive_space, classify("", " ", 0, 0, .typing_first_space)); + // Enter is always a boundary. + try t.expectEqual(EditKind.other, classify("", "\n", 0, 0, .typing_other)); + // Multi-char insert = paste. + try t.expectEqual(EditKind.other, classify("", "hello", 0, 0, .typing_other)); + // A multi-byte codepoint is still one typed character. + try t.expectEqual(EditKind.typing_other, classify("", "é", 0, 0, .typing_other)); + // Deletion direction from the caret. + try t.expectEqual(EditKind.deleting_left, classify("a", "", 4, 5, .other)); + try t.expectEqual(EditKind.deleting_right, classify("a", "", 5, 5, .other)); + // Typing over a selection is a replacement. + try t.expectEqual(EditKind.other, classify("sel", "x", 0, 3, .typing_other)); +} + +/// Drives the history the way the widget does, so the tests read like real editing sessions. +const Harness = struct { + gpa: Allocator, + text: std.ArrayList(u8) = .empty, + hist: History = .{}, + cursor: usize = 0, + + fn deinit(self: *Harness) void { + self.text.deinit(self.gpa); + self.hist.deinit(self.gpa); + } + + fn type_(self: *Harness, s: []const u8) !void { + var it = (try std.unicode.Utf8View.init(s)).iterator(); + while (it.nextCodepointSlice()) |ch| { + self.hist.begin(.collapsed(self.cursor)); + try self.text.insertSlice(self.gpa, self.cursor, ch); + self.hist.note(self.gpa, self.cursor, "", ch); + self.cursor += ch.len; + self.hist.end(self.gpa, .collapsed(self.cursor)); + } + } + + fn backspace(self: *Harness, n: usize) !void { + for (0..n) |_| { + if (self.cursor == 0) return; + self.hist.begin(.collapsed(self.cursor)); + const removed = self.text.items[self.cursor - 1 .. self.cursor]; + self.hist.note(self.gpa, self.cursor - 1, removed, ""); + try self.text.replaceRange(self.gpa, self.cursor - 1, 1, ""); + self.cursor -= 1; + self.hist.end(self.gpa, .collapsed(self.cursor)); + } + } + + fn undo(self: *Harness) ?Range { + const sel = self.hist.applyUndo(self.gpa, &self.text) orelse return null; + self.cursor = @min(sel.head, self.text.items.len); + return sel; + } + fn redo(self: *Harness) ?Range { + const sel = self.hist.applyRedo(self.gpa, &self.text) orelse return null; + self.cursor = @min(sel.head, self.text.items.len); + return sel; + } + fn steps(self: Harness) usize { + return self.hist.undo_stack.items.len; + } +}; + +test "a run of typing is one undo step" { + var h: Harness = .{ .gpa = t.allocator }; + defer h.deinit(); + + try h.type_("hello"); + try t.expectEqual(@as(usize, 1), h.steps()); + _ = h.undo(); + try t.expectEqualStrings("", h.text.items); +} + +// This is the rule that produces word-granular undo. A space *opens* a new group, and the +// character after a single space *joins* it — so each group is "leading space + word", and one +// undo takes back exactly one word. +test "typing groups as space-plus-word" { + var h: Harness = .{ .gpa = t.allocator }; + defer h.deinit(); + + try h.type_("the quick brown"); + try t.expectEqual(@as(usize, 3), h.steps()); // "the" / " quick" / " brown" + + _ = h.undo(); + try t.expectEqualStrings("the quick", h.text.items); + _ = h.undo(); + try t.expectEqualStrings("the", h.text.items); + _ = h.undo(); + try t.expectEqualStrings("", h.text.items); +} + +test "a second consecutive space closes the run" { + var h: Harness = .{ .gpa = t.allocator }; + defer h.deinit(); + + // VSCode: `abc |d` no undo stop, `abc |d` undo stop. So the double space is its own + // group and "def" starts another. + try h.type_("abc def"); + try t.expectEqual(@as(usize, 3), h.steps()); // "abc" / " " / "def" + _ = h.undo(); + try t.expectEqualStrings("abc ", h.text.items); + _ = h.undo(); + try t.expectEqualStrings("abc", h.text.items); +} + +test "typing then deleting are separate steps" { + var h: Harness = .{ .gpa = t.allocator }; + defer h.deinit(); + + try h.type_("hello"); + try h.backspace(2); + try t.expectEqual(@as(usize, 2), h.steps()); + + _ = h.undo(); + try t.expectEqualStrings("hello", h.text.items); // the deletions unwind together + _ = h.undo(); + try t.expectEqualStrings("", h.text.items); +} + +test "Enter is an undo boundary" { + var h: Harness = .{ .gpa = t.allocator }; + defer h.deinit(); + + try h.type_("abc"); + try h.type_("\n"); + try h.type_("def"); + try t.expectEqual(@as(usize, 3), h.steps()); + _ = h.undo(); + try t.expectEqualStrings("abc\n", h.text.items); +} + +test "undo restores the selection, not just an offset" { + const a = t.allocator; + var hist: History = .{}; + defer hist.deinit(a); + var text: std.ArrayList(u8) = .empty; + defer text.deinit(a); + try text.appendSlice(a, "hello world"); + + // Type "X" over the selected "world". + hist.begin(.init(6, 11)); + hist.note(a, 6, "world", "X"); + try text.replaceRange(a, 6, 5, "X"); + hist.end(a, .collapsed(7)); + + const sel = hist.applyUndo(a, &text).?; + try t.expectEqualStrings("hello world", text.items); + try t.expectEqual(@as(usize, 6), sel.start()); + try t.expectEqual(@as(usize, 11), sel.end()); +} + +test "redo replays the group and restores the after-selection" { + var h: Harness = .{ .gpa = t.allocator }; + defer h.deinit(); + + try h.type_("hello"); + _ = h.undo(); + try t.expectEqualStrings("", h.text.items); + const sel = h.redo().?; + try t.expectEqualStrings("hello", h.text.items); + try t.expectEqual(@as(usize, 5), sel.head); +} + +test "closeGroup makes the next edit start a new step (the save case)" { + var h: Harness = .{ .gpa = t.allocator }; + defer h.deinit(); + + try h.type_("abc"); + try t.expectEqual(@as(usize, 1), h.steps()); + const saved_id = h.hist.topOpId(); + + h.hist.closeGroup(); // as Document.save() will do + try h.type_("def"); + try t.expectEqual(@as(usize, 2), h.steps()); + // The saved state is still reachable by exactly one undo. + _ = h.undo(); + try t.expectEqualStrings("abc", h.text.items); + try t.expectEqual(saved_id, h.hist.topOpId()); +} + +test "merging still bumps the id so dirty tracking stays correct" { + var h: Harness = .{ .gpa = t.allocator }; + defer h.deinit(); + + try h.type_("ab"); + const id_after_save = h.hist.topOpId(); + try h.type_("c"); // merges into the same group + try t.expectEqual(@as(usize, 1), h.steps()); + try t.expect(h.hist.topOpId() != id_after_save); +} + +test "a new edit after undo clears the redo stack" { + var h: Harness = .{ .gpa = t.allocator }; + defer h.deinit(); + + try h.type_("abc"); + _ = h.undo(); + try t.expect(h.hist.canRedo()); + try h.type_("z"); + try t.expect(!h.hist.canRedo()); +} + +test "undo/redo round-trips a long mixed session" { + var h: Harness = .{ .gpa = t.allocator }; + defer h.deinit(); + + try h.type_("the quick"); + try h.backspace(3); + try h.type_("ck brown fox"); + try h.type_("\n"); + try h.type_("second line"); + const final = try t.allocator.dupe(u8, h.text.items); + defer t.allocator.free(final); + + const depth = h.steps(); + for (0..depth) |_| _ = h.undo(); + try t.expectEqualStrings("", h.text.items); + for (0..depth) |_| _ = h.redo(); + try t.expectEqualStrings(final, h.text.items); +} + +test "cut-shaped deletion is one undoable step" { + // Widget cut now goes through begin/noteRemoved/end — same capture shape as this. + var h: Harness = .{ .gpa = t.allocator }; + defer h.deinit(); + + try h.type_("hello world"); + // Cut "world": delete [6, 11). + h.hist.begin(.init(6, 11)); + h.hist.note(h.gpa, 6, "world", ""); + try h.text.replaceRange(h.gpa, 6, 5, ""); + h.cursor = 6; + h.hist.end(h.gpa, .collapsed(6)); + + try t.expectEqualStrings("hello ", h.text.items); + _ = h.undo(); + try t.expectEqualStrings("hello world", h.text.items); +} diff --git a/src/plugins/text/src/textcore/LineIndex.zig b/src/plugins/text/src/textcore/LineIndex.zig new file mode 100644 index 00000000..8d5f9a3d --- /dev/null +++ b/src/plugins/text/src/textcore/LineIndex.zig @@ -0,0 +1,323 @@ +//! Cached line-start offsets plus display-column math. +//! +//! Replaces the ad-hoc `std.mem.lastIndexOfScalar(u8, text[0..pos], '\n')` rescans sprinkled +//! through the editor, and the byte-based column arithmetic in `TextEntryWidget.insertIndent` +//! (`column = cursor - line_start`), which is wrong for tabs and for any non-ASCII line. +//! +//! Movement does *not* need this — with `break_lines = false` every motion is a local scan +//! (see `movement.zig`). This exists for the things that genuinely want random access by +//! line: line counts, goto-definition reveal, and the indent logic landing in step 4. + +const std = @import("std"); + +const LineIndex = @This(); + +const Allocator = std.mem.Allocator; + +/// Byte offset of the first character of each line. `starts[0]` is always 0, so this is +/// never empty for a valid index — `lineCount()` == `starts.len`. +starts: std.ArrayList(usize) = .empty, +tab_size: u8 = 4, + +pub fn deinit(self: *LineIndex, gpa: Allocator) void { + self.starts.deinit(gpa); + self.* = .{}; +} + +/// Full O(n) rebuild — on load, and after a full-buffer replace. +pub fn rebuild(self: *LineIndex, gpa: Allocator, text: []const u8) !void { + self.starts.clearRetainingCapacity(); + try self.starts.append(gpa, 0); + for (text, 0..) |c, i| { + if (c == '\n') try self.starts.append(gpa, i + 1); + } +} + +/// Incremental fixup after `text[pos..pos+removed_len]` was replaced by `inserted_len` bytes. +/// `text` must already be the post-edit buffer. +/// +/// Only lines at or after the one containing `pos` can move, so entries up to and including +/// that line are kept verbatim, the edited span is rescanned, and the untouched tail is +/// shifted by the length delta. +pub fn applyEdit( + self: *LineIndex, + gpa: Allocator, + text: []const u8, + pos: usize, + removed_len: usize, + inserted_len: usize, +) !void { + if (self.starts.items.len == 0) return self.rebuild(gpa, text); + + const first_line = self.lineOf(pos); + const keep = first_line + 1; + const old_removed_end = pos + removed_len; + + // Shifted tail: old line starts that survived the removal, in new coordinates. Collected + // before truncating because the shift can be negative. + var tail: std.ArrayList(usize) = .empty; + defer tail.deinit(gpa); + for (self.starts.items[keep..]) |s| { + if (s > old_removed_end) { + try tail.append(gpa, s + inserted_len - removed_len); + } + } + + self.starts.shrinkRetainingCapacity(keep); + + // Rescan from the start of the first affected line through the end of the inserted text. + // Everything past that is unchanged and already accounted for by `tail`. + const scan_from = self.starts.items[first_line]; + const scan_to = @min(pos + inserted_len, text.len); + var i = scan_from; + while (i < scan_to) : (i += 1) { + if (text[i] == '\n') try self.starts.append(gpa, i + 1); + } + + try self.starts.appendSlice(gpa, tail.items); +} + +pub fn lineCount(self: LineIndex) usize { + return @max(1, self.starts.items.len); +} + +/// 0-based line containing byte offset `off`. +pub fn lineOf(self: LineIndex, off: usize) usize { + const items = self.starts.items; + if (items.len == 0) return 0; + var lo: usize = 0; + var hi: usize = items.len; // find greatest i with items[i] <= off + while (lo + 1 < hi) { + const mid = lo + (hi - lo) / 2; + if (items[mid] <= off) lo = mid else hi = mid; + } + return lo; +} + +pub fn lineStart(self: LineIndex, line: usize) usize { + const items = self.starts.items; + if (items.len == 0) return 0; + return items[@min(line, items.len - 1)]; +} + +/// End of `line`, *excluding* its trailing newline. +pub fn lineEnd(self: LineIndex, text: []const u8, line: usize) usize { + const items = self.starts.items; + if (line + 1 < items.len) { + const next = items[line + 1]; + return if (next > 0 and next - 1 <= text.len) next - 1 else text.len; + } + return text.len; +} + +pub fn lineSlice(self: LineIndex, text: []const u8, line: usize) []const u8 { + const s = self.lineStart(line); + const e = self.lineEnd(text, line); + if (s > text.len or e > text.len or s > e) return ""; + return text[s..e]; +} + +/// Display column of `off`: tabs advance to the next multiple of `tab_size`, and each +/// codepoint counts as one column (continuation bytes count as zero). +pub fn displayCol(self: LineIndex, text: []const u8, off: usize) u32 { + return colBetween(text, self.lineStart(self.lineOf(off)), off, self.tab_size); +} + +/// Nearest byte offset on `line` at or before display column `col`. Lands on a codepoint +/// boundary, and never past the line's end. +pub fn offsetAtCol(self: LineIndex, text: []const u8, line: usize, col: u32) usize { + const s = self.lineStart(line); + const e = self.lineEnd(text, line); + return offsetAtColIn(text, s, e, col, self.tab_size); +} + +/// Leading whitespace of the line containing `off`, as a slice *into `text`* — no fixed-size +/// buffer, so no silent truncation at depth (the current code caps at `[128]u8`). +pub fn indentOf(self: LineIndex, text: []const u8, off: usize) []const u8 { + const line = self.lineOf(off); + const s = self.lineStart(line); + const e = self.lineEnd(text, line); + var i = s; + while (i < e and (text[i] == ' ' or text[i] == '\t')) : (i += 1) {} + return text[s..i]; +} + +/// Width of that indent in display columns. +pub fn indentWidthOf(self: LineIndex, text: []const u8, off: usize) u32 { + const ind = self.indentOf(text, off); + return colBetween(ind, 0, ind.len, self.tab_size); +} + +// -- shared column math, also used by movement.zig ------------------------------------------ + +/// Display columns spanned by `text[from..to]`, starting from column 0 at `from`. +pub fn colBetween(text: []const u8, from: usize, to: usize, tab_size: u8) u32 { + const ts: u32 = if (tab_size == 0) 4 else tab_size; + var col: u32 = 0; + var i = from; + const stop = @min(to, text.len); + while (i < stop) : (i += 1) { + const c = text[i]; + if (c == '\t') { + col += ts - (col % ts); + } else if (c & 0xC0 != 0x80) { + col += 1; // codepoint start; continuation bytes add nothing + } + } + return col; +} + +/// Byte offset within `text[from..to]` at or before display column `col`. +pub fn offsetAtColIn(text: []const u8, from: usize, to: usize, col: u32, tab_size: u8) usize { + const ts: u32 = if (tab_size == 0) 4 else tab_size; + var cur: u32 = 0; + var i = from; + const stop = @min(to, text.len); + while (i < stop) { + if (cur >= col) return i; + const c = text[i]; + if (c == '\t') { + cur += ts - (cur % ts); + i += 1; + } else { + cur += 1; + i += 1; + while (i < stop and text[i] & 0xC0 == 0x80) : (i += 1) {} + } + } + return stop; +} + +// -- tests ---------------------------------------------------------------------------------- + +test "rebuild records every line start" { + const gpa = std.testing.allocator; + var idx: LineIndex = .{}; + defer idx.deinit(gpa); + try idx.rebuild(gpa, "abc\ndef\n\nghi"); + try std.testing.expectEqualSlices(usize, &.{ 0, 4, 8, 9 }, idx.starts.items); + try std.testing.expectEqual(@as(usize, 4), idx.lineCount()); +} + +test "lineOf / lineStart / lineEnd" { + const gpa = std.testing.allocator; + const text = "abc\ndef\n\nghi"; + var idx: LineIndex = .{}; + defer idx.deinit(gpa); + try idx.rebuild(gpa, text); + + try std.testing.expectEqual(@as(usize, 0), idx.lineOf(0)); + try std.testing.expectEqual(@as(usize, 0), idx.lineOf(3)); // the '\n' belongs to line 0 + try std.testing.expectEqual(@as(usize, 1), idx.lineOf(4)); + try std.testing.expectEqual(@as(usize, 3), idx.lineOf(11)); + + try std.testing.expectEqual(@as(usize, 3), idx.lineEnd(text, 0)); + try std.testing.expectEqual(@as(usize, 8), idx.lineEnd(text, 2)); // empty line + try std.testing.expectEqual(@as(usize, 12), idx.lineEnd(text, 3)); + try std.testing.expectEqualStrings("def", idx.lineSlice(text, 1)); + try std.testing.expectEqualStrings("", idx.lineSlice(text, 2)); +} + +test "displayCol expands tabs to the next tab stop" { + const gpa = std.testing.allocator; + const text = "\tab\tc"; + var idx: LineIndex = .{ .tab_size = 4 }; + defer idx.deinit(gpa); + try idx.rebuild(gpa, text); + + try std.testing.expectEqual(@as(u32, 0), idx.displayCol(text, 0)); + try std.testing.expectEqual(@as(u32, 4), idx.displayCol(text, 1)); // past the tab + try std.testing.expectEqual(@as(u32, 6), idx.displayCol(text, 3)); // past "ab" + try std.testing.expectEqual(@as(u32, 8), idx.displayCol(text, 4)); // past the 2nd tab +} + +test "displayCol counts codepoints, not bytes" { + const gpa = std.testing.allocator; + const text = "héllo"; // 'é' is two bytes + var idx: LineIndex = .{}; + defer idx.deinit(gpa); + try idx.rebuild(gpa, text); + try std.testing.expectEqual(@as(u32, 5), idx.displayCol(text, text.len)); +} + +test "offsetAtCol lands on codepoint boundaries" { + const gpa = std.testing.allocator; + const text = "héllo"; + var idx: LineIndex = .{}; + defer idx.deinit(gpa); + try idx.rebuild(gpa, text); + + try std.testing.expectEqual(@as(usize, 0), idx.offsetAtCol(text, 0, 0)); + try std.testing.expectEqual(@as(usize, 1), idx.offsetAtCol(text, 0, 1)); + try std.testing.expectEqual(@as(usize, 3), idx.offsetAtCol(text, 0, 2)); // skipped 'é' + try std.testing.expectEqual(@as(usize, 6), idx.offsetAtCol(text, 0, 99)); // clamps +} + +test "offsetAtCol clamps to line end, not document end" { + const gpa = std.testing.allocator; + const text = "ab\nlonger line"; + var idx: LineIndex = .{}; + defer idx.deinit(gpa); + try idx.rebuild(gpa, text); + try std.testing.expectEqual(@as(usize, 2), idx.offsetAtCol(text, 0, 40)); +} + +test "indentOf returns full indent without truncation" { + const gpa = std.testing.allocator; + var buf: [400]u8 = undefined; + @memset(buf[0..300], ' '); + @memcpy(buf[300..303], "abc"); + const text = buf[0..303]; + + var idx: LineIndex = .{}; + defer idx.deinit(gpa); + try idx.rebuild(gpa, text); + try std.testing.expectEqual(@as(usize, 300), idx.indentOf(text, 302).len); + try std.testing.expectEqual(@as(u32, 300), idx.indentWidthOf(text, 302)); +} + +// The important one: `applyEdit` must be indistinguishable from a full `rebuild` for any +// edit. Randomised so it covers newline insertion/removal at every position class. +test "applyEdit matches rebuild across random edits" { + const gpa = std.testing.allocator; + var prng: std.Random.DefaultPrng = .init(0x7e47c0de); + const rand = prng.random(); + + const inserts = [_][]const u8{ "", "x", "\n", "ab\ncd", "\n\n\n", "hello", "\nq" }; + + var round: usize = 0; + while (round < 400) : (round += 1) { + var text: std.ArrayList(u8) = .empty; + defer text.deinit(gpa); + const seed_len = rand.uintLessThan(usize, 40); + for (0..seed_len) |_| { + try text.append(gpa, if (rand.uintLessThan(u8, 4) == 0) '\n' else 'a'); + } + + var incremental: LineIndex = .{}; + defer incremental.deinit(gpa); + try incremental.rebuild(gpa, text.items); + + for (0..6) |_| { + const pos = if (text.items.len == 0) 0 else rand.uintAtMost(usize, text.items.len); + const max_rm = text.items.len - pos; + const removed_len = if (max_rm == 0) 0 else rand.uintAtMost(usize, max_rm); + const ins = inserts[rand.uintLessThan(usize, inserts.len)]; + + try text.replaceRange(gpa, pos, removed_len, ins); + try incremental.applyEdit(gpa, text.items, pos, removed_len, ins.len); + + var fresh: LineIndex = .{}; + defer fresh.deinit(gpa); + try fresh.rebuild(gpa, text.items); + + std.testing.expectEqualSlices(usize, fresh.starts.items, incremental.starts.items) catch |err| { + std.debug.print( + "round {d}: pos={d} removed={d} ins=\"{f}\" text=\"{f}\"\n", + .{ round, pos, removed_len, std.zig.fmtString(ins), std.zig.fmtString(text.items) }, + ); + return err; + }; + } + } +} diff --git a/src/plugins/text/src/textcore/Range.zig b/src/plugins/text/src/textcore/Range.zig new file mode 100644 index 00000000..ebbf006b --- /dev/null +++ b/src/plugins/text/src/textcore/Range.zig @@ -0,0 +1,124 @@ +//! A single caret plus its (possibly empty) selection. +//! +//! `anchor` is where the selection began, `head` is where the caret is now. Either may be +//! the smaller offset — selecting backwards is normal and has to round-trip, which is why +//! this is an anchor/head pair rather than dvui's ordered `start`/`end`/`cursor` triple. +//! `Selection.toDvui` does the lowering. + +const std = @import("std"); + +const Range = @This(); + +anchor: usize, +head: usize, +/// Sticky target column for vertical motion, in **display** columns (tabs expanded, one +/// column per codepoint). Set on the first up/down of a run and preserved across subsequent +/// ones, so travelling down through a short line and back out doesn't lose the column. +/// Cleared by any horizontal motion or edit. +goal_col: ?u32 = null, + +pub fn collapsed(off: usize) Range { + return .{ .anchor = off, .head = off }; +} + +pub fn init(anchor: usize, head: usize) Range { + return .{ .anchor = anchor, .head = head }; +} + +pub fn cursor(self: Range) usize { + return self.head; +} + +pub fn start(self: Range) usize { + return @min(self.anchor, self.head); +} + +pub fn end(self: Range) usize { + return @max(self.anchor, self.head); +} + +pub fn len(self: Range) usize { + return self.end() - self.start(); +} + +pub fn isEmpty(self: Range) bool { + return self.anchor == self.head; +} + +pub fn contains(self: Range, off: usize) bool { + return off >= self.start() and off < self.end(); +} + +/// Touching counts as overlapping — two carets that meet must merge into one, otherwise a +/// multi-cursor edit applies twice at the same spot. +pub fn overlaps(self: Range, other: Range) bool { + return self.start() <= other.end() and other.start() <= self.end(); +} + +/// Move the caret to `head`. `extend` is the Shift modifier: false collapses the anchor +/// onto the new head, true leaves the anchor where it was. +pub fn withHead(self: Range, head: usize, extend: bool) Range { + return .{ + .anchor = if (extend) self.anchor else head, + .head = head, + .goal_col = null, + }; +} + +/// `withHead`, but preserving `goal_col` — used only by vertical motion, which is the one +/// caller that wants the sticky column to survive. +pub fn withHeadKeepGoal(self: Range, head: usize, extend: bool, goal_col: ?u32) Range { + return .{ + .anchor = if (extend) self.anchor else head, + .head = head, + .goal_col = goal_col, + }; +} + +pub fn merged(self: Range, other: Range) Range { + const lo = @min(self.start(), other.start()); + const hi = @max(self.end(), other.end()); + // Keep the direction of whichever range's head sits at an extreme, so merging while + // drag-selecting backwards doesn't silently flip the caret to the other end. + const backwards = self.head <= self.anchor and other.head <= other.anchor; + return if (backwards) + .{ .anchor = hi, .head = lo } + else + .{ .anchor = lo, .head = hi }; +} + +test "start/end round-trip regardless of direction" { + const fwd: Range = .init(2, 7); + const back: Range = .init(7, 2); + try std.testing.expectEqual(@as(usize, 2), fwd.start()); + try std.testing.expectEqual(@as(usize, 7), fwd.end()); + try std.testing.expectEqual(@as(usize, 2), back.start()); + try std.testing.expectEqual(@as(usize, 7), back.end()); + // `init` is (anchor, head) — a backwards selection has its caret at the *low* end. + try std.testing.expectEqual(@as(usize, 2), back.cursor()); + try std.testing.expectEqual(@as(usize, 7), fwd.cursor()); +} + +test "withHead collapses unless extending" { + const r: Range = .init(2, 7); + try std.testing.expect(r.withHead(9, false).isEmpty()); + try std.testing.expectEqual(@as(usize, 2), r.withHead(9, true).anchor); + try std.testing.expectEqual(@as(usize, 9), r.withHead(9, true).head); +} + +test "withHead clears goal_col but withHeadKeepGoal does not" { + const r: Range = .{ .anchor = 0, .head = 0, .goal_col = 12 }; + try std.testing.expectEqual(@as(?u32, null), r.withHead(4, false).goal_col); + try std.testing.expectEqual(@as(?u32, 12), r.withHeadKeepGoal(4, false, 12).goal_col); +} + +test "touching ranges overlap" { + try std.testing.expect((Range.init(0, 3)).overlaps(.init(3, 6))); + try std.testing.expect(!(Range.init(0, 3)).overlaps(.init(4, 6))); +} + +test "merge preserves backwards direction" { + const m = (Range.init(6, 3)).merged(.init(4, 1)); + try std.testing.expectEqual(@as(usize, 6), m.anchor); + try std.testing.expectEqual(@as(usize, 1), m.head); +} diff --git a/src/plugins/text/src/textcore/Selection.zig b/src/plugins/text/src/textcore/Selection.zig new file mode 100644 index 00000000..c939d6a2 --- /dev/null +++ b/src/plugins/text/src/textcore/Selection.zig @@ -0,0 +1,230 @@ +//! A sorted, non-overlapping set of `Range`s — the document's cursors. +//! +//! Multi-cursor is in the type from day one even though the editor currently only ever holds +//! one range. Every motion goes through `mapRanges`, so adding Ctrl+D later doesn't mean +//! revisiting each motion individually. + +const std = @import("std"); +const Range = @import("Range.zig"); + +const Selection = @This(); + +const Allocator = std.mem.Allocator; + +/// INVARIANT (restored by `normalize`): sorted ascending by `Range.start()`, and no two +/// entries overlap or touch. +ranges: std.ArrayList(Range) = .empty, +/// Index into `ranges` of the primary cursor — the one that drives scroll-to-cursor and the +/// single-cursor-only features (completion anchor, hover, signature help). +primary: usize = 0, + +pub fn deinit(self: *Selection, gpa: Allocator) void { + self.ranges.deinit(gpa); + self.* = .{}; +} + +pub fn single(gpa: Allocator, r: Range) !Selection { + var self: Selection = .{}; + try self.ranges.append(gpa, r); + return self; +} + +pub fn collapsedAt(gpa: Allocator, off: usize) !Selection { + return single(gpa, .collapsed(off)); +} + +pub fn clone(self: Selection, gpa: Allocator) !Selection { + return .{ + .ranges = try self.ranges.clone(gpa), + .primary = self.primary, + }; +} + +pub fn count(self: Selection) usize { + return self.ranges.items.len; +} + +/// The primary range, or a collapsed range at 0 for an empty selection (which shouldn't +/// happen, but callers shouldn't have to branch on it). +pub fn primaryRange(self: Selection) Range { + if (self.ranges.items.len == 0) return .collapsed(0); + return self.ranges.items[@min(self.primary, self.ranges.items.len - 1)]; +} + +pub fn setPrimaryRange(self: *Selection, r: Range) void { + if (self.ranges.items.len == 0) return; + self.ranges.items[@min(self.primary, self.ranges.items.len - 1)] = r; +} + +/// Sort by start, then merge any ranges that overlap or touch, keeping `primary` pointed at +/// whichever entry absorbed the old primary. Called after every motion and every edit — +/// this is what stops two cursors drifting into each other and applying an edit twice. +pub fn normalize(self: *Selection, gpa: Allocator) !void { + _ = gpa; + const items = self.ranges.items; + if (items.len <= 1) { + self.primary = if (items.len == 0) 0 else 0; + return; + } + + // Track the primary by identity through the sort: tag each range with its index, sort, + // then follow the tag. Insertion sort — cursor counts are small (tens, not thousands) + // and this keeps the sort stable without an allocation. + var primary_range = self.primaryRange(); + var i: usize = 1; + while (i < items.len) : (i += 1) { + const key = items[i]; + var j = i; + while (j > 0 and items[j - 1].start() > key.start()) : (j -= 1) { + items[j] = items[j - 1]; + } + items[j] = key; + } + + var out: usize = 0; + var k: usize = 1; + while (k < items.len) : (k += 1) { + if (items[out].overlaps(items[k])) { + const merged = items[out].merged(items[k]); + // If either side was the primary, the merged range inherits that role. + if (rangeEql(items[out], primary_range) or rangeEql(items[k], primary_range)) { + primary_range = merged; + } + items[out] = merged; + } else { + out += 1; + items[out] = items[k]; + } + } + self.ranges.shrinkRetainingCapacity(out + 1); + + self.primary = 0; + for (self.ranges.items, 0..) |r, idx| { + if (rangeEql(r, primary_range)) { + self.primary = idx; + break; + } + } +} + +fn rangeEql(a: Range, b: Range) bool { + return a.anchor == b.anchor and a.head == b.head; +} + +pub fn addCursor(self: *Selection, gpa: Allocator, r: Range) !void { + try self.ranges.append(gpa, r); + self.primary = self.ranges.items.len - 1; + try self.normalize(gpa); +} + +pub fn collapseToPrimary(self: *Selection, gpa: Allocator) !void { + const p = self.primaryRange(); + self.ranges.clearRetainingCapacity(); + try self.ranges.append(gpa, p); + self.primary = 0; +} + +/// Apply `f` to every range, then re-normalize. Every motion routes through here, so a +/// motion written once works for one cursor or fifty. +pub fn mapRanges( + self: *Selection, + gpa: Allocator, + ctx: anytype, + comptime f: fn (@TypeOf(ctx), Range) Range, +) !void { + for (self.ranges.items) |*r| r.* = f(ctx, r.*); + try self.normalize(gpa); +} + +/// Lowering to dvui's ordered `{cursor, start, end}` triple. dvui's `Selection` is a +/// projection written *into* the layout each frame — never read back as truth. That's the +/// whole point: layout resolves motion a frame late, so it can't be the source of record. +pub const Dvui = struct { cursor: usize, start: usize, end: usize }; + +pub fn toDvui(self: Selection) Dvui { + const p = self.primaryRange(); + return .{ .cursor = p.head, .start = p.start(), .end = p.end() }; +} + +/// Lifting from dvui — used at the one boundary where the layout legitimately owns the +/// change: mouse click/drag hit-testing, which genuinely needs glyph positions. +pub fn fromDvui(gpa: Allocator, d: Dvui) !Selection { + // Reconstruct direction from which end the cursor sits at. + const r: Range = if (d.cursor == d.start and d.start != d.end) + .init(d.end, d.start) + else + .init(d.start, d.end); + return single(gpa, r); +} + +test "normalize sorts and merges touching ranges" { + const gpa = std.testing.allocator; + var sel: Selection = .{}; + defer sel.deinit(gpa); + try sel.ranges.append(gpa, .init(10, 14)); + try sel.ranges.append(gpa, .init(0, 3)); + try sel.ranges.append(gpa, .init(3, 6)); + try sel.normalize(gpa); + + try std.testing.expectEqual(@as(usize, 2), sel.count()); + try std.testing.expectEqual(@as(usize, 0), sel.ranges.items[0].start()); + try std.testing.expectEqual(@as(usize, 6), sel.ranges.items[0].end()); + try std.testing.expectEqual(@as(usize, 10), sel.ranges.items[1].start()); +} + +test "normalize keeps primary pointed at the surviving range" { + const gpa = std.testing.allocator; + var sel: Selection = .{}; + defer sel.deinit(gpa); + try sel.ranges.append(gpa, .init(10, 14)); + try sel.ranges.append(gpa, .init(0, 3)); + sel.primary = 0; // the (10,14) one + try sel.normalize(gpa); + + try std.testing.expectEqual(@as(usize, 10), sel.primaryRange().start()); +} + +test "disjoint ranges are left alone" { + const gpa = std.testing.allocator; + var sel: Selection = .{}; + defer sel.deinit(gpa); + try sel.ranges.append(gpa, .init(0, 2)); + try sel.ranges.append(gpa, .init(4, 6)); + try sel.ranges.append(gpa, .init(8, 10)); + try sel.normalize(gpa); + try std.testing.expectEqual(@as(usize, 3), sel.count()); +} + +test "dvui round-trip preserves backwards selection" { + const gpa = std.testing.allocator; + var sel = try Selection.single(gpa, .init(9, 4)); + defer sel.deinit(gpa); + + const d = sel.toDvui(); + try std.testing.expectEqual(@as(usize, 4), d.cursor); + try std.testing.expectEqual(@as(usize, 4), d.start); + try std.testing.expectEqual(@as(usize, 9), d.end); + + var back = try Selection.fromDvui(gpa, d); + defer back.deinit(gpa); + try std.testing.expectEqual(@as(usize, 9), back.primaryRange().anchor); + try std.testing.expectEqual(@as(usize, 4), back.primaryRange().head); +} + +test "mapRanges applies to every cursor" { + const gpa = std.testing.allocator; + var sel: Selection = .{}; + defer sel.deinit(gpa); + try sel.ranges.append(gpa, .collapsed(0)); + try sel.ranges.append(gpa, .collapsed(10)); + + const shift = struct { + fn f(by: usize, r: Range) Range { + return .collapsed(r.head + by); + } + }.f; + try sel.mapRanges(gpa, @as(usize, 5), shift); + + try std.testing.expectEqual(@as(usize, 5), sel.ranges.items[0].head); + try std.testing.expectEqual(@as(usize, 15), sel.ranges.items[1].head); +} diff --git a/src/plugins/text/src/textcore/Transaction.zig b/src/plugins/text/src/textcore/Transaction.zig new file mode 100644 index 00000000..29f034ba --- /dev/null +++ b/src/plugins/text/src/textcore/Transaction.zig @@ -0,0 +1,257 @@ +//! One undoable unit of change: an ordered list of edits plus the selection either side of it. +//! +//! **Coordinate convention: sequential, not pre-transaction.** Each `Edit.pos` is valid at the +//! moment that edit was applied, i.e. after every edit before it in the list. That's the right +//! convention here because these are captured *after the fact* from the editing widget's own +//! mutations, so the offsets we're handed are already sequential — normalizing them to +//! pre-transaction coordinates would mean undoing arithmetic we'd only redo on replay. (A +//! future `Builder` for edits composed *ahead* of application — multi-cursor, refactors — +//! wants the opposite convention and gets its own type; the two must not be confused.) +//! +//! Replay is therefore: forward in order, inverse in reverse order. + +const std = @import("std"); +const Range = @import("Range.zig"); + +const Transaction = @This(); + +const Allocator = std.mem.Allocator; + +pub const Edit = struct { + pos: usize, + /// Bytes that were at `pos` before this edit (owned). + removed: []u8 = &.{}, + /// Bytes that replaced them (owned). + inserted: []u8 = &.{}, + + pub fn deinit(self: *Edit, gpa: Allocator) void { + gpa.free(self.removed); + gpa.free(self.inserted); + self.* = .{ .pos = 0 }; + } +}; + +edits: std.ArrayList(Edit) = .empty, +/// Selection before the first edit — restored on undo. The old `UndoStack` could only return a +/// single guessed offset, so undoing a "type over a selection" lost the selection entirely. +sel_before: Range = .collapsed(0), +/// Selection after the last edit — restored on redo. +sel_after: Range = .collapsed(0), + +pub fn deinit(self: *Transaction, gpa: Allocator) void { + for (self.edits.items) |*e| e.deinit(gpa); + self.edits.deinit(gpa); + self.* = .{}; +} + +pub fn isEmpty(self: Transaction) bool { + for (self.edits.items) |e| { + if (e.removed.len != 0 or e.inserted.len != 0) return false; + } + return true; +} + +/// Append one edit, taking copies of both byte slices. +pub fn append( + self: *Transaction, + gpa: Allocator, + pos: usize, + removed: []const u8, + inserted: []const u8, +) !void { + const removed_copy = try gpa.dupe(u8, removed); + errdefer gpa.free(removed_copy); + const inserted_copy = try gpa.dupe(u8, inserted); + errdefer gpa.free(inserted_copy); + try self.edits.append(gpa, .{ + .pos = pos, + .removed = removed_copy, + .inserted = inserted_copy, + }); +} + +/// Re-apply (redo): forward, in order. Either the whole transaction lands or the buffer is +/// left unchanged — a mid-list failure used to leave a half-applied state that made every +/// subsequent undo retry hit `EditOutOfRange`. +pub fn applyForward(self: Transaction, gpa: Allocator, text: *std.ArrayList(u8)) !void { + var applied: usize = 0; + errdefer { + // Roll back the prefix we already applied (inverse, newest-first). + var j = applied; + while (j > 0) { + j -= 1; + const e = self.edits.items[j]; + text.replaceRange(gpa, e.pos, e.inserted.len, e.removed) catch {}; + } + } + for (self.edits.items) |e| { + if (e.pos > text.items.len) return error.EditOutOfRange; + if (e.pos + e.removed.len > text.items.len) return error.EditOutOfRange; + try text.replaceRange(gpa, e.pos, e.removed.len, e.inserted); + applied += 1; + } +} + +/// Reverse (undo): inverse of each edit, walked backwards so every `pos` is valid again as we +/// reach it. Atomic with respect to the buffer — see `applyForward`. +pub fn applyInverse(self: Transaction, gpa: Allocator, text: *std.ArrayList(u8)) !void { + var applied: usize = 0; + errdefer { + // Re-forward the suffix we already inverted (oldest-of-suffix first). + const start = self.edits.items.len - applied; + for (self.edits.items[start..]) |e| { + text.replaceRange(gpa, e.pos, e.removed.len, e.inserted) catch {}; + } + } + var i = self.edits.items.len; + while (i > 0) { + i -= 1; + const e = self.edits.items[i]; + if (e.pos > text.items.len) return error.EditOutOfRange; + if (e.pos + e.inserted.len > text.items.len) return error.EditOutOfRange; + try text.replaceRange(gpa, e.pos, e.inserted.len, e.removed); + applied += 1; + } +} + +// -- tests ------------------------------------------------------------------------------------ + +const t = std.testing; + +fn listOf(gpa: Allocator, s: []const u8) !std.ArrayList(u8) { + var l: std.ArrayList(u8) = .empty; + try l.appendSlice(gpa, s); + return l; +} + +test "forward then inverse restores the original text" { + const a = t.allocator; + + var txn: Transaction = .{}; + defer txn.deinit(a); + try txn.append(a, 0, "", "hello"); + try txn.append(a, 5, "", " world"); + + var text = try listOf(a, ""); + defer text.deinit(a); + + try txn.applyForward(a, &text); + try t.expectEqualStrings("hello world", text.items); + try txn.applyInverse(a, &text); + try t.expectEqualStrings("", text.items); +} + +test "inverse walks backwards so later edits unwind first" { + const a = t.allocator; + var text = try listOf(a, "abc"); + defer text.deinit(a); + + var txn: Transaction = .{}; + defer txn.deinit(a); + // Simulate two sequential insertions as the widget would report them. + try txn.append(a, 3, "", "d"); + try text.replaceRange(a, 3, 0, "d"); + try txn.append(a, 4, "", "e"); + try text.replaceRange(a, 4, 0, "e"); + try t.expectEqualStrings("abcde", text.items); + + try txn.applyInverse(a, &text); + try t.expectEqualStrings("abc", text.items); + try txn.applyForward(a, &text); + try t.expectEqualStrings("abcde", text.items); +} + +test "replacement edits round-trip" { + const a = t.allocator; + var text = try listOf(a, "the quick fox"); + defer text.deinit(a); + + var txn: Transaction = .{}; + defer txn.deinit(a); + try txn.append(a, 4, "quick", "slow"); + try txn.applyForward(a, &text); + try t.expectEqualStrings("the slow fox", text.items); + try txn.applyInverse(a, &text); + try t.expectEqualStrings("the quick fox", text.items); +} + +test "isEmpty ignores no-op edits" { + const a = t.allocator; + var txn: Transaction = .{}; + defer txn.deinit(a); + try t.expect(txn.isEmpty()); + try txn.append(a, 0, "", ""); + try t.expect(txn.isEmpty()); + try txn.append(a, 0, "", "x"); + try t.expect(!txn.isEmpty()); +} + +test "out-of-range edits error instead of corrupting the buffer" { + const a = t.allocator; + var text = try listOf(a, "ab"); + defer text.deinit(a); + + var txn: Transaction = .{}; + defer txn.deinit(a); + try txn.append(a, 99, "", "x"); + try t.expectError(error.EditOutOfRange, txn.applyForward(a, &text)); + try t.expectEqualStrings("ab", text.items); +} + +test "inverse failure rolls back a partially-applied group" { + // Simulates the cut-without-history desync: a merged group whose later edits no longer + // match the buffer. Without atomicity, undoing would strip the matching suffix and leave + // the buffer half-rewound when the stale prefix fails. + const a = t.allocator; + var text = try listOf(a, "hello x"); + defer text.deinit(a); + + var txn: Transaction = .{}; + defer txn.deinit(a); + // Stale "world" inserts that aren't in the buffer anymore… + try txn.append(a, 6, "", "w"); + try txn.append(a, 7, "", "o"); + try txn.append(a, 8, "", "r"); + try txn.append(a, 9, "", "l"); + try txn.append(a, 10, "", "d"); + // …followed by a real "x" that is. + try txn.append(a, 6, "", "x"); + + try t.expectError(error.EditOutOfRange, txn.applyInverse(a, &text)); + try t.expectEqualStrings("hello x", text.items); +} + +// Property: any sequence of random sequential edits round-trips exactly. +test "random edit sequences round-trip" { + const a = t.allocator; + var prng: std.Random.DefaultPrng = .init(0xbeefcafe); + const rand = prng.random(); + const inserts = [_][]const u8{ "", "x", "\n", "ab", "hello", " " }; + + var round: usize = 0; + while (round < 300) : (round += 1) { + var text = try listOf(a, "seed text\nsecond line"); + defer text.deinit(a); + const original = try a.dupe(u8, text.items); + defer a.free(original); + + var txn: Transaction = .{}; + defer txn.deinit(a); + + for (0..5) |_| { + const pos = rand.uintAtMost(usize, text.items.len); + const removed_len = rand.uintAtMost(usize, text.items.len - pos); + const ins = inserts[rand.uintLessThan(usize, inserts.len)]; + try txn.append(a, pos, text.items[pos .. pos + removed_len], ins); + try text.replaceRange(a, pos, removed_len, ins); + } + + const after = try a.dupe(u8, text.items); + defer a.free(after); + + try txn.applyInverse(a, &text); + try t.expectEqualStrings(original, text.items); + try txn.applyForward(a, &text); + try t.expectEqualStrings(after, text.items); + } +} diff --git a/src/plugins/text/src/textcore/completion.zig b/src/plugins/text/src/textcore/completion.zig new file mode 100644 index 00000000..1063b248 --- /dev/null +++ b/src/plugins/text/src/textcore/completion.zig @@ -0,0 +1,84 @@ +//! What (if anything) of a completion candidate can honestly be drawn as inline ghost text +//! after the caret. Pure buffer logic, for the same reason as the rest of `textcore/` — the +//! widget owns the actual splicing. + +const std = @import("std"); +const pairs = @import("pairs.zig"); + +/// The dimmed suffix to show after the caret for `candidate`, or null when there is nothing +/// that can be shown without misrepresenting it. +/// +/// A candidate's text is the **whole** replacement for `[replace_start, replace_end)`, not a +/// suffix of what's already typed: the provider matches fuzzily (`arlst` matches `ArrayList`), +/// so the typed characters generally aren't a removable prefix. Ghost text can only ever +/// *append* after the caret, so it is shown only for candidates where the typed span really is +/// a literal prefix, and only for the part past it. Everything else stays perfectly usable from +/// the dropdown — which shows the full label and accepts via the replace range — it just gets +/// no inline preview, exactly as VSCode behaves. +/// +/// Also suppressed mid-word: with the caret at `App|lication`, splicing a candidate in at the +/// cursor renders as `AppApplicationlication`, which reads as corrupted text rather than as a +/// suggestion. +pub fn ghostSuffix( + text: []const u8, + cursor: usize, + candidate: []const u8, + replace_start: usize, + replace_end: usize, +) ?[]const u8 { + if (cursor > text.len) return null; + if (cursor < text.len and pairs.isWordByte(text[cursor])) return null; + + // Anything else means the candidate isn't anchored at the caret (a stale result, or a + // provider replacing a span the caret isn't at the end of) — there's no position at which + // appending its text would read correctly. + if (replace_end != cursor) return null; + if (replace_start > replace_end or replace_end > text.len) return null; + + const typed = text[replace_start..replace_end]; + if (typed.len > candidate.len) return null; + if (!std.mem.eql(u8, candidate[0..typed.len], typed)) return null; + + const suffix = candidate[typed.len..]; + return if (suffix.len == 0) null else suffix; +} + +// -- tests -------------------------------------------------------------------------------------- + +const testing = std.testing; + +test "shows only the untyped remainder" { + // `s|` with candidate `std` → ghost `td`, not `std`. + try testing.expectEqualStrings("td", ghostSuffix("s", 1, "std", 0, 1).?); + try testing.expectEqualStrings("d", ghostSuffix("st", 2, "std", 0, 2).?); + // Nothing typed yet: the whole candidate is the remainder. + try testing.expectEqualStrings("std", ghostSuffix("", 0, "std", 0, 0).?); +} + +test "no ghost when the candidate is fully typed" { + try testing.expectEqual(@as(?[]const u8, null), ghostSuffix("std", 3, "std", 0, 3)); +} + +test "no ghost in the middle of a word" { + // `App|lication` must not render as `AppApplicationlication`. + try testing.expectEqual(@as(?[]const u8, null), ghostSuffix("Application", 3, "Application", 0, 3)); +} + +test "no ghost for a fuzzy match the caret text does not literally prefix" { + try testing.expectEqual(@as(?[]const u8, null), ghostSuffix("arlst", 5, "ArrayList", 0, 5)); + // Case-differing prefixes count as fuzzy too — accepting still works via the replace range. + try testing.expectEqual(@as(?[]const u8, null), ghostSuffix("ar", 2, "ArrayList", 0, 2)); +} + +test "no ghost for a candidate not anchored at the caret" { + // Replace range ends before the caret (stale result). + try testing.expectEqual(@as(?[]const u8, null), ghostSuffix("std.", 4, "std", 0, 3)); + // Out of range spans are rejected rather than trusted. + try testing.expectEqual(@as(?[]const u8, null), ghostSuffix("st", 9, "std", 0, 9)); + try testing.expectEqual(@as(?[]const u8, null), ghostSuffix("st", 2, "std", 2, 1)); +} + +test "ghost is allowed before punctuation and whitespace" { + try testing.expectEqualStrings("td", ghostSuffix("s)", 1, "std", 0, 1).?); + try testing.expectEqualStrings("td", ghostSuffix("s ", 1, "std", 0, 1).?); +} diff --git a/src/plugins/text/src/textcore/movement.zig b/src/plugins/text/src/textcore/movement.zig new file mode 100644 index 00000000..f367532b --- /dev/null +++ b/src/plugins/text/src/textcore/movement.zig @@ -0,0 +1,498 @@ +//! Cursor motion, as pure functions of `(text, offset) -> offset`. +//! +//! Nothing here touches a widget, a layout, or frame state. That is the entire point: the +//! old path set `TextLayoutWidget.sel_move`, a **single-slot** union resolved later during +//! the render pass, so a second motion arriving in the same frame was silently dropped and +//! vertical motion round-tripped through `dataSet`/`dataGet` across two frames. +//! +//! No `LineIndex` is required. The editor sets `break_lines = false` (one source line is +//! exactly one visual row), so every motion below is a local scan bounded by line length. +//! If wrapping is ever added, vertical motion grows a `VisualLines` seam; nothing else here +//! changes. + +const std = @import("std"); +const Range = @import("Range.zig"); +const LineIndex = @import("LineIndex.zig"); + +pub const Granularity = enum { + char, + subword, + word, + /// Home/End — horizontal, within the current line. + line_boundary, + /// Up/Down by one line. + line, + page, + document, +}; + +pub const Dir = enum { + backward, + forward, + + pub fn sign(self: Dir) i32 { + return switch (self) { + .backward => -1, + .forward => 1, + }; + } +}; + +pub const Opts = struct { + tab_size: u8 = 4, + /// Rows per PageUp/PageDown. The caller knows the viewport; default is a sane fallback. + page_lines: u32 = 20, +}; + +// -- UTF-8 boundaries ------------------------------------------------------------------------ + +fn isContinuation(c: u8) bool { + return c & 0xC0 == 0x80; +} + +/// Start of the codepoint immediately before `off`, or 0. +pub fn prevCharStart(text: []const u8, off: usize) usize { + if (off == 0) return 0; + var i = @min(off, text.len) - 1; + while (i > 0 and isContinuation(text[i])) : (i -= 1) {} + return i; +} + +/// Start of the codepoint immediately after `off`, or `text.len`. +pub fn nextCharStart(text: []const u8, off: usize) usize { + if (off >= text.len) return text.len; + var i = off + 1; + while (i < text.len and isContinuation(text[i])) : (i += 1) {} + return i; +} + +/// Snap `off` back to the nearest codepoint boundary. +pub fn alignToChar(text: []const u8, off: usize) usize { + var i = @min(off, text.len); + while (i > 0 and i < text.len and isContinuation(text[i])) : (i -= 1) {} + return i; +} + +pub fn charLeft(text: []const u8, off: usize) usize { + return prevCharStart(text, off); +} + +pub fn charRight(text: []const u8, off: usize) usize { + return nextCharStart(text, off); +} + +// -- character classes ----------------------------------------------------------------------- + +const Class = enum { space, word, punct }; + +/// `_` is a word character and every non-ASCII byte is too — a code editor must treat +/// `snake_case` and identifiers with non-ASCII letters as single words. (dvui's +/// `word_breaks` list puts `_` in the *separator* set, which is wrong for source code.) +fn classOf(c: u8) Class { + if (c == ' ' or c == '\t' or c == '\n' or c == '\r') return .space; + if (c == '_' or c >= 0x80) return .word; + if (std.ascii.isAlphanumeric(c)) return .word; + return .punct; +} + +fn isUpper(c: u8) bool { + return c >= 'A' and c <= 'Z'; +} + +fn isLower(c: u8) bool { + return c >= 'a' and c <= 'z'; +} + +fn isWordByte(c: u8) bool { + return classOf(c) == .word; +} + +// -- word motion ----------------------------------------------------------------------------- + +/// Skip whitespace forward, then consume the run of like-classed characters. `foo| bar` goes +/// to `foo bar|` — the classic "end of word" behaviour, symmetric with `wordLeft`. +pub fn wordRight(text: []const u8, off: usize) usize { + var i = @min(off, text.len); + while (i < text.len and classOf(text[i]) == .space) i = nextCharStart(text, i); + if (i >= text.len) return text.len; + const cls = classOf(text[i]); + while (i < text.len and classOf(text[i]) == cls) i = nextCharStart(text, i); + return i; +} + +pub fn wordLeft(text: []const u8, off: usize) usize { + var i = @min(off, text.len); + while (i > 0 and classOf(text[prevCharStart(text, i)]) == .space) i = prevCharStart(text, i); + if (i == 0) return 0; + const cls = classOf(text[prevCharStart(text, i)]); + while (i > 0 and classOf(text[prevCharStart(text, i)]) == cls) i = prevCharStart(text, i); + return i; +} + +/// Like `wordRight`, but a word is further split into sub-words: `snake_case` breaks as +/// `snake` + `_case`, `camelCase` as `camel` + `Case`, and `HTTPServer` as `HTTP` + `Server` +/// (the acronym rule — a run of capitals belongs together except for the last one, which +/// starts the following word). +pub fn subwordRight(text: []const u8, off: usize) usize { + var i = @min(off, text.len); + while (i < text.len and classOf(text[i]) == .space) i = nextCharStart(text, i); + if (i >= text.len) return text.len; + + if (classOf(text[i]) == .punct) { + while (i < text.len and classOf(text[i]) == .punct) i = nextCharStart(text, i); + return i; + } + + // Leading underscores attach to the sub-word that follows them. + while (i < text.len and text[i] == '_') i += 1; + if (i >= text.len or !isWordByte(text[i])) return i; + + const started_upper = isUpper(text[i]); + i = nextCharStart(text, i); + if (started_upper) { + // Acronym run: keep consuming capitals, but stop before the capital that begins the + // next word (i.e. one immediately followed by a lowercase letter). + while (i < text.len and isUpper(text[i]) and + !(i + 1 < text.len and isLower(text[i + 1]))) : (i += 1) + {} + } + while (i < text.len and isWordByte(text[i]) and !isUpper(text[i]) and text[i] != '_') { + i = nextCharStart(text, i); + } + return i; +} + +/// The `subwordRight` segmentation walked backwards. Like `wordLeft` vs `wordRight`, the two +/// aren't offset-for-offset inverses across whitespace — forward stops at the *end* of a +/// sub-word, backward at its *start* — but within a word they agree exactly. +pub fn subwordLeft(text: []const u8, off: usize) usize { + var i = @min(off, text.len); + while (i > 0 and classOf(text[prevCharStart(text, i)]) == .space) i = prevCharStart(text, i); + if (i == 0) return 0; + + if (classOf(text[prevCharStart(text, i)]) == .punct) { + while (i > 0 and classOf(text[prevCharStart(text, i)]) == .punct) i = prevCharStart(text, i); + return i; + } + + const before = i; + while (i > 0) { + const p = prevCharStart(text, i); + if (!isWordByte(text[p]) or isUpper(text[p]) or text[p] == '_') break; + i = p; + } + const consumed_lower = i != before; + + if (i > 0 and isUpper(text[i - 1])) { + if (consumed_lower) { + // A single leading capital on a CamelWord we just walked back through. + i -= 1; + } else { + // No lowercase tail, so this is an acronym run — take all of it. + while (i > 0 and isUpper(text[i - 1])) i -= 1; + } + } + // Underscores attach to the sub-word that follows, so absorb them on the way back. + while (i > 0 and text[i - 1] == '_') i -= 1; + return i; +} + +// -- line geometry (local scans) --------------------------------------------------------------- + +pub fn lineStartOf(text: []const u8, off: usize) usize { + const i = @min(off, text.len); + return if (std.mem.lastIndexOfScalar(u8, text[0..i], '\n')) |nl| nl + 1 else 0; +} + +/// End of the line containing `off`, excluding the newline. +pub fn lineEndOf(text: []const u8, off: usize) usize { + const i = @min(off, text.len); + return std.mem.indexOfScalarPos(u8, text, i, '\n') orelse text.len; +} + +fn prevLineStart(text: []const u8, line_start: usize) ?usize { + if (line_start == 0) return null; + return lineStartOf(text, line_start - 1); +} + +fn nextLineStart(text: []const u8, line_start: usize) ?usize { + const nl = std.mem.indexOfScalarPos(u8, text, line_start, '\n') orelse return null; + return nl + 1; +} + +/// VSCode's Home: first non-whitespace character of the line, unless already there, in which +/// case column 0. The current `line_start` keybind jumps straight to column 0 unconditionally. +pub fn lineHomeSmart(text: []const u8, off: usize) usize { + const s = lineStartOf(text, off); + const e = lineEndOf(text, off); + var first = s; + while (first < e and (text[first] == ' ' or text[first] == '\t')) : (first += 1) {} + return if (off == first) s else first; +} + +// -- vertical motion --------------------------------------------------------------------------- + +pub const Vertical = struct { off: usize, goal_col: u32 }; + +/// Move `lines` rows (negative = up), holding `goal_col` if one is already established. +/// Running off either end lands on the document boundary, keeping the goal column — same as +/// VSCode, so Up at the first line goes to offset 0 rather than doing nothing. +pub fn verticalMove( + text: []const u8, + off: usize, + goal_col: ?u32, + lines: i32, + opts: Opts, +) Vertical { + const cur_start = lineStartOf(text, off); + const col = goal_col orelse LineIndex.colBetween(text, cur_start, off, opts.tab_size); + if (lines == 0) return .{ .off = off, .goal_col = col }; + + var target = cur_start; + var remaining: u32 = @abs(lines); + while (remaining > 0) : (remaining -= 1) { + const next = if (lines < 0) prevLineStart(text, target) else nextLineStart(text, target); + if (next) |n| { + target = n; + } else { + // Off the end of the document in this direction. + return .{ .off = if (lines < 0) 0 else text.len, .goal_col = col }; + } + } + + const target_end = lineEndOf(text, target); + return .{ + .off = LineIndex.offsetAtColIn(text, target, target_end, col, opts.tab_size), + .goal_col = col, + }; +} + +// -- the single entry point ---------------------------------------------------------------------- + +/// Resolve a motion immediately. This is what the key handler calls — one function, one +/// frame, no deferral, no dropped repeats. +pub fn move( + text: []const u8, + r: Range, + g: Granularity, + dir: Dir, + extend: bool, + opts: Opts, +) Range { + const head = @min(r.head, text.len); + + // Collapsing a selection: an unshifted Left/Right with something selected moves to the + // near edge rather than moving by one character. Matches VSCode and the previous + // behaviour at TextEntryWidget.zig:1561. + if (!extend and !r.isEmpty() and (g == .char or g == .word or g == .subword)) { + return .collapsed(switch (dir) { + .backward => r.start(), + .forward => r.end(), + }); + } + + switch (g) { + .char => return r.withHead(switch (dir) { + .backward => charLeft(text, head), + .forward => charRight(text, head), + }, extend), + + .word => return r.withHead(switch (dir) { + .backward => wordLeft(text, head), + .forward => wordRight(text, head), + }, extend), + + .subword => return r.withHead(switch (dir) { + .backward => subwordLeft(text, head), + .forward => subwordRight(text, head), + }, extend), + + .line_boundary => return r.withHead(switch (dir) { + .backward => lineHomeSmart(text, head), + .forward => lineEndOf(text, head), + }, extend), + + .document => return r.withHead(switch (dir) { + .backward => 0, + .forward => text.len, + }, extend), + + .line, .page => { + const rows: i32 = if (g == .line) 1 else @intCast(opts.page_lines); + const v = verticalMove(text, head, r.goal_col, rows * dir.sign(), opts); + return r.withHeadKeepGoal(v.off, extend, v.goal_col); + }, + } +} + +// -- tests -------------------------------------------------------------------------------------- + +const t = std.testing; + +test "char motion steps whole codepoints" { + const text = "aébc"; + try t.expectEqual(@as(usize, 1), charRight(text, 0)); + try t.expectEqual(@as(usize, 3), charRight(text, 1)); // over the 2-byte 'é' + try t.expectEqual(@as(usize, 1), charLeft(text, 3)); + try t.expectEqual(@as(usize, 0), charLeft(text, 0)); + try t.expectEqual(@as(usize, text.len), charRight(text, text.len)); +} + +test "word motion is symmetric" { + const text = "foo bar baz"; + try t.expectEqual(@as(usize, 3), wordRight(text, 0)); + try t.expectEqual(@as(usize, 7), wordRight(text, 3)); + try t.expectEqual(@as(usize, 12), wordRight(text, 7)); + try t.expectEqual(@as(usize, 12), wordRight(text, 12)); + + try t.expectEqual(@as(usize, 9), wordLeft(text, 12)); + try t.expectEqual(@as(usize, 4), wordLeft(text, 9)); + try t.expectEqual(@as(usize, 0), wordLeft(text, 4)); + try t.expectEqual(@as(usize, 0), wordLeft(text, 0)); +} + +test "underscore is a word character, punctuation is its own class" { + const text = "snake_case+other"; + try t.expectEqual(@as(usize, 10), wordRight(text, 0)); // whole snake_case + try t.expectEqual(@as(usize, 11), wordRight(text, 10)); // the '+' alone + try t.expectEqual(@as(usize, 16), wordRight(text, 11)); +} + +test "word motion crosses newlines" { + const text = "ab\n\ncd"; + try t.expectEqual(@as(usize, 2), wordRight(text, 0)); + try t.expectEqual(@as(usize, 6), wordRight(text, 2)); + try t.expectEqual(@as(usize, 0), wordLeft(text, 4)); +} + +test "subword stops at underscores and camel humps" { + // 0 5 11 16 21 + const text = "snake_case camelCase HTTPServer"; + try t.expectEqual(@as(usize, 5), subwordRight(text, 0)); // "snake" + try t.expectEqual(@as(usize, 10), subwordRight(text, 5)); // "_case" + try t.expectEqual(@as(usize, 16), subwordRight(text, 10)); // "camel" + try t.expectEqual(@as(usize, 20), subwordRight(text, 16)); // "Case" + try t.expectEqual(@as(usize, 25), subwordRight(text, 20)); // "HTTP" — acronym rule + try t.expectEqual(@as(usize, 31), subwordRight(text, 25)); // "Server" + + try t.expectEqual(@as(usize, 25), subwordLeft(text, 31)); // start of "Server" + try t.expectEqual(@as(usize, 21), subwordLeft(text, 25)); // start of "HTTP" + try t.expectEqual(@as(usize, 16), subwordLeft(text, 21)); // start of "Case" + try t.expectEqual(@as(usize, 11), subwordLeft(text, 16)); // start of "camel" + try t.expectEqual(@as(usize, 5), subwordLeft(text, 11)); // start of "_case" + try t.expectEqual(@as(usize, 0), subwordLeft(text, 5)); // start of "snake" +} + +test "subword never crosses a word boundary that word motion wouldn't" { + const text = "alpha_beta gammaDelta"; + var i: usize = 0; + var guard: usize = 0; + while (i < text.len and guard < 50) : (guard += 1) { + const next = subwordRight(text, i); + try t.expect(next > i); // always makes progress + try t.expect(next <= wordRight(text, i) or classOf(text[i]) == .space); + i = next; + } + try t.expectEqual(@as(usize, text.len), i); +} + +test "subword handles punctuation and underscore runs" { + try t.expectEqual(@as(usize, 2), subwordRight("a+b", 1)); // the '+' alone + try t.expectEqual(@as(usize, 4), subwordRight("a__b", 1)); // "__b" + try t.expectEqual(@as(usize, 1), subwordLeft("a__b", 4)); // symmetric within the word +} + +test "smart home toggles between first non-blank and column zero" { + const text = " indented"; + try t.expectEqual(@as(usize, 4), lineHomeSmart(text, 8)); // from inside → first non-blank + try t.expectEqual(@as(usize, 0), lineHomeSmart(text, 4)); // already there → column 0 + try t.expectEqual(@as(usize, 4), lineHomeSmart(text, 0)); // and back +} + +test "line end stops before the newline" { + const text = "abc\ndef"; + try t.expectEqual(@as(usize, 3), lineEndOf(text, 1)); + try t.expectEqual(@as(usize, 7), lineEndOf(text, 5)); +} + +test "vertical motion keeps the goal column across a short line" { + // Columns: 0123456789 + const text = "long line\nab\nanother long\n"; + const opts: Opts = .{}; + + // Start at column 8 on line 0. + const d1 = verticalMove(text, 8, null, 1, opts); + try t.expectEqual(@as(u32, 8), d1.goal_col); + try t.expectEqual(@as(usize, 12), d1.off); // clamped to end of "ab" + + // Down again, carrying the goal column — must land back at column 8, not column 2. + const d2 = verticalMove(text, d1.off, d1.goal_col, 1, opts); + try t.expectEqual(@as(usize, 13 + 8), d2.off); + try t.expectEqual(@as(u32, 8), d2.goal_col); +} + +test "vertical motion past the ends clamps to the document" { + const text = "abc\ndef"; + try t.expectEqual(@as(usize, 0), verticalMove(text, 1, null, -1, .{}).off); + try t.expectEqual(@as(usize, text.len), verticalMove(text, 5, null, 1, .{}).off); +} + +test "vertical motion respects tab stops" { + const text = "\t\tx\nabcdefghi"; + const v = verticalMove(text, 2, null, 1, .{ .tab_size = 4 }); + try t.expectEqual(@as(u32, 8), v.goal_col); + try t.expectEqual(@as(usize, 4 + 8), v.off); +} + +test "move collapses a selection instead of stepping" { + const text = "hello world"; + const sel: Range = .init(2, 7); + try t.expectEqual(@as(usize, 2), move(text, sel, .char, .backward, false, .{}).head); + try t.expectEqual(@as(usize, 7), move(text, sel, .char, .forward, false, .{}).head); + // ...but shift-extends normally. + try t.expectEqual(@as(usize, 8), move(text, sel, .char, .forward, true, .{}).head); + try t.expectEqual(@as(usize, 2), move(text, sel, .char, .forward, true, .{}).anchor); +} + +test "move clears goal_col on horizontal motion and keeps it on vertical" { + const text = "abcdef\nghijkl"; + const r: Range = .{ .anchor = 3, .head = 3, .goal_col = 3 }; + try t.expectEqual(@as(?u32, null), move(text, r, .char, .forward, false, .{}).goal_col); + try t.expectEqual(@as(?u32, 3), move(text, r, .line, .forward, false, .{}).goal_col); +} + +// Repeated motion must be exactly N single motions — the property the old single-slot +// `sel_move` union broke when two key events landed in one frame. +test "repeated motions compose" { + const text = "one two three four"; + var r: Range = .collapsed(0); + for (0..3) |_| r = move(text, r, .word, .forward, false, .{}); + try t.expectEqual(@as(usize, 13), r.head); + + var back: Range = .collapsed(text.len); + for (0..3) |_| back = move(text, back, .word, .backward, false, .{}); + try t.expectEqual(@as(usize, 4), back.head); +} + +test "motion never lands mid-codepoint" { + const text = "aéb→c\nxé\n→→"; + var prng: std.Random.DefaultPrng = .init(0xc0ffee); + const rand = prng.random(); + + const grans = [_]Granularity{ .char, .word, .subword, .line_boundary, .line, .page, .document }; + var iter: usize = 0; + while (iter < 2000) : (iter += 1) { + var r: Range = .collapsed(rand.uintAtMost(usize, text.len)); + r.head = alignToChar(text, r.head); + r.anchor = r.head; + for (0..4) |_| { + const g = grans[rand.uintLessThan(usize, grans.len)]; + const dir: Dir = if (rand.boolean()) .forward else .backward; + r = move(text, r, g, dir, rand.boolean(), .{}); + try t.expect(r.head <= text.len); + try t.expect(r.anchor <= text.len); + if (r.head < text.len) try t.expect(!isContinuation(text[r.head])); + if (r.anchor < text.len) try t.expect(!isContinuation(text[r.anchor])); + } + } +} diff --git a/src/plugins/text/src/textcore/pairs.zig b/src/plugins/text/src/textcore/pairs.zig new file mode 100644 index 00000000..97041a77 --- /dev/null +++ b/src/plugins/text/src/textcore/pairs.zig @@ -0,0 +1,452 @@ +//! Auto-closing bracket/quote decisions — the "what should this keystroke do" half of +//! VSCode's `editor.autoClosingBrackets`/`autoSurround`/`autoClosingDelete`, resolved against +//! the byte buffer alone so it is testable without a window (see `textcore.zig`'s invariant). +//! Applying the result (mutating the buffer and the selection) stays in the widget. + +const std = @import("std"); + +/// One auto-closed pair. Quotes need slightly different rules than brackets — their opener and +/// closer are the same byte, so lookahead alone can't tell "closing the string I'm in" from +/// "starting a new one" — hence `is_quote`. +pub const Pair = struct { open: u8, close: u8, is_quote: bool }; + +/// Deliberately language-agnostic: the text widget has no notion of which language a document +/// is in, and these six pairs are ones every language it is likely to open agrees on. A +/// per-language table would belong on `sdk.language.LanguageSupport`, not here. +pub const all = [_]Pair{ + .{ .open = '{', .close = '}', .is_quote = false }, + .{ .open = '(', .close = ')', .is_quote = false }, + .{ .open = '[', .close = ']', .is_quote = false }, + .{ .open = '"', .close = '"', .is_quote = true }, + .{ .open = '\'', .close = '\'', .is_quote = true }, + .{ .open = '`', .close = '`', .is_quote = true }, +}; + +pub fn forOpen(c: u8) ?Pair { + for (all) |p| { + if (p.open == c) return p; + } + return null; +} + +pub fn forClose(c: u8) ?Pair { + for (all) |p| { + if (p.close == c) return p; + } + return null; +} + +/// Identifier byte, by the same alphanumeric/underscore convention the LSP client's prefix +/// derivation and the completion list's typed-prefix highlight already use. +pub fn isWordByte(c: u8) bool { + return std.ascii.isAlphanumeric(c) or c == '_'; +} + +/// What typing one character should do. +pub const Action = union(enum) { + /// Insert the character normally (including replacing any selection) — the default. + insert, + /// Insert `open` and `close` at the cursor, leaving the cursor between them. + close_pair: Pair, + /// A closer identical to the one typed already sits at the cursor: move past it instead of + /// inserting a second one. + step_over, + /// Wrap the active selection in the pair rather than replacing it, keeping it selected. + surround: Pair, +}; + +/// Decides what typing `ch` does with the caret at `[sel_start, sel_end)` in `text`. +/// +/// Step-over is deliberately *untracked*: it fires on any matching closer directly after the +/// cursor, rather than only on closers the editor itself auto-inserted. Tracking would mean +/// persisting byte offsets across frames (the widget struct is rebuilt every frame) and +/// shifting them on every edit, for a difference that only shows up when someone deliberately +/// types a closer immediately before an unrelated one — the same tradeoff Sublime Text makes. +pub fn onTyped(text: []const u8, sel_start: usize, sel_end: usize, ch: u8) Action { + if (sel_end > text.len or sel_start > sel_end) return .insert; + + if (sel_start != sel_end) { + const p = forOpen(ch) orelse return .insert; + // Wrapping several lines in quotes is nearly always a mistake (and leaves an + // unterminated string in every language this editor highlights); brackets over multiple + // lines are normal. + if (p.is_quote and std.mem.indexOfScalar(u8, text[sel_start..sel_end], '\n') != null) return .insert; + return .{ .surround = p }; + } + + const cursor = sel_start; + const prev: ?u8 = if (cursor > 0) text[cursor - 1] else null; + const next: ?u8 = if (cursor < text.len) text[cursor] else null; + + // Checked before the opener path so a quote closes the string it's inside rather than + // opening a new one. + if (next != null and next.? == ch and forClose(ch) != null) return .step_over; + + const p = forOpen(ch) orelse return .insert; + + // Don't auto-close directly before a word: `(` typed just before `foo` is far more likely + // to be wrapping `foo` in parens than opening an empty pair, and an unwanted `)` there is + // more annoying to clean up than a missing one is to type. + if (next) |n| { + if (isWordByte(n)) return .insert; + } + if (p.is_quote) { + // An apostrophe after a word character is punctuation, not a string opener (`don't`), + // and one after a backslash is escaped. + if (prev) |pv| { + if (isWordByte(pv) or pv == '\\') return .insert; + } + } + return .{ .close_pair = p }; +} + +/// One bracket inside a nesting-colour pass: the byte of the glyph, and the palette index for +/// it (indent + kind offset). See `nestMarks`. +pub const NestMark = struct { + byte: usize, + /// Palette index: line indent level plus a per-kind stride so `{`, `(`, and `[` at the + /// same indent don't share a colour. Matching pairs of the same kind still match. + depth: u8, +}; + +/// Leading-whitespace indent level of the line containing `pos`, measured in `tab_size`-wide +/// columns (spaces count 1, tabs jump to the next stop). Trailing content on the line is +/// ignored — ` if (x) {` and a lone `}` at the same indent both report level 1. +pub fn indentLevelAt(text: []const u8, pos: usize, tab_size: u8) u8 { + const ts: usize = if (tab_size == 0) 4 else tab_size; + const line_start = if (std.mem.lastIndexOfScalar(u8, text[0..@min(pos, text.len)], '\n')) |nl| nl + 1 else 0; + var col: usize = 0; + var i = line_start; + while (i < text.len) : (i += 1) { + switch (text[i]) { + ' ' => col += 1, + '\t' => col = (col / ts + 1) * ts, + else => break, + } + } + return @truncate(col / ts); +} + +/// Per-kind offset into the palette. Spaced around a 7-colour wheel so braces / parens / +/// square brackets at the same indent land on visually distinct slots (and stay distinct +/// after `palette.bracket`'s scramble). Open and close of a kind share the offset, so a +/// matching pair still paints the same colour. +fn kindOffset(c: u8) u8 { + return switch (c) { + '{', '}' => 0, + '(', ')' => 3, + '[', ']' => 5, + else => 0, + }; +} + +/// Rainbow-bracket scan: every non-quote `{`, `(`, `[` and its closer in +/// `[range_start, range_end)` gets a mark whose `depth` is **indent level + kind offset**. +/// Matching pairs of the same kind sit at the same indent in well-formatted code, so they +/// share a colour; different kinds at that indent take different palette slots; one indent +/// deeper advances every kind together. +/// +/// Writes ascending by `byte` into `out` and returns how many were written. Stops appending +/// when `out` is full rather than allocating — callers size for a screenful of brackets; a +/// full buffer just leaves later ones uncolored for that frame, never incorrect. +/// +/// Quote characters themselves are skipped (which `"` closes which needs a lexer). Brackets +/// *inside* strings still get coloured — same lexical tradeoff as `matchAt`. +pub fn nestMarks(text: []const u8, range_start: usize, range_end: usize, tab_size: u8, out: []NestMark) usize { + if (range_start >= range_end or range_start > text.len) return 0; + const end = @min(range_end, text.len); + + var n: usize = 0; + var i = range_start; + while (i < end and n < out.len) : (i += 1) { + const c = text[i]; + const is_bracket = if (forOpen(c)) |p| !p.is_quote else if (forClose(c)) |p| !p.is_quote else false; + if (!is_bracket) continue; + out[n] = .{ .byte = i, .depth = indentLevelAt(text, i, tab_size) +% kindOffset(c) }; + n += 1; + } + return n; +} + +/// How far `matchAt` will scan in each direction before giving up. A match further away than +/// this is off-screen by orders of magnitude, so finding it would change nothing on screen — +/// but scanning for it runs on every frame of every open editor, including 60MB files where an +/// unmatched brace at the top would otherwise walk the whole buffer every frame. +pub const match_scan_budget: usize = 200_000; + +/// Byte offsets of the bracket next to `cursor` and the one that closes/opens it, ascending, or +/// null when the caret isn't next to a bracket (or its partner isn't found within +/// `match_scan_budget`). Drives match highlighting. +/// +/// Brackets only — quotes are excluded because their two halves are the same byte, so deciding +/// which `"` closes which needs to know whether the caret is inside a string, which needs a +/// lexer. For the same reason this scan is purely lexical: a bracket inside a string or comment +/// (`const s = "{";`) counts toward nesting like any other, so it can pair up with the wrong +/// partner. Fixing that means asking tree-sitter which node each candidate is in — worth doing +/// if it ever grates, but it costs a per-frame tree query and only pays off in code that puts +/// unbalanced brackets in string literals. +pub fn matchAt(text: []const u8, cursor: usize) ?[2]usize { + if (cursor > text.len) return null; + // The bracket *before* the caret wins: with the caret at `foo()|`, VSCode highlights the + // `)` just typed rather than looking past it. + if (cursor > 0) { + if (matchFrom(text, cursor - 1)) |m| return m; + } + if (cursor < text.len) { + if (matchFrom(text, cursor)) |m| return m; + } + return null; +} + +/// The pair for the bracket at `i`, ascending, or null when `i` isn't a bracket or has no +/// partner in budget. Nesting is counted per bracket kind, so a stray `(` inside a `{}` block +/// doesn't throw the brace match off. +fn matchFrom(text: []const u8, i: usize) ?[2]usize { + const c = text[i]; + if (forOpen(c)) |p| { + if (p.is_quote) return null; + var depth: usize = 1; + var j = i + 1; + const limit = @min(text.len, i + 1 +| match_scan_budget); + while (j < limit) : (j += 1) { + if (text[j] == p.open) { + depth += 1; + } else if (text[j] == p.close) { + depth -= 1; + if (depth == 0) return .{ i, j }; + } + } + return null; + } + if (forClose(c)) |p| { + if (p.is_quote) return null; + var depth: usize = 1; + var j = i; + const limit = i -| match_scan_budget; + while (j > limit) { + j -= 1; + if (text[j] == p.close) { + depth += 1; + } else if (text[j] == p.open) { + depth -= 1; + if (depth == 0) return .{ j, i }; + } + } + return null; + } + return null; +} + +/// True when `cursor` sits directly between an empty pair (`{|}`), so Backspace should take out +/// both halves — VSCode's `editor.autoClosingDelete`. Deleting the `{` of `{|}` and leaving the +/// orphaned `}` behind is never what was meant. +pub fn deletesPair(text: []const u8, cursor: usize) bool { + if (cursor == 0 or cursor >= text.len) return false; + const p = forOpen(text[cursor - 1]) orelse return false; + return text[cursor] == p.close; +} + +// -- tests -------------------------------------------------------------------------------------- + +const testing = std.testing; + +/// `text` with `|` marking the caret (or `[`…`]` a selection) is unreadable next to real +/// brackets, which is exactly what these tests are full of — so positions are passed directly. +fn expectAction(text: []const u8, start: usize, end: usize, ch: u8, want: std.meta.Tag(Action)) !void { + try testing.expectEqual(want, std.meta.activeTag(onTyped(text, start, end, ch))); +} + +test "opener at end of buffer auto-closes" { + try expectAction("pub const Test = struct ", 24, 24, '{', .close_pair); +} + +test "opener before whitespace or a closer auto-closes" { + try expectAction("f() ", 2, 2, '(', .close_pair); // before ')' + try expectAction("a b", 2, 2, '[', .close_pair); // before ' ' + try expectAction("x\ny", 1, 1, '{', .close_pair); // before '\n' +} + +test "opener directly before a word does not auto-close" { + try expectAction("foo", 0, 0, '(', .insert); + try expectAction("call foo", 5, 5, '[', .insert); + // Still auto-closes when the word is behind, not ahead. + try expectAction("foo", 3, 3, '(', .close_pair); +} + +test "typing a closer steps over the one already there" { + try expectAction("()", 1, 1, ')', .step_over); + try expectAction("{}", 1, 1, '}', .step_over); + // Only for closers — an opener sitting ahead is not stepped over, it auto-closes as usual. + try expectAction("((", 1, 1, '(', .close_pair); +} + +test "quote closes the string it is in rather than opening a new one" { + try expectAction("\"\"", 1, 1, '"', .step_over); + try expectAction("x = ", 4, 4, '"', .close_pair); +} + +test "apostrophe after a word or a backslash is not a string opener" { + try expectAction("don", 3, 3, '\'', .insert); + try expectAction("'\\", 2, 2, '\'', .insert); +} + +test "opener with a selection surrounds it" { + try expectAction("wrap me", 0, 4, '(', .surround); + // A closer never surrounds — it replaces, like any other character. + try expectAction("wrap me", 0, 4, ')', .insert); + // Quotes decline to wrap across lines; brackets don't. + try expectAction("a\nb", 0, 3, '"', .insert); + try expectAction("a\nb", 0, 3, '{', .surround); +} + +test "out of range selection falls through to a plain insert" { + try expectAction("ab", 1, 9, '(', .insert); + try expectAction("ab", 2, 1, '(', .insert); +} + +test "matchAt pairs the bracket the caret is next to" { + const text = "fn f() {}"; + // Caret after the `(`: matches forward to `)`. + try testing.expectEqual([2]usize{ 4, 5 }, matchAt(text, 5).?); + // Caret after the `)`: the bracket *before* the caret wins. + try testing.expectEqual([2]usize{ 4, 5 }, matchAt(text, 6).?); + // Caret before the `{` with a non-bracket behind it: matches the one ahead. + try testing.expectEqual([2]usize{ 7, 8 }, matchAt(text, 7).?); +} + +test "matchAt counts nesting" { + const text = "{ a { b } c }"; + try testing.expectEqual([2]usize{ 0, 12 }, matchAt(text, 1).?); + try testing.expectEqual([2]usize{ 4, 8 }, matchAt(text, 5).?); + // Backward from the outer closer. + try testing.expectEqual([2]usize{ 0, 12 }, matchAt(text, 13).?); +} + +test "matchAt counts each bracket kind independently" { + // The stray `(` must not throw off the brace match. + const text = "{ ( }"; + try testing.expectEqual([2]usize{ 0, 4 }, matchAt(text, 1).?); +} + +test "matchAt finds nothing when there is nothing to match" { + try testing.expectEqual(@as(?[2]usize, null), matchAt("plain text", 3)); + try testing.expectEqual(@as(?[2]usize, null), matchAt("{ unclosed", 1)); + try testing.expectEqual(@as(?[2]usize, null), matchAt("unopened }", 10)); + try testing.expectEqual(@as(?[2]usize, null), matchAt("", 0)); + try testing.expectEqual(@as(?[2]usize, null), matchAt("{}", 99)); + // Quotes are excluded — which `"` closes which needs a lexer, not a scan. + try testing.expectEqual(@as(?[2]usize, null), matchAt("\"\"", 1)); +} + +test "matchAt gives up past the scan budget" { + const gpa = testing.allocator; + const far = match_scan_budget + 16; + const text = try gpa.alloc(u8, far + 2); + defer gpa.free(text); + @memset(text, ' '); + text[0] = '{'; + text[far + 1] = '}'; + try testing.expectEqual(@as(?[2]usize, null), matchAt(text, 1)); + + // The same brace pair just inside the budget is still found. + text[far + 1] = ' '; + text[match_scan_budget] = '}'; + try testing.expectEqual([2]usize{ 0, match_scan_budget }, matchAt(text, 1).?); +} + +test "indentLevelAt counts spaces and tabs in tab_size units" { + try testing.expectEqual(@as(u8, 0), indentLevelAt("foo", 0, 4)); + try testing.expectEqual(@as(u8, 1), indentLevelAt(" foo", 4, 4)); + try testing.expectEqual(@as(u8, 2), indentLevelAt(" foo", 8, 4)); + try testing.expectEqual(@as(u8, 1), indentLevelAt("\tfoo", 1, 4)); + try testing.expectEqual(@as(u8, 2), indentLevelAt("\t\tfoo", 2, 4)); + // Position mid-line still reads that line's leading whitespace. + try testing.expectEqual(@as(u8, 1), indentLevelAt(" if (x) {\n", 10, 4)); +} + +test "nestMarks colours by indent level so matching pairs share a colour" { + const text = + \\{ + \\ { + \\ { + \\ } + \\ } + \\} + ; + var out: [16]NestMark = undefined; + const n = nestMarks(text, 0, text.len, 4, &out); + try testing.expectEqual(@as(usize, 6), n); + // Braces use kind offset 0 — openers at indent 0, 1, 2; closers reverse at 2, 1, 0. + try testing.expectEqual(@as(usize, 0), out[0].byte); + try testing.expectEqual(@as(u8, 0), out[0].depth); + try testing.expectEqual(@as(u8, 1), out[1].depth); + try testing.expectEqual(@as(u8, 2), out[2].depth); + try testing.expectEqual(@as(u8, 2), out[3].depth); + try testing.expectEqual(@as(u8, 1), out[4].depth); + try testing.expectEqual(@as(u8, 0), out[5].depth); +} + +test "nestMarks different kinds at the same indent get different colours" { + const text = "{ ( [ ] ) }"; + var out: [8]NestMark = undefined; + const n = nestMarks(text, 0, text.len, 4, &out); + try testing.expectEqual(@as(usize, 6), n); + // Same indent, three kind offsets — openers disagree, each closer matches its opener. + try testing.expect(out[0].depth != out[1].depth); // { vs ( + try testing.expect(out[1].depth != out[2].depth); // ( vs [ + try testing.expect(out[0].depth != out[2].depth); // { vs [ + try testing.expectEqual(out[0].depth, out[5].depth); // { } + try testing.expectEqual(out[1].depth, out[4].depth); // ( ) + try testing.expectEqual(out[2].depth, out[3].depth); // [ ] +} + +test "nestMarks paren and brace at the same indent disagree" { + const text = + \\fn f( + \\ x: u32, + \\) void { + \\ _ = x; + \\} + ; + var out: [8]NestMark = undefined; + const n = nestMarks(text, 0, text.len, 4, &out); + try testing.expectEqual(@as(usize, 4), n); // ( ) { } + try testing.expectEqual(out[0].depth, out[1].depth); // ( ) + try testing.expectEqual(out[2].depth, out[3].depth); // { } + try testing.expect(out[0].depth != out[2].depth); // ( vs { +} + +test "nestMarks mid-range uses each line's own indent" { + const text = + \\{ + \\ { } + \\} + ; + // Inner `{ }` sit on the indented middle line — range covering only that line. + const mid = std.mem.indexOf(u8, text, "{ }").?; + var out: [4]NestMark = undefined; + const n = nestMarks(text, mid, mid + 3, 4, &out); + try testing.expectEqual(@as(usize, 2), n); + try testing.expectEqual(@as(u8, 1), out[0].depth); + try testing.expectEqual(@as(u8, 1), out[1].depth); +} + +test "nestMarks skips quote characters themselves" { + var out: [8]NestMark = undefined; + const n = nestMarks("\"{}\"", 0, 4, 4, &out); + // The braces inside the string are still lexical brackets (same tradeoff as `matchAt`); + // the quote characters themselves are not coloured. + try testing.expectEqual(@as(usize, 2), n); + try testing.expectEqual(@as(usize, 1), out[0].byte); + try testing.expectEqual(@as(usize, 2), out[1].byte); +} + +test "deletesPair only between an empty pair" { + try testing.expect(deletesPair("{}", 1)); + try testing.expect(deletesPair("f('')", 3)); + try testing.expect(!deletesPair("{a}", 1)); // not empty + try testing.expect(!deletesPair("{}", 0)); // not between + try testing.expect(!deletesPair("{}", 2)); // past the closer + try testing.expect(!deletesPair("{)", 1)); // mismatched + try testing.expect(!deletesPair("", 0)); +} diff --git a/src/plugins/text/src/textcore/textcore.zig b/src/plugins/text/src/textcore/textcore.zig new file mode 100644 index 00000000..f5cb46ab --- /dev/null +++ b/src/plugins/text/src/textcore/textcore.zig @@ -0,0 +1,41 @@ +//! Headless text-editing model for the `text` plugin. +//! +//! **Invariant: nothing under `textcore/` may import `dvui`.** Two reasons: +//! +//! 1. Editing and motion stop depending on layout state that resolves a frame late. dvui's +//! `TextLayoutWidget.Selection` becomes a *projection* written before layout each frame, +//! never the source of truth (`Selection.toDvui`). The one place layout legitimately owns +//! a change is mouse hit-testing, which is lifted back via `Selection.fromDvui`. +//! 2. It keeps this module pure enough to sit in the `addTest`-per-pure-root list in +//! `build/app.zig`, so the logic is unit-tested without a window or a GPU. +//! +//! This file is the single test root — Zig collects `test` blocks only from files in an +//! artifact's root module, and relative `@import`s (unlike named ones) are part of that same +//! module, so one `addTest` rooted here covers every file below. + +const std = @import("std"); + +pub const Range = @import("Range.zig"); +pub const Selection = @import("Selection.zig"); +pub const LineIndex = @import("LineIndex.zig"); +pub const movement = @import("movement.zig"); +pub const Transaction = @import("Transaction.zig"); +pub const History = @import("History.zig"); +pub const pairs = @import("pairs.zig"); +pub const completion = @import("completion.zig"); + +pub const Granularity = movement.Granularity; +pub const Dir = movement.Dir; +pub const MoveOpts = movement.Opts; + +test { + std.testing.refAllDecls(@This()); + _ = Range; + _ = Selection; + _ = LineIndex; + _ = movement; + _ = Transaction; + _ = History; + _ = pairs; + _ = completion; +} diff --git a/src/plugins/text/src/widgets/TextEntryWidget.zig b/src/plugins/text/src/widgets/TextEntryWidget.zig index f48b16d6..af020d6c 100644 --- a/src/plugins/text/src/widgets/TextEntryWidget.zig +++ b/src/plugins/text/src/widgets/TextEntryWidget.zig @@ -5,6 +5,8 @@ const std = @import("std"); const dvui = @import("dvui"); const sdk = @import("fizzy_sdk"); const perf = @import("core").perf; +const palette = @import("core").palette; +const tc = @import("../textcore/textcore.zig"); pub const HighlightStyle = sdk.language.HighlightStyle; pub const TreeSitterHighlight = sdk.language.TreeSitterHighlight; @@ -232,12 +234,16 @@ pub const TreeSitterParser = if (dvui.useTreeSitter) struct { /// e.g. typing over a selection replaces it); `endEdit` commits it. Fired at the same points /// as the pre-existing `textChangedRemoved`/`textChangedAdded` calls, so `bytes` is always /// read before it's overwritten by the mutation that follows. +/// Undo/redo capture hook. `beginEdit`/`endEdit` carry the selection either side of the +/// mutation: `beginEdit`'s is what undo restores (so undoing "type over a selection" +/// re-selects the restored text), and it also tells the history whether a removal was a +/// backspace or a forward delete, which decides undo grouping. pub const EditNotify = struct { ctx: *anyopaque, - beginEdit: *const fn (ctx: *anyopaque) void, + beginEdit: *const fn (ctx: *anyopaque, sel_before: tc.Range) void, noteRemoved: *const fn (ctx: *anyopaque, pos: usize, bytes: []const u8) void, noteInserted: *const fn (ctx: *anyopaque, pos: usize, bytes: []const u8) void, - endEdit: *const fn (ctx: *anyopaque) void, + endEdit: *const fn (ctx: *anyopaque, sel_after: tc.Range) void, }; pub const InitOptions = struct { @@ -321,6 +327,22 @@ pub const InitOptions = struct { /// reason as `tab_inserts_indent`; the text plugin turns this on unconditionally since it's /// baseline code-editing behavior, not a user preference. auto_indent_newline: bool = false, + /// When true (and `multiline`), typing one of `auto_pairs`' openers also inserts its closer + /// and leaves the cursor between the two, typing a closer that's already sitting right after + /// the cursor steps over it instead of inserting a second one, Backspace between an empty + /// pair deletes both halves, and typing an opener with a selection active wraps the selection + /// instead of replacing it — VSCode's `editor.autoClosingBrackets`/`autoSurround`. Off by + /// default for the same reusability reason as `tab_inserts_indent`. + auto_close_pairs: bool = false, + /// When true, the bracket next to the caret and its partner are drawn with a highlight + /// behind them (VSCode's `editor.matchBrackets`). Recomputed every frame from the buffer — + /// see `tc.pairs.matchAt`, including what it deliberately doesn't handle. + highlight_matching_bracket: bool = false, + /// When true, every `{`/`(`/`[` (and closer) is tinted from `core.palette.bracket` by + /// **indent level + kind offset** — matching pairs of one kind share a colour, but a + /// paren and a brace at the same indent do not. Off by default for the same reusability + /// reason as `tab_inserts_indent`. + rainbow_brackets: bool = false, }; /// Byte span of a tree-sitter token, used by `hovered_span` below. @@ -328,9 +350,10 @@ pub const Span = struct { start: usize, end: usize }; /// One completion candidate, as shown in `CompletionState.items` — either owned by /// `Document.completion_items` (which both `TextEditor.zig` and this widget then just borrow -/// a slice of for the frame) or, in principle, any other caller. `text` is already trimmed to -/// a pure suffix (never re-shows characters already typed before the cursor) — see -/// `sdk.language.CompletionItem.insert_text`. `text` must not contain '\n': ghost text is +/// a slice of for the frame) or, in principle, any other caller. `text` is the **full** +/// replacement for `[replace_start, replace_end)`, not a suffix of what's already typed — see +/// `sdk.language.CompletionItem.insert_text`, and `completionGhost` for how the ghost-text +/// preview derives a suffix from it (and declines to show one when it can't). `text` must not contain '\n': ghost text is /// spliced in by rewinding `TextLayoutWidget.bytes_seen`, which only accounts for a /// single-line visual advance; multi-line snippets aren't supported by this SDK's /// intentionally minimal `CompletionItem` shape in the first place. `label` is the full, @@ -351,8 +374,9 @@ pub const CompletionCandidate = struct { documentation: []const u8, }; -/// The completion list currently being shown: `items[selected]`'s text is spliced into -/// `draw()` as ghost text right after `anchor`, and the same list + selection drive the +/// The completion list currently being shown: the untyped remainder of `items[selected]`'s text +/// is spliced into `draw()` as ghost text right after `anchor` (when there is one — see +/// `completionGhost`), and the same list + selection drive the /// dropdown rendered by `TextEditor.drawCompletionList`. Up/Down (`processEvents`) change /// `selected`; Tab/Enter accept `items[selected]`; Escape clears this entirely. pub const CompletionState = struct { @@ -438,6 +462,14 @@ signature_hint: ?[]const u8 = null, /// range and would otherwise splice the ghost text twice (visibly duplicating it, e.g. typing /// `s` and seeing `stdtd` instead of `std`). Reset at the top of `draw()`. ghost_text_emitted: bool = false, +/// Byte offsets (ascending) of the matched bracket pair to highlight this frame, or null when +/// the caret isn't next to one / `highlight_matching_bracket` is off. Computed once at the top +/// of `draw()` and consumed by `emitChunk`, rather than per chunk — the scan is over the whole +/// buffer, and every chunk would otherwise redo it. +bracket_match: ?[2]usize = null, +/// Nesting-depth marks for rainbow brackets this frame (sorted by byte). Points into a stack +/// buffer owned by `draw()` for the duration of the call; empty when `rainbow_brackets` is off. +bracket_nests: []const tc.pairs.NestMark = &.{}, init_opts: InitOptions, text: []u8, @@ -702,8 +734,30 @@ pub fn draw(self: *TextEntryWidget) void { dvui.dataRemove(null, self.data().id, "_hovered_span"); }; self.ghost_text_emitted = false; + self.bracket_match = blk: { + if (!self.init_opts.highlight_matching_bracket) break :blk null; + // Only for the focused editor, and only with a collapsed caret: with a split open, two + // editors both marking "their" bracket pair reads as though both are live, and while a + // selection is up the selection highlight is what the eye is tracking anyway. + if (self.data().id != dvui.focusedWidgetId()) break :blk null; + const sel = self.textLayout.selectionGet(self.len); + if (!sel.empty()) break :blk null; + break :blk tc.pairs.matchAt(self.text[0..self.len], sel.cursor); + }; self.drawBeforeText(); + // Rainbow nesting must run *after* `drawBeforeText` so `cache_layout_bytes` (and therefore + // `highlightByteRange`) is valid — otherwise the first frame with `cache_layout` on would + // colour the whole document instead of the viewport. + var nest_buf: [512]tc.pairs.NestMark = undefined; + self.bracket_nests = &.{}; + if (self.init_opts.rainbow_brackets) { + const range = self.highlightByteRange() orelse ByteRange{ .start = 0, .end = self.len }; + const tab_size: u8 = if (self.init_opts.tab_size == 0) 4 else self.init_opts.tab_size; + const n = tc.pairs.nestMarks(self.text[0..self.len], range.start, range.end, tab_size, &nest_buf); + self.bracket_nests = nest_buf[0..n]; + } + if (self.len == 0) { if (self.init_opts.placeholder) |placeholder| { if (self.data().accesskit_node()) |ak_node| { @@ -889,23 +943,13 @@ pub fn draw(self: *TextEntryWidget) void { var iter = ts_parser.queryCursorCaptureIterator(qc.?, self.text); iter.debug = ts.log_captures; - // Restrict the capture walk to the visible byte range instead of the whole document - // — this is the dominant per-frame cost (see perf log above). `drawBeforeText` - // already computed this exactly, from real measured line heights (not an assumed - // bytes-per-pixel density): `cache_layout_bytes` — set via `TextLayoutWidget. - // bytesNeeded`, which also extends the range to cover the cursor or an active - // selection if either is off-screen. Null on the first frame (before - // `byte_heights` exists yet), in which case we don't restrict — same as any frame - // `cache_layout` isn't active. Text outside the queried range still renders via the - // gap/leftover chunks below — it's just uncolored until scrolled into (padded) range. - if (self.textLayout.cache_layout_bytes) |clb| { - // Small pad for the *next* frame's scroll movement before this data is - // refreshed — much smaller than an estimate-based pad needs, since the base - // range itself is exact rather than approximate. - const byte_pad: usize = @min(self.len / 20, 8_000); - const byte_start = clb.start -| byte_pad; - const byte_end = @min(self.len, clb.end + byte_pad); - iter.setByteRange(byte_start, byte_end); + // 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); } while (true) { //const capture_start = perfBegin(); @@ -918,7 +962,7 @@ pub fn draw(self: *TextEntryWidget) void { if (start < nstart) { // render non highlighted text up to this node //const shape_start = perfBegin(); - self.emitChunk(start, self.text[start..nstart], .{}, false); + 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 @@ -937,7 +981,7 @@ pub fn draw(self: *TextEntryWidget) void { } //const shape_start = perfBegin(); - self.emitChunk(nstart, self.text[nstart..nend], opts, true); + self.emitChunk(nstart, self.text[nstart..nend], opts, true, captureAllowsRainbow(capture_name)); //perfAccumShape(shape_start); start = nend; @@ -946,7 +990,7 @@ pub fn draw(self: *TextEntryWidget) void { if (start < self.len) { // any leftover non highlighted text //const shape_start = perfBegin(); - self.emitChunk(start, self.text[start..self.len], .{}, false); + self.emitChunk(start, self.text[start..self.len], .{}, false, true); //perfAccumShape(shape_start); } @@ -959,37 +1003,116 @@ pub fn draw(self: *TextEntryWidget) void { } // simple text - self.emitChunk(0, self.text[0..self.len], self.data().options.strip(), false); + self.emitChunk(0, self.text[0..self.len], self.data().options.strip(), false, true); self.textLayout.addTextDone(self.data().options.strip()); self.drawAfterText(); } +pub const ByteRange = struct { start: usize, end: usize }; + +/// Byte range the tree-sitter capture walk should cover this frame: what the viewport shows, +/// plus a screenful of headroom on each side so a scroll doesn't outrun the highlighting before +/// the next frame recomputes it. Null means "don't restrict" (first frame, before any layout +/// data exists, or `cache_layout` off). +/// +/// Deliberately *not* just `TextLayoutWidget.cache_layout_bytes`, which is what this used to +/// pass straight through. That range answers a different question — which bytes does *layout* +/// have to walk — and it is wrong for highlighting in two ways that both scale badly: +/// +/// 1. It stretches to cover the caret whenever the caret is off screen (see `bytesNeeded`'s +/// `include_cursor`), so scrolling away from the caret in a large file grew the queried +/// range toward the whole document. Measured on a 3.4k-line file: 2,868 captures per frame +/// while scrolling versus 1,231 sitting still, for pixels that are identical either way. +/// 2. The old pad around it was `len / 20` (capped at 8KB), i.e. proportional to the +/// *document*. On that same file it queried ~13.6KB of padding around a ~2KB viewport — +/// 87% of the work thrown away — which is why per-frame cost tracked file size even +/// though the visible line count never changed. +/// +/// The result is intersected with the layout range because bytes outside that never get emitted +/// this frame anyway, so querying them could only produce captures with nothing to color. +/// +/// `byte_heights` is last frame's data, recorded every `ByteHeight.dist` logical pixels — the +/// same source and staleness `bytesNeeded` itself works from, and the coarse spacing only ever +/// makes the range a superset of what's visible. +pub fn highlightByteRange(self: *TextEntryWidget) ?ByteRange { + const clb = self.textLayout.cache_layout_bytes orelse return null; + const heights = self.textLayout.byte_heights; + if (heights.len == 0) return .{ .start = clb.start, .end = clb.end }; + + const viewport = self.scroll.si.viewport; + // Headroom on each side, sized from the viewport rather than the document: enough that a + // normal scroll stays colored between recomputes, without dragging in text nobody can see. + const pad = viewport.h / 2; + const top = viewport.y - pad; + const bottom = viewport.y + viewport.h + pad; + + var start: usize = 0; + var end: usize = self.len; + for (heights) |bh| { + if (bh.height <= top) start = bh.byte; + if (bh.height >= bottom) { + end = bh.byte; + break; + } + } + + return .{ + .start = @max(start, clb.start), + .end = @min(@min(end, clb.end), self.len), + }; +} + /// 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 /// comment for why only one of the two ever occupies this slot at once. const Ghost = struct { text: []const u8, anchor: usize }; +/// The dimmed suffix to show after the cursor for the selected candidate, or null when this +/// candidate has nothing that can honestly be rendered inline — see `tc.completion.ghostSuffix` +/// for the rules (and why the answer is often "nothing", by design). +fn completionGhost(self: *TextEntryWidget, completion: CompletionState) ?Ghost { + // `drawCompletion` never sets `current_completion` with an empty `items` — see + // `TextEditor.zig` — so `selected` is always a valid index here. + const candidate = completion.items[completion.selected]; + const suffix = tc.completion.ghostSuffix( + self.text[0..self.len], + completion.anchor, + candidate.text, + candidate.replace_start, + candidate.replace_end, + ) orelse return null; + return .{ .text = suffix, .anchor = completion.anchor }; +} + +/// Tree-sitter highlight captures where brackets are *content*, not structure — rainbow +/// tint would override the grey comment / green string styling. Names are dotted prefixes +/// (`comment.line`, `string.special`, …), matching how highlight styles are resolved above. +fn captureAllowsRainbow(capture_name: []const u8) bool { + if (std.mem.startsWith(u8, capture_name, "comment")) return false; + if (std.mem.startsWith(u8, capture_name, "string")) return false; + if (std.mem.startsWith(u8, capture_name, "character")) return false; + return true; +} + /// Emits `chunk` — a slice of `self.text` starting at absolute byte offset `chunk_start` — /// into `self.textLayout`, exactly like the plain `addText`/`addTextHover` call it replaces -/// (`is_hover` selects which, matching the call site), *unless* this frame's `Ghost.anchor` -/// falls inside this chunk, in which case the chunk is split at the anchor and the ghost text -/// is spliced in between the two halves, dimmed. +/// (`is_hover` selects which, matching the call site) — except that the chunk is split wherever +/// this frame wants something extra drawn inside it: +/// +/// - this frame's `Ghost.anchor`, where dimmed ghost text is spliced between the two halves; +/// - either byte of `bracket_match`, re-emitted on its own with a highlight behind it. /// -/// The splice needs `TextLayoutWidget.bytes_seen` rewound by the ghost text's length -/// afterward — `addTextEx` advances it unconditionally, and it must track only *real* -/// document bytes for cursor/selection hit-testing (`cursor_rect`, click routing) to stay -/// correct for every real chunk emitted after this one. `bytes_seen` is a plain public field -/// already reached into directly elsewhere in this codebase (e.g. `selection`, `cursor_rect` -/// at `TextEditor.zig`), so this isn't reaching past an abstraction that wants to stay -/// opaque — but the same call path also advances a *second*, independent counter -/// (`cache_layout_bytes_seen`) whenever `cache_layout` is on, which `addTextDone` asserts -/// stays equal to `bytes_seen` — rewinding only `bytes_seen` would desync that pair and trip -/// the assert. Rewinding both in lockstep keeps `cache_layout` usable during a completion — -/// needed now that tree-sitter-highlighted docs rely on `cache_layout` for viewport culling -/// (see `TextEditor.zig`). -fn emitChunk(self: *TextEntryWidget, chunk_start: usize, chunk: []const u8, opts: dvui.Options, is_hover: bool) void { +/// `allow_rainbow` is false for comment/string captures so brackets inside them keep the +/// capture's colour (grey comments, etc.) instead of the rainbow override. The caret-match +/// wash still applies — it's a fill behind the glyph, not a text-colour steal. +/// +/// Doing both through the same text pipeline (rather than painting rects at computed +/// coordinates) is what keeps them correct under wrapping, horizontal scrolling, and +/// `cache_layout`'s viewport culling — a split chunk is still just text runs, laid out by the +/// same code as everything around it. +fn emitChunk(self: *TextEntryWidget, chunk_start: usize, chunk: []const u8, opts: dvui.Options, is_hover: bool, allow_rainbow: bool) void { const emitPlain = struct { fn call(w: *TextEntryWidget, start: usize, text: []const u8, o: dvui.Options, hover: bool) void { if (text.len == 0) return; @@ -1021,53 +1144,160 @@ fn emitChunk(self: *TextEntryWidget, chunk_start: usize, chunk: []const u8, opts } }.call; + const chunk_end = chunk_start + chunk.len; + + // Ghost text to splice inside *this* chunk, if any. `ghost_text_emitted` matters because + // consecutive chunks touch (one's end byte offset equals the next one's start), so when the + // anchor sits exactly on that shared boundary both chunks would otherwise believe the + // anchor is theirs and splice the ghost text twice (e.g. typing `s` and seeing `stdtd` + // instead of `std` — the ghost suffix `td` spliced in on both sides). const ghost: ?Ghost = blk: { - if (self.current_completion) |completion| { - // `drawCompletion` never sets `current_completion` with an empty `items` — see - // `TextEditor.zig` — so `selected` is always a valid index here. - break :blk .{ .text = completion.items[completion.selected].text, .anchor = completion.anchor }; - } - if (self.signature_hint) |hint| { - break :blk .{ .text = hint, .anchor = self.textLayout.selectionGet(self.len).cursor }; - } - break :blk null; - }; - const g = ghost orelse { - emitPlain(self, chunk_start, chunk, opts, is_hover); - return; + if (self.ghost_text_emitted) break :blk null; + const g: Ghost = found: { + if (self.current_completion) |completion| { + // A completion showing always claims the ghost-text slot, even when it has no + // suffix worth drawing (`completionGhost` returning null) — falling through to + // the signature hint there would flash a hint under an open completion list. + break :found self.completionGhost(completion) orelse break :blk null; + } + if (self.signature_hint) |hint| { + break :found .{ .text = hint, .anchor = self.textLayout.selectionGet(self.len).cursor }; + } + break :blk null; + }; + // Not in this chunk, or unsafe to splice at all (see `CompletionCandidate.text`'s doc + // comment on the no-newline requirement). + if (g.anchor < chunk_start or g.anchor > chunk_end) break :blk null; + if (std.mem.indexOfScalar(u8, g.text, '\n') != null) break :blk null; + break :blk g; }; - // Fast path: ghost text already spliced elsewhere this draw, anchor not in this chunk, or - // ghost text unsafe to splice this frame (see `CompletionCandidate.text`'s doc comment on - // the no-newline requirement) — stays branch-cheap for the common case, which is every - // chunk on every frame without a completion or signature hint showing. `ghost_text_emitted` - // matters because consecutive chunks touch (one's end byte offset equals the next one's - // start), so when the anchor sits exactly on that shared boundary both chunks would - // otherwise believe the anchor is theirs and splice the ghost text twice (e.g. typing `s` - // and seeing `stdtd` instead of `std` — the ghost suffix `td` spliced in on both sides). - if (self.ghost_text_emitted or g.anchor < chunk_start or g.anchor > chunk_start + chunk.len or - std.mem.indexOfScalar(u8, g.text, '\n') != null) + // Bracket restyles falling in this chunk — rainbow nesting marks and/or the caret-adjacent + // match pair. `< chunk_end` rather than `<=`: a bracket exactly on the shared boundary + // belongs to the next chunk, which is where its byte actually gets emitted. Nest marks are + // already sorted ascending; the caret pair is at most two bytes and gets merged in. + var marks_buf: [256]BracketMark = undefined; + var marks_n: usize = 0; { + var nest_i: usize = 0; + // Skip nests before this chunk so subsequent chunks don't re-walk them. + while (nest_i < self.bracket_nests.len and self.bracket_nests[nest_i].byte < chunk_start) : (nest_i += 1) {} + while (nest_i < self.bracket_nests.len and marks_n < marks_buf.len) : (nest_i += 1) { + const nm = self.bracket_nests[nest_i]; + if (nm.byte >= chunk_end) break; + // Skip rainbow marks inside comments/strings — still walk past them so later + // chunks don't re-scan these bytes. Caret-match merge below can still flag them. + if (!allow_rainbow) continue; + marks_buf[marks_n] = .{ .byte = nm.byte, .depth = nm.depth, .caret_match = false }; + marks_n += 1; + } + if (self.bracket_match) |bm| { + for (bm) |m| { + if (m < chunk_start or m >= chunk_end) continue; + // Merge onto an existing nest mark at the same byte, or append. + var merged = false; + for (marks_buf[0..marks_n]) |*existing| { + if (existing.byte == m) { + existing.caret_match = true; + merged = true; + break; + } + } + if (!merged and marks_n < marks_buf.len) { + marks_buf[marks_n] = .{ .byte = m, .depth = null, .caret_match = true }; + marks_n += 1; + // Keep ascending — caret-only marks are rare (≤2) so a tiny insertion is fine. + var j = marks_n - 1; + while (j > 0 and marks_buf[j].byte < marks_buf[j - 1].byte) : (j -= 1) { + const tmp = marks_buf[j - 1]; + marks_buf[j - 1] = marks_buf[j]; + marks_buf[j] = tmp; + } + } + } + } + } + const marks = marks_buf[0..marks_n]; + + // The overwhelmingly common case: nothing to splice or restyle in this chunk. Kept first so + // the per-chunk cost of both features is one null check on frames where neither is showing. + if (ghost == null and marks.len == 0) { emitPlain(self, chunk_start, chunk, opts, is_hover); return; } - self.ghost_text_emitted = true; - const split = g.anchor - chunk_start; - emitPlain(self, chunk_start, chunk[0..split], opts, is_hover); + // Walk the chunk left to right, emitting each plain stretch up to the next splice point. + // A ghost anchor coinciding with a bracket goes first (it sits *at* the caret, before that + // byte). Nest marks and the caret-match wash share a single emission when they land on the + // same glyph. + var pos = chunk_start; + var mark_i: usize = 0; + var pending_ghost = ghost; + while (mark_i < marks.len or pending_ghost != null) { + const ghost_at: ?usize = if (pending_ghost) |g| g.anchor else null; + const mark_at: ?usize = if (mark_i < marks.len) marks[mark_i].byte else null; + const take_ghost = if (ghost_at) |ga| (mark_at == null or ga <= mark_at.?) else false; + const at = if (take_ghost) ghost_at.? else mark_at.?; + + emitPlain(self, pos, chunk[pos - chunk_start .. at - chunk_start], opts, is_hover); + pos = at; + + if (take_ghost) { + self.emitGhost(pending_ghost.?.text); + pending_ghost = null; + } else { + // Deliberately not `is_hover`: this is a single bracket byte carved out of the + // middle of a token, and registering it as its own hover span would report a + // one-byte token to goto-definition/hover. Brackets are never a definition target, + // so dropping the hit-test for exactly this byte costs nothing. + emitPlain(self, at, chunk[at - chunk_start ..][0..1], opts.override(bracketStyle(marks[mark_i])), false); + pos = at + 1; + mark_i += 1; + } + } + emitPlain(self, pos, chunk[pos - chunk_start ..], opts, is_hover); +} - self.textLayout.addText(g.text, .{ +const BracketMark = struct { + byte: usize, + /// Indent-level palette index when rainbow-coloured; null when this mark exists only as a caret match. + depth: ?u8, + caret_match: bool, +}; + +/// Rainbow text colour from the fixed Fizzy palette, plus the caret-match wash when this glyph +/// is the pair next to the caret. The wash still comes from the active theme's highlight so it +/// follows the chrome; the glyph colour does not — that's the point of a fixed palette. +fn bracketStyle(mark: BracketMark) dvui.Options { + var o: dvui.Options = .{}; + if (mark.depth) |d| o.color_text = palette.bracket(d); + if (mark.caret_match) o.color_fill = dvui.themeGet().color(.highlight, .fill).opacity(0.35); + return o; +} + +/// Splices `text` into the layout at the current position, dimmed, without letting it count as +/// real document bytes. +/// +/// `addTextEx` advances `TextLayoutWidget.bytes_seen` unconditionally, and that counter must +/// track only *real* document bytes for cursor/selection hit-testing (`cursor_rect`, click +/// routing) to stay correct for every chunk emitted after this one — so it gets rewound by the +/// ghost text's length. `bytes_seen` is a plain public field already reached into directly +/// elsewhere in this codebase (e.g. `selection`, `cursor_rect` at `TextEditor.zig`), so this +/// isn't reaching past an abstraction that wants to stay opaque — but the same call path also +/// advances a *second*, independent counter (`cache_layout_bytes_seen`) whenever `cache_layout` +/// is on, which `addTextDone` asserts stays equal to `bytes_seen`; rewinding only `bytes_seen` +/// would desync that pair and trip the assert. Rewinding both in lockstep keeps `cache_layout` +/// usable during a completion — needed now that tree-sitter-highlighted docs rely on it for +/// viewport culling (see `TextEditor.zig`). +fn emitGhost(self: *TextEntryWidget, text: []const u8) void { + self.ghost_text_emitted = true; + self.textLayout.addText(text, .{ .color_text = self.textLayout.data().options.color(.text).opacity(0.5), }); - self.textLayout.bytes_seen -= g.text.len; + self.textLayout.bytes_seen -= text.len; if (self.textLayout.cache_layout) { - // `addTextEx` bumped this by `g.text.len` too (same call path as `bytes_seen`, whenever - // cache_layout is on) — rewind it the same amount so it stays equal to `bytes_seen`, - // which `addTextDone` asserts. - self.textLayout.cache_layout_bytes_seen -= g.text.len; + self.textLayout.cache_layout_bytes_seen -= text.len; } - - emitPlain(self, g.anchor, chunk[split..], opts, is_hover); } pub fn drawBeforeText(self: *TextEntryWidget) void { @@ -1203,8 +1433,8 @@ pub fn textTyped(self: *TextEntryWidget, new: []const u8, selected: bool) void { return; } - if (self.init_opts.edit_notify) |en| en.beginEdit(en.ctx); - defer if (self.init_opts.edit_notify) |en| en.endEdit(en.ctx); + if (self.init_opts.edit_notify) |en| en.beginEdit(en.ctx, self.currentRange()); + defer if (self.init_opts.edit_notify) |en| en.endEdit(en.ctx, self.currentRange()); var sel = self.textLayout.selectionGet(self.len); if (!sel.empty()) { @@ -1495,126 +1725,28 @@ pub fn processEvent(self: *TextEntryWidget, e: *Event) void { break :blk; } - if (ke.action == .down and ke.matchBind("text_start")) { - e.handle(@src(), self.data()); - self.textLayout.selection.moveCursor(0, false); - self.textLayout.scroll_to_cursor = true; - break :blk; - } - - if (ke.action == .down and ke.matchBind("text_end")) { - e.handle(@src(), self.data()); - self.textLayout.selection.moveCursor(std.math.maxInt(usize), false); - self.textLayout.scroll_to_cursor = true; - break :blk; - } - - if (ke.action == .down and ke.matchBind("line_start")) { - e.handle(@src(), self.data()); - if (self.textLayout.sel_move == .none) { - self.textLayout.sel_move = .{ .expand_pt = .{ .select = false, .which = .home } }; - } - break :blk; - } - - if (ke.action == .down and ke.matchBind("line_end")) { - e.handle(@src(), self.data()); - if (self.textLayout.sel_move == .none) { - self.textLayout.sel_move = .{ .expand_pt = .{ .select = false, .which = .end } }; - } - break :blk; - } - - if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("word_left")) { - e.handle(@src(), self.data()); - if (!self.textLayout.selection.empty()) { - self.textLayout.selection.moveCursor(self.textLayout.selection.start, false); - } else { - if (self.textLayout.sel_move == .none) { - self.textLayout.sel_move = .{ .word_left_right = .{ .select = false } }; - } - if (self.textLayout.sel_move == .word_left_right) { - self.textLayout.sel_move.word_left_right.count -= 1; - } - } - break :blk; - } - - if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("word_right")) { - e.handle(@src(), self.data()); - if (!self.textLayout.selection.empty()) { - self.textLayout.selection.moveCursor(self.textLayout.selection.end, false); - self.textLayout.selection.affinity = .before; - } else { - if (self.textLayout.sel_move == .none) { - self.textLayout.sel_move = .{ .word_left_right = .{ .select = false } }; - } - if (self.textLayout.sel_move == .word_left_right) { - self.textLayout.sel_move.word_left_right.count += 1; - } - } - break :blk; - } - - if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("char_left")) { - e.handle(@src(), self.data()); - if (!self.textLayout.selection.empty()) { - self.textLayout.selection.moveCursor(self.textLayout.selection.start, false); - } else { - if (self.textLayout.sel_move == .none) { - self.textLayout.sel_move = .{ .char_left_right = .{ .select = false } }; - } - if (self.textLayout.sel_move == .char_left_right) { - self.textLayout.sel_move.char_left_right.count -= 1; - } - } - break :blk; - } - - if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("char_right")) { - e.handle(@src(), self.data()); - if (!self.textLayout.selection.empty()) { - self.textLayout.selection.moveCursor(self.textLayout.selection.end, false); - self.textLayout.selection.affinity = .before; - } else { - if (self.textLayout.sel_move == .none) { - self.textLayout.sel_move = .{ .char_left_right = .{ .select = false } }; + // All keyboard motion, resolved in-model. See `motion_binds` / `applyMotion`. + if (ke.action == .down or ke.action == .repeat) { + inline for (motion_binds) |m| { + if (ke.matchBind(m.bind)) { + e.handle(@src(), self.data()); + self.applyMotion(m.granularity, m.dir, false); + break :blk; } - if (self.textLayout.sel_move == .char_left_right) { - self.textLayout.sel_move.char_left_right.count += 1; + if (ke.matchBind(m.bind ++ "_select")) { + e.handle(@src(), self.data()); + self.applyMotion(m.granularity, m.dir, true); + break :blk; } } - break :blk; - } - - if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("char_up")) { - e.handle(@src(), self.data()); - if (self.textLayout.sel_move == .none) { - self.textLayout.sel_move = .{ .cursor_updown = .{ .select = false } }; - } - if (self.textLayout.sel_move == .cursor_updown) { - self.textLayout.sel_move.cursor_updown.count -= 1; - } - break :blk; - } - - if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("char_down")) { - e.handle(@src(), self.data()); - if (self.textLayout.sel_move == .none) { - self.textLayout.sel_move = .{ .cursor_updown = .{ .select = false } }; - } - if (self.textLayout.sel_move == .cursor_updown) { - self.textLayout.sel_move.cursor_updown.count += 1; - } - break :blk; } switch (ke.code) { .backspace => { if (ke.action == .down or ke.action == .repeat) { e.handle(@src(), self.data()); - if (self.init_opts.edit_notify) |en| en.beginEdit(en.ctx); - defer if (self.init_opts.edit_notify) |en| en.endEdit(en.ctx); + if (self.init_opts.edit_notify) |en| en.beginEdit(en.ctx, self.currentRange()); + defer if (self.init_opts.edit_notify) |en| en.endEdit(en.ctx, self.currentRange()); var sel = self.textLayout.selectionGet(self.len); if (!sel.empty()) { // just delete selection @@ -1651,6 +1783,19 @@ pub fn processEvent(self: *TextEntryWidget, e: *Event) void { sel.end = sel.cursor; sel.start = sel.cursor; self.textLayout.scroll_to_cursor = true; + } else if (self.betweenEmptyPair(sel.cursor)) { + // Take out both halves of an empty pair at once — deleting the `{` of + // `{|}` and leaving the orphaned `}` behind is never what was meant. + const start = sel.cursor - 1; + const end = sel.cursor + 1; + self.textChangedRemoved(start, end); + if (self.init_opts.edit_notify) |en| en.noteRemoved(en.ctx, start, self.text[start..end]); + @memmove(self.text[start..][0 .. self.len - end], self.text[end..self.len]); + self.setLen(self.len - 2); + sel.cursor = start; + sel.start = start; + sel.end = start; + self.textLayout.scroll_to_cursor = true; } else if (sel.cursor > 0) { // delete character just before cursor // @@ -1674,8 +1819,8 @@ pub fn processEvent(self: *TextEntryWidget, e: *Event) void { .delete => { if (ke.action == .down or ke.action == .repeat) { e.handle(@src(), self.data()); - if (self.init_opts.edit_notify) |en| en.beginEdit(en.ctx); - defer if (self.init_opts.edit_notify) |en| en.endEdit(en.ctx); + if (self.init_opts.edit_notify) |en| en.beginEdit(en.ctx, self.currentRange()); + defer if (self.init_opts.edit_notify) |en| en.endEdit(en.ctx, self.currentRange()); var sel = self.textLayout.selectionGet(self.len); if (!sel.empty()) { // just delete selection @@ -1754,7 +1899,13 @@ pub fn processEvent(self: *TextEntryWidget, e: *Event) void { e.handle(@src(), self.data()); var new = std.mem.sliceTo(set.txt, 0); if (self.init_opts.multiline) { - self.textTyped(new, set.selected); + // Only single, committed characters take the auto-pair path: `set.selected` + // marks in-progress IME composition (which gets replaced wholesale on the + // next event, so inserting a closer mid-composition would strand it), and a + // multi-byte `new` is a paste or a composed sequence, never a keystroke. + const auto_paired = self.init_opts.auto_close_pairs and new.len == 1 and + !set.selected and self.handleAutoPair(new[0]); + if (!auto_paired) self.textTyped(new, set.selected); } else { var i: usize = 0; while (i < new.len) { @@ -1879,6 +2030,91 @@ fn autoInsertCallParens(self: *TextEntryWidget) void { /// character over a selection; see `tab_inserts_indent`'s doc comment). Snaps to the next /// tab stop when inserting spaces, matching VSCode's default Tab behavior: after 2 typed /// characters, Tab adds 2 spaces to reach column 4, not a flat 4 more. +// -- keyboard motion --------------------------------------------------------------------------- +// +// Motion is resolved in `textcore.movement` against the byte buffer, immediately, and only +// then written into dvui's `Selection`. The previous path instead set +// `TextLayoutWidget.sel_move` — a **single-slot** union resolved later during the render pass, +// which meant a second motion arriving in the same frame was dropped on the floor (every +// handler was guarded by `if (sel_move == .none)`) and up/down round-tripped through +// `dataSet`/`dataGet` across two frames. dvui's Selection is now a projection, not the source +// of truth; the only place layout still owns a selection change is mouse hit-testing, which +// genuinely needs glyph positions. + +/// Sticky goal column for vertical motion. Lives in dvui's per-widget store because the +/// widget struct itself is rebuilt every frame. dvui garbage-collects this the first frame +/// the widget isn't drawn, which is the behaviour we want — switching tabs should not carry a +/// stale target column back. +const goal_col_key = "_textcore_goal_col"; + +fn currentRange(self: *TextEntryWidget) tc.Range { + const sel = self.textLayout.selectionGet(self.len); + // dvui stores an ordered {start, end} plus a cursor; recover the anchor/head direction + // from which end the cursor sits at, so a backwards selection keeps extending backwards. + const r: tc.Range = if (sel.cursor == sel.start and sel.start != sel.end) + .init(sel.end, sel.start) + else + .init(sel.start, sel.end); + return .{ + .anchor = r.anchor, + .head = r.head, + .goal_col = dvui.dataGet(null, self.data().id, goal_col_key, u32), + }; +} + +fn setRange(self: *TextEntryWidget, r: tc.Range) void { + const sel = self.textLayout.selectionGet(self.len); + sel.cursor = r.head; + sel.start = r.start(); + sel.end = r.end(); + sel.affinity = .after; + + if (r.goal_col) |g| { + dvui.dataSet(null, self.data().id, goal_col_key, g); + } else { + dvui.dataRemove(null, self.data().id, goal_col_key); + } + self.textLayout.scroll_to_cursor = true; +} + +fn moveOpts(self: *TextEntryWidget) tc.MoveOpts { + return .{ .tab_size = if (self.init_opts.tab_size == 0) 4 else @intCast(self.init_opts.tab_size) }; +} + +fn applyMotion(self: *TextEntryWidget, g: tc.Granularity, dir: tc.Dir, extend: bool) void { + self.setRange(tc.movement.move( + self.text[0..self.len], + self.currentRange(), + g, + dir, + extend, + self.moveOpts(), + )); +} + +/// Keyboard motions, as (dvui keybind name, granularity, direction). Each entry also covers +/// its `_select` shift variant — dvui defines those binds, but neither this widget nor +/// upstream's ever handled them, so shift+arrow selected nothing at all. +const motion_binds = [_]struct { + bind: []const u8, + granularity: tc.Granularity, + dir: tc.Dir, +}{ + // Most-specific modifiers first. The binds are mutually exclusive on both platforms + // (`char_left` requires alt/control *up*, `word_left` requires it down), so this is + // ordering for readability rather than correctness. + .{ .bind = "text_start", .granularity = .document, .dir = .backward }, + .{ .bind = "text_end", .granularity = .document, .dir = .forward }, + .{ .bind = "line_start", .granularity = .line_boundary, .dir = .backward }, + .{ .bind = "line_end", .granularity = .line_boundary, .dir = .forward }, + .{ .bind = "word_left", .granularity = .word, .dir = .backward }, + .{ .bind = "word_right", .granularity = .word, .dir = .forward }, + .{ .bind = "char_left", .granularity = .char, .dir = .backward }, + .{ .bind = "char_right", .granularity = .char, .dir = .forward }, + .{ .bind = "char_up", .granularity = .line, .dir = .backward }, + .{ .bind = "char_down", .granularity = .line, .dir = .forward }, +}; + fn insertIndent(self: *TextEntryWidget) void { const tab_size: usize = if (self.init_opts.tab_size == 0) 4 else self.init_opts.tab_size; if (!self.init_opts.insert_spaces) { @@ -1922,6 +2158,71 @@ fn oneIndentUnit(self: *TextEntryWidget, buf: []u8) []const u8 { return buf[0..n]; } +/// Applies `tc.pairs.onTyped`'s decision for a single typed character — see `auto_close_pairs` +/// for the behavior and `pairs.zig` for the rules. Returns true when it fully handled `ch` (the +/// caller must not also insert it), false to let the normal insert path run. +fn handleAutoPair(self: *TextEntryWidget, ch: u8) bool { + const sel = self.textLayout.selectionGet(self.len); + switch (tc.pairs.onTyped(self.text[0..self.len], sel.start, sel.end, ch)) { + .insert => return false, + .step_over => { + sel.cursor += 1; + sel.start = sel.cursor; + sel.end = sel.cursor; + self.textLayout.scroll_to_cursor = true; + return true; + }, + .surround => |p| return self.surroundSelection(p), + .close_pair => |p| { + const both = [_]u8{ p.open, p.close }; + self.textTyped(&both, false); + + const after = self.textLayout.selectionGet(self.len); + if (after.cursor > 0) { + after.cursor -= 1; + after.start = after.cursor; + after.end = after.cursor; + } + self.textLayout.scroll_to_cursor = true; + return true; + }, + } +} + +/// Wraps the active selection in `p` instead of replacing it (VSCode's `editor.autoSurround`), +/// leaving the same text selected inside the new pair. Built as one `textTyped` call over an +/// arena copy so it lands as a single undo step; on allocation failure it returns false and the +/// caller falls back to the plain "typing replaces the selection" behavior. +fn surroundSelection(self: *TextEntryWidget, p: tc.pairs.Pair) bool { + const sel = self.textLayout.selectionGet(self.len); + const start = sel.start; + const end = @min(sel.end, self.len); + if (end <= start) return false; + + const inner = self.text[start..end]; + const arena = dvui.currentWindow().arena(); + const wrapped = arena.alloc(u8, inner.len + 2) catch return false; + wrapped[0] = p.open; + @memcpy(wrapped[1..][0..inner.len], inner); + wrapped[inner.len + 1] = p.close; + + self.textTyped(wrapped, false); + + // `textTyped` can insert less than asked when the buffer hits its limit, so re-derive the + // inner span from where the cursor actually ended up rather than trusting `inner.len`. + const after = self.textLayout.selectionGet(self.len); + const inner_end = after.cursor -| 1; + after.start = @min(start + 1, inner_end); + after.end = inner_end; + after.cursor = inner_end; + self.textLayout.scroll_to_cursor = true; + return true; +} + +fn betweenEmptyPair(self: *TextEntryWidget, cursor: usize) bool { + return self.init_opts.auto_close_pairs and tc.pairs.deletesPair(self.text[0..self.len], cursor); +} + /// VSCode-style Enter: carries the current line's leading whitespace onto the new line, adds /// one more indent level after an opening bracket (`{`, `(`, `[`), and — when the cursor sits /// directly between a matching bracket pair — splits it onto three lines with the closer @@ -1978,8 +2279,14 @@ pub fn cut(self: *TextEntryWidget) void { // copy selection to clipboard dvui.clipboardTextSet(self.text[sel.start..sel.end]); - // delete selection + // Same begin/note/end path as backspace-over-selection — without it the buffer + // shrinks while History still thinks the cut bytes are there, and the next undo + // either partially applies then hits EditOutOfRange, or key-repeat spams that error. + if (self.init_opts.edit_notify) |en| en.beginEdit(en.ctx, self.currentRange()); + defer if (self.init_opts.edit_notify) |en| en.endEdit(en.ctx, self.currentRange()); + self.textChangedRemoved(sel.start, sel.end); + if (self.init_opts.edit_notify) |en| en.noteRemoved(en.ctx, sel.start, self.text[sel.start..sel.end]); @memmove(self.text[sel.start..][0 .. self.len - sel.end], self.text[sel.end..self.len]); self.setLen(self.len - (sel.end - sel.start)); sel.end = sel.start; diff --git a/src/plugins/workbench/src/Workbench.zig b/src/plugins/workbench/src/Workbench.zig index 35deaa90..d941ac0b 100644 --- a/src/plugins/workbench/src/Workbench.zig +++ b/src/plugins/workbench/src/Workbench.zig @@ -289,7 +289,10 @@ fn svcRevealPosition(ctx: *anyopaque, path: []const u8, line: u32, character: u3 if (host.pluginForExtension(std.fs.path.extension(path)) == null) return false; const wb = runtime.workbench(); - const owned_path = try wb.allocator.dupe(u8, path); + // Same canonical spelling `openFilePath` will store on the document (`std.fs.path.resolve` + // ≡ `fizzy.paths.normalize`) — otherwise `pollPendingReveals`'s exact `docFromPath` could + // miss a `.`-laden URI-derived path on the fast path. + const owned_path = try std.fs.path.resolve(wb.allocator, &.{path}); errdefer wb.allocator.free(owned_path); try wb.pending_reveals.append(wb.allocator, .{ .path = owned_path, .line = line, .character = character }); // `openFilePath` returning `false` here (as opposed to an actual error) only ever means @@ -302,7 +305,7 @@ fn svcRevealPosition(ctx: *anyopaque, path: []const u8, line: u32, character: u3 // `open_side`: mint a fresh grouping so the load lands in a new split instead of the // current one — mirrors the file tree's "Open to the side" menu action exactly. const target_grouping = if (open_side) wb.newGroupingID() else wb.currentGroupingID(); - _ = host.openFilePath(path, target_grouping) catch |err| { + _ = host.openFilePath(owned_path, target_grouping) catch |err| { wb.pending_reveals.items.len -= 1; wb.allocator.free(owned_path); return err; diff --git a/src/plugins/workbench/src/files.zig b/src/plugins/workbench/src/files.zig index 65eed2a9..b96cd16a 100644 --- a/src/plugins/workbench/src/files.zig +++ b/src/plugins/workbench/src/files.zig @@ -3,6 +3,7 @@ const builtin = @import("builtin"); const dvui = @import("dvui"); const wdvui = @import("core").dvui; const fuzzy = @import("core").fuzzy; +const palette = @import("core").palette; const runtime = @import("runtime.zig"); const icons = @import("icons"); const Workspace = @import("Workspace.zig"); @@ -204,8 +205,8 @@ pub fn drawFiles(path: []const u8, tree: *wdvui.TreeWidget) !void { } const color = dvui.themeGet().color(.control, .fill_hover); - // Folder rows tint their caret from the per-row fill (`control.fill`, optionally overridden - // by `fileRowFillColor`); the project row has no per-row tint, so it takes the base. + // Folder rows tint their caret from the per-row palette colour (optionally overridden + // by `fileRowFillColor`); the project row has no per-row tint, so it takes the theme base. const caret_color = dvui.themeGet().color(.control, .fill); { @@ -352,18 +353,35 @@ var filter_index_stale: bool = true; const filter_index_max_files: usize = 200_000; const filter_index_max_depth: usize = 32; +/// Hard cap on drawn filtered rows. Every row is a real tree widget — an id, an icon, a +/// run-split highlighted label — so the list length is a per-frame cost, not just a scroll +/// length. Uncapped, a one-letter query over a large project matched nearly the whole index +/// and drew tens of thousands of rows per frame, which froze the app. Past a few hundred hits +/// the ranking is noise anyway; the answer is to type another character. +const filter_max_rows: usize = 300; + +/// Last ranking, reused while neither the query nor the index has changed. Without this the +/// whole index is re-scored on every frame the explorer draws, not just on each keystroke. +var filter_cache_query: []u8 = &.{}; +var filter_cache_rows: std.ArrayListUnmanaged(SimpleEntry) = .empty; +var filter_cache_valid: bool = false; + /// Drop the cached path index. Called when the filter closes and whenever this module creates, /// deletes, renames, or moves something — those are the only mutations that happen while the /// explorer is open, and re-walking on the next keystroke is cheap enough to not need finer /// invalidation. pub fn invalidateFilterIndex() void { filter_index_stale = true; + filter_cache_valid = false; } fn freeFilterIndex() void { const gpa = runtime.allocator(); for (filter_index.items) |p| gpa.free(p); filter_index.clearRetainingCapacity(); + // Cached rows borrow the index strings. + filter_cache_valid = false; + filter_cache_rows.clearRetainingCapacity(); } pub fn deinitFilterIndex() void { @@ -373,6 +391,9 @@ pub fn deinitFilterIndex() void { if (filter_index_root.len > 0) gpa.free(filter_index_root); filter_index_root = &.{}; filter_index_stale = true; + filter_cache_rows.deinit(gpa); + if (filter_cache_query.len > 0) gpa.free(filter_cache_query); + filter_cache_query = &.{}; } /// Rebuild the index for `root` if it's missing, stale, or was built for a different project. @@ -438,6 +459,13 @@ fn rankedFilterRows(root_directory: []const u8, filter_text: []const u8) []const var query = fuzzy.Query.init(filter_text); if (query.isEmpty()) return &.{}; + // Ranking depends only on the query and the index, and both change far less often than + // frames do — the explorer redraws on hover, scroll, animation, every peer widget. + if (filter_cache_valid and std.mem.eql(u8, filter_cache_query, filter_text)) { + return filter_cache_rows.items; + } + + const gpa = runtime.allocator(); const arena = dvui.currentWindow().arena(); const Hit = fuzzy.Ranked(usize); var hits: std.ArrayListUnmanaged(Hit) = .empty; @@ -450,16 +478,25 @@ fn rankedFilterRows(root_directory: []const u8, filter_text: []const u8) []const } fuzzy.sort(usize, hits.items); - var rows: std.ArrayListUnmanaged(SimpleEntry) = .empty; + // Rows borrow the index strings, so the cache lives exactly as long as the index does — + // `freeFilterIndex` drops it. + filter_cache_rows.clearRetainingCapacity(); for (hits.items) |hit| { + if (filter_cache_rows.items.len >= filter_max_rows) break; const abs_path = filter_index.items[hit.item]; - rows.append(arena, .{ + filter_cache_rows.append(gpa, .{ .name = std.fs.path.basename(abs_path), .kind = .file, .dir = std.fs.path.dirname(abs_path) orelse root_directory, }) catch break; } - return rows.items; + + if (filter_cache_query.len > 0) gpa.free(filter_cache_query); + filter_cache_query = gpa.dupe(u8, filter_text) catch &.{}; + // A failed dupe just means the next frame re-ranks; never claim a cache we can't key. + filter_cache_valid = filter_cache_query.len == filter_text.len; + + return filter_cache_rows.items; } /// One row to draw. `dir` is normally null — the row's parent directory is whichever directory @@ -694,7 +731,9 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, u 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 }); - var color = dvui.themeGet().color(.control, .fill); + // Fixed Fizzy palette (theme-independent) so row accents stay stable across + // theme switches and line up with rainbow bracket colours in the editor. + var color = palette.at(color_id.*); if (runtime.host().fileRowFillColor(color_id.*)) |tint| { color = tint; } diff --git a/src/sdk/EditorAPI.zig b/src/sdk/EditorAPI.zig index 34706e66..49da4423 100644 --- a/src/sdk/EditorAPI.zig +++ b/src/sdk/EditorAPI.zig @@ -173,8 +173,15 @@ pub const VTable = struct { /// Draws a standard menu-item row (separator above, label + keybind hint, click-detect) /// inside whatever menu is currently open, and returns whether it was clicked this frame. - /// `keybind_name` looks up a registered shortcut hint (e.g. `"format"`), or pass `null` for - /// none. `title` also seeds the widget's id — use a distinct title per call site. + /// `command_id` is the registered `Command` this row runs (e.g. `"text.format"`), or null + /// for a row that is not a command; the shell resolves its current chord from the keymap + /// and draws it as the accelerator, so a user rebinding it in the Keyboard Shortcuts pane + /// updates the row with no further work from the plugin. `title` also seeds the widget's + /// id — use a distinct title per call site. + /// + /// This took a dvui *bind name* before (`"format"`, `"grid_layout"`). Those names are a + /// separate, flat namespace that plugin commands generally have no entry in, so the hint was + /// blank for every plugin item in practice, and rebinding could not reach it at all. /// /// Menu section contributions (`Host.registerMenuSection`) must draw items through this /// rather than calling `dvui.menuItem()`/`dvui.separator()` directly: dvui tracks "the @@ -186,7 +193,7 @@ pub const VTable = struct { /// ReleaseFast). Routing through this function keeps the actual widget construction in the /// shell's own compiled code, where that state is always valid — the plugin just gets a /// plain bool back, the same shape as every other shell-owned-context call on this vtable. - drawMenuItem: *const fn (ctx: *anyopaque, title: []const u8, keybind_name: ?[]const u8) bool, + drawMenuItem: *const fn (ctx: *anyopaque, title: []const u8, command_id: ?[]const u8) bool, /// Reads `/.settings.zon`, or null if absent/unavailable. Caller-owned /// (free with the same allocator `Host` uses). @@ -425,8 +432,8 @@ pub fn logLine(self: EditorAPI, level: std.log.Level, scope: []const u8, message self.vtable.logLine(self.ctx, level, scope, message); } -pub fn drawMenuItem(self: EditorAPI, title: []const u8, keybind_name: ?[]const u8) bool { - return self.vtable.drawMenuItem(self.ctx, title, keybind_name); +pub fn drawMenuItem(self: EditorAPI, title: []const u8, command_id: ?[]const u8) bool { + return self.vtable.drawMenuItem(self.ctx, title, command_id); } pub fn loadPluginSettingsFile(self: EditorAPI, id: []const u8) ?[]u8 { diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig index d97afee1..89d4c556 100644 --- a/src/sdk/Host.zig +++ b/src/sdk/Host.zig @@ -446,11 +446,12 @@ pub fn logLine(self: *Host, level: std.log.Level, scope: []const u8, message: [] } /// Draw a standard menu-item row inside the currently open menu; returns whether it was -/// clicked. False (never drawn/clicked) when no shell is installed. See +/// clicked. `command_id` names the `Command` the row runs, and the shell draws that command's +/// current chord beside it. False (never drawn/clicked) when no shell is installed. See /// `EditorAPI.VTable.drawMenuItem`'s doc comment — `Host.registerMenuSection` draw callbacks /// must go through this instead of calling dvui's menu widgets directly. -pub fn drawMenuItem(self: *Host, title: []const u8, keybind_name: ?[]const u8) bool { - return if (self.shell_api) |a| a.drawMenuItem(title, keybind_name) else false; +pub fn drawMenuItem(self: *Host, title: []const u8, command_id: ?[]const u8) bool { + return if (self.shell_api) |a| a.drawMenuItem(title, command_id) else false; } // ---- per-plugin settings store --------------------------------------------- @@ -677,12 +678,46 @@ pub fn registerFileIcon(self: *Host, drawer: FileIcon) !void { try self.file_icons.append(self.allocator, drawer); } -/// Draw the file-tree row icon for `ext`/`path` via the first registered drawer that handles it. -/// Returns true if a plugin drew it; false means the caller should draw a generic default. +/// Draw the file-tree row icon for `ext`/`path`. +/// +/// Order (first success wins): +/// 1. Language plugin that claims the extension (tree-sitter or preview) → that plugin's logo +/// 2. Explicit `registerFileIcon` drawers (pixi sprites, image glyph, text code glyph, …) +/// 3. Specialized document owner (`fileTypePriority` below the text fallback) → that plugin's logo +/// +/// Returns false when nothing claimed it — caller draws a generic filesystem default. +/// **Caller must reserve a `core.dvui.treeRowGlyph` slot**; drawers use `expand = .ratio`. pub fn drawFileIcon(self: *Host, ext: []const u8, path: []const u8, color: dvui.Color) bool { + // Language plugins (zig, json, markdown, …) own the identity of their formats even when + // `text` owns the document — prefer their logo over the generic code glyph. + for (self.language_support.items) |*ls| { + const owner = ls.owner orelse continue; + var claimed = false; + if (ls.vtable.treeSitterHighlight) |hook| { + if (hook(owner.state, ext) != null) claimed = true; + } + if (!claimed) { + if (ls.vtable.supportsPreview) |supports| { + if (supports(owner.state, ext)) claimed = true; + } + } + if (!claimed) continue; + if (self.drawPluginIcon(owner.id)) return true; + } + for (self.file_icons.items) |drawer| { if (drawer.draw(drawer.ctx, ext, path, color)) return true; } + + // Specialized document plugins (pixi, image, …) that didn't register a FileIcon drawer + // still get their logo when they uniquely claim the extension. + if (self.pluginForExtension(ext)) |p| { + if (p.fileTypePriority(ext)) |prio| { + if (prio < Plugin.file_type_fallback_priority) { + if (self.drawPluginIcon(p.id)) return true; + } + } + } return false; } @@ -897,6 +932,16 @@ pub fn previewProviderFor(self: *Host, ext: []const u8) ?*LanguageSupport { return null; } +/// Notify every language provider that a document was opened/reloaded. Providers gate on +/// `ext` themselves (see `LanguageSupport.VTable.documentOpened`). Non-blocking. +pub fn documentOpenedFor(self: *Host, ext: []const u8, path: []const u8, bytes: []const u8) void { + for (self.language_support.items) |*ls| { + const hook = ls.vtable.documentOpened orelse continue; + const owner = ls.owner orelse continue; + hook(owner.state, ext, path, bytes); + } +} + /// Non-blocking hover lookup: the first provider with a cached/ready hover result for /// `byte_offset` in `bytes` (the document at `path`), or null. See /// `LanguageSupport.VTable.hover`. @@ -1327,6 +1372,16 @@ test "unregisterPlugin removes a plugin's contributions, service, and resets act .setString = struct { fn f(_: *anyopaque, _: usize, _: []const u8) void {} }.f, + .getZonText = struct { + fn f(_: *anyopaque, _: usize, _: std.mem.Allocator) []const u8 { + return ""; + } + }.f, + .setZonText = struct { + fn f(_: *anyopaque, _: usize, _: []const u8) bool { + return false; + } + }.f, .persist = struct { fn f(_: *anyopaque, _: *Plugin) void {} }.f, diff --git a/src/sdk/dylib.zig b/src/sdk/dylib.zig index 8758214c..916ba838 100644 --- a/src/sdk/dylib.zig +++ b/src/sdk/dylib.zig @@ -91,6 +91,12 @@ const sdk_boundary_types = .{ regions.CenterProvider, regions.MenuContribution, regions.MenuSectionContribution, + // Reached only through `Host.native_menu_items`' backing slice — a *data* pointer + // `hashType` deliberately never follows (see the `HoverResult` note below) — so without + // this entry a field added here would change the real cross-plugin layout of that list + // without moving the fingerprint, and the shell would read a plugin's differently-shaped + // struct. Same lesson as `CompletionItem` / `Setting`. + regions.NativeMenuItem, regions.Command, language_mod.LanguageSupport, language_mod.LanguageSupport.VTable, diff --git a/src/sdk/language.zig b/src/sdk/language.zig index adf9daf8..3c564d09 100644 --- a/src/sdk/language.zig +++ b/src/sdk/language.zig @@ -16,11 +16,9 @@ const Plugin = @import("Plugin.zig"); /// happen to be statically linked into the same binary and so share one copy of this /// `threadlocal`. It is NOT a valid way to pass data to a genuinely dynamically-loaded /// plugin dylib — each `.dylib` gets its own private compiled copy of every SDK source file, -/// so a write from the host's copy is invisible from inside the plugin's copy (see -/// `src/sdk/version.zig`'s 0.13.0 changelog entry for the same class of bug with -/// `core.dvui.dialog_close_rect_override`). `hover`/`gotoDefinition` take `path` as an -/// explicit parameter instead, precisely because third-party language plugins (e.g. `zig`) -/// *are* loaded as separate dylibs. +/// so a write from the host's copy is invisible from inside the plugin's copy. +/// `hover`/`gotoDefinition` take `path` as an explicit parameter instead, precisely +/// because third-party language plugins (e.g. `zig`) *are* loaded as separate dylibs. threadlocal var preview_document_path: []const u8 = ""; pub fn setPreviewDocumentPath(path: []const u8) void { @@ -74,6 +72,13 @@ pub const LanguageSupport = struct { /// frame the pane is visible, so a provider should cache its own parse internally /// keyed by content hash). previewPane: ?*const fn (state: *anyopaque, ext: []const u8, bytes: []const u8, id_extra: u64, gpa: std.mem.Allocator) anyerror!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 + /// latency on first use. Gate on `ext` yourself and no-op when the file isn't yours. + /// Must never block; kick off async work as a side effect. `bytes`/`path` are only + /// valid for the duration of the call — copy anything the background work will need. + documentOpened: ?*const fn (state: *anyopaque, ext: []const u8, path: []const u8, bytes: []const u8) void = null, /// Non-blocking: return a cached/ready hover result for `byte_offset` in `bytes` /// (the document at `path`). Called every frame the mouse dwells over a token, so it /// must never block — kick off async work as a side effect. Three-state return: @@ -98,9 +103,10 @@ pub const LanguageSupport = struct { /// trigger — so it must never block; kick off async work as a side effect and return /// null until a result is cached. When the cache is populated from a background /// thread, call `sdk.refresh()` so the idle GUI wakes. Never returns an empty - /// (zero-length) slice — that's the same as null. The editor shows the first - /// candidate's `insert_text` as ghost text and all of them in a scrollable dropdown - /// list; Up/Down changes which one is "current" for both. + /// (zero-length) slice — that's the same as null. The editor shows every candidate in a + /// scrollable dropdown list, plus — for the current one, when its `insert_text` literally + /// extends what's already typed — the remainder as ghost text after the caret; Up/Down + /// changes which one is "current" for both. completion: ?*const fn (state: *anyopaque, ext: []const u8, path: []const u8, bytes: []const u8, byte_offset: usize) ?[]const CompletionItem = null, /// Non-blocking, same convention as `hover`/`completion`: many language servers send /// completion candidates with an empty/placeholder `documentation` up front (a lazy-load diff --git a/src/sdk/regions.zig b/src/sdk/regions.zig index 989d0824..ee503ed1 100644 --- a/src/sdk/regions.zig +++ b/src/sdk/regions.zig @@ -103,6 +103,16 @@ pub const NativeMenuItem = struct { /// titled from that contribution's `title`). parent_menu_id: []const u8, title: []const u8, + /// The registered `Command` this item stands for, e.g. `"text.format"`. Optional, and + /// purely about the *chord*: `run` is still what a click invokes. The shell stamps this + /// command's current binding onto the `NSMenuItem` as its key equivalent and restamps on + /// every rebind, so the macOS menu shows the same shortcut as the in-app one instead of + /// none at all. Leave null for an item with no command behind it — the item then never + /// carries a shortcut. + command: ?[]const u8 = null, + /// SF Symbol name for the item's icon (e.g. `"wand.and.stars"`), matching what the shell's + /// own items use. Null draws no icon. + sf_symbol: ?[]const u8 = null, /// See `MenuContribution.hidden`. hidden: bool = false, ctx: ?*anyopaque = null, diff --git a/src/sdk/sdk_version.zig b/src/sdk/sdk_version.zig index acf8cb24..b4bcf03c 100644 --- a/src/sdk/sdk_version.zig +++ b/src/sdk/sdk_version.zig @@ -15,5 +15,5 @@ const std = @import("std"); pub const sdk_version = std.SemanticVersion{ .major = 0, .minor = 1, - .patch = 43, + .patch = 44, }; diff --git a/src/sdk/settings.zig b/src/sdk/settings.zig index bb44abd6..eee20a70 100644 --- a/src/sdk/settings.zig +++ b/src/sdk/settings.zig @@ -1,16 +1,30 @@ //! Comptime settings API for plugins — Zig build-options-style declaration. //! //! const MySettings = sdk.settings.Schema(struct { -//! insert_spaces_on_tab: bool = true, -//! tab_size: u8 = 4, -//! format_on_save: bool = false, +//! insert_spaces_on_tab: sdk.settings.Value(bool, .{ +//! .description = "Insert spaces instead of a tab character when pressing Tab.", +//! }) = .init(true), +//! tab_size: sdk.settings.Value(TabSize, .{ +//! .description = "Number of spaces a tab occupies.", +//! }) = .init(.@"4"), //! }); //! MySettings.load(host, plugin.id, &values); //! try MySettings.register(host, &plugin, .{ .title = "Text", .value = &values }); //! -//! Plugins register a typed value + field metadata only. The **shell** draws a shared -//! settings UI from `SettingsSchema.fields` (see `PluginSettingsPane`) — plugins do not -//! supply a `draw` callback. +//! **Every setting describes itself.** A field's *type* is a `Value(T, opts)` cell carrying the +//! payload type plus its metadata — a **required** description, and optional display name and +//! numeric bounds. All of that is comptime (`opts` is a type parameter), so a cell costs exactly +//! `@sizeOf(T)` at runtime; only the payload is ever stored. Reads/writes go through +//! `.get()`/`.set()`. +//! +//! Plugins register a typed value + field metadata only. The **shell** draws a shared settings UI +//! from `SettingsSchema.fields` (see `PluginSettingsPane`/`SettingRow`) — plugins do not supply a +//! `draw` callback. +//! +//! **The on-disk shape is the payloads, not the cells.** Everything persistence touches goes +//! through `Plain(T)` — a comptime mirror of `T` with the same field names but bare payload types +//! — so `settings.zon` holds `.{ .tab_size = .@"8" }` exactly as it did before cells existed. No +//! migration, and R11 reconciliation / R12 diff-only persistence are unaffected. //! //! Loaded-only: a `SettingsSchema` exists in the Host registry only while the plugin is //! registered. @@ -19,7 +33,9 @@ const dvui = @import("dvui"); const Plugin = @import("Plugin.zig"); const runtime = @import("runtime.zig"); -pub const TypeTag = enum { bool, int, float, string, enumeration, color }; +/// `other` is the escape hatch: any type `std.zon` can round-trip is a legal setting, and the +/// shell draws whatever it has no dedicated control for as editable zon text. +pub const TypeTag = enum { bool, int, float, string, enumeration, color, other }; pub const IntKind = struct { min: i64, @@ -48,50 +64,98 @@ pub const Kind = union(TypeTag) { string: void, enumeration: EnumKind, color: void, + other: void, }; pub const Setting = struct { + /// The field name, which is also the key in `settings.zon` — shown under the label and + /// matched by the settings search, so a user who knows the zon key can find its row. key: []const u8, + /// Human-readable name derived from `key` (`insert_spaces_on_tab` → `Insert spaces on tab`) + /// unless the cell overrode it with `Options.name`. label: []const u8, + /// What the setting does, in a sentence or two. Required at declaration — see `Options`. + description: []const u8, kind: Kind, }; -/// Type-erased read/write of a `Schema(T).Value` for the shell's generic settings UI. -pub const Access = struct { - getBool: *const fn (value: *anyopaque, field_index: usize) bool, - setBool: *const fn (value: *anyopaque, field_index: usize, v: bool) void, - getInt: *const fn (value: *anyopaque, field_index: usize) i64, - setInt: *const fn (value: *anyopaque, field_index: usize, v: i64) void, - getFloat: *const fn (value: *anyopaque, field_index: usize) f64, - setFloat: *const fn (value: *anyopaque, field_index: usize, v: f64) void, - getEnumIndex: *const fn (value: *anyopaque, field_index: usize) usize, - setEnumIndex: *const fn (value: *anyopaque, field_index: usize, choice_index: usize) void, - getString: *const fn (value: *anyopaque, field_index: usize) []const u8, - setString: *const fn (value: *anyopaque, field_index: usize, v: []const u8) void, - /// Persist `value` via `host.storePluginSettings(owner.id, zon)` and notify `owner`. - persist: *const fn (value: *anyopaque, owner: *Plugin) void, - /// Parse `blob` into `value` and notify `owner.settingsChanged(blob)` — the reconciliation- - /// path counterpart to `persist` (which goes the other direction: live value → disk). Used - /// only by external-change reconciliation (see R11 in docs/PLUGIN_MANIFEST_PLAN.md), never - /// by an in-app edit through the settings pane. - applyBlob: *const fn (value: *anyopaque, owner: *Plugin, blob: []const u8) void, +/// Per-setting metadata, supplied as the second (comptime) parameter of `Value`. +/// +/// `description` has no default, so leaving it out is a compile error at the declaration site. +/// That's the whole point: the settings UI has a permanent place for it, and no shell-side table +/// can drift from the plugin that owns the setting. +pub const Options = struct { + description: []const u8, + /// Overrides the name derived from the field name. + name: ?[]const u8 = null, + /// Numeric bounds. Ints/enums derive theirs from the type itself (bit width, tag list), so + /// these matter mostly for floats, where a bare `f32` carries no notion of range. + min: ?f64 = null, + max: ?f64 = null, + step: ?f64 = null, }; -pub const SettingsSchema = struct { - owner: *Plugin, - title: []const u8, - fields: []const Setting, - /// Pointer to the plugin's `Schema(T).Value` (stable for the loaded lifetime). - value: *anyopaque, - access: *const Access, - /// Hash of the last `.settings` blob text actually applied to `value` (seeded at `register()` - /// time from whatever `loadPluginSettings` returned, if anything). Lets external-change - /// reconciliation skip re-parsing/re-notifying a plugin whose own `.plugins..settings` - /// hasn't changed, even when *some other* part of settings.zon has (see R11) — without this, - /// any change anywhere in the file would spuriously renotify every loaded plugin, not just - /// the one that changed. - last_applied_hash: u64 = 0, -}; +/// One self-describing setting. Use it as a field type in the struct handed to `Schema`, with the +/// default value supplied by the field default: +/// +/// window_opacity: Value(f32, .{ .description = "…", .min = 0, .max = 1 }) = .init(0.9), +/// +/// The metadata lives in the type, so `@sizeOf(Value(T, …)) == @sizeOf(T)`. +pub fn Value(comptime T: type, comptime opts: Options) type { + return struct { + const Self = @This(); + + v: T, + + pub const Payload = T; + pub const setting_options = opts; + /// Marker `Schema` uses to tell a cell from a bare field (see `isCell`). + pub const is_setting_cell = true; + + pub fn init(default_payload: T) Self { + return .{ .v = default_payload }; + } + + pub fn get(self: Self) T { + return self.v; + } + + pub fn set(self: *Self, payload: T) void { + self.v = payload; + } + }; +} + +fn isCell(comptime T: type) bool { + return switch (@typeInfo(T)) { + .@"struct" => @hasDecl(T, "is_setting_cell"), + else => false, + }; +} + +/// Compile error for a settings struct field that isn't a `Value(...)` cell. Bare fields used to +/// be the whole API, so this names the fix rather than just the rule. +fn requireCell(comptime T: type, comptime field_name: []const u8, comptime owner: type) void { + if (!isCell(T)) @compileError( + "sdk.settings.Schema: field '" ++ field_name ++ "' of " ++ @typeName(owner) ++ + " must be a settings cell — `" ++ field_name ++ + ": sdk.settings.Value(" ++ @typeName(T) ++ + ", .{ .description = \"…\" }) = .init()`. Every setting needs a description.", + ); +} + +/// `insert_spaces_on_tab` → `Insert spaces on tab`: underscores become spaces and the first letter +/// is capitalized. Sentence case, not title case — a description follows it, and title-casing +/// every word reads like a menu item rather than a setting name. +fn deriveLabel(comptime key: []const u8) []const u8 { + comptime { + var buf: [key.len]u8 = undefined; + for (key, 0..) |c, i| buf[i] = if (c == '_') ' ' else c; + if (buf.len > 0) buf[0] = std.ascii.toUpper(buf[0]); + const frozen = buf; + return &frozen; + } +} fn typeTagFor(comptime T: type) TypeTag { return switch (@typeInfo(T)) { @@ -99,11 +163,8 @@ fn typeTagFor(comptime T: type) TypeTag { .int => .int, .float => .float, .@"enum" => .enumeration, - .pointer => |p| if (p.size == .slice and p.child == u8) - .string - else - @compileError("sdk.settings.Schema: unsupported field type " ++ @typeName(T)), - else => @compileError("sdk.settings.Schema: unsupported field type " ++ @typeName(T)), + .pointer => |p| if (p.size == .slice and p.child == u8) .string else .other, + else => .other, }; } @@ -125,17 +186,29 @@ fn enumChoices(comptime EnumT: type) []const []const u8 { return &frozen; } -/// Derive a field's `Kind` from its Zig type. `int`/`enumeration` get bounds/choices for free -/// from the type itself (bit-width, tag names); `float` has no such source (a bare `f32` carries -/// no notion of range), so it falls back to `FloatKind`'s defaults (0..1, step 0.01). -fn kindFor(comptime T: type) Kind { +/// Derive a field's `Kind` from its payload type, refined by whatever bounds the cell declared. +/// `int`/`enumeration` get bounds/choices for free from the type itself (bit-width, tag names); +/// `float` has no such source (a bare `f32` carries no notion of range), so it falls back to +/// `FloatKind`'s defaults (0..1, step 0.01) unless `Options` narrowed them. +fn kindFor(comptime T: type, comptime opts: Options) Kind { return switch (typeTagFor(T)) { .bool => .{ .bool = {} }, - .int => .{ .int = .{ .min = intBounds(T).min, .max = intBounds(T).max } }, - .float => .{ .float = .{} }, + .int => blk: { + const bounds = intBounds(T); + break :blk .{ .int = .{ + .min = if (opts.min) |m| @intFromFloat(m) else bounds.min, + .max = if (opts.max) |m| @intFromFloat(m) else bounds.max, + } }; + }, + .float => .{ .float = .{ + .min = opts.min orelse 0, + .max = opts.max orelse 1, + .step = opts.step orelse 0.01, + } }, .string => .{ .string = {} }, .enumeration => .{ .enumeration = .{ .choices = enumChoices(T) } }, .color => .{ .color = {} }, + .other => .{ .other = {} }, }; } @@ -143,37 +216,127 @@ fn buildSettings(comptime T: type) [std.meta.fields(T).len]Setting { const struct_fields = std.meta.fields(T); var out: [struct_fields.len]Setting = undefined; inline for (struct_fields, 0..) |f, i| { + requireCell(f.type, f.name, T); + const opts = f.type.setting_options; out[i] = .{ .key = f.name, - .label = f.name, - .kind = kindFor(f.type), + .label = opts.name orelse deriveLabel(f.name), + .description = opts.description, + .kind = kindFor(f.type.Payload, opts), }; } return out; } -/// Build a settings namespace for plain struct `T`. Every field of `T` must declare a default -/// value — required to compute `default_value` below, which the non-default-only persistence -/// (see `diffSerialize`, R12 in docs/PLUGIN_MANIFEST_PLAN.md) diffs every value against. +/// Comptime mirror of a cell struct with the same field names but bare payload types, carrying +/// each cell's declared default. Everything that touches disk — `std.zon` parse/stringify, the +/// diff against defaults — works on this, which is why the on-disk format never learned that +/// cells exist. +fn Plain(comptime T: type) type { + const cell_fields = std.meta.fields(T); + var names: [cell_fields.len][:0]const u8 = undefined; + var types: [cell_fields.len]type = undefined; + var attrs: [cell_fields.len]std.builtin.Type.StructField.Attributes = undefined; + inline for (cell_fields, 0..) |f, i| { + requireCell(f.type, f.name, T); + const P = f.type.Payload; + const cell_default = f.defaultValue() orelse @compileError( + "sdk.settings.Schema: field '" ++ f.name ++ "' of " ++ @typeName(T) ++ + " needs a default value (`= .init()`) — required so settings.zon only " ++ + "has to record what differs from it", + ); + const payload_default: P = cell_default.v; + names[i] = f.name; + types[i] = P; + attrs[i] = .{ .default_value_ptr = @ptrCast(&payload_default) }; + } + const frozen_names = names; + const frozen_types = types; + const frozen_attrs = attrs; + return @Struct(.auto, null, &frozen_names, &frozen_types, &frozen_attrs); +} + +/// Type-erased read/write of a `Schema(T).Value` for the shell's generic settings UI. +pub const Access = struct { + getBool: *const fn (value: *anyopaque, field_index: usize) bool, + setBool: *const fn (value: *anyopaque, field_index: usize, v: bool) void, + getInt: *const fn (value: *anyopaque, field_index: usize) i64, + setInt: *const fn (value: *anyopaque, field_index: usize, v: i64) void, + getFloat: *const fn (value: *anyopaque, field_index: usize) f64, + setFloat: *const fn (value: *anyopaque, field_index: usize, v: f64) void, + getEnumIndex: *const fn (value: *anyopaque, field_index: usize) usize, + setEnumIndex: *const fn (value: *anyopaque, field_index: usize, choice_index: usize) void, + getString: *const fn (value: *anyopaque, field_index: usize) []const u8, + setString: *const fn (value: *anyopaque, field_index: usize, v: []const u8) void, + /// Zon text of one field's payload, allocated in `arena` — how the shell shows (and edits) a + /// `.other` setting it has no dedicated control for. Returns "" if serialization fails. + getZonText: *const fn (value: *anyopaque, field_index: usize, arena: std.mem.Allocator) []const u8, + /// Parse `text` as zon into one field. Returns false (leaving the field untouched) when the + /// text doesn't parse as that field's type — the pane keeps showing the old value. + setZonText: *const fn (value: *anyopaque, field_index: usize, text: []const u8) bool, + /// Persist `value` via `host.storePluginSettings(owner.id, zon)` and notify `owner`. + persist: *const fn (value: *anyopaque, owner: *Plugin) void, + /// Parse `blob` into `value` and notify `owner.settingsChanged(blob)` — the reconciliation- + /// path counterpart to `persist` (which goes the other direction: live value → disk). Used + /// only by external-change reconciliation (see R11 in docs/PLUGIN_MANIFEST_PLAN.md), never + /// by an in-app edit through the settings pane. + applyBlob: *const fn (value: *anyopaque, owner: *Plugin, blob: []const u8) void, +}; + +pub const SettingsSchema = struct { + owner: *Plugin, + title: []const u8, + fields: []const Setting, + /// Pointer to the plugin's `Schema(T).Value` (stable for the loaded lifetime). + value: *anyopaque, + access: *const Access, + /// Hash of the last `.settings` blob text actually applied to `value` (seeded at `register()` + /// time from whatever `loadPluginSettings` returned, if anything). Lets external-change + /// reconciliation skip re-parsing/re-notifying a plugin whose own `.plugins..settings` + /// hasn't changed, even when *some other* part of settings.zon has (see R11) — without this, + /// any change anywhere in the file would spuriously renotify every loaded plugin, not just + /// the one that changed. + last_applied_hash: u64 = 0, +}; + +/// Build a settings namespace for `T`, a struct whose every field is a `Value(...)` cell with a +/// default (`= .init(…)`). The default is required to compute `default_value` below, which the +/// non-default-only persistence (see `diffSerialize`, R12 in docs/PLUGIN_MANIFEST_PLAN.md) diffs +/// every value against. pub fn Schema(comptime T: type) type { const struct_fields = std.meta.fields(T); const built_settings = buildSettings(T); - const default_value: T = blk: { - inline for (struct_fields) |f| { - _ = f.defaultValue() orelse @compileError( - "sdk.settings.Schema: field '" ++ f.name ++ "' of " ++ @typeName(T) ++ - " needs a default value (required so settings.zon only has to record what " ++ - "differs from it)", - ); - } - break :blk .{}; - }; - return struct { - pub const Value = T; + /// The plugin's own settings struct (the one made of cells). Named `Cells` rather than + /// `Value` so it doesn't shadow the module-level `Value` cell constructor. + pub const Cells = T; + /// Bare-payload mirror — the shape that is parsed from and written to `settings.zon`. + pub const Payloads = Plain(T); pub const settings: []const Setting = &built_settings; + const default_payloads: Payloads = .{}; + + /// Payload type of field `i`. + fn PayloadOf(comptime i: usize) type { + return struct_fields[i].type.Payload; + } + + /// Live payload of a field, by name — cells are transparent to everything in here. + fn payloadPtr(v: *T, comptime name: []const u8) *@FieldType(Payloads, name) { + return &@field(v.*, name).v; + } + + fn toPayloads(v: T) Payloads { + var out: Payloads = undefined; + inline for (struct_fields) |f| @field(out, f.name) = @field(v, f.name).v; + return out; + } + + fn fromPayloads(out: *T, p: Payloads) void { + inline for (struct_fields) |f| @field(out.*, f.name).v = @field(p, f.name); + } + pub fn load(host: anytype, id: []const u8, out: *T) void { const blob = host.loadPluginSettings(id) orelse return; defer host.allocator.free(blob); @@ -184,11 +347,11 @@ pub fn Schema(comptime T: type) type { /// documents `blob` as "the whole, freshly-serialized zon text." What's actually /// persisted to disk is `diffSerialize`'s smaller, non-default-only blob instead; the two /// don't need to match byte-for-byte, and a plugin's own `applyZon`-based re-parse can't - /// tell the difference either way (missing fields fill from `T`'s own defaults). + /// tell the difference either way (missing fields fill from the declared defaults). fn fullSerialize(gpa: std.mem.Allocator, value: T) ![]u8 { var aw: std.Io.Writer.Allocating = .init(gpa); errdefer aw.deinit(); - try std.zon.stringify.serialize(value, .{}, &aw.writer); + try std.zon.stringify.serialize(toPayloads(value), .{}, &aw.writer); return aw.toOwnedSlice(); } @@ -196,30 +359,34 @@ pub fn Schema(comptime T: type) type { return switch (@typeInfo(FT)) { .bool, .int, .float, .@"enum" => a == b, .pointer => |p| if (p.size == .slice and p.child == u8) std.mem.eql(u8, a, b) else false, - else => false, + else => std.meta.eql(a, b), }; } fn isAllDefault(value: T) bool { + const p = toPayloads(value); inline for (struct_fields) |f| { - if (!fieldEqual(f.type, @field(value, f.name), @field(default_value, f.name))) return false; + const P = f.type.Payload; + if (!fieldEqual(P, @field(p, f.name), @field(default_payloads, f.name))) return false; } return true; } - /// Serializes only the fields of `value` that differ from `T`'s own declared defaults — + /// Serializes only the fields of `value` that differ from their declared defaults — /// e.g. `.{ .tab_size = 8 }` instead of every field. Returns `null` when `value` is /// entirely default, signaling "nothing to persist" (the caller removes any existing /// on-disk entry for this id entirely, rather than writing an empty/default blob). fn diffSerialize(gpa: std.mem.Allocator, value: T) !?[]u8 { if (isAllDefault(value)) return null; + const p = toPayloads(value); var aw: std.Io.Writer.Allocating = .init(gpa); errdefer aw.deinit(); try aw.writer.writeAll(".{\n"); inline for (struct_fields) |f| { - if (!fieldEqual(f.type, @field(value, f.name), @field(default_value, f.name))) { + const P = f.type.Payload; + if (!fieldEqual(P, @field(p, f.name), @field(default_payloads, f.name))) { try aw.writer.print(" .{f} = ", .{std.zig.fmtId(f.name)}); - try std.zon.stringify.serialize(@field(value, f.name), .{}, &aw.writer); + try std.zon.stringify.serialize(@field(p, f.name), .{}, &aw.writer); try aw.writer.writeAll(",\n"); } } @@ -245,40 +412,40 @@ pub fn Schema(comptime T: type) type { } } - /// True when `T`'s field `name` currently points at storage this schema allocated, rather - /// than at the declared default. See `freeOwned` for the rule this encodes. + /// True when `value`'s field `name` currently points at storage this schema allocated, + /// rather than at the declared default. See `freeOwned` for the rule this encodes. fn fieldIsOwned(comptime name: []const u8, value: T) bool { - return @field(value, name).ptr != @field(default_value, name).ptr; + return @field(value, name).v.ptr != @field(default_payloads, name).ptr; } /// Releases every string field of `value` that this schema allocated. /// - /// **Ownership rule for `[]const u8` settings fields:** a field's bytes belong to this + /// **Ownership rule for `[]const u8` settings payloads:** a field's bytes belong to this /// schema (allocated with `runtime.allocator()` by `applyZon`/`setString`) *unless* the - /// slice is exactly `T`'s declared default, which lives in the plugin image's constant - /// data and must never reach an allocator. Pointer identity is the test — a parsed value - /// that happens to *equal* the default string is still schema-owned and still freed. + /// slice is exactly the declared default, which lives in the plugin image's constant data + /// and must never reach an allocator. Pointer identity is the test — a parsed value that + /// happens to *equal* the default string is still schema-owned and still freed. /// /// Whole-struct `std.zon.parse.free` can't be used here: a settings.zon that omits a /// field (the normal case, since only non-default fields are persisted — R12) makes /// `std.zon.parse` fill it from that same declared default, so freeing the parse result - /// wholesale would hand a string literal to the allocator. Non-string fields need no - /// release at all — `typeTagFor` restricts `T` to scalars, enums, and `[]const u8`. + /// wholesale would hand a string literal to the allocator. Payloads that aren't strings + /// or don't contain any need no release at all. fn freeOwned(gpa: std.mem.Allocator, value: T) void { inline for (struct_fields) |f| { - if (comptime typeTagFor(f.type) == .string) { - if (fieldIsOwned(f.name, value)) gpa.free(@field(value, f.name)); + if (comptime typeTagFor(f.type.Payload) == .string) { + if (fieldIsOwned(f.name, value)) gpa.free(@field(value, f.name).v); } } } - /// Releases anything this schema allocated into `value` and resets it to `T`'s declared + /// Releases anything this schema allocated into `value` and resets it to the declared /// defaults. Plugins with a string setting should call this when they tear down the /// value they passed to `register` (a plugin whose settings are all scalars/enums may /// skip it — it's a no-op there). Safe to call more than once. pub fn deinit(value: *T) void { freeOwned(runtime.allocator(), value.*); - value.* = default_value; + fromPayloads(value, default_payloads); } pub fn applyZon(out: *T, blob: []const u8) void { @@ -289,14 +456,14 @@ pub fn Schema(comptime T: type) type { }; defer gpa.free(blob_z); - const parsed = std.zon.parse.fromSliceAlloc(T, gpa, blob_z, null, .{ + const parsed = std.zon.parse.fromSliceAlloc(Payloads, gpa, blob_z, null, .{ .ignore_unknown_fields = true, }) catch |err| { dvui.log.warn("sdk.settings: failed to parse settings: {s}", .{@errorName(err)}); return; }; freeOwned(gpa, out.*); - out.* = parsed; + fromPayloads(out, parsed); } fn asValue(ptr: *anyopaque) *T { @@ -307,8 +474,8 @@ pub fn Schema(comptime T: type) type { const v = asValue(ptr); inline for (struct_fields, 0..) |f, i| { if (i == field_index) { - if (@typeInfo(f.type) != .bool) return false; - return @field(v.*, f.name); + if (@typeInfo(f.type.Payload) != .bool) return false; + return payloadPtr(v, f.name).*; } } return false; @@ -318,7 +485,7 @@ pub fn Schema(comptime T: type) type { const v = asValue(ptr); inline for (struct_fields, 0..) |f, i| { if (i == field_index) { - if (@typeInfo(f.type) == .bool) @field(v.*, f.name) = b; + if (@typeInfo(f.type.Payload) == .bool) payloadPtr(v, f.name).* = b; return; } } @@ -328,8 +495,8 @@ pub fn Schema(comptime T: type) type { const v = asValue(ptr); inline for (struct_fields, 0..) |f, i| { if (i == field_index) { - if (@typeInfo(f.type) != .int) return 0; - return @intCast(@field(v.*, f.name)); + if (@typeInfo(f.type.Payload) != .int) return 0; + return @intCast(payloadPtr(v, f.name).*); } } return 0; @@ -339,8 +506,9 @@ pub fn Schema(comptime T: type) type { const v = asValue(ptr); inline for (struct_fields, 0..) |f, i| { if (i == field_index) { - if (@typeInfo(f.type) == .int) { - @field(v.*, f.name) = std.math.cast(f.type, n) orelse @field(v.*, f.name); + if (@typeInfo(f.type.Payload) == .int) { + const p = payloadPtr(v, f.name); + p.* = std.math.cast(f.type.Payload, n) orelse p.*; } return; } @@ -351,8 +519,8 @@ pub fn Schema(comptime T: type) type { const v = asValue(ptr); inline for (struct_fields, 0..) |f, i| { if (i == field_index) { - if (@typeInfo(f.type) != .float) return 0; - return @floatCast(@field(v.*, f.name)); + if (@typeInfo(f.type.Payload) != .float) return 0; + return @floatCast(payloadPtr(v, f.name).*); } } return 0; @@ -362,7 +530,7 @@ pub fn Schema(comptime T: type) type { const v = asValue(ptr); inline for (struct_fields, 0..) |f, i| { if (i == field_index) { - if (@typeInfo(f.type) == .float) @field(v.*, f.name) = @floatCast(n); + if (@typeInfo(f.type.Payload) == .float) payloadPtr(v, f.name).* = @floatCast(n); return; } } @@ -372,13 +540,14 @@ pub fn Schema(comptime T: type) type { const v = asValue(ptr); inline for (struct_fields, 0..) |f, i| { if (i == field_index) { - if (@typeInfo(f.type) != .@"enum") return 0; + const P = f.type.Payload; + if (@typeInfo(P) != .@"enum") return 0; // Declaration-order position, matching `enumChoices`' `choices` list and - // `setEnumIndex`'s `std.meta.tags(f.type)[choice_index]` — not the enum's - // raw backing integer, which for an explicit-value enum like `TabSize` + // `setEnumIndex`'s `std.meta.tags(P)[choice_index]` — not the enum's raw + // backing integer, which for an explicit-value enum like `TabSize` // (`@"2" = 2, @"4" = 4, @"8" = 8`) is out of range for `choices.len`. - const current = @field(v.*, f.name); - const tags = std.meta.tags(f.type); + const current = payloadPtr(v, f.name).*; + const tags = std.meta.tags(P); inline for (tags, 0..) |t, ti| { if (t == current) return ti; } @@ -392,9 +561,10 @@ pub fn Schema(comptime T: type) type { const v = asValue(ptr); inline for (struct_fields, 0..) |f, i| { if (i == field_index) { - if (@typeInfo(f.type) == .@"enum") { - const tags = std.meta.tags(f.type); - if (choice_index < tags.len) @field(v.*, f.name) = tags[choice_index]; + const P = f.type.Payload; + if (@typeInfo(P) == .@"enum") { + const tags = std.meta.tags(P); + if (choice_index < tags.len) payloadPtr(v, f.name).* = tags[choice_index]; } return; } @@ -405,7 +575,7 @@ pub fn Schema(comptime T: type) type { const v = asValue(ptr); inline for (struct_fields, 0..) |f, i| { if (i == field_index) { - if (comptime typeTagFor(f.type) == .string) return @field(v.*, f.name); + if (comptime typeTagFor(f.type.Payload) == .string) return payloadPtr(v, f.name).*; return ""; } } @@ -420,19 +590,58 @@ pub fn Schema(comptime T: type) type { const gpa = runtime.allocator(); inline for (struct_fields, 0..) |f, i| { if (i == field_index) { - if (comptime typeTagFor(f.type) == .string) { + if (comptime typeTagFor(f.type.Payload) == .string) { const copy = gpa.dupe(u8, s) catch |err| { dvui.log.warn("sdk.settings: failed to set '{s}': {s}", .{ f.name, @errorName(err) }); return; }; - if (fieldIsOwned(f.name, v.*)) gpa.free(@field(v.*, f.name)); - @field(v.*, f.name) = copy; + if (fieldIsOwned(f.name, v.*)) gpa.free(@field(v.*, f.name).v); + payloadPtr(v, f.name).* = copy; } return; } } } + fn getZonText(ptr: *anyopaque, field_index: usize, arena: std.mem.Allocator) []const u8 { + const v = asValue(ptr); + inline for (struct_fields, 0..) |f, i| { + if (i == field_index) { + var aw: std.Io.Writer.Allocating = .init(arena); + std.zon.stringify.serialize(payloadPtr(v, f.name).*, .{}, &aw.writer) catch { + aw.deinit(); + return ""; + }; + return aw.toOwnedSlice() catch ""; + } + } + return ""; + } + + /// Parses `text` as this field's payload type. Anything the parser rejects leaves the + /// live value alone — the pane re-seeds itself from it, so a half-typed entry can't + /// clobber a good setting. + fn setZonText(ptr: *anyopaque, field_index: usize, text: []const u8) bool { + const v = asValue(ptr); + const gpa = runtime.allocator(); + inline for (struct_fields, 0..) |f, i| { + if (i == field_index) { + const P = f.type.Payload; + const text_z = gpa.dupeZ(u8, text) catch return false; + defer gpa.free(text_z); + const parsed = std.zon.parse.fromSliceAlloc(P, gpa, text_z, null, .{}) catch return false; + // Strings follow the same ownership rule as `setString`; other payloads are + // plain values the parse allocated nothing for. + if (comptime typeTagFor(P) == .string) { + if (fieldIsOwned(f.name, v.*)) gpa.free(@field(v.*, f.name).v); + } + payloadPtr(v, f.name).* = parsed; + return true; + } + } + return false; + } + const access_vtable: Access = .{ .getBool = getBool, .setBool = setBool, @@ -444,12 +653,14 @@ pub fn Schema(comptime T: type) type { .setEnumIndex = setEnumIndex, .getString = getString, .setString = setString, + .getZonText = getZonText, + .setZonText = setZonText, .persist = persistValue, .applyBlob = applyBlobValue, }; /// Queues `value`'s **non-default fields only** for the next merged settings.zon write, or - /// queues the whole entry's removal when nothing differs from `T`'s declared defaults. + /// queues the whole entry's removal when nothing differs from the declared defaults. /// Returns false (already logged) if it couldn't queue anything. /// /// Shared by both writers — the in-app edit path (`persistValue`) and the external @@ -544,12 +755,12 @@ pub fn Schema(comptime T: type) type { const testing = std.testing; -test "Schema() derives field metadata from a plain struct" { +test "Schema() derives field metadata from a struct of cells" { const S = Schema(struct { - insert_spaces_on_tab: bool = true, - tab_size: u8 = 4, - ratio: f32 = 1.0, - mode: enum { fast, slow } = .fast, + insert_spaces_on_tab: Value(bool, .{ .description = "Spaces, not tabs." }) = .init(true), + tab_size: Value(u8, .{ .description = "Tab width." }) = .init(4), + ratio: Value(f32, .{ .description = "A ratio." }) = .init(1.0), + mode: Value(enum { fast, slow }, .{ .description = "How fast." }) = .init(.fast), }); try testing.expectEqual(@as(usize, 4), S.settings.len); @@ -560,6 +771,32 @@ test "Schema() derives field metadata from a plain struct" { try testing.expectEqual(TypeTag.float, std.meta.activeTag(S.settings[2].kind)); try testing.expectEqual(TypeTag.enumeration, std.meta.activeTag(S.settings[3].kind)); try testing.expectEqualStrings("fast", S.settings[3].kind.enumeration.choices[0]); + + // Every field carries the description it declared, and no field can omit one (that's a + // compile error at the declaration site, so it can't be asserted here). + try testing.expectEqualStrings("Spaces, not tabs.", S.settings[0].description); +} + +test "labels are derived in sentence case, and Options.name overrides" { + const S = Schema(struct { + insert_spaces_on_tab: Value(bool, .{ .description = "d" }) = .init(true), + tab_size: Value(u8, .{ .description = "d", .name = "Tab Size (spaces)" }) = .init(4), + }); + + try testing.expectEqualStrings("insert_spaces_on_tab", S.settings[0].key); + try testing.expectEqualStrings("Insert spaces on tab", S.settings[0].label); + // Override wins, key is untouched. + try testing.expectEqualStrings("tab_size", S.settings[1].key); + try testing.expectEqualStrings("Tab Size (spaces)", S.settings[1].label); +} + +test "Options bounds refine the derived float kind" { + const S = Schema(struct { + opacity: Value(f32, .{ .description = "d", .min = 0.2, .max = 4, .step = 0.5 }) = .init(1), + }); + try testing.expectEqual(@as(f64, 0.2), S.settings[0].kind.float.min); + try testing.expectEqual(@as(f64, 4), S.settings[0].kind.float.max); + try testing.expectEqual(@as(f64, 0.5), S.settings[0].kind.float.step); } test "getEnumIndex/setEnumIndex use declaration-order position, not the enum's backing value" { @@ -569,71 +806,72 @@ test "getEnumIndex/setEnumIndex use declaration-order position, not the enum's b // 0/1/2/... — the settings pane's dropdown preview showed "?" for every value. const TabSize = enum(u8) { @"2" = 2, @"4" = 4, @"8" = 8 }; const S = Schema(struct { - tab_size: TabSize = .@"4", + tab_size: Value(TabSize, .{ .description = "d" }) = .init(.@"4"), }); - var value: S.Value = .{ .tab_size = .@"4" }; + var value: S.Cells = .{}; // Declaration-order position (1), not the backing value (4). try testing.expectEqual(@as(usize, 1), S.access_vtable.getEnumIndex(&value, 0)); S.access_vtable.setEnumIndex(&value, 0, 2); - try testing.expectEqual(TabSize.@"8", value.tab_size); + try testing.expectEqual(TabSize.@"8", value.tab_size.get()); try testing.expectEqual(@as(usize, 2), S.access_vtable.getEnumIndex(&value, 0)); } -test "applyZon parses a zon blob into the value type" { +test "applyZon parses a bare-payload zon blob into the cells" { runtime.installRuntime(&testing.allocator, null, null); const S = Schema(struct { - tab_size: u8 = 4, - format_on_save: bool = false, + tab_size: Value(u8, .{ .description = "d" }) = .init(4), + format_on_save: Value(bool, .{ .description = "d" }) = .init(false), }); - var value: S.Value = .{}; + var value: S.Cells = .{}; + // Exactly the on-disk shape from before cells existed — payloads, not `.{ .v = … }`. S.applyZon(&value, ".{ .tab_size = 8, .format_on_save = true }"); - try testing.expectEqual(@as(u8, 8), value.tab_size); - try testing.expectEqual(true, value.format_on_save); + try testing.expectEqual(@as(u8, 8), value.tab_size.get()); + try testing.expectEqual(true, value.format_on_save.get()); } test "applyZon replaces a string field without freeing its declared default" { // Regression: `applyZon` used to `std.zon.parse.free` the whole previous value. A string - // field still holding `T`'s declared default points at constant data, so that freed a + // field still holding the declared default points at constant data, so that freed a // non-allocation — and since R12 omits default fields from disk, a *parsed* value hits this // too (missing fields are filled from the same literals). `testing.allocator` panics on // both the invalid free and any leak, so this test covers both directions. runtime.installRuntime(&testing.allocator, null, null); const S = Schema(struct { - greeting: []const u8 = "hello", - tab_size: u8 = 4, + greeting: Value([]const u8, .{ .description = "d" }) = .init("hello"), + tab_size: Value(u8, .{ .description = "d" }) = .init(4), }); - var value: S.Value = .{}; + var value: S.Cells = .{}; S.applyZon(&value, ".{ .greeting = \"hi\" }"); - try testing.expectEqualStrings("hi", value.greeting); + try testing.expectEqualStrings("hi", value.greeting.get()); // Second apply must free the first parse's allocation, not the literal. S.applyZon(&value, ".{ .greeting = \"hey\", .tab_size = 8 }"); - try testing.expectEqualStrings("hey", value.greeting); - try testing.expectEqual(@as(u8, 8), value.tab_size); + try testing.expectEqualStrings("hey", value.greeting.get()); + try testing.expectEqual(@as(u8, 8), value.tab_size.get()); // A blob that omits the field puts the declared default literal back... S.applyZon(&value, ".{ .tab_size = 2 }"); - try testing.expectEqualStrings("hello", value.greeting); + try testing.expectEqualStrings("hello", value.greeting.get()); // ...and releasing the value then has nothing to free for that field. S.deinit(&value); - try testing.expectEqualStrings("hello", value.greeting); + try testing.expectEqualStrings("hello", value.greeting.get()); } test "setString copies into schema-owned storage and releases the previous value" { runtime.installRuntime(&testing.allocator, null, null); const S = Schema(struct { - greeting: []const u8 = "hello", + greeting: Value([]const u8, .{ .description = "d" }) = .init("hello"), }); - var value: S.Value = .{}; + var value: S.Cells = .{}; var scratch: [8]u8 = "howdy\x00\x00\x00".*; S.access_vtable.setString(&value, 0, scratch[0..5]); // Owned copy, not a borrow of the caller's buffer. @@ -649,11 +887,11 @@ test "diffSerialize compares string fields by content, not pointer" { runtime.installRuntime(&testing.allocator, null, null); const S = Schema(struct { - greeting: []const u8 = "hello", + greeting: Value([]const u8, .{ .description = "d" }) = .init("hello"), }); // An allocated copy that *equals* the default is still default for persistence purposes. - var value: S.Value = .{}; + var value: S.Cells = .{}; S.access_vtable.setString(&value, 0, "hello"); try testing.expect(try S.diffSerialize(testing.allocator, value) == null); @@ -666,12 +904,12 @@ test "diffSerialize compares string fields by content, not pointer" { test "diffSerialize returns null when every field is default (R12 non-default-only persistence)" { const S = Schema(struct { - insert_spaces_on_tab: bool = true, - tab_size: u8 = 4, - format_on_save: bool = false, + insert_spaces_on_tab: Value(bool, .{ .description = "d" }) = .init(true), + tab_size: Value(u8, .{ .description = "d" }) = .init(4), + format_on_save: Value(bool, .{ .description = "d" }) = .init(false), }); - const all_default: S.Value = .{}; + const all_default: S.Cells = .{}; try testing.expect(try S.diffSerialize(testing.allocator, all_default) == null); } @@ -681,46 +919,76 @@ test "isAllDefault treats a field explicitly spelled out at its default as still // so the entry is redundant and should come back out of the file. (The partial case — some // fields default, some not — is `diffSerialize`'s job; see the test below.) const S = Schema(struct { - insert_spaces_on_tab: bool = true, - tab_size: u8 = 4, + insert_spaces_on_tab: Value(bool, .{ .description = "d" }) = .init(true), + tab_size: Value(u8, .{ .description = "d" }) = .init(4), }); try testing.expect(S.isAllDefault(.{})); - try testing.expect(S.isAllDefault(.{ .insert_spaces_on_tab = true, .tab_size = 4 })); - try testing.expect(!S.isAllDefault(.{ .insert_spaces_on_tab = false })); - try testing.expect(!S.isAllDefault(.{ .tab_size = 8 })); + try testing.expect(S.isAllDefault(.{ .insert_spaces_on_tab = .init(true), .tab_size = .init(4) })); + try testing.expect(!S.isAllDefault(.{ .insert_spaces_on_tab = .init(false) })); + try testing.expect(!S.isAllDefault(.{ .tab_size = .init(8) })); } -test "diffSerialize emits only the fields that differ from T's own defaults" { +test "diffSerialize emits only the fields that differ from the declared defaults" { const S = Schema(struct { - insert_spaces_on_tab: bool = true, - tab_size: u8 = 4, - format_on_save: bool = false, + insert_spaces_on_tab: Value(bool, .{ .description = "d" }) = .init(true), + tab_size: Value(u8, .{ .description = "d" }) = .init(4), + format_on_save: Value(bool, .{ .description = "d" }) = .init(false), }); - const changed: S.Value = .{ .tab_size = 8 }; + const changed: S.Cells = .{ .tab_size = .init(8) }; const blob = (try S.diffSerialize(testing.allocator, changed)).?; defer testing.allocator.free(blob); - try testing.expect(std.mem.indexOf(u8, blob, "tab_size") != null); + // The on-disk shape is bare payloads: `.tab_size = 8`, not `.tab_size = .{ .v = 8 }`. + try testing.expect(std.mem.indexOf(u8, blob, "tab_size = 8") != null); + try testing.expect(std.mem.indexOf(u8, blob, ".v") == null); try testing.expect(std.mem.indexOf(u8, blob, "insert_spaces_on_tab") == null); try testing.expect(std.mem.indexOf(u8, blob, "format_on_save") == null); } -test "a partial (diff-only) blob still fills the rest from T's own declared defaults" { +test "a partial (diff-only) blob still fills the rest from the declared defaults" { runtime.installRuntime(&testing.allocator, null, null); const S = Schema(struct { - insert_spaces_on_tab: bool = true, - tab_size: u8 = 4, - format_on_save: bool = false, + insert_spaces_on_tab: Value(bool, .{ .description = "d" }) = .init(true), + tab_size: Value(u8, .{ .description = "d" }) = .init(4), + format_on_save: Value(bool, .{ .description = "d" }) = .init(false), }); - var value: S.Value = .{}; + var value: S.Cells = .{}; // Exactly the shape `diffSerialize` would have produced for `.{ .tab_size = 8 }`. S.applyZon(&value, ".{ .tab_size = 8 }"); - try testing.expectEqual(@as(u8, 8), value.tab_size); - try testing.expectEqual(true, value.insert_spaces_on_tab); - try testing.expectEqual(false, value.format_on_save); + try testing.expectEqual(@as(u8, 8), value.tab_size.get()); + try testing.expectEqual(true, value.insert_spaces_on_tab.get()); + try testing.expectEqual(false, value.format_on_save.get()); +} + +test "a type with no dedicated control is still a legal setting, edited as zon text" { + runtime.installRuntime(&testing.allocator, null, null); + + const Margins = struct { x: i32 = 0, y: i32 = 0 }; + const S = Schema(struct { + margins: Value(Margins, .{ .description = "Edge padding." }) = .init(.{}), + }); + + try testing.expectEqual(TypeTag.other, std.meta.activeTag(S.settings[0].kind)); + + var value: S.Cells = .{}; + try testing.expect(S.access_vtable.setZonText(&value, 0, ".{ .x = 3, .y = 7 }")); + try testing.expectEqual(@as(i32, 3), value.margins.get().x); + try testing.expectEqual(@as(i32, 7), value.margins.get().y); + + // A value that doesn't parse leaves the live setting alone. + try testing.expect(!S.access_vtable.setZonText(&value, 0, "not zon at all (")); + try testing.expectEqual(@as(i32, 3), value.margins.get().x); + + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const text = S.access_vtable.getZonText(&value, 0, arena_state.allocator()); + try testing.expect(std.mem.indexOf(u8, text, "3") != null); + + // `.other` payloads take part in diff-vs-default like anything else. + try testing.expect(try S.diffSerialize(testing.allocator, .{}) == null); } diff --git a/src/sdk/version.zig b/src/sdk/version.zig index 9d520223..5a22cb8c 100644 --- a/src/sdk/version.zig +++ b/src/sdk/version.zig @@ -63,7 +63,7 @@ pub const sdk_version = @import("sdk_version.zig").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 = 0x19f79cb227194111; +pub const recorded_sdk_shape_fingerprint: u64 = 0xf5802c523f9bbc82; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/tests/bench/bench_text.zig b/tests/bench/bench_text.zig new file mode 100644 index 00000000..563e96e3 --- /dev/null +++ b/tests/bench/bench_text.zig @@ -0,0 +1,197 @@ +//! `zig build bench-text` — frame-cost benchmark for the text editor's draw path. +//! +//! Drives the real `TextEntryWidget` (the same one `TextEditor.zig` uses, with the same +//! options the editor passes it) over a real Zig source file 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 — tree-sitter query + capture walk, text shaping, layout, event handling. That is +//! where the editor's per-frame time actually goes (see the tree-sitter/shape split below); +//! the GPU submission it omits is the part that doesn't change with document size. +//! +//! Always compare runs at the same `-Doptimize`. The C libraries vendored inside dvui +//! (tree-sitter, the grammar, freetype) compile at the *app's* optimize level, so a Debug run +//! measures an unoptimized tree-sitter and is several times slower than what ships. + +const std = @import("std"); +const dvui = @import("dvui"); +const TextEntryWidget = @import("text").TextEntryWidget; + +/// Two of this repo's own sources — a big one, because that is where per-frame costs that scale +/// with document size show up, and a small one to expose which costs *don't* scale — plus dvui's +/// example query file. All three come in as anonymous imports wired in `build/app.zig` rather +/// than as checked-in fixtures: the repo is already full of Zig, and copies would be ~200KB to +/// keep in sync for no gain. They do drift as those files are edited, so treat numbers as +/// comparable within a session (which is what A/B work needs), not across months. +const sample_large = @embedFile("sample_large"); +const sample_small = @embedFile("sample_small"); +const ts_queries = @embedFile("ts_zig_queries"); + +/// dvui bundles the Zig grammar for its own examples; the real editor gets one from the +/// external `zig` language plugin instead. Same grammar, same query shape — what's being timed +/// (query + per-capture chunk emission) doesn't depend on which of the two supplied it. +extern fn tree_sitter_zig() callconv(.c) *dvui.c.TSLanguage; + +const ts_highlights = [_]TextEntryWidget.HighlightStyle{ + .{ .name = "comment", .opts = .{ .color_text = .fromHex("6A9955") } }, + .{ .name = "keyword", .opts = .{ .color_text = .fromHex("569CD6") } }, + .{ .name = "identifier", .opts = .{ .color_text = .fromHex("D4D4D4") } }, + .{ .name = "function", .opts = .{ .color_text = .fromHex("DCDCAA") } }, + .{ .name = "type", .opts = .{ .color_text = .fromHex("4EC9B0") } }, + .{ .name = "string", .opts = .{ .color_text = .fromHex("CE9178") } }, +}; + +/// Live document + knobs for the frame function, which `dvui.App.frameFunction` requires to +/// take no arguments. +var text: std.ArrayListUnmanaged(u8) = .empty; +var pending_sel: ?usize = null; +var cache_layout: bool = true; +var tree_sitter: bool = true; +var typing: bool = false; +/// Lines scrolled per frame, for the scrolling case — 0 keeps the viewport still. +var scroll_lines_per_frame: f32 = 0; +/// Wheel ticks sent once before the timed frames, to park the viewport somewhere other than the +/// very top — where the query range is clipped by the start of the document and so understates +/// what a mid-document frame really costs. +var park_scroll_ticks: f32 = 0; + +fn frame() !dvui.App.Result { + var te: TextEntryWidget = undefined; + te.init(@src(), .{ + .multiline = true, + .break_lines = false, + .scroll_horizontal = true, + .cache_layout = cache_layout, + .text = .{ .array_list = .{ + .backing = &text, + .allocator = std.testing.allocator, + .limit = 64 * 1024 * 1024, + } }, + .tree_sitter = if (tree_sitter) .{ + .language = @ptrCast(tree_sitter_zig()), + .queries = ts_queries, + .highlights = &ts_highlights, + } else null, + .tab_inserts_indent = true, + .tab_size = 4, + .insert_spaces = true, + .auto_indent_newline = true, + .auto_close_pairs = true, + .highlight_matching_bracket = true, + }, .{ .expand = .both }); + + dvui.focusWidget(te.data().id, null, null); + + if (pending_sel) |c| { + const sel = te.textLayout.selectionGet(te.len); + sel.start = c; + sel.cursor = c; + sel.end = c; + pending_sel = null; + } + + te.processEvents(); + te.draw(); + te.deinit(); + 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 = 200, .y = 200 } }); + _ = try cw.addEventMouseWheel(ticks, .vertical, null); +} + +fn nowNs() i128 { + return std.Io.Clock.boot.now(dvui.io).nanoseconds; +} + +/// Runs `iters` timed frames after letting the widget settle, and reports µs/frame. +fn run(label: []const u8, sample: []const u8, cursor: usize) !void { + text.clearRetainingCapacity(); + try text.appendSlice(std.testing.allocator, sample); + pending_sel = cursor; + + var t = try dvui.testing.init(.{ .allocator = std.testing.allocator }); + defer { + t.deinit(); + text.deinit(std.testing.allocator); + text = .empty; + } + + // Warm up: first frames build the tree-sitter tree, the glyph atlas and the byte-height + // cache, none of which recur. + for (0..15) |_| _ = try dvui.testing.step(frame); + + if (park_scroll_ticks != 0) { + try sendScroll(park_scroll_ticks); + for (0..5) |_| _ = try dvui.testing.step(frame); + } + + // Report the *minimum* of several rounds, not the mean. Anything else on the machine can + // only ever make a round slower, so the fastest round is the closest estimate of the work + // actually being measured — with the mean, a background compile moved results by 30%, + // which is larger than most of the differences worth acting on. + const rounds: usize = 5; + const iters: usize = 60; + var best_ns: i128 = std.math.maxInt(i128); + for (0..rounds) |_| { + // Every round scrolls the same span, so the min across rounds compares like with like. + if (scroll_lines_per_frame != 0) { + try sendScroll(10_000); + _ = try dvui.testing.step(frame); + } + const t0 = nowNs(); + for (0..iters) |_| { + if (typing) try dvui.testing.writeText("x"); + if (scroll_lines_per_frame != 0) try sendScroll(-scroll_lines_per_frame * 20); + _ = try dvui.testing.step(frame); + } + best_ns = @min(best_ns, nowNs() - t0); + } + const per_frame_us: u64 = @intCast(@divTrunc(best_ns, iters * 1000)); + + std.debug.print(" {s:<34} {d:>6} us/frame\n", .{ label, per_frame_us }); +} + +test "bench: text editor frame cost" { + std.debug.print("\n== text editor frame cost — {s} ==\n", .{@tagName(@import("builtin").mode)}); + + const cases = [_]struct { name: []const u8, text: []const u8 }{ + .{ .name = "large (Editor.zig)", .text = sample_large }, + .{ .name = "small (App.zig)", .text = sample_small }, + }; + + for (cases) |c| { + std.debug.print(" {s}, {d} bytes\n", .{ c.name, c.text.len }); + + tree_sitter = true; + cache_layout = true; + typing = false; + scroll_lines_per_frame = 0; + try run("idle, top of file (the app)", c.text, 0); + park_scroll_ticks = -400; + try run("idle, viewport mid-document", c.text, 0); + park_scroll_ticks = 0; + + scroll_lines_per_frame = 3; + try run("scrolling 3 lines/frame", c.text, 0); + tree_sitter = false; + try run("scrolling, no highlighting", c.text, 0); + tree_sitter = true; + scroll_lines_per_frame = 0; + scroll_lines_per_frame = 0; + + typing = true; + try run("typing", c.text, 0); + typing = false; + + tree_sitter = false; + try run("idle, no syntax highlighting", c.text, 0); + tree_sitter = true; + } +} diff --git a/tests/integration.zig b/tests/integration.zig index 1a384c0a..1c089d27 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -22,6 +22,7 @@ const std = @import("std"); const dvui = @import("dvui"); const fizzy = @import("fizzy"); const shim = @import("fizzy_shim.zig"); +const TextEntryWidget = @import("text").TextEntryWidget; test "shim brings up a dvui.testing window with usable fizzy globals" { var ctx = try shim.init(std.testing.allocator); @@ -34,3 +35,338 @@ test "shim brings up a dvui.testing window with usable fizzy globals" { try std.testing.expect(fizzy.app == ctx.app); try std.testing.expect(fizzy.editor == ctx.editor); } + +// -- menu accelerators ------------------------------------------------------------------------- + +// The regression that made every plugin menu row show a blank accelerator: a chord that exists +// only in the keymap — which is every chord a user assigns in the Keyboard Shortcuts pane, and +// every plugin command's chord — used to be looked up in `dvui.Window.keybinds` under a *bind +// name* the command has no entry for, so the row rendered nothing while the key itself worked. +test "a menu row shows a chord the keymap has and dvui's bind map does not" { + var ctx = try shim.init(std.testing.allocator); + defer ctx.deinit(std.testing.allocator); + + const editor = ctx.editor; + defer editor.keymap.deinit(editor.host.allocator); + + try editor.keymap.add(editor.host.allocator, .{ + .stroke = .{ .first = .{ .key = .f, .mods = .{ .command = true } } }, + .command = "text.format", + .source = .user, + }); + + // The premise: nothing in dvui's flat bind namespace answers for this command. + try std.testing.expect(!dvui.currentWindow().keybinds.contains("format")); + + const kb = fizzy.Editor.Keybinds.menuKeybindFor(editor, "text.format"); + try std.testing.expectEqual(dvui.enums.Key.f, kb.key.?); + try std.testing.expectEqual(true, kb.command.?); + try std.testing.expectEqual(false, kb.shift.?); + + // A command with no binding at all still draws nothing. + const unbound = fizzy.Editor.Keybinds.menuKeybindFor(editor, "text.nosuchcommand"); + try std.testing.expect(unbound.key == null); +} + +// On macOS a menu item's key equivalent *is* the dispatch path, and two items holding the same +// one is a coin flip AppKit resolves by menu order rather than by keymap layer. So the shadowed +// command has to give the chord up — otherwise binding `cmd+f` to Format Document (over the +// profile's Open Folder) leaves both menus advertising `⌘F` and the wrong one firing. +test "a shell command shadowed by a user binding gives up its native chord" { + var ctx = try shim.init(std.testing.allocator); + defer ctx.deinit(std.testing.allocator); + + const editor = ctx.editor; + defer editor.keymap.deinit(editor.host.allocator); + + try editor.keymap.add(editor.host.allocator, .{ + .stroke = .{ .first = .{ .key = .f, .mods = .{ .command = true } } }, + .command = "fizzy.openFolder", + .source = .profile, + }); + try editor.keymap.add(editor.host.allocator, .{ + .stroke = .{ .first = .{ .key = .f, .mods = .{ .command = true } } }, + .command = "text.format", + .source = .user, + }); + + // The winner keeps the chord in both menu bars; the loser shows none and holds no macOS key + // equivalent, so neither menu can advertise — or fire — a shortcut that runs something else. + try std.testing.expectEqual(dvui.enums.Key.f, fizzy.Editor.Keybinds.menuKeybindFor(editor, "text.format").key.?); + try std.testing.expect(fizzy.Editor.Keybinds.menuKeybindFor(editor, "fizzy.openFolder").key == null); + try std.testing.expect(!fizzy.Editor.Keybinds.chordShadowed(editor, "text.format")); + try std.testing.expect(fizzy.Editor.Keybinds.chordShadowed(editor, "fizzy.openFolder")); +} + +// -- text editing: auto-closing pairs + auto-indent Enter --------------------------------------- +// +// These drive the real `TextEntryWidget` through real frames and real key/text events, because +// what they cover is the half of the behavior that has no pure-logic seam: applying a decision +// from `textcore.pairs` to the buffer and the selection. The decisions themselves (when to +// close, step over, surround, or stay out of the way) are unit-tested in +// `src/plugins/text/src/textcore/pairs.zig`. + +/// Backing buffer for `textEntryFrame` — file-scope because `dvui.App.frameFunction` takes no +/// arguments, and the widget struct is rebuilt from this buffer every frame anyway. +var te_text: std.ArrayListUnmanaged(u8) = .empty; +/// Selection to force at the start of the next frame, as `{start, cursor, end}`. Consumed (set +/// back to null) by the frame that applies it, so later frames keep whatever editing produced. +var te_pending_sel: ?[3]usize = null; +/// The bracket pair the last drawn frame highlighted — copied out of the widget (which is a +/// stack local rebuilt every frame) so tests can assert on what was actually drawn. +var te_last_bracket_match: ?[2]usize = null; +/// Matches what `TextEditor.zig` passes: the editor draws with layout caching on, and it +/// changes which bytes get emitted per frame, so the harness has to run the same way. +var te_cache_layout: bool = true; + +fn textEntryFrame() !dvui.App.Result { + var te: TextEntryWidget = undefined; + te.init(@src(), .{ + .multiline = true, + .break_lines = false, + .scroll_horizontal = true, + .text = .{ .array_list = .{ + .backing = &te_text, + .allocator = std.testing.allocator, + .limit = 64 * 1024, + } }, + .cache_layout = te_cache_layout, + .tab_inserts_indent = true, + .tab_size = 4, + .insert_spaces = true, + .auto_indent_newline = true, + .auto_close_pairs = true, + .highlight_matching_bracket = true, + }, .{ .expand = .both }); + + dvui.focusWidget(te.data().id, null, null); + + if (te_pending_sel) |s| { + const sel = te.textLayout.selectionGet(te.len); + sel.start = s[0]; + sel.cursor = s[1]; + sel.end = s[2]; + te_pending_sel = null; + } + + te.processEvents(); + if (te_scroll_to) |fraction| { + const si = te.scroll.si; + si.viewport.y = fraction * @max(0, si.virtual_size.h - si.viewport.h); + } + te.draw(); + te_last_bracket_match = te.bracket_match; + te_highlight_range = te.highlightByteRange(); + te_byte_heights = te.textLayout.byte_heights; + te_viewport = te.scroll.si.viewport; + te.deinit(); + return .ok; +} + +/// Brings up a headless window with `te_text` seeded to `text` and the caret at `cursor`, then +/// runs one settling frame so the widget exists and holds focus before events are sent. +fn textEntryCtx(text: []const u8, cursor: usize) !dvui.testing { + te_text.clearRetainingCapacity(); + try te_text.appendSlice(std.testing.allocator, text); + te_pending_sel = .{ cursor, cursor, cursor }; + + var t = try dvui.testing.init(.{ .allocator = std.testing.allocator }); + errdefer t.deinit(); + try dvui.testing.settle(textEntryFrame); + return t; +} + +fn deinitTextEntry(t: *dvui.testing) void { + t.deinit(); + te_text.deinit(std.testing.allocator); + te_text = .empty; +} + +test "typing an opening brace inserts its closer and leaves the caret between them" { + var t = try textEntryCtx("pub const Test = struct ", 24); + defer deinitTextEntry(&t); + + try dvui.testing.writeText("{"); + try dvui.testing.settle(textEntryFrame); + + try std.testing.expectEqualStrings("pub const Test = struct {}", te_text.items); +} + +test "Enter between a brace pair puts the closer on its own dedented line" { + // The caret sits between `{` and `}` on an already-indented line — VSCode's three-line + // split: opener line, indented empty line with the caret, closer back at the outer indent. + var t = try textEntryCtx(" const S = struct {}", 22); + defer deinitTextEntry(&t); + + try dvui.testing.pressKey(.enter, .none); + try dvui.testing.settle(textEntryFrame); + + try std.testing.expectEqualStrings(" const S = struct {\n \n }", te_text.items); +} + +test "typing a closer steps over the auto-inserted one instead of doubling it" { + var t = try textEntryCtx("call", 4); + defer deinitTextEntry(&t); + + try dvui.testing.writeText("("); + try dvui.testing.settle(textEntryFrame); + try std.testing.expectEqualStrings("call()", te_text.items); + + try dvui.testing.writeText("1"); + try dvui.testing.settle(textEntryFrame); + try dvui.testing.writeText(")"); + try dvui.testing.settle(textEntryFrame); + + try std.testing.expectEqualStrings("call(1)", te_text.items); +} + +test "typing an opener directly before a word does not auto-close" { + var t = try textEntryCtx("foo", 0); + defer deinitTextEntry(&t); + + try dvui.testing.writeText("("); + try dvui.testing.settle(textEntryFrame); + + try std.testing.expectEqualStrings("(foo", te_text.items); +} + +test "Backspace between an empty pair deletes both halves" { + var t = try textEntryCtx("call()", 5); + defer deinitTextEntry(&t); + + try dvui.testing.pressKey(.backspace, .none); + try dvui.testing.settle(textEntryFrame); + + try std.testing.expectEqualStrings("call", te_text.items); +} + +test "Backspace next to a non-empty pair deletes one character" { + var t = try textEntryCtx("call(1)", 5); + defer deinitTextEntry(&t); + + try dvui.testing.pressKey(.backspace, .none); + try dvui.testing.settle(textEntryFrame); + + try std.testing.expectEqualStrings("call1)", te_text.items); +} + +test "the matched bracket pair is highlighted while the caret sits next to one" { + // `fn f() {` … the caret goes right after the `(`. + var t = try textEntryCtx("fn f() {}", 5); + defer deinitTextEntry(&t); + + try std.testing.expectEqual(@as(?[2]usize, .{ 4, 5 }), te_last_bracket_match); + + // Both halves of the pair land in the same emitted chunk here, which is the case that + // splits one chunk twice — reaching `addTextDone`'s bytes_seen assert without tripping it + // is the point of drawing this frame at all. + te_pending_sel = .{ 8, 8, 8 }; + try dvui.testing.settle(textEntryFrame); + try std.testing.expectEqual(@as(?[2]usize, .{ 7, 8 }), te_last_bracket_match); +} + +test "no bracket highlight away from a bracket or while text is selected" { + var t = try textEntryCtx("fn f() {}", 2); + defer deinitTextEntry(&t); + try std.testing.expectEqual(@as(?[2]usize, null), te_last_bracket_match); + + // A selection means the selection highlight is what the eye tracks — stay out of its way. + te_pending_sel = .{ 4, 6, 6 }; + try dvui.testing.settle(textEntryFrame); + try std.testing.expectEqual(@as(?[2]usize, null), te_last_bracket_match); +} + +test "an unmatched bracket is not highlighted" { + var t = try textEntryCtx("fn f( {}", 5); + defer deinitTextEntry(&t); + + try std.testing.expectEqual(@as(?[2]usize, null), te_last_bracket_match); +} + +test "editing stays correct on a frame that highlighted a bracket pair" { + // The caret sits inside `()` — so the frame before each keystroke splits that chunk for the + // highlight. If the splice desynced `bytes_seen`, the caret would drift and these + // characters would land somewhere other than between the parens. + var t = try textEntryCtx("call()", 5); + defer deinitTextEntry(&t); + + try dvui.testing.writeText("a"); + try dvui.testing.settle(textEntryFrame); + try dvui.testing.writeText("b"); + try dvui.testing.settle(textEntryFrame); + + try std.testing.expectEqualStrings("call(ab)", te_text.items); +} + +test "typing an opener with a selection wraps it instead of replacing it" { + var t = try textEntryCtx("wrap me", 0); + defer deinitTextEntry(&t); + + te_pending_sel = .{ 0, 4, 4 }; + try dvui.testing.settle(textEntryFrame); + + try dvui.testing.writeText("("); + try dvui.testing.settle(textEntryFrame); + + try std.testing.expectEqualStrings("(wrap) me", te_text.items); +} + +// -- syntax-highlight query range --------------------------------------------------------------- + +/// The highlight range the last drawn frame chose, plus dvui's own record of where each byte +/// landed vertically — enough to check the range against ground truth rather than against the +/// same formula that produced it. +var te_highlight_range: ?TextEntryWidget.ByteRange = null; +var te_byte_heights: []const dvui.TextLayoutWidget.ByteHeight = &.{}; +var te_viewport: dvui.Rect = .{}; +var te_scroll_to: ?f32 = null; + +test "the highlight query range covers every byte the viewport shows" { + // A document tall enough that the viewport is a small fraction of it — the case where + // querying only the visible slice matters, and where getting the mapping wrong would leave + // most of the screen uncolored. + const line = " const value: u32 = call(arg) + other[idx]; // comment\n"; + var doc: std.ArrayListUnmanaged(u8) = .empty; + defer doc.deinit(std.testing.allocator); + for (0..600) |_| try doc.appendSlice(std.testing.allocator, line); + + var t = try textEntryCtx(doc.items, 0); + defer deinitTextEntry(&t); + + // Several scroll positions, including one far from the caret (which is what used to drag + // the range back toward byte 0 and made scrolling cost more the further you got). + for ([_]f32{ 0, 0.25, 0.5, 0.9 }) |fraction| { + te_scroll_to = fraction; + for (0..3) |_| _ = try dvui.testing.step(textEntryFrame); + + const range = te_highlight_range orelse { + // No layout data yet is a valid answer only before anything has been drawn. + try std.testing.expect(te_byte_heights.len == 0); + continue; + }; + + // Ground truth: dvui recorded, for real, which byte sits at which height. Every byte + // whose recorded height falls inside the viewport must be inside the queried range, or + // that text draws unhighlighted. Note the resolution limit — dvui records one entry per + // `ByteHeight.dist` (200) logical pixels, so this catches a range in the wrong + // coordinate space or off by a screenful, not one off by a few lines. The full-viewport + // pad is what covers that margin. + var checked: usize = 0; + for (te_byte_heights) |bh| { + if (bh.height < te_viewport.y or bh.height > te_viewport.y + te_viewport.h) continue; + checked += 1; + if (bh.byte < range.start or bh.byte > range.end) { + std.debug.print( + " visible byte {d} (height {d}) fell outside the queried range {d}..{d} " ++ + "at scroll {d} (viewport y={d} h={d}) — that text would draw uncolored\n", + .{ bh.byte, bh.height, range.start, range.end, fraction, te_viewport.y, te_viewport.h }, + ); + } + try std.testing.expect(bh.byte >= range.start); + try std.testing.expect(bh.byte <= range.end); + } + // The assertions above pass trivially if nothing was in view — make sure something was. + try std.testing.expect(checked > 0); + } + te_scroll_to = null; +}