Skip to content

Commit 7100dad

Browse files
committed
rework settings, add descriptions
1 parent 8288bb7 commit 7100dad

16 files changed

Lines changed: 894 additions & 296 deletions

docs/PLUGINS.md

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ the first time; use it as reference after that.
4747
| `Plugin` | A plugin's identity + **vtable** of optional hooks. The shell calls these; a plugin implements only what it needs. |
4848
| `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`. |
4949
| `regions` | The contribution structs a plugin registers: `SidebarView`, `BottomView`, `CenterProvider`, `MenuContribution`, `Command`, `LanguageSupport`, … There is no settings region here — see `sdk.settings` below. |
50-
| `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. |
50+
| `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. |
5151
| `dylib` / `dvui_context` | The C-ABI entry contract + dvui-context injection used when a plugin is loaded as a runtime library. |
5252

5353
**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
327327

328328
There is no `registerSettingsSection` and no author-written settings schema in
329329
`plugin.zig.zon`. Persisted settings are declared as a plain Zig struct (build-options-style,
330-
comptime-derived), then registered from `register(host)`:
330+
comptime-derived) whose every field is a self-describing `sdk.settings.Value` cell, then
331+
registered from `register(host)`:
331332

332333
```zig
333334
const MySettings = sdk.settings.Schema(struct {
334-
insert_spaces_on_tab: bool = true,
335-
tab_size: enum(u8) { two = 2, four = 4, eight = 8 } = .four,
336-
format_on_save: bool = false,
335+
insert_spaces_on_tab: sdk.settings.Value(bool, .{
336+
.description = "Insert spaces instead of a tab character when pressing Tab.",
337+
}) = .init(true),
338+
tab_size: sdk.settings.Value(enum(u8) { two = 2, four = 4, eight = 8 }, .{
339+
.description = "How many columns a tab occupies.",
340+
}) = .init(.four),
341+
format_on_save: sdk.settings.Value(bool, .{
342+
.description = "Reformat the document each time it is saved.",
343+
}) = .init(false),
337344
});
338-
var settings: MySettings.Value = .{};
345+
var settings: MySettings.Cells = .{};
339346
340347
pub fn register(host: *sdk.Host) !void {
341348
plugin.state = @ptrCast(&plugin_state);
@@ -347,18 +354,41 @@ pub fn register(host: *sdk.Host) !void {
347354
.value = &settings, // shell draws shared controls
348355
});
349356
}
357+
358+
// Read a value with `.get()`, write one with `.set()`:
359+
if (settings.format_on_save.get()) formatDocument(doc);
350360
```
351361

352-
`Schema(T)` comptime-walks `T`'s fields (`bool`/`int`/`float`/`enum`/`[]const u8`) into a
353-
`Setting` table — each entry's `kind: Kind` is a `union(TypeTag)` carrying only the metadata that
362+
**Every setting describes itself.** A cell's second (comptime) parameter is
363+
`settings.Options{ description, name, min, max, step }`, and `description` has no default — a
364+
setting declared without one is a compile error. The pane has a permanent place for it (see the
365+
row shape below), so there is no shell-side table of strings that can drift from the plugin that
366+
owns the setting. Metadata lives entirely in the *type*: `@sizeOf(Value(T, …)) == @sizeOf(T)`,
367+
and only the payload is ever stored or persisted.
368+
369+
The shell draws each setting as one group, VSCode-style: the name (derived from the field name —
370+
`insert_spaces_on_tab``Insert spaces on tab`, or `Options.name` to override), the zon key
371+
underneath in smaller dim mono, the wrapped description, and then the control at full width.
372+
Booleans are the exception — their checkbox sits before the description on one line. Both the
373+
name and the key are matched by the settings search, so users can find a row by either.
374+
375+
`Schema(T)` comptime-walks `T`'s cells into a `Setting` table (`key`, `label`, `description`,
376+
`kind`) — each entry's `kind: Kind` is a `union(TypeTag)` carrying only the metadata that payload
354377
type actually uses (`IntKind{min,max,choices}`, `FloatKind{min,max,step}`, `EnumKind{choices}`,
355-
void for bool/string/color), rather than one flat struct with every type's bounds fields present
356-
on every entry — and generates `Value` (= `T`), `load`/`store` (a zon round-trip through
378+
void for bool/string/color/other), rather than one flat struct with every type's bounds fields
379+
present on every entry — and generates `Cells` (= `T`), `Payloads` (the bare-value mirror that is
380+
what actually round-trips through zon), `load`/`store` (through
357381
`Host.loadPluginSettings`/`storePluginSettings`), `applyZon` (parse a blob directly into the live
358382
value — the primitive `Access.applyBlob` wraps with a `settingsChanged` notification, used by
359383
external-change reconciliation, see below), and `register` (wires a `SettingsSchema` + typed
360384
`Access` vtable into the Host).
361385

386+
**Any type is a legal setting.** `bool`/int/float/`enum`/`[]const u8` get dedicated controls;
387+
anything else `std.zon` can round-trip (a struct, an array, an optional) is `TypeTag.other` and
388+
the pane draws it as its zon text in an editable entry — text that doesn't parse simply doesn't
389+
commit. The on-disk shape is always the bare payload (`.tab_size = 8`), never the cell, so
390+
`settings.zon` files written before cells existed keep loading unchanged.
391+
362392
Every plugin's block lives as a real, hand-editable nested ZON struct literal keyed by plugin
363393
id inside the shell's own `{config}/settings.zon`:
364394

@@ -382,7 +412,7 @@ scalars/enums. Don't free a string field yourself, and don't assign one directly
382412
persisted — go through the settings pane or `applyZon`, or you'll leak the schema's copy.
383413

384414
Author fields live under `.settings` so they can never collide with the shell-reserved
385-
`.enabled`. Only fields that differ from `T`'s own declared defaults are written; an all-default
415+
`.enabled`. Only fields whose payload differs from the cell's declared default is written; an all-default
386416
value removes that plugin's `.settings` (and if also disabled, the whole `.plugins.<id>` entry).
387417
`src/editor/SettingsPluginsZon.zig` locates/composes fields by source byte-span via the same
388418
`std.zig.Ast`/`ZonGen` machinery `std.zon.parse` uses, so nothing else in the file is

docs/PLUGIN_MANIFEST_PLAN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
| 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. |
3737
| 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. |
3838
| 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. |
39+
| 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.46** (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`. |
3940
| Old Phase 2 (sidecar enforcement) | **cancelled** | superseded by this revision |
4041

4142
---

sdk/sdk_version.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ const std = @import("std");
55
pub const sdk_version = std.SemanticVersion{
66
.major = 0,
77
.minor = 1,
8-
.patch = 45,
8+
.patch = 46,
99
};

src/core/dvui.zig

Lines changed: 39 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,45 @@ pub fn labelHighlighted(
9090
}
9191
}
9292

93+
/// `labelHighlighted`'s body, for callers that already own a `TextLayoutWidget` — appends `text`
94+
/// to `tl` with the bytes `query` matched tinted, everything else in `plain_color`. Used for the
95+
/// settings tree's rows, where the caller controls wrapping and font.
96+
pub fn addHighlightedText(
97+
tl: *dvui.TextLayoutWidget,
98+
text: []const u8,
99+
query: *const fuzzy.Query,
100+
plain: bool,
101+
plain_color: dvui.Color,
102+
) void {
103+
if (query.isEmpty()) {
104+
tl.addText(text, .{ .color_text = plain_color });
105+
return;
106+
}
107+
108+
var buf: [fuzzy.highlight_buf_len]usize = undefined;
109+
const hits = fuzzy.highlight(text, query, &buf, .{ .plain = plain });
110+
if (hits.len == 0) {
111+
tl.addText(text, .{ .color_text = plain_color });
112+
return;
113+
}
114+
115+
const matched = dvui.themeGet().color(.highlight, .fill);
116+
var i: usize = 0;
117+
var h: usize = 0;
118+
while (i < text.len) {
119+
if (h < hits.len and hits[h] == i) {
120+
// Consume the whole contiguous run of matched bytes in one addText.
121+
const start = i;
122+
while (h < hits.len and hits[h] == i) : (h += 1) i += 1;
123+
tl.addText(text[start..i], .{ .color_text = matched });
124+
} else {
125+
const start = i;
126+
i = if (h < hits.len) hits[h] else text.len;
127+
tl.addText(text[start..i], .{ .color_text = plain_color });
128+
}
129+
}
130+
}
131+
93132
/// Reserve one tree-row glyph slot: a box of exactly `treeRowGlyphSize()`, into which the caller
94133
/// draws a caret, an icon, an image, or a letter.
95134
///
@@ -116,69 +155,6 @@ pub fn treeRowGlyph(src: std.builtin.SourceLocation, opts: dvui.Options) *dvui.B
116155
return dvui.box(src, .{ .dir = .horizontal }, defaults.override(opts));
117156
}
118157

119-
/// A full-width boolean control shaped like the settings dropdowns and sliders rather than dvui's
120-
/// bare checkbox: one wide button whose whole surface toggles, with the state word ("Enabled" /
121-
/// "Disabled") and a purely decorative checkmark parked at the right edge (`gravity_x = 1.0`),
122-
/// exactly where a dropdown puts its current value and triangle.
123-
///
124-
/// The checkmark is drawn with `dvui.checkmark` into a spacer rect, so it picks up the same
125-
/// theme colours as a real checkbox but takes no events — the button is the only hit target.
126-
///
127-
/// Returns true (and flips `target`) on the frame it was clicked.
128-
pub fn toggle(src: std.builtin.SourceLocation, target: *bool, opts: dvui.Options) bool {
129-
const defaults: dvui.Options = .{
130-
.name = "Toggle",
131-
.role = .check_box,
132-
.margin = dvui.Rect.all(4),
133-
.padding = dvui.Rect.all(6),
134-
.corners = dvui.CornerRect.all(1000),
135-
.background = true,
136-
.style = .control,
137-
.expand = .horizontal,
138-
};
139-
const options = defaults.override(opts);
140-
141-
var bw: dvui.ButtonWidget = undefined;
142-
bw.init(src, .{}, options);
143-
bw.processEvents();
144-
bw.drawBackground();
145-
bw.drawFocus();
146-
147-
{
148-
var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{
149-
.expand = .vertical,
150-
.gravity_x = 1.0,
151-
.background = false,
152-
});
153-
defer hbox.deinit();
154-
155-
dvui.label(@src(), "{s}", .{if (target.*) "Enabled" else "Disabled"}, .{
156-
.gravity_y = 0.5,
157-
.margin = .all(0),
158-
.padding = .all(0),
159-
});
160-
161-
const check_size = options.fontGet().textHeight();
162-
const s = dvui.spacer(@src(), .{
163-
.min_size_content = dvui.Size.all(check_size),
164-
.max_size_content = .size(dvui.Size.all(check_size)),
165-
.gravity_y = 0.5,
166-
.margin = .{ .x = 6 },
167-
});
168-
if (bw.data().visible()) {
169-
dvui.checkmark(target.*, false, s.borderRectScale(), bw.pressed(), bw.hovered(), .{
170-
.corners = dvui.CornerRect.all(2),
171-
});
172-
}
173-
}
174-
175-
const clicked = bw.clicked();
176-
bw.deinit();
177-
178-
if (clicked) target.* = !target.*;
179-
return clicked;
180-
}
181-
182158
/// Currently this is specialized for the layers paned widget, just includes icon and dragging flag so we know when the pane is dragging
183159
pub fn paned(src: std.builtin.SourceLocation, init_opts: PanedWidget.InitOptions, opts: dvui.Options) *PanedWidget {
184160
var ret = dvui.widgetAlloc(PanedWidget);

0 commit comments

Comments
 (0)