From 6774f5784cfa209fc6c2202017c4c278ddb714f3 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:06:10 -0500 Subject: [PATCH 01/46] app: the crash reporter's docs say a plugin's panic is forwarded (#436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `app/crash/crash.zig`'s header still listed "forward a plugin dylib's panic message to the host" as a follow-up. That landed in #360 (released in sdk 0.2.23): the generated dylib root's `sdk.dylib.panic` forwards through `EditorAPI.panic` → `fizzyPanic` → `std.debug.panicExtra` → the root's `crash.panic`, so the report carries the plugin's message and frames. The comment now describes that path. Comment only; no code changes, so no SDK impact. **Verified:** read the path end to end (`sdk/src/dylib.zig:363`, `src/editor/Editor.zig:2406`, `src/Entry.zig:253`); `scripts/check-fmt.sh` clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- app/crash/crash.zig | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/crash/crash.zig b/app/crash/crash.zig index 84949fdce..1a7095be7 100644 --- a/app/crash/crash.zig +++ b/app/crash/crash.zig @@ -11,10 +11,9 @@ //! - a panic in the app's own code (`panic`, the root file's panic handler); //! - a fault anywhere in the process (`handleSegfault`, std's segfault handler, which the app //! switches on in every build mode): SIGSEGV, SIGBUS, SIGILL, SIGFPE, or an access violation; -//! - an abort or trap anywhere (SIGABRT, SIGTRAP; POSIX only). A plugin's panic lands here: its -//! dylib has its own std, whose panic handler prints the message and aborts. The message then -//! reaches stderr but not the report; carrying it is the dylib entry's `panic` forwarding to -//! the host's `panic` through an exported symbol, a follow-up. +//! - a panic in a plugin dylib, which has its own std: the generated root's handler +//! (`sdk.dylib.panic`) forwards it through `EditorAPI.panic` to `panic`, message and frames; +//! - an abort or trap anywhere (SIGABRT, SIGTRAP; POSIX only). //! //! After the report, each hands on to what would have run without it: std's own handler, which //! prints the symbolized stack trace, or the signal's default action. From 3fe6d6fb9a3b46ff8c5c7aaedbd4688fd89610e4 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:11:02 -0500 Subject: [PATCH 02/46] sdk: release 0.2.27 (#439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changes `sdk_version` goes from 0.2.26 to 0.2.27. Merging this tags `sdk-v0.2.27` and publishes its tarball. **Why now:** the agent plugin's `stroke` step (fizzyedit/agent#11, on agent `main`) uses `tape.Script.StrokePoint` from #431, which no released SDK has, so agent's new CI (fizzyedit/agent#13) can't pass against its pinned SDK until it repins to a release that carries it. ## SDK impact - [ ] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [x] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag **`sdk` PRs since `sdk-v0.2.26`:** - **#434:** two optional document vtable members, `historyLine` (a document's undo history as a line of steps, with a `version`) and `historyGoTo` (undo or redo to a step), with `Plugin.HistoryLine`. An owner without them has no timeline; undo and redo are unchanged. The fingerprint moved, so the repin automation opens a repin PR in each store plugin. **Reaching plugins, no fingerprint move:** - **#431:** `tape`: a stroke carries the pointer in straight lines (`Script.StrokePoint`). - **#432:** `tape`: a caption follows where the pointer reaches, not a pen's path. - **#433:** `replay`: a tape's pointer gives way to a cursor the app draws itself. - **#427:** `core`: the bubble spinner keeps its bubbles near one size at any scale. Open `sdk`-labelled #437 (the Zig 0.17 switch) is a draft and not in this release. ## Verified - [x] macOS: - On this head, `zig build test-sdk-version`: 12/12 steps succeeded. `scripts/pack-sdk.sh` packs `fizzy-sdk-v0.2.27.tar.gz`. - fizzyedit/agent (main plus fizzyedit/agent#14) builds against this `sdk/` (`--fork`) for x86_64-windows, x86_64-linux-gnu and aarch64-macos, and `zig build test` passes. - [ ] Windows: - [ ] Linux: CI. - [ ] Web: ## Follow-ups - The repin automation opens `sdk: repin to fizzy SDK 0.2.27` PRs in the store plugins whose build needs it. agent repins too, and then fizzyedit/agent#13's CI can go green. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- sdk/sdk_version.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/sdk_version.zig b/sdk/sdk_version.zig index 3b5658a15..7700a3180 100644 --- a/sdk/sdk_version.zig +++ b/sdk/sdk_version.zig @@ -23,5 +23,5 @@ const std = @import("std"); pub const sdk_version = std.SemanticVersion{ .major = 0, .minor = 2, - .patch = 26, + .patch = 27, }; From 0129bdb16bcde225892d925f50a649f2cc706624 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:47:05 -0500 Subject: [PATCH 03/46] zig: builtin.target.os, .cpu and .abi in place of builtin.os (#435) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of fizzyedit/plans#5 (`plans/ZIG_0_17_PLAN.md`, step 0) ## What changes Nothing anyone sees. `@import("builtin").os`, `.cpu`, `.abi` and `.object_format` become `builtin.target.os` and so on: 235 sites in 51 files, a pure rename. Zig 0.17 deprecates the short forms and 0.18 removes them. 0.16 already has `builtin.target`, so this lands on `main` now and keeps the 0.17 switch smaller. The step's other item, the `@hasDecl` audit, needs no change. 0.17 makes `@hasDecl` see only `pub` declarations in every file, so a non-`pub` target would quietly read as absent. All 28 sites in fizzy look for a `pub` declaration. No store plugin or example declares `service_version` or a layout's `context()` in a way that would be missed, and `docs/PLUGINS.md` already shows `pub const service_version`. ## SDK impact - [x] None. `sdk/tape` and `sdk/replay` are touched by the rename; the fingerprint does not move (`zig build test-sdk-version` passes). - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build test-integration` (53/53 steps, 522/522 tests), `zig build check-web`, `zig build test-sdk-version`, all on 0.16. - [ ] Windows: CI's cross-compile. - [ ] Linux: CI. - [x] Web: `zig build check-web`. ## Follow-ups None for this step. The rest of the 0.17 work goes on the `zig/0.17` integration branch (plans#5, "Where the work lives"). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- app/Secrets.zig | 2 +- app/crash/crash.zig | 18 +-- app/crash/image.zig | 12 +- app/restart.zig | 4 +- app/settings/Settings.zig | 4 +- app/settings/SettingsMigration.zig | 2 +- app/single_instance/singleton_native.zig | 6 +- app/store/PluginLoader.zig | 8 +- app/store/registry/compat.zig | 4 +- app/update/auto_update.zig | 4 +- app/watch/FolderWatcher.zig | 2 +- app/watch/SettingsWatcher.zig | 6 +- backend/src/GpuRenderer.zig | 8 +- backend/src/SDLBackend.zig | 130 +++++++++++----------- backend/src/platform/dialogs.zig | 6 +- backend/src/platform/geometry.zig | 6 +- backend/src/platform/linux_titlebar.zig | 8 +- backend/src/platform/macos_monitor.zig | 22 ++-- backend/src/platform/menu.zig | 10 +- backend/src/platform/root.zig | 2 +- backend/src/platform/titlebar.zig | 4 +- backend/src/platform/wayland_blur.zig | 2 +- backend/src/platform/win32_titlebar.zig | 12 +- backend/src/platform/window.zig | 46 ++++---- backend/src/viewports.zig | 8 +- build/velopack.zig | 2 +- core/lsp/Client.zig | 4 +- core/paths.zig | 4 +- core/platform.zig | 12 +- core/shell_env.zig | 2 +- core/vfs/zip.zig | 4 +- libs/dvui-singleton-app/src/root.zig | 2 +- libs/dvui-singleton-app/src/unix_impl.zig | 2 +- plugins/workbench/src/Workspace.zig | 2 +- plugins/workbench/src/files.zig | 2 +- sdk/tape/chord.zig | 2 +- sdk/tape/ring.zig | 2 +- spikes/shared-globals/host.zig | 2 +- spikes/windows-composition/src/main.zig | 2 +- src/Entry.zig | 10 +- src/backend/WebBackend.zig | 8 +- src/backend/backend_native.zig | 14 +-- src/backend/file_assoc.zig | 6 +- src/editor/DocumentWatcher.zig | 4 +- src/editor/Editor.zig | 32 +++--- src/editor/Keybinds.zig | 6 +- src/editor/caption_buttons.zig | 2 +- src/editor/captures.zig | 2 +- src/editor/dialogs/AboutFizzy.zig | 2 +- src/editor/explorer/settings.zig | 2 +- src/editor/layout.zig | 2 +- 51 files changed, 235 insertions(+), 235 deletions(-) diff --git a/app/Secrets.zig b/app/Secrets.zig index 2beff51b3..b7f4e91ae 100644 --- a/app/Secrets.zig +++ b/app/Secrets.zig @@ -162,7 +162,7 @@ test "secrets round-trip through a 0600 file and never through settings" { // On disk: only the owner can read it, and a fresh instance reads it back. const st = try tmp.dir.statFile(io, "secrets", .{}); - if (builtin.os.tag != .windows) try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(st.permissions.toMode() & 0o777))); + if (builtin.target.os.tag != .windows) try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(st.permissions.toMode() & 0o777))); var again = try Secrets.init(a, io, dir); defer again.deinit(); try std.testing.expectEqualStrings("1//abc\"quoted\"", again.get("drive.refresh_token").?); diff --git a/app/crash/crash.zig b/app/crash/crash.zig index 1a7095be7..606f2dcea 100644 --- a/app/crash/crash.zig +++ b/app/crash/crash.zig @@ -24,7 +24,7 @@ const builtin = @import("builtin"); pub const image = @import("image.zig"); /// Where crash reports can be written: native targets std can catch faults on. -pub const supported = builtin.cpu.arch != .wasm32 and std.debug.have_segfault_handling_support; +pub const supported = builtin.target.cpu.arch != .wasm32 and std.debug.have_segfault_handling_support; pub const Module = struct { name_buf: [32]u8 = undefined, @@ -89,7 +89,7 @@ pub fn writeReport( try w.print(": {s}\n", .{s.name}), } try w.print("\n{s} {s}, sdk {s}, {t} {t} {t}\n\nmodules:\n", .{ - run.app, run.version, run.sdk, builtin.os.tag, builtin.cpu.arch, builtin.mode, + run.app, run.version, run.sdk, builtin.target.os.tag, builtin.target.cpu.arch, builtin.mode, }); for (modules, 0..) |*m, i| { try w.print(" {d} {s} {s} 0x{x} 0x{x} ", .{ i, m.name(), m.version(), m.image.base, m.image.end - m.image.base }); @@ -143,7 +143,7 @@ var run_info: ?Run = null; /// The report's file, once `writeTo` has named it. var path_buf: [std.fs.max_path_bytes:0]u8 = undefined; var path_len: usize = 0; -var path_w: if (builtin.os.tag == .windows) [std.fs.max_path_bytes:0]u16 else void = undefined; +var path_w: if (builtin.target.os.tag == .windows) [std.fs.max_path_bytes:0]u16 else void = undefined; /// Starts reporting for this run: enters the executable as module 0 and catches aborts and /// traps. Call once, first thing. std's segfault handler is the root's to switch on @@ -153,7 +153,7 @@ pub fn init(run: Run) void { if (!supported) return; run_info = run; addModule(run.app, run.version, @intFromPtr(&init)); - if (builtin.os.tag != .windows) { + if (builtin.target.os.tag != .windows) { const act: std.posix.Sigaction = .{ .handler = .{ .sigaction = onAbortOrTrap }, .mask = std.posix.sigemptyset(), @@ -168,7 +168,7 @@ pub fn init(run: Run) void { /// Nothing is written there unless the run crashes. pub fn writeTo(io: std.Io, config_folder: []const u8) void { if (!supported) return; - const pid = if (builtin.os.tag == .windows) win.GetCurrentProcessId() else std.c.getpid(); + const pid = if (builtin.target.os.tag == .windows) win.GetCurrentProcessId() else std.c.getpid(); const launched = std.Io.Timestamp.now(io, .real).toMilliseconds(); const sep = std.fs.path.sep; const path = std.fmt.bufPrintZ(&path_buf, "{s}{c}crashes{c}{d}-{d}.txt", .{ config_folder, sep, sep, launched, pid }) catch return; @@ -177,7 +177,7 @@ pub fn writeTo(io: std.Io, config_folder: []const u8) void { std.log.warn("crash reports: can't create {s}: {t}", .{ dir, err }); return; }; - if (builtin.os.tag == .windows) { + if (builtin.target.os.tag == .windows) { const n = std.unicode.wtf8ToWtf16Le(&path_w, path) catch return; if (n >= path_w.len) return; path_w[n] = 0; @@ -247,7 +247,7 @@ fn report(what: What, where: Where) void { } fn writeFile(text: []const u8) bool { - if (builtin.os.tag == .windows) { + if (builtin.target.os.tag == .windows) { const h = win.CreateFileW(&path_w, win.GENERIC_WRITE, 0, null, win.CREATE_ALWAYS, win.FILE_ATTRIBUTE_NORMAL, null); if (h == win.INVALID_HANDLE_VALUE) return false; defer _ = win.CloseHandle(h); @@ -260,7 +260,7 @@ fn writeFile(text: []const u8) bool { } fn writeStderr(text: []const u8) void { - if (builtin.os.tag == .windows) { + if (builtin.target.os.tag == .windows) { _ = win.writeAll(win.GetStdHandle(win.STD_ERROR_HANDLE) orelse return, text); } else _ = posixWriteAll(2, text); } @@ -277,7 +277,7 @@ fn posixWriteAll(fd: std.c.fd_t, text: []const u8) bool { /// The file name of the image holding `address`, for a frame in no known module. fn imageName(address: usize, buf: []u8) ?[]const u8 { - if (builtin.os.tag == .windows) return null; + if (builtin.target.os.tag == .windows) return null; var info: Dl_info = undefined; if (dladdr(@ptrFromInt(address), &info) == 0) return null; const path = std.mem.span(info.dli_fname orelse return null); diff --git a/app/crash/image.zig b/app/crash/image.zig index f441d5301..b99cfbed1 100644 --- a/app/crash/image.zig +++ b/app/crash/image.zig @@ -33,7 +33,7 @@ pub const Image = struct { /// The image `address` lies in, or null where the platform can't say (or it lies in none). pub fn containing(address: usize) ?Image { - return switch (builtin.os.tag) { + return switch (builtin.target.os.tag) { .macos, .ios, .tvos, .visionos, .watchos => macho(address), .linux, .freebsd, .netbsd, .openbsd, .dragonfly => elf(address), .windows => pe(address), @@ -101,9 +101,9 @@ fn elf(address: usize) ?Image { fn gnuBuildId(notes: []const u8) ?[]const u8 { var at: usize = 0; while (at + 12 <= notes.len) { - const namesz = std.mem.readInt(u32, notes[at..][0..4], builtin.cpu.arch.endian()); - const descsz = std.mem.readInt(u32, notes[at + 4 ..][0..4], builtin.cpu.arch.endian()); - const kind = std.mem.readInt(u32, notes[at + 8 ..][0..4], builtin.cpu.arch.endian()); + const namesz = std.mem.readInt(u32, notes[at..][0..4], builtin.target.cpu.arch.endian()); + const descsz = std.mem.readInt(u32, notes[at + 4 ..][0..4], builtin.target.cpu.arch.endian()); + const kind = std.mem.readInt(u32, notes[at + 8 ..][0..4], builtin.target.cpu.arch.endian()); const name_at = at + 12; const desc_at = name_at + std.mem.alignForward(usize, namesz, 4); const next = desc_at + std.mem.alignForward(usize, descsz, 4); @@ -158,12 +158,12 @@ fn pe(address: usize) ?Image { } test "the image holding this code contains it, and names its build" { - if (builtin.os.tag != .macos and builtin.os.tag != .linux and builtin.os.tag != .windows) return error.SkipZigTest; + if (builtin.target.os.tag != .macos and builtin.target.os.tag != .linux and builtin.target.os.tag != .windows) return error.SkipZigTest; const here = @intFromPtr(&containing); const image = containing(here) orelse return error.NoImage; try std.testing.expect(image.contains(here)); // Zig's Mach-O linker always writes an LC_UUID; ELF and PE only carry an id when asked to. - if (builtin.os.tag == .macos) try std.testing.expectEqual(16, image.buildId().len); + if (builtin.target.os.tag == .macos) try std.testing.expectEqual(16, image.buildId().len); } test "a GNU build-id note is found among others" { diff --git a/app/restart.zig b/app/restart.zig index 923648c8a..d2baf6224 100644 --- a/app/restart.zig +++ b/app/restart.zig @@ -141,7 +141,7 @@ fn start(io: std.Io, gpa: std.mem.Allocator, handover_to: ?[]const u8) !void { // out of the terminal's foreground group, which is what the hangup is sent to. Windows: a // console of its own (no-op for a Release build, which has none), so closing the launcher's // console does not close it. - if (comptime builtin.os.tag.isDarwin()) { + if (comptime builtin.target.os.tag.isDarwin()) { _ = try core.darwin_spawn.spawn(gpa, .{ .argv = argv.items, .stdin = .discard, .stdout = .discard, .stderr = .discard, .new_session = true }, null); return; } @@ -150,7 +150,7 @@ fn start(io: std.Io, gpa: std.mem.Allocator, handover_to: ?[]const u8) !void { .stdin = .ignore, .stdout = .ignore, .stderr = .ignore, - .pgid = if (builtin.os.tag == .windows) null else 0, + .pgid = if (builtin.target.os.tag == .windows) null else 0, .create_no_window = true, }); } diff --git a/app/settings/Settings.zig b/app/settings/Settings.zig index 6cb8ad1aa..7f540c268 100644 --- a/app/settings/Settings.zig +++ b/app/settings/Settings.zig @@ -86,7 +86,7 @@ glass_shader: bool = true, /// Whether a float can leave the main window into an OS window of its own, and a view be carried /// past the main window to open one there. Taken at launch: a change waits for a restart, which the /// app says (`Editor.restartPending`). On by default on macOS; `FIZZY_POPOUT` overrides it. -float_windows: bool = builtin.os.tag == .macos, +float_windows: bool = builtin.target.os.tag == .macos, /// Whether a view drag's glass — the carried view and the drop zones — is the OS's own (Liquid Glass, /// macOS 26) rather than the app's, where floats are windows. Applied at once; `FIZZY_NATIVE_GLASS` @@ -126,7 +126,7 @@ plugin_update_mode: PluginUpdateMode = .prompt, /// frosted. Where the window is opaque (the web, Linux), the glass the app draws over it: little /// colour, well frosted — the dialogs' glass as it was. pub const GlassDefaults = struct { opacity_dark: f32, opacity_light: f32 }; -pub const default_glass: GlassDefaults = if (builtin.target.cpu.arch == .wasm32 or (builtin.os.tag != .macos and builtin.os.tag != .windows)) +pub const default_glass: GlassDefaults = if (builtin.target.cpu.arch == .wasm32 or (builtin.target.os.tag != .macos and builtin.target.os.tag != .windows)) .{ .opacity_dark = 0.2, .opacity_light = 0.2 } else .{ .opacity_dark = 0.7, .opacity_light = 0.3 }; diff --git a/app/settings/SettingsMigration.zig b/app/settings/SettingsMigration.zig index 901819ead..475941ec3 100644 --- a/app/settings/SettingsMigration.zig +++ b/app/settings/SettingsMigration.zig @@ -258,7 +258,7 @@ pub fn windowGlass(allocator: std.mem.Allocator, data: [:0]const u8, settings: * defer std.zon.parse.free(allocator, legacy); if (legacy.dialog_opacity == null and legacy.dialog_blur == null and legacy.dialog_lift == null and legacy.dialog_detail == null and legacy.dialog_refraction == null) return; - const opaque_window = comptime builtin.target.cpu.arch == .wasm32 or (builtin.os.tag != .macos and builtin.os.tag != .windows); + const opaque_window = comptime builtin.target.cpu.arch == .wasm32 or (builtin.target.os.tag != .macos and builtin.target.os.tag != .windows); if (opaque_window) if (legacy.dialog_opacity) |o| { settings.window_opacity_dark = std.math.clamp(o, 0, 1); settings.window_opacity_light = std.math.clamp(o, 0, 1); diff --git a/app/single_instance/singleton_native.zig b/app/single_instance/singleton_native.zig index 51ca33339..9432cc721 100644 --- a/app/single_instance/singleton_native.zig +++ b/app/single_instance/singleton_native.zig @@ -93,7 +93,7 @@ pub fn acquireLock(gpa: std.mem.Allocator, argv: []const []const u8) !void { if (profile.root) |root| { profile_id = try profile.lockId(gpa, app_id, root); id = profile_id.?; - if (builtin.os.tag != .windows) { + if (builtin.target.os.tag != .windows) { run_dir = try profile.runtimeDir(gpa, root); // `/-.sock`, as `dvui-singleton-app` names it; a uid is at most 10 digits. const socket_len = run_dir.?.len + 1 + id.len + 1 + 10 + ".sock".len; @@ -402,7 +402,7 @@ fn resolveAbsolute(gpa: std.mem.Allocator, cwd: []const u8, path: []const u8) ![ } fn pickUnixSocketDir(buf: *[std.fs.max_path_bytes]u8) []const u8 { - if (builtin.os.tag == .windows) return "."; // unused on Windows + if (builtin.target.os.tag == .windows) return "."; // unused on Windows // Prefer TMPDIR (macOS sets it per-user) → /tmp fallback. // Use the libc env to avoid plumbing `process.Environ` here. if (std.c.getenv("TMPDIR")) |env_ptr| { @@ -412,7 +412,7 @@ fn pickUnixSocketDir(buf: *[std.fs.max_path_bytes]u8) []const u8 { return buf[0..len]; } } - if (builtin.os.tag == .linux) { + if (builtin.target.os.tag == .linux) { if (std.c.getenv("XDG_RUNTIME_DIR")) |env_ptr| { const len = std.mem.len(env_ptr); if (len > 0 and len < buf.len) { diff --git a/app/store/PluginLoader.zig b/app/store/PluginLoader.zig index dd0b8f337..f0c53e85f 100644 --- a/app/store/PluginLoader.zig +++ b/app/store/PluginLoader.zig @@ -17,7 +17,7 @@ const crash = @import("crash"); /// Zig 0.16.0's `std.DynLib` dropped Windows support; this thin wrapper restores it for /// Windows while delegating elsewhere. Shape matches `std.DynLib.{open, close, lookup}`. -pub const DynLib = if (builtin.os.tag == .windows) WindowsDynLib else std.DynLib; +pub const DynLib = if (builtin.target.os.tag == .windows) WindowsDynLib else std.DynLib; const WindowsDynLib = struct { const windows = std.os.windows; @@ -100,7 +100,7 @@ pub const PreRegister = struct { /// Platform-specific plugin dylib extension. pub fn pluginExtension() []const u8 { - return switch (builtin.os.tag) { + return switch (builtin.target.os.tag) { .windows => "dll", .macos => "dylib", else => "so", @@ -126,7 +126,7 @@ pub fn builtinPluginPath( } fn nativeEnviron() std.process.Environ { - if (builtin.os.tag == .windows) { + if (builtin.target.os.tag == .windows) { return .{ .block = .global }; } var n: usize = 0; @@ -634,7 +634,7 @@ pub fn probeVersionInfo(path: []const u8) ?PluginVersionInfo { test "builtin plugin path joins exe_dir/plugins//." { const path = try builtinPluginPath(std.testing.allocator, "/app", "pixi"); defer std.testing.allocator.free(path); - switch (builtin.os.tag) { + switch (builtin.target.os.tag) { .windows => try std.testing.expectEqualStrings("/app/plugins/pixi/pixi.dll", path), .macos => try std.testing.expectEqualStrings("/app/plugins/pixi/pixi.dylib", path), else => try std.testing.expectEqualStrings("/app/plugins/pixi/pixi.so", path), diff --git a/app/store/registry/compat.zig b/app/store/registry/compat.zig index c1362727b..575018a35 100644 --- a/app/store/registry/compat.zig +++ b/app/store/registry/compat.zig @@ -14,13 +14,13 @@ const builtin = @import("builtin"); pub fn hostKey() []const u8 { // The web: one key for the wasm side module a plugin publishes for the browser. if (builtin.target.cpu.arch == .wasm32) return "web-wasm32"; - const os = switch (builtin.os.tag) { + const os = switch (builtin.target.os.tag) { .macos => "macos", .linux => "linux", .windows => "windows", else => "unknown", }; - const arch = switch (builtin.cpu.arch) { + const arch = switch (builtin.target.cpu.arch) { .aarch64 => "aarch64", .x86_64 => "x86_64", else => "unknown", diff --git a/app/update/auto_update.zig b/app/update/auto_update.zig index bb04b740e..2cc28af01 100644 --- a/app/update/auto_update.zig +++ b/app/update/auto_update.zig @@ -49,7 +49,7 @@ fn castManager(m: *anyopaque) *Vpk.vpkc_update_manager_t { /// so we don't spam logs or Velopack errors on every frame. pub fn installLayoutSupported(io: std.Io) bool { if (!impl) return false; - if (builtin.os.tag != .macos) return true; + if (builtin.target.os.tag != .macos) return true; var buf: [std.fs.max_path_bytes]u8 = undefined; const n = std.process.executablePath(io, &buf) catch return false; @@ -197,7 +197,7 @@ pub fn appRunHook() void { // CLI flags during the install / update / uninstall lifecycle, and the hooks // registered here are dispatched from inside vpkc_app_run() based on which // flag is set. Hooks must be installed *before* vpkc_app_run runs. - if (comptime builtin.os.tag == .windows) { + if (comptime builtin.target.os.tag == .windows) { Vpk.vpkc_app_set_hook_after_install(hookAfterInstall); Vpk.vpkc_app_set_hook_after_update(hookAfterUpdate); Vpk.vpkc_app_set_hook_before_uninstall(hookBeforeUninstall); diff --git a/app/watch/FolderWatcher.zig b/app/watch/FolderWatcher.zig index 316cf29ba..3ff5f0301 100644 --- a/app/watch/FolderWatcher.zig +++ b/app/watch/FolderWatcher.zig @@ -57,7 +57,7 @@ const max_events: usize = 512; /// of deep paths can't crowd out everything else and no path length is special-cased. const path_arena_bytes: usize = 64 * 1024; -pub const have_impl = switch (builtin.os.tag) { +pub const have_impl = switch (builtin.target.os.tag) { .macos, .linux, .windows => true, else => false, }; diff --git a/app/watch/SettingsWatcher.zig b/app/watch/SettingsWatcher.zig index f713f1b1f..1b59afa7a 100644 --- a/app/watch/SettingsWatcher.zig +++ b/app/watch/SettingsWatcher.zig @@ -27,7 +27,7 @@ const SettingsWatcher = @This(); /// file a moment to finish before `Editor.reconcileExternalSettingsChange` reads it. const debounce_ns: i128 = 200 * std.time.ns_per_ms; -pub const have_impl = switch (builtin.os.tag) { +pub const have_impl = switch (builtin.target.os.tag) { .macos, .linux, .windows => true, else => false, }; @@ -108,7 +108,7 @@ fn classify(config_folder: []const u8, path: []const u8) Kind { const top = rest[0 .. std.mem.indexOfAny(u8, rest, "/\\") orelse rest.len]; for (state_folders) |name| if (std.mem.eql(u8, top, name)) return .ignored; } - const ext = switch (builtin.os.tag) { + const ext = switch (builtin.target.os.tag) { .windows => ".dll", .macos => ".dylib", else => ".so", @@ -139,7 +139,7 @@ pub fn emptyStateFolder(io: std.Io, path: []const u8) void { } test classify { - const ext = switch (builtin.os.tag) { + const ext = switch (builtin.target.os.tag) { .windows => ".dll", .macos => ".dylib", else => ".so", diff --git a/backend/src/GpuRenderer.zig b/backend/src/GpuRenderer.zig index 87eccb081..50d5b9f41 100644 --- a/backend/src/GpuRenderer.zig +++ b/backend/src/GpuRenderer.zig @@ -273,7 +273,7 @@ pub fn primary() ?*GpuRenderer { fn createDevice(window: *c.SDL_Window) ?*c.SDL_GPUDevice { const formats = c.SDL_GPU_SHADERFORMAT_SPIRV | c.SDL_GPU_SHADERFORMAT_DXIL | c.SDL_GPU_SHADERFORMAT_MSL; const debug = builtin.mode == .Debug; - if (builtin.os.tag == .windows and c.SDL_GetWindowFlags(window) & c.SDL_WINDOW_TRANSPARENT != 0) { + if (builtin.target.os.tag == .windows and c.SDL_GetWindowFlags(window) & c.SDL_WINDOW_TRANSPARENT != 0) { if (c.SDL_CreateGPUDevice(formats, debug, "direct3d12")) |device| return device; log.warn("no D3D12 device ({s}); the transparent window will be opaque", .{c.SDL_GetError()}); } @@ -411,7 +411,7 @@ pub fn destroy(self: *GpuRenderer) void { /// (SDL_GPU tags it sRGB, on claim and on every swapchain-parameter change). See /// `macos_monitor.m`. fn prepareLayer(window: *c.SDL_Window) void { - if (builtin.os.tag != .macos) return; + if (builtin.target.os.tag != .macos) return; const nswindow = c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null) orelse return; fizzy_native_metal_layer_prepare(nswindow); } @@ -657,7 +657,7 @@ pub fn claimViewport(self: *GpuRenderer, window: *c.SDL_Window) !void { log.err("SDL_ClaimWindowForGPUDevice (viewport) failed: {s}", .{c.SDL_GetError()}); return error.GpuClaimWindow; } - const mode: c.SDL_GPUPresentMode = if (builtin.os.tag == .macos) + const mode: c.SDL_GPUPresentMode = if (builtin.target.os.tag == .macos) c.SDL_GPU_PRESENTMODE_VSYNC else if (c.SDL_WindowSupportsGPUPresentMode(self.device, window, c.SDL_GPU_PRESENTMODE_IMMEDIATE)) c.SDL_GPU_PRESENTMODE_IMMEDIATE @@ -1193,7 +1193,7 @@ fn formatSupported(format: dvui.enums.TexturePixelFormat) bool { /// Where R, G, B and A sit in a texel of `format`, as bytes in memory; A null when the format /// has none (opaque). fn layout(format: dvui.enums.TexturePixelFormat) struct { r: u2, g: u2, b: u2, a: ?u2 } { - const little = builtin.cpu.arch.endian() == .little; + const little = builtin.target.cpu.arch.endian() == .little; // The `_8_8_8_8` formats are packed 32-bit words, named from the high byte down: in memory // on a little-endian machine they are the byte order reversed. const f: dvui.enums.TexturePixelFormat = if (!little) format else switch (format) { diff --git a/backend/src/SDLBackend.zig b/backend/src/SDLBackend.zig index 1eebc977e..925b969ff 100644 --- a/backend/src/SDLBackend.zig +++ b/backend/src/SDLBackend.zig @@ -366,7 +366,7 @@ pub fn health() Health.Snapshot { var n: c_int = 0; if (c.SDL_GetWindows(&n)) |windows| c.SDL_free(@ptrCast(windows)); s.os_windows = @intCast(@max(n, 0)); - if (comptime builtin.os.tag == .macos) { + if (comptime builtin.target.os.tag == .macos) { var all: c_uint = 0; var visible: c_uint = 0; fizzy_macos_window_counts(&all, &visible); @@ -412,7 +412,7 @@ const quit_signals = struct { var wake_fd: posix.fd_t = -1; fn install() void { - if (comptime builtin.os.tag == .windows) return; + if (comptime builtin.target.os.tag == .windows) return; if (wake_fd == -1) wake_fd = spawnWaker() orelse return; for (signals, &chained) |sig, *slot| { if (slot.* != null) continue; @@ -450,7 +450,7 @@ const quit_signals = struct { /// Puts SDL's handlers back, so `SDL_Quit` finds its own and resets the signals to their /// defaults. The thread stays parked on its pipe for the rest of the process. fn uninstall() void { - if (comptime builtin.os.tag == .windows) return; + if (comptime builtin.target.os.tag == .windows) return; for (signals, &chained) |sig, *slot| if (slot.*) |old| { posix.sigaction(sig, &old, null); slot.* = null; @@ -557,7 +557,7 @@ fn createWindowRenderer(options: InitOptions, share_device_of: ?*GpuRenderer) !s // adjust window size for content scale if (scale != 1.0 and saved_geometry == null) { - if (builtin.abi.isAndroid()) { + if (builtin.target.abi.isAndroid()) { // log.error fails on Android but SDL_Log will show up in LogCat c.SDL_Log("[ERROR] Android doesn't support SDL_SetWindowSize"); } else { @@ -570,7 +570,7 @@ fn createWindowRenderer(options: InitOptions, share_device_of: ?*GpuRenderer) !s } if (options.min_size) |size| { - if (builtin.abi.isAndroid()) { + if (builtin.target.abi.isAndroid()) { c.SDL_Log("[ERROR] Android doesn't support SDL_SetWindowMinimumSize"); } else { try toErr(c.SDL_SetWindowMinimumSize( @@ -582,7 +582,7 @@ fn createWindowRenderer(options: InitOptions, share_device_of: ?*GpuRenderer) !s } if (options.max_size) |size| { - if (builtin.abi.isAndroid()) { + if (builtin.target.abi.isAndroid()) { c.SDL_Log("[ERROR] Android doesn't support SDL_SetWindowMaximumSize"); } else { try toErr(c.SDL_SetWindowMaximumSize( @@ -759,7 +759,7 @@ fn configureBackend(back: *SDLBackend, options: InitOptions) !void { back.clear_window_on_begin = true; if (options.icon) |bytes| { - if (builtin.abi.isAndroid()) { + if (builtin.target.abi.isAndroid()) { // log.error fails on Android but SDL_Log will show up in LogCat c.SDL_Log("[ERROR] Android doesn't support setting a custom icon at runtime"); } else { @@ -770,7 +770,7 @@ fn configureBackend(back: *SDLBackend, options: InitOptions) !void { pub fn init(io: std.Io, window: *c.SDL_Window, gpu: *GpuRenderer) SDLBackend { dvui.io = io; - if (builtin.os.tag == .macos) { + if (builtin.target.os.tag == .macos) { fizzy_native_monitor_install(); if (cocoaWindow(window)) |nswindow| { fizzy_native_disable_titlebar_separator(nswindow); @@ -792,7 +792,7 @@ extern "c" fn fizzy_native_viewport_presented(nswindow: ?*anyopaque) void; extern "c" fn fizzy_native_live_resize_next_frame(nswindow: *anyopaque, wait_s: f64, since_start_s: f64) void; fn cocoaWindow(window: *c.SDL_Window) ?*anyopaque { - if (builtin.os.tag != .macos) return null; + if (builtin.target.os.tag != .macos) return null; return c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null); } @@ -800,12 +800,12 @@ fn cocoaWindow(window: *c.SDL_Window) ?*anyopaque { /// inside (`appIterate`): AppKit's tracking loop on macOS; on Windows the modal move/size loop, /// where SDL runs frames from a timer and from each step (fizzyedit/SDL's live-resize patch). fn inLiveResize(self: *SDLBackend) bool { - if (builtin.os.tag == .macos) { + if (builtin.target.os.tag == .macos) { if (cocoaWindow(self.window)) |nswindow| return fizzy_native_in_live_resize(nswindow) != 0; } // The thread's move/size loop: any of fizzy's windows', the main one or a popped-out float's // (`Viewport.win32_loop`), where SDL runs the frame from the loop's timer and from each step. - if (builtin.os.tag == .windows) { + if (builtin.target.os.tag == .windows) { var info = std.mem.zeroes(win32_gui.GUITHREADINFO); info.cbSize = @sizeOf(win32_gui.GUITHREADINFO); if (win32_gui.GetGUIThreadInfo(0, &info) != 0) return info.flags & win32_gui.GUI_INMOVESIZE != 0; @@ -818,7 +818,7 @@ fn inLiveResize(self: *SDLBackend) bool { /// `waitTime`), so an animation keeps the display's rate while no resize step is drawing it /// (`fizzy_native_live_resize_next_frame` in `macos_monitor.m`). fn liveResizeNextFrame(self: *SDLBackend, win: *const dvui.Window, wait_micros: u32) void { - if (comptime builtin.os.tag != .macos) return; + if (comptime builtin.target.os.tag != .macos) return; const nswindow = cocoaWindow(self.window) orelse return; const wait_s: f64 = if (wait_micros == std.math.maxInt(u32)) -1 else @as(f64, @floatFromInt(wait_micros)) / std.time.us_per_s; const since_start_ns = @max(0, self.nanoTime() - win.frame_time_ns); @@ -934,7 +934,7 @@ pub fn cursorShow(_: *SDLBackend, value: ?bool) bool { pub fn native(self: *SDLBackend, _: *dvui.Window) dvui.Window.Native { const props = c.SDL_GetWindowProperties(self.window); - switch (builtin.os.tag) { + switch (builtin.target.os.tag) { .windows => return .{ .hwnd = c.SDL_GetPointerProperty(props, c.SDL_PROP_WINDOW_WIN32_HWND_POINTER, null) }, .macos => return .{ .cocoa_window = c.SDL_GetPointerProperty(props, c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null) }, else => return {}, @@ -1099,9 +1099,9 @@ const ViewportKind = enum { window, carry, overlay, menu }; /// (`Viewport.ride`) — none for a menu opened in a float that is out. `dialog`: a dialog's window /// (`Viewport.dialog`). pub fn viewportOpenMenu(self: *SDLBackend, at: viewport_map.Rect, radius: f32, ride: Ride, dialog: bool) ?*Viewport { - if (comptime builtin.os.tag != .macos and builtin.os.tag != .windows) return null; + if (comptime builtin.target.os.tag != .macos and builtin.target.os.tag != .windows) return null; const vp = self.openViewport(at, "", .menu, .os) orelse return null; - switch (comptime builtin.os.tag) { + switch (comptime builtin.target.os.tag) { .macos => { _ = fizzy_macos_viewport_menu(cocoaWindow(vp.window), cocoaWindow(self.window), platform.window.ns_visual_effect_material, radius, @intFromBool(dialog)); vp.ride = switch (ride) { @@ -1130,19 +1130,19 @@ pub fn viewportOpenMenu(self: *SDLBackend, at: viewport_map.Rect, radius: f32, r /// `vp`'s window just above `other`'s, where it is not already (macOS). pub fn viewportOrderAbove(_: *SDLBackend, vp: *Viewport, other: *Viewport) void { - if (comptime builtin.os.tag != .macos) return; + if (comptime builtin.target.os.tag != .macos) return; fizzy_macos_viewport_order_above(cocoaWindow(vp.window), cocoaWindow(other.window)); } /// `vp`'s window at `other`'s level, just above it (macOS). pub fn viewportLift(_: *SDLBackend, vp: *Viewport, other: *Viewport) void { - if (comptime builtin.os.tag != .macos) return; + if (comptime builtin.target.os.tag != .macos) return; fizzy_macos_viewport_lift(cocoaWindow(vp.window), cocoaWindow(other.window)); } /// `vp`'s window back at a window's own level, after `viewportLift` (macOS). pub fn viewportSettle(self: *SDLBackend, vp: *Viewport) void { - if (comptime builtin.os.tag != .macos) return; + if (comptime builtin.target.os.tag != .macos) return; fizzy_macos_viewport_settle(cocoaWindow(vp.window), cocoaWindow(self.window)); self.stack_stale = true; } @@ -1174,7 +1174,7 @@ pub fn viewportMainOffset(_: *SDLBackend, vp: *Viewport, offset: viewport_map.Po /// window): borderless and clear, the pointer passing through it to what is under, kept above every /// window, in no window list, never focused. macOS. Placed and drawn as any viewport. pub fn viewportOpenCarry(self: *SDLBackend, at: viewport_map.Rect) ?*Viewport { - if (comptime builtin.os.tag != .macos) return null; + if (comptime builtin.target.os.tag != .macos) return null; return self.openViewport(at, "", .carry, .os); } @@ -1183,14 +1183,14 @@ pub fn viewportOpenCarry(self: *SDLBackend, at: viewport_map.Rect) ?*Viewport { /// grown to its display, clear but for its glass, the pointer passing through it. macOS 26, where /// `liquidGlassAvailable`. pub fn viewportOpenOverlay(self: *SDLBackend, at: viewport_map.Rect) ?*Viewport { - if (comptime builtin.os.tag != .macos) return null; + if (comptime builtin.target.os.tag != .macos) return null; if (!liquidGlassAvailable()) return null; return self.openViewport(at, "", .overlay, .os); } /// Whether the OS has Liquid Glass to draw (`viewportOpenOverlay`): macOS 26. pub fn liquidGlassAvailable() bool { - if (comptime builtin.os.tag != .macos) return false; + if (comptime builtin.target.os.tag != .macos) return false; return fizzy_macos_liquid_glass_available() != 0; } @@ -1224,7 +1224,7 @@ pub fn viewportOverlayGlass(_: *SDLBackend, vp: *Viewport, shapes: []const Glass /// RGBA rows, `w` by `h`, copied; null, none. Once a drag: its place each frame is cheap, as an /// image under the glass rather than a window of its own presented every frame. pub fn viewportOverlayPhotoImage(_: *SDLBackend, vp: *Viewport, rgba: ?[]const u8, w: u32, h: u32) void { - if (comptime builtin.os.tag != .macos) return; + if (comptime builtin.target.os.tag != .macos) return; const px = rgba orelse { fizzy_macos_viewport_overlay_photo_image(cocoaWindow(vp.window), null, 0, 0); return; @@ -1300,7 +1300,7 @@ fn openViewport(self: *SDLBackend, at: viewport_map.Rect, title_text: [:0]const // A menu's window is a carry window that takes the pointer, never focused: the window it opened // from keeps the keyboard, as an OS menu leaves it. // One framed by the app (`ViewportFrame.app`) is borderless on macOS as well. - const titled = builtin.os.tag == .macos and !carry and frame == .os; + const titled = builtin.target.os.tag == .macos and !carry and frame == .os; const frame_flag: c.SDL_WindowFlags = if (titled) 0 else c.SDL_WINDOW_BORDERLESS; const size_flag: c.SDL_WindowFlags = if (carry) 0 else c.SDL_WINDOW_RESIZABLE; const focus_flag: c.SDL_WindowFlags = if (menu) c.SDL_WINDOW_NOT_FOCUSABLE else 0; @@ -1324,22 +1324,22 @@ fn openViewport(self: *SDLBackend, at: viewport_map.Rect, title_text: [:0]const // its own while the viewport was being resized — put the main window over it. Not on macOS, // where SDL makes it a child window that moves with its parent, and the windows that left // the main one stay where they are when it moves (`plans/POPOUT_WINDOWS_PLAN.md`, decision 2). - if (comptime builtin.os.tag != .macos) _ = c.SDL_SetWindowParent(window, self.window); + if (comptime builtin.target.os.tag != .macos) _ = c.SDL_SetWindowParent(window, self.window); self.viewport_slots[slot] = .{ .window = window, .band = b, .anchor = anchor, .density = d, .screen = placed.screen, .frame = placed.frame, .passive = carry and !menu, .overlay = role == .overlay, .menu = menu, .app_frame = frame == .app }; const vp = &self.viewport_slots[slot].?; // Dressed by `viewportOpenMenu`, and no hit test: nothing in it moves or resizes it. if (menu) return vp; if (role == .overlay) { - if (comptime builtin.os.tag == .macos) fizzy_macos_viewport_overlay(cocoaWindow(window), cocoaWindow(self.window)); + if (comptime builtin.target.os.tag == .macos) fizzy_macos_viewport_overlay(cocoaWindow(window), cocoaWindow(self.window)); return vp; } if (carry) { - if (comptime builtin.os.tag == .macos) fizzy_macos_viewport_carry(cocoaWindow(window), cocoaWindow(self.window), platform.window.ns_visual_effect_material); + if (comptime builtin.target.os.tag == .macos) fizzy_macos_viewport_carry(cocoaWindow(window), cocoaWindow(self.window), platform.window.ns_visual_effect_material); return vp; } // The slot holds it for the window's life, so SDL may keep the pointer. _ = c.SDL_SetWindowHitTest(window, viewportHitTest, vp); - if (comptime builtin.os.tag == .macos) if (frame == .app) { + if (comptime builtin.target.os.tag == .macos) if (frame == .app) { fizzy_macos_viewport_plain(cocoaWindow(window)); fizzy_macos_viewport_windows_item(cocoaWindow(window), title_text.ptr); }; @@ -1357,14 +1357,14 @@ pub fn viewportCarryShape(_: *SDLBackend, vp: *Viewport, radius: ?f32, alpha: f3 /// A carry window's Liquid Glass as the lens (the carried view) or as frost (a float's window growing /// out of it, as its own material is). macOS 26; nothing elsewhere. pub fn viewportCarryLens(_: *SDLBackend, vp: *Viewport, lens: bool) void { - if (comptime builtin.os.tag != .macos) return; + if (comptime builtin.target.os.tag != .macos) return; fizzy_macos_viewport_carry_lens(cocoaWindow(vp.window), @intFromBool(lens)); } /// Points from `vp`'s window's left edge past the OS's own buttons in its title bar: the traffic /// lights of a titled macOS window, with the margin before them. 0 elsewhere. pub fn viewportButtonsWidth(_: *SDLBackend, vp: *Viewport) f32 { - if (comptime builtin.os.tag != .macos) return 0; + if (comptime builtin.target.os.tag != .macos) return 0; if (vp.passive) return 0; return @floatCast(fizzy_macos_window_buttons_width(cocoaWindow(vp.window))); } @@ -1372,7 +1372,7 @@ pub fn viewportButtonsWidth(_: *SDLBackend, vp: *Viewport) f32 { /// The corner radius of a titled window, points: what a float's window is rounded by /// (`viewportCarryShape` grows the carried glass into it). pub fn windowCornerRadius(_: *SDLBackend) f32 { - if (comptime builtin.os.tag != .macos) return 0; + if (comptime builtin.target.os.tag != .macos) return 0; return @floatCast(fizzy_macos_window_corner_radius()); } @@ -1417,7 +1417,7 @@ pub fn viewportFade(_: *SDLBackend, vp: *Viewport, alpha: f32) void { /// windows anywhere — a window split out of the main one would open where the compositor likes, and /// none could follow a drag (`plans/POPOUT_WINDOWS_PLAN.md`). Floats stay in the main window there. pub fn viewportsAvailable() bool { - if (comptime builtin.os.tag != .linux) return true; + if (comptime builtin.target.os.tag != .linux) return true; const driver = c.SDL_GetCurrentVideoDriver() orelse return false; return !std.mem.eql(u8, std.mem.span(driver), "wayland"); } @@ -1452,7 +1452,7 @@ pub fn viewportClose(self: *SDLBackend, vp: *Viewport) void { _ = c.SDL_RaiseWindow(self.window); }; // The window as SDL made it, for SDL to destroy (`viewportGlass`). - if (comptime builtin.os.tag == .macos) { + if (comptime builtin.target.os.tag == .macos) { platform.macos_monitor.unwatch(v.window); if (c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(v.window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null)) |ns| fizzy_macos_viewport_unglass(ns); } @@ -1472,7 +1472,7 @@ pub fn viewportClose(self: *SDLBackend, vp: *Viewport) void { fn placeWindow(vp: *Viewport, to: viewport_map.ScreenRect, was: viewport_map.ScreenRect) void { if (std.meta.eql(to, was)) return; vp.app_placed_ns = c.SDL_GetTicksNS(); - if (comptime builtin.os.tag == .macos) { + if (comptime builtin.target.os.tag == .macos) { vp.frame_pending = true; return; } @@ -1486,7 +1486,7 @@ pub fn viewportPlace(_: *SDLBackend, vp: *Viewport, frame: viewport_map.Rect) vi // In a fullscreen Space, or on its way into or out of one, its frame is AppKit's: the float // follows the window (`viewportFollowWindow`), never the other way. A window put somewhere in // a Space is taken out of it. - if (comptime builtin.os.tag == .macos) { + if (comptime builtin.target.os.tag == .macos) { if (platform.macos_monitor.osOwnsFrame(vp.window)) return vp.frame; } const placed = viewport_map.place(vp.band, vp.anchor, vp.density, frame); @@ -1594,10 +1594,10 @@ pub fn viewportHints(_: *SDLBackend, vp: *Viewport, hints: ?struct { drag: viewp .drag = win.of(h.drag, o, d), .keep = win.of(h.keep, o, d), .glass = win.of(h.glass, o, d), - .edge = if (builtin.os.tag == .macos or maximized) 0 else h.edge / d, + .edge = if (builtin.target.os.tag == .macos or maximized) 0 else h.edge / d, // Where the OS resizes from no edge (macOS), the float's own resize zones stay its own. - .app_side = if (builtin.os.tag == .macos) h.app_side / d else 0, - .app_corner = if (builtin.os.tag == .macos) h.app_corner / d else 0, + .app_side = if (builtin.target.os.tag == .macos) h.app_side / d else 0, + .app_corner = if (builtin.target.os.tag == .macos) h.app_corner / d else 0, }; } @@ -1613,7 +1613,7 @@ pub fn viewportOsPlaced(_: *SDLBackend, vp: *Viewport) ?viewport_map.Rect { /// when the window is no longer the size it was (resized, snapped or maximized), not just moved. /// Null otherwise — and for a window the OS moved with no press (a keyboard snap). pub fn viewportOsMoveEnded(_: *SDLBackend, vp: *Viewport) ?struct { resized: bool } { - if (comptime builtin.os.tag == .windows) { + if (comptime builtin.target.os.tag == .windows) { if (!vp.win32_loop.ended) return null; vp.win32_loop.ended = false; return .{ .resized = vp.win32_loop.resized }; @@ -1627,7 +1627,7 @@ pub fn viewportOsMoveEnded(_: *SDLBackend, vp: *Viewport) ?struct { resized: boo /// snapping and all, until the button is let go (`win32_titlebar.viewportDragMove`). False where /// that is not done (not Windows): the app goes on moving the window until the release. pub fn viewportDragMove(_: *SDLBackend, vp: *Viewport) bool { - if (comptime builtin.os.tag != .windows) return false; + if (comptime builtin.target.os.tag != .windows) return false; const hwnd = c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(vp.window), c.SDL_PROP_WINDOW_WIN32_HWND_POINTER, null) orelse return false; platform.win32_titlebar.viewportDragMove(hwnd); return true; @@ -1640,7 +1640,7 @@ pub fn viewportTitle(_: *SDLBackend, vp: *Viewport, text: []const u8) void { var buf: [128]u8 = undefined; const z = std.fmt.bufPrintZ(&buf, "{s}", .{text[0..@min(text.len, buf.len - 1)]}) catch return; _ = c.SDL_SetWindowTitle(vp.window, z.ptr); - if (comptime builtin.os.tag == .macos) fizzy_macos_viewport_windows_item(cocoaWindow(vp.window), z.ptr); + if (comptime builtin.target.os.tag == .macos) fizzy_macos_viewport_windows_item(cocoaWindow(vp.window), z.ptr); } /// The least the OS may resize `vp`'s window to: `size` physical pixels of the frame, its float's @@ -1689,7 +1689,7 @@ extern fn fizzy_macos_viewport_windows_item(nswindow: ?*anyopaque, title: [*:0]c /// - Elsewhere: none, for now. pub fn viewportGlass(self: *SDLBackend, vp: *Viewport, inset: f32, radius: f32, dark: bool) bool { const props = c.SDL_GetWindowProperties(vp.window); - switch (comptime builtin.os.tag) { + switch (comptime builtin.target.os.tag) { .macos => { const ns = c.SDL_GetPointerProperty(props, c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null) orelse return false; const main_ns = c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(self.window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null); @@ -1759,7 +1759,7 @@ fn density(self: *SDLBackend) f32 { // and the two arrive apart — the frames after a window jumps its size into or out of full screen // read half or twice the density, the main window's picture laid out at the wrong scale in the // corner and floats placed through it shrunk and moved (the user). - if (comptime builtin.os.tag == .macos) if (cocoaWindow(self.window)) |ns| { + if (comptime builtin.target.os.tag == .macos) if (cocoaWindow(self.window)) |ns| { const s = fizzy_macos_window_backing_scale(ns); if (s > 0) return @floatCast(s); }; @@ -1946,12 +1946,12 @@ fn viewportFollowWindow(vp: *Viewport) void { if (std.meta.eql(now, vp.screen)) return; // Placing a window is asynchronous on X11: just after the app put it somewhere, what it says // of itself is still on its way there. - if (comptime builtin.os.tag == .linux) { + if (comptime builtin.target.os.tag == .linux) { if (c.SDL_GetTicksNS() -% vp.app_placed_ns < 250 * std.time.ns_per_ms) return; } // Under a held press, and no move loop to say when it ends (not Windows): the OS is moving it // until the press is let go. - if (comptime builtin.os.tag != .windows) { + if (comptime builtin.target.os.tag != .windows) { if (!vp.os_moving and c.SDL_GetGlobalMouseState(null, null) != 0) { vp.os_moving = true; vp.os_start = vp.screen; @@ -1985,7 +1985,7 @@ fn viewportHitTest(_: ?*c.SDL_Window, area: [*c]const c.SDL_Point, data: ?*anyop pub fn setCursor(self: *SDLBackend, cursor: dvui.enums.Cursor) void { // NOTE: SDL3's UIKit driver has no system cursors, so SDL_CreateSystemCursor always fails there. - if (builtin.os.tag == .ios) return; + if (builtin.target.os.tag == .ios) return; if (cursor == self.cursor_last) return; defer self.cursor_last = cursor; const new_shown_state = if (cursor == .hidden) false else if (self.cursor_last == .hidden) true else null; @@ -2118,7 +2118,7 @@ pub fn renderPresent(self: *SDLBackend) void { // empty, then with a picture a frame of the drag away from it. var transacted: [max_viewports]bool = @splat(false); var any = false; - if (comptime builtin.os.tag == .macos) { + if (comptime builtin.target.os.tag == .macos) { for (&self.viewport_slots, 0..) |*slot, i| { const vp = if (slot.*) |*v| v else continue; if (vp.app_frame and vp.shown) { @@ -2158,7 +2158,7 @@ pub fn renderPresent(self: *SDLBackend) void { } } } - defer if (comptime builtin.os.tag == .macos) if (any) { + defer if (comptime builtin.target.os.tag == .macos) if (any) { for (&self.viewport_slots, transacted) |*slot, t| { if (!t) continue; const vp = if (slot.*) |*v| v else continue; @@ -2196,7 +2196,7 @@ pub fn renderPresent(self: *SDLBackend) void { // A frame inside a Windows live resize is drawn before the loop goes on: one queued behind // others reaches the screen a composite or more after the size it was drawn for, as an edge // with nothing on it or a frame cut off (docs/WINDOWS_LIVE_RESIZE.md). - self.gpu.present(self.clear_window_on_begin, builtin.os.tag == .windows and self.inLiveResize()); + self.gpu.present(self.clear_window_on_begin, builtin.target.os.tag == .windows and self.inLiveResize()); // Shown once there is a frame in it (or empty, above). Not made key: the window it came out of // keeps the keyboard until the viewport is clicked. for (&self.viewport_slots, first_frame, map_empty) |*slot, show, empty| { @@ -2209,7 +2209,7 @@ pub fn renderPresent(self: *SDLBackend) void { vp.mapped = true; // Riding on its window from here (`Viewport.ride`) — not before it shows: made a child // window, AppKit shows it at once, before it has a picture. - if (comptime builtin.os.tag == .macos) if (vp.ride) |parent| { + if (comptime builtin.target.os.tag == .macos) if (vp.ride) |parent| { _ = c.SDL_SetWindowParent(vp.window, parent); if (!vp.dialog) fizzy_macos_viewport_menu_attached(cocoaWindow(vp.window)); }; @@ -2220,7 +2220,7 @@ pub fn renderPresent(self: *SDLBackend) void { // Over the main window as it first shows: SDL showed it below the key window. Once // (`noteMainForward`): from there it stacks as any window does. And once more as the app first // activates, which brings the main window in front of them (`fizzy_macos_watch_first_activation`). - if (comptime builtin.os.tag == .macos) { + if (comptime builtin.target.os.tag == .macos) { fizzy_macos_watch_first_activation(); if (fizzy_macos_took_first_activation() != 0) for (&self.viewport_slots) |*slot| if (slot.*) |*v| { v.over_main = true; @@ -2317,7 +2317,7 @@ pub fn begin(self: *SDLBackend, arena: std.mem.Allocator) !void { // A float's window standing still on its way into or out of full screen: its picture has // taken this frame's step (`begin_hook`), and the float follows it there — as it did the // window's moves — and back to the window as the way ends. - if (comptime builtin.os.tag == .macos) for (&self.viewport_slots) |*slot| { + if (comptime builtin.target.os.tag == .macos) for (&self.viewport_slots) |*slot| { const vp = if (slot.*) |*v| v else continue; if (vp.picture_at[0] != 0 or vp.picture_at[1] != 0 or platform.window.windowSpacePicture(vp.window) != null) viewportFollowWindow(vp); @@ -2341,7 +2341,7 @@ fn mainPicture(self: *const SDLBackend) ?viewport_map.Rect { /// picture, while it stands still on its way into or out of full screen (`main_picture`). Null: /// the frame is the window. pub fn pictureInWindow(self: *SDLBackend) ?struct { at: dvui.Rect.Physical, all: dvui.Rect.Physical } { - if (comptime builtin.os.tag != .macos) return null; + if (comptime builtin.target.os.tag != .macos) return null; const p = self.mainPicture() orelse return null; // The drawable the frame goes into: the window's size the moment it jumps (the monitor keeps it // so), where SDL's word on its pixel size comes a frame or more later. @@ -2364,7 +2364,7 @@ pub fn pictureInWindow(self: *SDLBackend) ?struct { at: dvui.Rect.Physical, all: /// way into or out of full screen ran choppy and stood still partway for moments at a time (the /// user). The floats keep theirs the same way while the main window moves (`renderPresent`). fn mainHeld(self: *SDLBackend) bool { - if (comptime builtin.os.tag != .macos) return false; + if (comptime builtin.target.os.tag != .macos) return false; // Its own way into or out of full screen is drawn whatever AppKit says of it on the way. if (platform.window.windowMovingItself(self.window)) return false; if (c.SDL_GetWindowFlags(self.window) & c.SDL_WINDOW_OCCLUDED != 0) return true; @@ -2391,7 +2391,7 @@ pub fn pixelSize(self: *SDLBackend) dvui.Size.Physical { } var w: c_int = 0; var h: c_int = 0; - if (builtin.os.tag == .macos) { + if (builtin.target.os.tag == .macos) { if (cocoaWindow(self.window)) |nswindow| { if (fizzy_native_metal_drawable_size(nswindow, &w, &h) != 0) { self.last_pixel_size = .{ .w = @floatFromInt(w), .h = @floatFromInt(h) }; @@ -2788,7 +2788,7 @@ pub fn addEvent(self: *SDLBackend, win: *dvui.Window, event: c.SDL_Event) !bool // events per second of a 60Hz display // // Normalize by 60/refresh_hz on macOS - const mac_wheel_scale: f32 = if (sdl3 and builtin.os.tag == .macos) blk: { + const mac_wheel_scale: f32 = if (sdl3 and builtin.target.os.tag == .macos) blk: { const display = c.SDL_GetDisplayForWindow(self.window); if (display == 0) break :blk 1.0; const mode = c.SDL_GetCurrentDisplayMode(display) orelse break :blk 1.0; @@ -2803,7 +2803,7 @@ pub fn addEvent(self: *SDLBackend, win: *dvui.Window, event: c.SDL_Event) !bool // the right classification immediately. var mouse_type: dvui.enums.MouseType = .unknown; - if (sdl3 and builtin.os.tag == .macos) { + if (sdl3 and builtin.target.os.tag == .macos) { const v = fizzy_native_monitor_last_scroll_precise(); if (v >= 0) { mouse_type = if (v != 0) .trackpad else .mouse; @@ -2854,9 +2854,9 @@ pub fn addEvent(self: *SDLBackend, win: *dvui.Window, event: c.SDL_Event) !bool if (self.log_events) { log.debug("event FOCUS_GAINED\n", .{}); } - if (dvui.accesskit_enabled and builtin.os.tag == .linux) { + if (dvui.accesskit_enabled and builtin.target.os.tag == .linux) { dvui.AccessKit.c.accesskit_unix_adapter_update_window_focus_state(win.accesskit.adapter, true); - } else if (dvui.accesskit_enabled and builtin.os.tag == .macos) { + } else if (dvui.accesskit_enabled and builtin.target.os.tag == .macos) { const events = dvui.AccessKit.c.accesskit_macos_subclassing_adapter_update_view_focus_state(win.accesskit.adapter, true); if (events) |evts| { dvui.AccessKit.c.accesskit_macos_queued_events_raise(evts); @@ -2868,9 +2868,9 @@ pub fn addEvent(self: *SDLBackend, win: *dvui.Window, event: c.SDL_Event) !bool if (self.log_events) { log.debug("event FOCUS_LOST\n", .{}); } - if (dvui.accesskit_enabled and builtin.os.tag == .linux) { + if (dvui.accesskit_enabled and builtin.target.os.tag == .linux) { dvui.AccessKit.c.accesskit_unix_adapter_update_window_focus_state(win.accesskit.adapter, false); - } else if (dvui.accesskit_enabled and builtin.os.tag == .macos) { + } else if (dvui.accesskit_enabled and builtin.target.os.tag == .macos) { const events = dvui.AccessKit.c.accesskit_macos_subclassing_adapter_update_view_focus_state(win.accesskit.adapter, false); if (events) |evts| { dvui.AccessKit.c.accesskit_macos_queued_events_raise(evts); @@ -2885,7 +2885,7 @@ pub fn addEvent(self: *SDLBackend, win: *dvui.Window, event: c.SDL_Event) !bool if (comptime sdl3) { self.trackGeometry(); } - if (dvui.accesskit_enabled and builtin.os.tag == .linux) { + if (dvui.accesskit_enabled and builtin.target.os.tag == .linux) { var x: i32, var y: i32 = .{ undefined, undefined }; _ = c.SDL_GetWindowPosition(win.backend.impl.window, &x, &y); var w: i32, var h: i32 = .{ undefined, undefined }; @@ -3262,7 +3262,7 @@ pub fn main(main_init: std.process.Init) !u8 { if (sdl3) _ = c.SDL_SetAppMetadata("DVUI App Example", "0.1", "com.example.dvui.app"); - if (builtin.os.tag == .windows) { // optional + if (builtin.target.os.tag == .windows) { // optional // on windows graphical apps have no console, so output goes to nowhere - attach it manually. related: https://github.com/ziglang/zig/issues/4196 dvui.Backend.Common.windowsAttachConsole() catch {}; } @@ -3689,12 +3689,12 @@ const live_resize_trace = struct { const Start = struct { t: f64, probe: [8]f64 }; fn enabled() bool { - if (comptime builtin.os.tag != .macos) return false; + if (comptime builtin.target.os.tag != .macos) return false; return fizzy_live_resize_trace_enabled() != 0; } fn begin(back: *SDLBackend) ?Start { - if (comptime builtin.os.tag != .macos) return null; + if (comptime builtin.target.os.tag != .macos) return null; if (!enabled()) return null; const nswindow = cocoaWindow(back.window) orelse return null; var p: [8]f64 = undefined; @@ -3709,7 +3709,7 @@ const live_resize_trace = struct { } fn end(s: Start, back: *SDLBackend, wait_micros: u32) void { - if (comptime builtin.os.tag != .macos) return; + if (comptime builtin.target.os.tag != .macos) return; const r = frame_rect; const p = s.probe; std.debug.print("[lr] {d:.6} frame {d} src={s} since={d:.1}ms took={d:.1}ms win={d}x{d} layer={d}x{d} drawable={d}x{d} swap={d}x{d} rect={d}x{d} wait={d}us\n", .{ diff --git a/backend/src/platform/dialogs.zig b/backend/src/platform/dialogs.zig index 68b6145fa..c800f95c9 100644 --- a/backend/src/platform/dialogs.zig +++ b/backend/src/platform/dialogs.zig @@ -67,7 +67,7 @@ pub fn showSaveFileDialog(cb: *const fn (?[][:0]const u8) void, filters: []const defer alloc().free(default); // Do not use our borderless/custom-frame main window as the dialog parent on Windows: the shell // may inherit extended style and the picker loses normal frame/close affordances. - const parent: ?*c.SDL_Window = if (builtin.os.tag == .windows) null else dvui.currentWindow().backend.impl.window; + const parent: ?*c.SDL_Window = if (builtin.target.os.tag == .windows) null else dvui.currentWindow().backend.impl.window; c.SDL_ShowSaveFileDialog(GenericSaveDialogCallback, @ptrCast(@alignCast(@constCast(cb))), parent, filters.ptr, @intCast(filters.len), default); } @@ -77,7 +77,7 @@ pub fn showOpenFileDialog(cb: *const fn (?[][:0]const u8) void, filters: []const break :blk std.fs.path.joinZ(alloc(), &.{ dir, default_filename }) catch "untitled"; }; defer alloc().free(default); - const parent: ?*c.SDL_Window = if (builtin.os.tag == .windows) null else dvui.currentWindow().backend.impl.window; + const parent: ?*c.SDL_Window = if (builtin.target.os.tag == .windows) null else dvui.currentWindow().backend.impl.window; c.SDL_ShowOpenFileDialog(GenericOpenDialogCallback, @ptrCast(@alignCast(@constCast(cb))), parent, filters.ptr, @intCast(filters.len), default.ptr, true); } @@ -87,7 +87,7 @@ pub fn showOpenFolderDialog(cb: *const fn (?[][:0]const u8) void, default_folder break :blk std.fmt.allocPrintSentinel(alloc(), "{s}", .{dir}, 0) catch "untitled"; }; defer alloc().free(default); - const parent: ?*c.SDL_Window = if (builtin.os.tag == .windows) null else dvui.currentWindow().backend.impl.window; + const parent: ?*c.SDL_Window = if (builtin.target.os.tag == .windows) null else dvui.currentWindow().backend.impl.window; c.SDL_ShowOpenFolderDialog(GenericOpenDialogCallback, @ptrCast(@alignCast(@constCast(cb))), parent, default.ptr, false); } diff --git a/backend/src/platform/geometry.zig b/backend/src/platform/geometry.zig index 1683bca6f..ab5276119 100644 --- a/backend/src/platform/geometry.zig +++ b/backend/src/platform/geometry.zig @@ -128,7 +128,7 @@ fn cocoaWindowOf(window: *c.SDL_Window) ?*anyopaque { /// Where `win` is now, as `Geometry`. pub fn capture(win: *dvui.Window) ?Geometry { - switch (builtin.os.tag) { + switch (builtin.target.os.tag) { .macos => { const cocoa = cocoaWindowOf(win.backend.impl.window) orelse return null; var out4: [4]f64 = .{0} ** 4; @@ -165,7 +165,7 @@ pub fn capture(win: *dvui.Window) ?Geometry { /// Put `win` at `g`: false where it was not (no window, or nowhere on the screens there are now). fn apply(win: *dvui.Window, g: Geometry) bool { - switch (builtin.os.tag) { + switch (builtin.target.os.tag) { .macos => { const cocoa = cocoaWindowOf(win.backend.impl.window) orelse return false; if (!frameOnScreens(.{ .x = g.x, .y = g.y, .w = g.w, .h = g.h })) return false; @@ -233,7 +233,7 @@ var linux_window: ?*c.SDL_Window = null; var linux_watching = false; fn watchLinux(win: *dvui.Window) void { - if (comptime builtin.os.tag == .macos or builtin.os.tag == .windows) return; + if (comptime builtin.target.os.tag == .macos or builtin.target.os.tag == .windows) return; linux_window = win.backend.impl.window; trackLinux(); if (linux_watching) return; diff --git a/backend/src/platform/linux_titlebar.zig b/backend/src/platform/linux_titlebar.zig index 75cbbf2cd..2b45246f3 100644 --- a/backend/src/platform/linux_titlebar.zig +++ b/backend/src/platform/linux_titlebar.zig @@ -31,7 +31,7 @@ var requested_insets: Insets = .{}; /// makes the window borderless with those frame insets. Where SDL keeps no margin (X11) /// `frameInsets` reads zero and nothing draws there. pub fn useClientDecorations(insets: Insets) void { - if (builtin.os.tag != .linux) return; + if (builtin.target.os.tag != .linux) return; // Only fizzy's own backend makes the window through a creation hook, against an SDL with // frame insets; dvui's keeps the desktop's decorations. if (comptime @hasDecl(backend, "window_create_hook") and @hasDecl(c, "SDL_PROP_WINDOW_CREATE_WAYLAND_FRAME_INSET_LEFT_NUMBER")) { @@ -52,7 +52,7 @@ fn addCreateProps(props: c.SDL_PropertiesID) void { /// The frame's margins in effect now, in window coordinates: those asked for while the window /// floats on Wayland; zero maximized, tiled or full screen, or where SDL keeps none. pub fn frameInsets(window: *c.SDL_Window) Insets { - if (comptime builtin.os.tag == .linux and @hasDecl(c, "SDL_PROP_WINDOW_WAYLAND_FRAME_INSET_LEFT_NUMBER")) { + if (comptime builtin.target.os.tag == .linux and @hasDecl(c, "SDL_PROP_WINDOW_WAYLAND_FRAME_INSET_LEFT_NUMBER")) { const props = c.SDL_GetWindowProperties(window); return .{ .left = @floatFromInt(c.SDL_GetNumberProperty(props, c.SDL_PROP_WINDOW_WAYLAND_FRAME_INSET_LEFT_NUMBER, 0)), @@ -71,7 +71,7 @@ pub fn frameInsets(window: *c.SDL_Window) Insets { /// Cheap to call every frame; it is sent to the compositor only when it changes, and taken with /// the frame presented next. pub fn blurBehind(window: *c.SDL_Window, radius: ?f32) bool { - if (comptime builtin.os.tag != .linux) return false; + if (comptime builtin.target.os.tag != .linux) return false; const props = c.SDL_GetWindowProperties(window); const display = c.SDL_GetPointerProperty(props, c.SDL_PROP_WINDOW_WAYLAND_DISPLAY_POINTER, null) orelse return false; const surface = c.SDL_GetPointerProperty(props, c.SDL_PROP_WINDOW_WAYLAND_SURFACE_POINTER, null) orelse return false; @@ -104,7 +104,7 @@ const resize_frame_points: f32 = 6; /// Apply the chrome to `win` (see `window.setStyle`): idempotent, cheap to call every frame. pub fn applyChrome(win: *dvui.Window) void { - if (builtin.os.tag != .linux) return; + if (builtin.target.os.tag != .linux) return; const window = win.backend.impl.window; if (styled_window == window) return; styled_window = window; diff --git a/backend/src/platform/macos_monitor.zig b/backend/src/platform/macos_monitor.zig index 674309e35..9bf665b28 100644 --- a/backend/src/platform/macos_monitor.zig +++ b/backend/src/platform/macos_monitor.zig @@ -180,7 +180,7 @@ fn macosLiveResizeUpdate(window: *c.SDL_Window) void { /// Wake the SDL event loop from an AppKit notification about `cocoa`'s window. Sync its sizes /// first so the next appIterate begin() sees transition-correct dimensions. export fn fizzy_macos_window_resize_cb(cocoa: ?*anyopaque) void { - if (comptime builtin.os.tag == .macos) { + if (comptime builtin.target.os.tag == .macos) { if (macos_pump_ready) { if (watchedOf(cocoa)) |w| macosSyncSizes(w); } @@ -200,7 +200,7 @@ export fn fizzy_macos_window_wake() void { /// Called from the monitor's 60Hz NSTimer for each window animating: its live sizes into SDL. export fn fizzy_macos_window_pump_sync(cocoa: ?*anyopaque) void { - if (comptime builtin.os.tag == .macos) { + if (comptime builtin.target.os.tag == .macos) { if (!macos_pump_ready) return; if (watchedOf(cocoa)) |w| macosSyncSizes(w); } @@ -211,7 +211,7 @@ export fn fizzy_macos_window_pump_sync(cocoa: ?*anyopaque) void { /// it between frames; a transition's stage, run from inside one, only wakes the loop /// (`macosLiveResizeUpdate`). export fn fizzy_macos_window_pump_render() void { - if (comptime builtin.os.tag == .macos) { + if (comptime builtin.target.os.tag == .macos) { if (!macos_pump_ready) return; const window = macos_monitor_window orelse return; macosLiveResizeUpdate(window); @@ -245,7 +245,7 @@ fn macosAppPresented(back: *Backend.SDLBackend) void { /// Sync AppKit → SDL before `Window.begin` during Space / zoom animations only, for every window /// animating. Registered on the SDL backend from `restoreWindowState`. fn macosAppPreBeginSync(back: *Backend.SDLBackend) void { - if (comptime builtin.os.tag != .macos) return; + if (comptime builtin.target.os.tag != .macos) return; if (!macos_pump_ready) return; _ = back; // Sync AppKit's live sizes into SDL during Space/zoom animations so dvui lays @@ -279,7 +279,7 @@ var macos_live_resize_saved_vsync: ?c_int = null; /// unless SDL draws the resize itself (`sdl_draws_live_resize` in `macos/window_monitor.m`), /// whose presents wait for no vsync. export fn fizzy_macos_window_live_resize_vsync(cocoa: ?*anyopaque, active: c_int) void { - if (comptime builtin.os.tag != .macos) return; + if (comptime builtin.target.os.tag != .macos) return; const window = macos_monitor_window orelse return; // The main window's: a float's window is drawn with the main one's frame, paced by it. if (cocoa == null or cocoaWindowOf(window) != cocoa) return; @@ -319,7 +319,7 @@ export fn fizzy_macos_window_reset_sync_cache(cocoa: ?*anyopaque) void { /// Reconcile SDL's cached sizes and Metal drawable with `cocoa`'s live AppKit bounds. /// Called at didEnter/didExit so steady state never keeps transition sizes. export fn fizzy_macos_window_commit_steady_state(cocoa: ?*anyopaque) void { - if (comptime builtin.os.tag != .macos) return; + if (comptime builtin.target.os.tag != .macos) return; if (!macos_pump_ready) return; const w = watchedOf(cocoa) orelse return; fizzy_macos_window_reset_sync_cache(cocoa); @@ -379,7 +379,7 @@ export fn fizzy_macos_origin_nudged(cap_x: f64, cap_y: f64, cur_x: f64, cur_y: f /// through SDL's styles, for fizzy's chrome (`styleTitled`): without that it came back from each /// round trip a title bar shorter. pub fn install(win: *dvui.Window) void { - if (comptime builtin.os.tag != .macos) return; + if (comptime builtin.target.os.tag != .macos) return; const back = win.backend.impl; const cocoa = cocoaWindowOf(back.window) orelse return; if (comptime !own_backend) return fizzy_macos_window_keep_full_size_content(cocoa); @@ -415,7 +415,7 @@ fn addWatched(w: Watched) void { /// followed (`install`): it goes full screen in a Space of its own, and its float follows it there /// and back. Call once its chrome is in place; `unwatch` before it closes. pub fn watch(window: *c.SDL_Window) void { - if (comptime builtin.os.tag != .macos) return; + if (comptime builtin.target.os.tag != .macos) return; const cocoa = cocoaWindowOf(window) orelse return; addWatched(.{ .window = window, .cocoa = cocoa, .follow_position = true }); fizzy_macos_window_install_resize_observer(cocoa); @@ -426,7 +426,7 @@ pub fn watch(window: *c.SDL_Window) void { /// Stop following a float's window (`watch`), as it closes. pub fn unwatch(window: *c.SDL_Window) void { - if (comptime builtin.os.tag != .macos) return; + if (comptime builtin.target.os.tag != .macos) return; const cocoa = cocoaWindowOf(window) orelse return; fizzy_macos_window_uninstall_monitor(cocoa); for (&watched) |*slot| { @@ -439,7 +439,7 @@ pub fn unwatch(window: *c.SDL_Window) void { /// Whether AppKit owns a watched window's frame: in a fullscreen Space, or on its way into or out /// of one, or un-zooming. Nothing may move it then. pub fn osOwnsFrame(window: *c.SDL_Window) bool { - if (comptime builtin.os.tag != .macos) return false; + if (comptime builtin.target.os.tag != .macos) return false; const cocoa = cocoaWindowOf(window) orelse return false; return fizzy_macos_window_in_fullscreen_space(cocoa) != 0 or fizzy_macos_window_transition_sync_active(cocoa) != 0; } @@ -461,7 +461,7 @@ pub const TitlebarState = struct { }; pub fn titlebarState(win: *dvui.Window) TitlebarState { - if (comptime builtin.os.tag != .macos) return .{}; + if (comptime builtin.target.os.tag != .macos) return .{}; const raw_ptr = cocoaWindowOf(win.backend.impl.window); const inset = if (raw_ptr != null) fizzy_macos_window_titlebar_inset(raw_ptr) else 0; const saved = fizzy_macos_window_saved_titlebar_inset(raw_ptr); diff --git a/backend/src/platform/menu.zig b/backend/src/platform/menu.zig index 56485ae67..df41db01a 100644 --- a/backend/src/platform/menu.zig +++ b/backend/src/platform/menu.zig @@ -156,7 +156,7 @@ fn setImage(item: objc.Object, symbol: [*:0]const u8, description: [*:0]const u8 /// Build the bar: `menus` in order after the app menu, Help last, the app menu's About retitled /// `about_title` and routed to `pollAbout`. Once; `hooks` answer for every item from then on. pub fn install(menus: []const Menu, h: Hooks, about_title: ?[:0]const u8) void { - if (comptime builtin.os.tag != .macos) return; + if (comptime builtin.target.os.tag != .macos) return; if (installed) return; hooks = h; const NSApplication = objc.getClass("NSApplication") orelse return; @@ -276,7 +276,7 @@ pub fn isFixedMenu(id: []const u8) bool { /// first, so this is safe — and cheap enough — to call on every change. An `ExtraMenu` with no /// item in it is left out. pub fn setExtras(menus: []const ExtraMenu, items: []const ExtraItem) void { - if (comptime builtin.os.tag != .macos) return; + if (comptime builtin.target.os.tag != .macos) return; if (!installed) return; const bar = main_menu orelse return; const t = target orelse return; @@ -335,7 +335,7 @@ pub fn setExtras(menus: []const ExtraMenu, items: []const ExtraItem) void { /// allocator is freed. The fixed menus stay on the bar for the rest of the process, every item /// enabled with its title as built. Not for a bar the app goes on using: `install` is once. pub fn deinit() void { - if (comptime builtin.os.tag != .macos) return; + if (comptime builtin.target.os.tag != .macos) return; hooks = null; const gpa = dialogs.allocator(); for (extra_leaves.items) |e| e.parent.msgSend(void, "removeItem:", .{e.item.value}); @@ -351,7 +351,7 @@ pub fn deinit() void { /// Fill list submenu `index` (the `index`th `.list` entry across the bar) with `titles`, in /// order; an item chosen reports its position in `titles`. Hidden while empty. pub fn setList(index: usize, titles: []const []const u8) void { - if (comptime builtin.os.tag != .macos) return; + if (comptime builtin.target.os.tag != .macos) return; if (index >= list_count) return; const menu = list_menus[index] orelse return; const t = target orelse return; @@ -373,7 +373,7 @@ pub fn setList(index: usize, titles: []const []const u8) void { /// `modifiers`) with `modifiers` (`modifier_command` and the rest); null clears it. An item not /// on the bar now is left alone. pub fn setKeyEquivalent(ref: Ref, key: ?[]const u8, modifiers: c_ulong) void { - if (comptime builtin.os.tag != .macos) return; + if (comptime builtin.target.os.tag != .macos) return; switch (ref.section) { .bar => if (bar_items.get(ref.tag)) |item| applyKey(item, key, modifiers), .extra => for (extra_leaves.items) |e| if (e.tag == ref.tag) applyKey(e.item, key, modifiers), diff --git a/backend/src/platform/root.zig b/backend/src/platform/root.zig index 8b41fd44a..b0aea352d 100644 --- a/backend/src/platform/root.zig +++ b/backend/src/platform/root.zig @@ -27,7 +27,7 @@ comptime { // The menu bar's Objective-C (`macos/menu_target.m`) calls these back, and it is compiled into // every app on macOS (`addPlatformObjC`), so they are kept whether or not the app ever reaches // `menu` — a plain dvui app with no menu bar of its own failed to link without them. - if (@import("builtin").os.tag == .macos) { + if (@import("builtin").target.os.tag == .macos) { _ = &menu.FizzyMenuActivated; _ = &menu.FizzyMenuEnabled; _ = &menu.FizzyMenuTitle; diff --git a/backend/src/platform/titlebar.zig b/backend/src/platform/titlebar.zig index b64c64fcb..afcde8ef4 100644 --- a/backend/src/platform/titlebar.zig +++ b/backend/src/platform/titlebar.zig @@ -28,11 +28,11 @@ const std = @import("std"); const builtin = @import("builtin"); /// Whether the app draws its own title bar on this platform. -pub const active = builtin.os.tag == .windows or builtin.os.tag == .linux; +pub const active = builtin.target.os.tag == .windows or builtin.target.os.tag == .linux; /// Whether the OS asks these hints where a press goes: where the app draws its own title bar, /// and on macOS, where AppKit moves the window from any press in its transparent titlebar's /// region unless the view there says otherwise (`interactiveAt`). -pub const hit_tested = active or builtin.os.tag == .macos; +pub const hit_tested = active or builtin.target.os.tag == .macos; /// A caption button the app draws (Windows 11-style: the app draws them, the backend hit-tests them). pub const TitleBarButton = enum { minimize, maximize, close }; diff --git a/backend/src/platform/wayland_blur.zig b/backend/src/platform/wayland_blur.zig index 828f2cd63..ad95efbcc 100644 --- a/backend/src/platform/wayland_blur.zig +++ b/backend/src/platform/wayland_blur.zig @@ -29,7 +29,7 @@ pub const Want = struct { /// Cheap to call every frame: the region is sent only when it changes. The first call binds the /// protocols, two round trips to the compositor. pub fn behind(display: *anyopaque, surface: *anyopaque, want: ?Want) bool { - if (comptime builtin.os.tag != .linux) return false; + if (comptime builtin.target.os.tag != .linux) return false; const s = ready(display) orelse return false; // What the compositor has said since (the blur turned off or on). _ = s.lib.display_dispatch_queue_pending(s.display, s.queue); diff --git a/backend/src/platform/win32_titlebar.zig b/backend/src/platform/win32_titlebar.zig index 66c70f640..cf018c495 100644 --- a/backend/src/platform/win32_titlebar.zig +++ b/backend/src/platform/win32_titlebar.zig @@ -24,7 +24,7 @@ const DWMWCP_ROUND: u32 = 2; /// Apply the chrome to `win` (see `window.setStyle`): idempotent, cheap to call every frame. pub fn applyChrome(win: *dvui.Window) void { - if (builtin.os.tag != .windows) return; + if (builtin.target.os.tag != .windows) return; const hwnd = getWin32Hwnd(win) orelse return; const hwnd_h = @as(win32.foundation.HWND, @ptrCast(hwnd)); const WS_SYSMENU: isize = 0x00080000; @@ -103,7 +103,7 @@ pub fn applyChrome(win: *dvui.Window) void { /// The backdrop's tint follows the app's theme: DWM draws Acrylic's dark or light variant by this /// window attribute, which otherwise follows the system's light or dark mode. pub fn setDarkMode(win: *dvui.Window, dark: bool) void { - if (builtin.os.tag != .windows) return; + if (builtin.target.os.tag != .windows) return; const hwnd = getWin32Hwnd(win) orelse return; const value: u32 = @intFromBool(dark); _ = win32.graphics.dwm.DwmSetWindowAttribute( @@ -116,7 +116,7 @@ pub fn setDarkMode(win: *dvui.Window, dark: bool) void { /// No caption or border tint: the app draws its own title bar in the client area. pub fn clearCaptionColors(win: *dvui.Window) void { - if (builtin.os.tag != .windows) return; + if (builtin.target.os.tag != .windows) return; const hwnd = getWin32Hwnd(win) orelse return; const hwnd_h = @as(win32.foundation.HWND, @ptrCast(hwnd)); @@ -380,7 +380,7 @@ pub const ViewportLoop = struct { /// shadow; no border. True when DWM gives it the backdrop (Windows 11 22H2 on); false leaves the /// glass on the app's opaque backing. pub fn viewportChrome(hwnd: *anyopaque, main_hwnd: ?*anyopaque, dark: bool, radius_pt: f32, loop: *ViewportLoop) bool { - if (builtin.os.tag != .windows) return false; + if (builtin.target.os.tag != .windows) return false; const h: win32.foundation.HWND = @ptrCast(hwnd); const dwm = win32.graphics.dwm; const corner: u32 = if (radius_pt < 1) DWMWCP_DONOTROUND else if (radius_pt < 6) DWMWCP_ROUNDSMALL else DWMWCP_ROUND; @@ -440,7 +440,7 @@ const menu_subclass_id: usize = 0x50584933; // "PXI3" /// fallback, so it is kept looking active (`menuSubclassProc`). True when DWM gives it the /// backdrop (Windows 11 22H2 on). pub fn viewportMenuChrome(hwnd: *anyopaque, dark: bool, radius_pt: f32) bool { - if (builtin.os.tag != .windows) return false; + if (builtin.target.os.tag != .windows) return false; const h: win32.foundation.HWND = @ptrCast(hwnd); const dwm = win32.graphics.dwm; const wm = win32.ui.windows_and_messaging; @@ -517,7 +517,7 @@ fn applyViewportBackdrop(h: win32.foundation.HWND) bool { /// from a press on a title bar. Posted, not sent: sent, the OS's move loop would run inside the /// frame that asked. pub fn viewportDragMove(hwnd: *anyopaque) void { - if (builtin.os.tag != .windows) return; + if (builtin.target.os.tag != .windows) return; _ = win32.ui.windows_and_messaging.PostMessageW(@ptrCast(hwnd), WM_FIZZY_DRAG_MOVE, 0, 0); } diff --git a/backend/src/platform/window.zig b/backend/src/platform/window.zig index 0215f0704..63279fbe3 100644 --- a/backend/src/platform/window.zig +++ b/backend/src/platform/window.zig @@ -100,14 +100,14 @@ pub const WindowGlassLook = struct { /// its edge, a compact toolbar's corners — where the OS has it (macOS 26). Once per window. /// Whether it is. pub fn liquidGlass(raw_ptr: *anyopaque) bool { - if (comptime builtin.os.tag != .macos) return false; + if (comptime builtin.target.os.tag != .macos) return false; return fizzy_macos_window_liquid_glass(raw_ptr, ns_visual_effect_material) != 0; } /// `window`'s Liquid Glass this frame (`liquidGlass`). Whether it has any: false, and nothing set, /// where it stands on vibrancy instead. pub fn liquidGlassLook(window: *c.SDL_Window, look: WindowGlass) bool { - if (comptime builtin.os.tag != .macos) return false; + if (comptime builtin.target.os.tag != .macos) return false; const ns = c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null) orelse return false; if (fizzy_macos_window_has_liquid_glass(ns) == 0) return false; fizzy_macos_window_liquid_glass_look(ns, &look); @@ -154,7 +154,7 @@ fn cocoaWindowOf(window: *c.SDL_Window) ?*anyopaque { /// Reveal the window (it is created hidden, so its chrome and geometry are in place first), /// maximized when `maximized`. pub fn show(win: *dvui.Window, maximized: bool) void { - if (comptime builtin.os.tag != .macos and builtin.os.tag != .windows and builtin.os.tag != .linux) return; + if (comptime builtin.target.os.tag != .macos and builtin.target.os.tag != .windows and builtin.target.os.tag != .linux) return; _ = c.SDL_ShowWindow(win.backend.impl.window); if (maximized) _ = c.SDL_MaximizeWindow(win.backend.impl.window); } @@ -167,7 +167,7 @@ pub fn show(win: *dvui.Window, maximized: bool) void { /// nothing. pub fn showUndrawn(win: *dvui.Window, maximized: bool) void { const window = win.backend.impl.window; - switch (builtin.os.tag) { + switch (builtin.target.os.tag) { .macos => _ = c.SDL_SetWindowOpacity(window, 0), .windows => setCloaked(win, true), else => {}, @@ -177,7 +177,7 @@ pub fn showUndrawn(win: *dvui.Window, maximized: bool) void { /// The first frame is on screen: let it be seen (`showUndrawn`). pub fn reveal(win: *dvui.Window) void { - switch (builtin.os.tag) { + switch (builtin.target.os.tag) { .macos => _ = c.SDL_SetWindowOpacity(win.backend.impl.window, 1), .windows => setCloaked(win, false), else => {}, @@ -185,7 +185,7 @@ pub fn reveal(win: *dvui.Window) void { } fn setCloaked(win: *dvui.Window, cloaked: bool) void { - if (comptime builtin.os.tag != .windows) return; + if (comptime builtin.target.os.tag != .windows) return; const hwnd = win32_titlebar.getWin32Hwnd(win) orelse return; const win32 = @import("win32"); const value: u32 = @intFromBool(cloaked); @@ -204,7 +204,7 @@ pub fn isMaximized(win: *dvui.Window) bool { /// a window over the desktop, and keeps its translucency and its edge (the user). Elsewhere, /// maximized or full screen. pub fn windowMaximized(window: *c.SDL_Window) bool { - if (builtin.os.tag == .macos) { + if (builtin.target.os.tag == .macos) { const raw_ptr = c.SDL_GetPointerProperty( c.SDL_GetWindowProperties(window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, @@ -221,7 +221,7 @@ pub fn windowMaximized(window: *c.SDL_Window) bool { pub fn windowUnseen(window: *c.SDL_Window) bool { const flags = c.SDL_GetWindowFlags(window); if (flags & (c.SDL_WINDOW_HIDDEN | c.SDL_WINDOW_MINIMIZED | c.SDL_WINDOW_OCCLUDED) != 0) return true; - if (builtin.os.tag != .macos) return false; + if (builtin.target.os.tag != .macos) return false; const ns = c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null) orelse return false; return fizzy_macos_window_visible(ns) == 0; } @@ -245,7 +245,7 @@ pub fn windowCovers(window: *c.SDL_Window) bool { /// (`space_transition`, until did-exit) says it is still in the transition: by the style alone it /// faded while AppKit showed opaque pictures of it, and popped in see-through as they went. fn windowInSpaceTransition(window: *c.SDL_Window) bool { - if (builtin.os.tag != .macos) return false; + if (builtin.target.os.tag != .macos) return false; const raw_ptr = c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null) orelse return false; return fizzy_macos_window_space_transition_active(raw_ptr) != 0; } @@ -253,7 +253,7 @@ fn windowInSpaceTransition(window: *c.SDL_Window) bool { /// Whether `window` is on its way into a fullscreen Space: it goes opaque at once /// (`Editor.easeWindowOpacity`), so the pictures AppKit animates it as are opaque too. pub fn windowEnteringSpace(window: *c.SDL_Window) bool { - if (builtin.os.tag != .macos) return false; + if (builtin.target.os.tag != .macos) return false; const raw_ptr = c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null) orelse return false; return fizzy_macos_window_space_transition_active(raw_ptr) != 0 and fizzy_macos_window_space_entering(raw_ptr) != 0; } @@ -266,7 +266,7 @@ pub fn enteringSpace(win: *dvui.Window) bool { /// out of one (`macos/window_monitor.m`'s own animation, where AppKit animates no pictures of it): /// its opacity follows this, the fade and the move one motion. Null otherwise. pub fn windowSpaceFullness(window: *c.SDL_Window) ?f32 { - if (builtin.os.tag != .macos) return null; + if (builtin.target.os.tag != .macos) return null; const raw_ptr = c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null) orelse return null; const f = fizzy_macos_window_space_fullness(raw_ptr); return if (f < 0) null else @floatCast(std.math.clamp(f, 0, 1)); @@ -280,7 +280,7 @@ pub fn spaceFullness(win: *dvui.Window) ?f32 { /// picture this frame: points from the window's top left — where the window would have been, were /// it moved a step a frame (`fizzy_macos_window_space_still`). Null on no such way. pub fn windowSpacePicture(window: *c.SDL_Window) ?struct { x: f32, y: f32, w: f32, h: f32 } { - if (builtin.os.tag != .macos) return null; + if (builtin.target.os.tag != .macos) return null; const raw_ptr = c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null) orelse return null; var x: f64 = 0; var y: f64 = 0; @@ -293,7 +293,7 @@ pub fn windowSpacePicture(window: *c.SDL_Window) ?struct { x: f32, y: f32, w: f3 /// Whether `window` is moving itself into or out of a fullscreen Space this frame /// (`macos/window_monitor.m`'s own animation). pub fn windowMovingItself(window: *c.SDL_Window) bool { - if (builtin.os.tag != .macos) return false; + if (builtin.target.os.tag != .macos) return false; const raw_ptr = c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null) orelse return false; return fizzy_macos_window_space_moving(raw_ptr) != 0; } @@ -305,7 +305,7 @@ pub fn windowMovingItself(window: *c.SDL_Window) bool { /// fullscreen flag (which is wrong for zoomed windows and only updates after animations). /// On non-macOS targets this is just `isMaximized`. pub fn isFullscreenChromeHidden(win: *dvui.Window) bool { - if (builtin.os.tag != .macos) return isMaximized(win); + if (builtin.target.os.tag != .macos) return isMaximized(win); const raw_ptr = c.SDL_GetPointerProperty( c.SDL_GetWindowProperties(win.backend.impl.window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, @@ -322,7 +322,7 @@ pub fn isFullscreenChromeHidden(win: *dvui.Window) bool { /// unconditionally every frame was a `SetWindowPos(SWP_FRAMECHANGED)` — a `WM_NCCALCSIZE` — a /// library load and a class-brush write each frame on Windows, and two style-mask writes on macOS. pub fn setStyle(win: *dvui.Window) void { - if (builtin.os.tag == .macos) { + if (builtin.target.os.tag == .macos) { const raw_ptr = c.SDL_GetPointerProperty( c.SDL_GetWindowProperties(win.backend.impl.window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, @@ -348,9 +348,9 @@ pub fn setStyle(win: *dvui.Window) void { const ns = c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(vp.window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null) orelse continue; styleTitled(ns); }; - } else if (builtin.os.tag == .windows) { + } else if (builtin.target.os.tag == .windows) { win32_titlebar.applyChrome(win); - } else if (builtin.os.tag == .linux) { + } else if (builtin.target.os.tag == .linux) { linux_titlebar.applyChrome(win); } } @@ -414,7 +414,7 @@ fn styleTitled(raw_ptr: *anyopaque) void { /// vibrancy read as the app does. The main window's and every float's own window's alike /// (`setBackground`, `SDLBackend.viewportGlass`). pub fn skin(raw_ptr: *anyopaque, color: dvui.Color, dark: bool) void { - if (comptime builtin.os.tag != .macos) return; + if (comptime builtin.target.os.tag != .macos) return; const window = objc.Object.fromId(raw_ptr); const NSColor = objc.getClass("NSColor").?; const new_color = NSColor.msgSend(objc.Object, "colorWithRed:green:blue:alpha:", .{ @@ -447,7 +447,7 @@ pub fn skin(raw_ptr: *anyopaque, color: dvui.Color, dark: bool) void { } pub fn setBackground(win: *dvui.Window, color: dvui.Color) void { - if (builtin.os.tag == .macos) { + if (builtin.target.os.tag == .macos) { const raw_ptr = c.SDL_GetPointerProperty( c.SDL_GetWindowProperties(win.backend.impl.window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, @@ -478,7 +478,7 @@ pub fn setBackground(win: *dvui.Window, color: dvui.Color) void { // SDL3 currently removes the shadow when the transparency flag for the window is set. This brings it back. window.msgSend(void, "setHasShadow:", .{true}); } - } else if (builtin.os.tag == .windows) { + } else if (builtin.target.os.tag == .windows) { setStyle(win); win32_titlebar.clearCaptionColors(win); // As macOS's NSAppearance above: the backdrop matches the dvui theme. @@ -552,7 +552,7 @@ fn wrapContentViewWithVibrancy(window: objc.Object) void { /// the window monitor (`FizzyWindowMonitor.m`) already follows; elsewhere SDL's. pub fn toggleFullscreen() void { const w = main() orelse return; - if (builtin.os.tag == .macos) { + if (builtin.target.os.tag == .macos) { const ns = objc.Object.fromId(cocoaWindowOf(w) orelse return); // From the run loop, as the green button asks, never from inside a frame (a command, a // key): AppKit pictures the window as it will be in full screen from inside @@ -575,14 +575,14 @@ pub fn toggleFullscreen() void { pub fn raise() void { if (main()) |w| _ = c.SDL_RaiseWindow(w); - if (builtin.os.tag == .macos) activateApp(); + if (builtin.target.os.tag == .macos) activateApp(); } /// macOS only: make fizzy the active application. `activate(ignoringOtherApps:)` is deprecated /// on 14+ in favour of `activate`, so try the new selector first and fall back — the old one /// still works and is the only one on earlier systems. fn activateApp() void { - if (builtin.os.tag != .macos) return; + if (builtin.target.os.tag != .macos) return; const NSApplication = objc.getClass("NSApplication") orelse return; const app = NSApplication.msgSend(objc.Object, "sharedApplication", .{}); if (app.value == null) return; diff --git a/backend/src/viewports.zig b/backend/src/viewports.zig index a082fb25a..bdb66d6ca 100644 --- a/backend/src/viewports.zig +++ b/backend/src/viewports.zig @@ -50,7 +50,7 @@ pub fn appActive() bool { /// Whether menus can be windows of their own (`openMenu`): macOS, and Windows (Acrylic popups), /// where viewports are. -pub const menus = builtin.os.tag == .macos or builtin.os.tag == .windows; +pub const menus = builtin.target.os.tag == .macos or builtin.target.os.tag == .windows; /// A window for a menu (`Popout`'s menus): borderless, above every window, never focused, the /// OS's material in it — rounded by `radius` points — and the pointer over it read in the main @@ -268,7 +268,7 @@ pub fn fade(vp: *Viewport, alpha: f32) void { } /// Whether a carried view can be shown past every window (`openCarry`): macOS. -pub const carries = builtin.os.tag == .macos; +pub const carries = builtin.target.os.tag == .macos; pub fn close(vp: *Viewport) void { dvui.currentWindow().backend.impl.viewportClose(vp); @@ -387,12 +387,12 @@ pub fn pinPointer(pin: Pin) void { /// from its edges (Windows: DWM; macOS: a titled window, as the main window is) — so the window is /// exactly the float's glass. Otherwise it is the glass with a clear /// margin round it, which the float draws its own shadow in. -pub const os_frame = builtin.os.tag == .windows or builtin.os.tag == .macos; +pub const os_frame = builtin.target.os.tag == .windows or builtin.target.os.tag == .macos; /// Whether a viewport's window opened `.os` has the OS's own buttons to close, minimize and zoom /// it — macOS's traffic lights, a titled window's — so its float draws no close button of its own. /// One opened `.app` has none anywhere. -pub const os_buttons = builtin.os.tag == .macos; +pub const os_buttons = builtin.target.os.tag == .macos; /// A material behind the float's glass in `vp`'s window — its rounded rect `inset` physical /// pixels in from the window's edge, `radius` its corners — for the float's frost to read the diff --git a/build/velopack.zig b/build/velopack.zig index f12d3d831..e8f27a068 100644 --- a/build/velopack.zig +++ b/build/velopack.zig @@ -170,7 +170,7 @@ pub fn addMsvcupSetupStep( const gen_path = vz.path("tools/gen_zig_libc_msvc.zig").getPath3(b, null).toString(b.allocator) catch |e| std.debug.panic("velopack: resolve gen_zig_libc_msvc.zig: {}", .{e}); - const run: *std.Build.Step.Run = switch (builtin.os.tag) { + const run: *std.Build.Step.Run = switch (builtin.target.os.tag) { .windows => blk: { const script_path = vz.path("tools/setup-msvc.ps1").getPath3(b, null).toString(b.allocator) catch |e| std.debug.panic("velopack: resolve setup-msvc.ps1: {}", .{e}); diff --git a/core/lsp/Client.zig b/core/lsp/Client.zig index f49e4786d..83e7557c7 100644 --- a/core/lsp/Client.zig +++ b/core/lsp/Client.zig @@ -1243,7 +1243,7 @@ fn spawnProcess(self: *Client, io: std.Io) !void { } fn spawnChild(gpa: std.mem.Allocator, io: std.Io, argv: []const []const u8, cwd: []const u8) !std.process.Child { - if (comptime builtin.os.tag == .macos) { + if (comptime builtin.target.os.tag == .macos) { // Prefers the fuller login-shell-resolved PATH (already resolved above, by // `resolveExecutable` → `shell_env.path`, and cached) over this process's own // inherited one, so the server itself — not just fizzy locating its binary — sees the @@ -1291,7 +1291,7 @@ fn spawnChild(gpa: std.mem.Allocator, io: std.Io, argv: []const []const u8, cwd: /// the very first language-server startup on Windows, independent of which mechanism spawned /// that background thread. fn resolveExecutable(gpa: std.mem.Allocator, io: std.Io, name: []const u8) ?[]u8 { - if (comptime builtin.os.tag == .windows) return null; + if (comptime builtin.target.os.tag == .windows) return null; if (std.mem.indexOfScalar(u8, name, '/') != null) return null; const inherited_path: ?[]const u8 = if (std.c.getenv("PATH")) |p| std.mem.span(p) else null; diff --git a/core/paths.zig b/core/paths.zig index 3673281a0..25f3f9c64 100644 --- a/core/paths.zig +++ b/core/paths.zig @@ -81,7 +81,7 @@ pub fn isNormalizedAbsolute(path: []const u8) bool { if (!std.fs.path.isAbsolute(path)) return false; // On Windows `resolve` also rewrites separators and drive-letter case; not worth // replicating, so only the POSIX shape claims the fast path. - if (builtin.os.tag == .windows) return false; + if (builtin.target.os.tag == .windows) return false; return isNormalizedPosixTail(path); } @@ -102,7 +102,7 @@ fn isNormalizedPosixTail(path: []const u8) bool { } test isNormalizedAbsolute { - if (builtin.os.tag == .windows) return error.SkipZigTest; + if (builtin.target.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; // Canonical shapes take the fast path, and agree with `normalize`. diff --git a/core/platform.zig b/core/platform.zig index 905deffb2..7923cb598 100644 --- a/core/platform.zig +++ b/core/platform.zig @@ -1,7 +1,7 @@ //! Runtime platform detection that works on both native and wasm. //! -//! On native, `builtin.os.tag` is the truth. On `wasm32-freestanding` it's -//! always `.freestanding`, so any `builtin.os.tag == .macos` check classifies +//! On native, `builtin.target.os.tag` is the truth. On `wasm32-freestanding` it's +//! always `.freestanding`, so any `builtin.target.os.tag == .macos` check classifies //! macOS web as "not mac" — which breaks ctrl/cmd shortcuts, pan/zoom defaults, //! menu glyphs, etc. This module gives fizzy a single source of truth that //! correctly reflects the running platform. @@ -21,10 +21,10 @@ const dvui = @import("dvui"); /// Cached result. Read via `isMacOS()`. Defaults to the native `os.tag` value; /// on wasm `cacheFromWindow` updates it once the DVUI window is initialized. -var cached_is_macos: bool = builtin.os.tag.isDarwin(); +var cached_is_macos: bool = builtin.target.os.tag.isDarwin(); /// Call once after `dvui.Window.init`. On native this is a no-op (the comptime -/// `builtin.os.tag` default is already correct). On wasm this probes DVUI's +/// `builtin.target.os.tag` default is already correct). On wasm this probes DVUI's /// `"ctrl/cmd"` binding to discover whether the browser's `navigator.platform` /// looked like macOS. pub fn cacheFromWindow(win: *dvui.Window) void { @@ -33,7 +33,7 @@ pub fn cacheFromWindow(win: *dvui.Window) void { cached_is_macos = kb.command orelse false; } -/// True if the running platform is macOS — unlike `builtin.os.tag == .macos`, right on web too. +/// True if the running platform is macOS — unlike `builtin.target.os.tag == .macos`, right on web too. pub inline fn isMacOS() bool { return cached_is_macos; } @@ -46,7 +46,7 @@ pub fn processEnviron() std.process.Environ { const empty: [:null]const ?[*:0]const u8 = &.{}; return .{ .block = .{ .slice = empty } }; } - if (@import("builtin").os.tag == .windows) { + if (@import("builtin").target.os.tag == .windows) { return .{ .block = .global }; } var n: usize = 0; diff --git a/core/shell_env.zig b/core/shell_env.zig index 581e9424c..8de174516 100644 --- a/core/shell_env.zig +++ b/core/shell_env.zig @@ -72,7 +72,7 @@ const Cache = struct { }; fn resolve(gpa: std.mem.Allocator, io: std.Io) !?[]u8 { - if (comptime builtin.os.tag != .macos) return null; + if (comptime builtin.target.os.tag != .macos) return null; const shell = if (std.c.getenv("SHELL")) |s| std.mem.span(s) else "/bin/zsh"; diff --git a/core/vfs/zip.zig b/core/vfs/zip.zig index c6dda19ad..1244d4560 100644 --- a/core/vfs/zip.zig +++ b/core/vfs/zip.zig @@ -210,13 +210,13 @@ const dos_epoch_date: u16 = (1 << 5) | 1; fn readStruct(comptime T: type, bytes: []const u8, off: u64) ?T { if (off + @sizeOf(T) > bytes.len) return null; var v: T = @bitCast(bytes[@intCast(off)..][0..@sizeOf(T)].*); - if (@import("builtin").cpu.arch.endian() == .big) std.mem.byteSwapAllFields(T, &v); + if (@import("builtin").target.cpu.arch.endian() == .big) std.mem.byteSwapAllFields(T, &v); return v; } fn writeStruct(allocator: Allocator, out: *std.ArrayList(u8), value: anytype) Allocator.Error!void { var v = value; - if (@import("builtin").cpu.arch.endian() == .big) std.mem.byteSwapAllFields(@TypeOf(v), &v); + if (@import("builtin").target.cpu.arch.endian() == .big) std.mem.byteSwapAllFields(@TypeOf(v), &v); try out.appendSlice(allocator, std.mem.asBytes(&v)); } diff --git a/libs/dvui-singleton-app/src/root.zig b/libs/dvui-singleton-app/src/root.zig index c877bf9aa..b2ab65d42 100644 --- a/libs/dvui-singleton-app/src/root.zig +++ b/libs/dvui-singleton-app/src/root.zig @@ -103,7 +103,7 @@ pub const max_args: u32 = 4096; pub const max_arg_bytes: u32 = 1 << 20; // 1 MiB per arg pub const max_total_bytes: u64 = 32 << 20; // 32 MiB per message -const Primary = if (builtin.os.tag == .windows) +const Primary = if (builtin.target.os.tag == .windows) @import("windows_impl.zig").Primary else @import("unix_impl.zig").Primary; diff --git a/libs/dvui-singleton-app/src/unix_impl.zig b/libs/dvui-singleton-app/src/unix_impl.zig index 300f225a3..a8531a327 100644 --- a/libs/dvui-singleton-app/src/unix_impl.zig +++ b/libs/dvui-singleton-app/src/unix_impl.zig @@ -219,7 +219,7 @@ fn chmodPath(path: []const u8, mode: std.c.mode_t) c_int { } test "shutdown returns while the listener waits in accept" { - if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + if (comptime builtin.target.os.tag == .windows) return error.SkipZigTest; const io = std.testing.io; var name_buf: [64]u8 = undefined; const app_id = try std.fmt.bufPrint(&name_buf, "fizzy-singleton-test-{d}", .{std.c.getpid()}); diff --git a/plugins/workbench/src/Workspace.zig b/plugins/workbench/src/Workspace.zig index 7f9ae51dd..7373f1422 100644 --- a/plugins/workbench/src/Workspace.zig +++ b/plugins/workbench/src/Workspace.zig @@ -787,7 +787,7 @@ fn paneSide(side: sdk.RegionSpec.Drop.Side) core.widgets.DockLayout.Side { fn drawCanvas(self: *Workspace, region: sdk.Host.Region, has_tabs: bool) !void { var content_color = dvui.themeGet().color(.window, .fill); - switch (builtin.os.tag) { + switch (builtin.target.os.tag) { .macos, .windows => { content_color = if (!runtime.host().isMaximized()) content_color.opacity(runtime.host().contentOpacity()) else content_color; }, diff --git a/plugins/workbench/src/files.zig b/plugins/workbench/src/files.zig index 89e5b14b5..9aabcd2b6 100644 --- a/plugins/workbench/src/files.zig +++ b/plugins/workbench/src/files.zig @@ -67,7 +67,7 @@ fn clearNewFilePath() void { runtime.workbench().clearPendingNewFilePath(); } -const open_message = if (builtin.os.tag == .macos) "Reveal in Finder" else "Reveal in File Browser"; +const open_message = if (builtin.target.os.tag == .macos) "Reveal in Finder" else "Reveal in File Browser"; pub const Extension = enum { unsupported, diff --git a/sdk/tape/chord.zig b/sdk/tape/chord.zig index 6ba439de9..28fc52159 100644 --- a/sdk/tape/chord.zig +++ b/sdk/tape/chord.zig @@ -11,7 +11,7 @@ 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.core.platform.isMacOS()` differs from `builtin.os.tag` on wasm). +/// (`fizzy.core.platform.isMacOS()` differs from `builtin.target.os.tag` on wasm). pub const Platform = enum { mac, other }; pub const Mods = packed struct { diff --git a/sdk/tape/ring.zig b/sdk/tape/ring.zig index 46b2f4bd6..cc00fd128 100644 --- a/sdk/tape/ring.zig +++ b/sdk/tape/ring.zig @@ -159,7 +159,7 @@ pub fn Ring(comptime name: []const u8, comptime Frame: type, comptime Record: ty /// Frames `from` up to (not including) `to`, their records and payloads, as one file: the /// header and the three arrays, the records' `first` and `bytes_at` rebased to the file's. pub fn write(self: *const Self, w: *std.Io.Writer, from: u64, to: u64) !void { - std.debug.assert(builtin.cpu.arch.endian() == .little); + std.debug.assert(builtin.target.cpu.arch.endian() == .little); const lo = @max(from, self.oldestWhole()); const hi = @min(to, self.frames_n); var n_records: u64 = 0; diff --git a/spikes/shared-globals/host.zig b/spikes/shared-globals/host.zig index 646744f65..a6146829e 100644 --- a/spikes/shared-globals/host.zig +++ b/spikes/shared-globals/host.zig @@ -17,7 +17,7 @@ pub fn main() !void { _ = try core.label("host-drawn"); // host renders 1 widget itself std.debug.print("[host] after host label(): widget_count={d} shape_calls={d}\n", .{ win.widget_count, ft.shape_calls }); - const ext = switch (builtin.os.tag) { + const ext = switch (builtin.target.os.tag) { .macos => "dylib", .windows => "dll", else => "so", diff --git a/spikes/windows-composition/src/main.zig b/spikes/windows-composition/src/main.zig index 712d38415..078ef65e4 100644 --- a/spikes/windows-composition/src/main.zig +++ b/spikes/windows-composition/src/main.zig @@ -20,7 +20,7 @@ const Overlay = @import("overlay.zig"); const u = @import("user32.zig"); comptime { - if (builtin.os.tag != .windows) @compileError("the composition spike is Windows only: -Dtarget=x86_64-windows-gnu or aarch64-windows-gnu"); + if (builtin.target.os.tag != .windows) @compileError("the composition spike is Windows only: -Dtarget=x86_64-windows-gnu or aarch64-windows-gnu"); } pub const std_options: std.Options = .{ .log_level = .info }; diff --git a/src/Entry.zig b/src/Entry.zig index 0708f394c..77b578664 100644 --- a/src/Entry.zig +++ b/src/Entry.zig @@ -61,10 +61,10 @@ const start_options_base: dvui.App.StartOptions = .{ .icon = icon, // Linux too, for the rounded corners outside the window's fill (`Editor`): Vulkan composites // the swapchain's alpha on Wayland. Its fill stays opaque — nothing blurs behind it there. - .transparent = if (builtin.os.tag == .macos or builtin.os.tag == .windows or builtin.os.tag == .linux) true else false, + .transparent = if (builtin.target.os.tag == .macos or builtin.target.os.tag == .windows or builtin.target.os.tag == .linux) true else false, // macOS: Cancel-leading dialog/footer order; other platforms: OK-leading (matches dialog header close vs icon). .window_init_options = .{ - .button_order = if (builtin.os.tag.isDarwin()) .cancel_ok else .ok_cancel, + .button_order = if (builtin.target.os.tag.isDarwin()) .cancel_ok else .ok_cancel, }, }; @@ -97,7 +97,7 @@ fn startOptions() dvui.App.StartOptions { // bundle's `.icns` — so the Dock icon visibly loses its background the moment fizzy // launches. Inside a bundle the `.icns` is already the right icon; leave it alone. Loose // dev builds have no bundle icon at all, so there we still want the runtime one. - if (comptime builtin.os.tag == .macos) { + if (comptime builtin.target.os.tag == .macos) { if (runningFromAppBundle(main_init.io)) opts.icon = null; } // A profile is the config folder itself (`app.profile`), for SDL's preferences too. @@ -123,7 +123,7 @@ fn startOptions() dvui.App.StartOptions { fizzy.backend.setSdlAppMetadata(AppInfo.display_name_z, app_version_z, AppInfo.bundle_id_z); // Linux: fizzy draws its own title bar and the window's shadow (`linux_titlebar`), which // SDL has to know before the window is made. - if (comptime builtin.os.tag == .linux) { + if (comptime builtin.target.os.tag == .linux) { const in = Constants.linux_window_shadow_insets; fizzy.backend.useClientDecorations(.{ .left = in.left, .top = in.top, .right = in.right, .bottom = in.bottom }); } @@ -395,7 +395,7 @@ pub fn AppInit(win: *dvui.Window) !void { // near the top of AppInit while the window was hidden). The window opens at // its saved windowed geometry; fullscreen/maximize are not restored. Windows // chrome goes here. - if (builtin.os.tag != .macos) { + if (builtin.target.os.tag != .macos) { fizzy.backend.setWindowStyle(win); } diff --git a/src/backend/WebBackend.zig b/src/backend/WebBackend.zig index fc3a0675b..5c923443a 100644 --- a/src/backend/WebBackend.zig +++ b/src/backend/WebBackend.zig @@ -327,14 +327,14 @@ export fn dvui_c_alloc(size: usize) ?*anyopaque { //log.debug("dvui_c_alloc {d} failed", .{size}); return null; }; - std.mem.writeInt(u64, buffer[0..@sizeOf(u64)], buffer.len, builtin.cpu.arch.endian()); + std.mem.writeInt(u64, buffer[0..@sizeOf(u64)], buffer.len, builtin.target.cpu.arch.endian()); //log.debug("dvui_c_alloc {*} {d}", .{ buffer.ptr + 8, size }); return buffer.ptr + 8; } pub export fn dvui_c_free(ptr: ?*anyopaque) void { const buffer = @as([*]align(8) u8, @ptrCast(@alignCast(ptr orelse return))) - 8; - const len = std.mem.readInt(u64, buffer[0..@sizeOf(u64)], builtin.cpu.arch.endian()); + const len = std.mem.readInt(u64, buffer[0..@sizeOf(u64)], builtin.target.cpu.arch.endian()); //log.debug("dvui_c_free {?*} {d}", .{ ptr, len - 8 }); gpa.free(buffer[0..@intCast(len)]); @@ -349,7 +349,7 @@ export fn dvui_c_realloc_sized(ptr: ?*anyopaque, oldsize: usize, newsize: usize) } //const buffer = @as([*]u8, @ptrCast(ptr.?)) - 8; - //const len = std.mem.readInt(u64, buffer[0..@sizeOf(u64)], builtin.cpu.arch.endian()); + //const len = std.mem.readInt(u64, buffer[0..@sizeOf(u64)], builtin.target.cpu.arch.endian()); //const slice = buffer[0..@intCast(len)]; //log.debug("dvui_c_realloc_sized buffer {*} {d}", .{ ptr, len }); @@ -361,7 +361,7 @@ export fn dvui_c_realloc_sized(ptr: ?*anyopaque, oldsize: usize, newsize: usize) dvui_c_free(ptr); return newptr; - //std.mem.writeInt(usize, slice[0..@sizeOf(usize)], slice.len, builtin.cpu.arch.endian()); + //std.mem.writeInt(usize, slice[0..@sizeOf(usize)], slice.len, builtin.target.cpu.arch.endian()); //return slice.ptr + 16; } diff --git a/src/backend/backend_native.zig b/src/backend/backend_native.zig index 34e113811..604c9193b 100644 --- a/src/backend/backend_native.zig +++ b/src/backend/backend_native.zig @@ -120,7 +120,7 @@ pub const revealWindow = platform.window.reveal; /// the frame is restored on top of the chrome, so the chrome's own resizing cannot move it. pub fn restoreWindowState(win: *dvui.Window) void { platform.window.attach(win); - if (comptime builtin.os.tag == .macos) setWindowStyle(win); + if (comptime builtin.target.os.tag == .macos) setWindowStyle(win); if (win.backend.impl.init_opts_save) |opts| if (opts.pref_path) |dir| { layout_store_dir = dir; platform.geometry.setStore(.{ .load = layoutStoreLoad, .save = layoutStoreSave }); @@ -143,7 +143,7 @@ pub const WindowGlassLook = platform.window.WindowGlassLook; /// The main window's Liquid Glass this frame, where it is a window of Liquid Glass (macOS 26): /// whether it is — its colour is then under the glass, and the frame draws no base of its own. pub fn windowGlass(win: *dvui.Window, look: WindowGlassLook) bool { - if (comptime builtin.os.tag != .macos) return false; + if (comptime builtin.target.os.tag != .macos) return false; return platform.window.liquidGlassLook(win.backend.impl.window, look.native()); } @@ -182,7 +182,7 @@ fn layoutStoreSave(_: ?*anyopaque, g: platform.geometry.Geometry) void { /// pane mid-transition; a zoom without a Space keeps the full strip. Fizzy's titlebar heights /// over the window's state (`platform.macos_monitor.titlebarState`). pub fn titlebarStripHeight(win: *dvui.Window) f32 { - if (builtin.os.tag != .macos) return Constants.titlebar_height; + if (builtin.target.os.tag != .macos) return Constants.titlebar_height; const t = platform.macos_monitor.titlebarState(win); const in: platform.window_layout.StripInputs = .{ .collapsed = t.collapsed, @@ -254,7 +254,7 @@ const native_menus: []const platform.menu.Menu = blk: { /// Build the macOS menu bar from `menu_model`. Once; safe to call again. pub fn setupMacOSMenuBar() void { - if (builtin.os.tag != .macos) return; + if (builtin.target.os.tag != .macos) return; platform.menu.install(native_menus, .{ .enabled = menuEnabled, .title = menuTitle, @@ -270,7 +270,7 @@ pub fn setupMacOSMenuBar() void { /// Let go of the menu bar before the editor its hooks read is freed. No-op off macOS. pub fn teardownMacOSMenuBar() void { - if (builtin.os.tag != .macos) return; + if (builtin.target.os.tag != .macos) return; platform.menu.deinit(); } @@ -320,7 +320,7 @@ fn menuTitle(_: ?*anyopaque, ref: platform.menu.Ref) ?[*:0]const u8 { /// Rebuild every plugin-contributed native menu and item from the host's registry: on every /// plugin load, unload and hide-toggle. Each item's tag is its index in `host.native_menu_items`. pub fn rebuildDynamicNativeMenus() void { - if (builtin.os.tag != .macos) return; + if (builtin.target.os.tag != .macos) return; const host = &fizzy.editor().app.host; var menus: std.ArrayListUnmanaged(platform.menu.ExtraMenu) = .empty; defer menus.deinit(alloc()); @@ -346,7 +346,7 @@ var native_recent_count: usize = 0; /// Fill the Recent Folders submenu from the recents, newest first. AppKit menus are retained /// state, so this has to run whenever the list changes. pub fn rebuildNativeRecentFolders() void { - if (comptime builtin.os.tag != .macos) return; + if (comptime builtin.target.os.tag != .macos) return; const folders = fizzy.editor().app.recents.folders.items; const titles = alloc().alloc([]const u8, folders.len) catch return; defer alloc().free(titles); diff --git a/src/backend/file_assoc.zig b/src/backend/file_assoc.zig index 50808807d..81cf8af0a 100644 --- a/src/backend/file_assoc.zig +++ b/src/backend/file_assoc.zig @@ -79,20 +79,20 @@ pub const ExtAssoc = struct { }; pub fn registerAll() void { - if (comptime builtin.os.tag != .windows) return; + if (comptime builtin.target.os.tag != .windows) return; impl.registerAll() catch |err| { std.log.warn("file_assoc: register failed: {t}", .{err}); }; } pub fn unregisterAll() void { - if (comptime builtin.os.tag != .windows) return; + if (comptime builtin.target.os.tag != .windows) return; impl.unregisterAll() catch |err| { std.log.warn("file_assoc: unregister failed: {t}", .{err}); }; } -const impl = if (builtin.os.tag == .windows) WindowsImpl else NoopImpl; +const impl = if (builtin.target.os.tag == .windows) WindowsImpl else NoopImpl; const NoopImpl = struct { fn registerAll() !void {} diff --git a/src/editor/DocumentWatcher.zig b/src/editor/DocumentWatcher.zig index 68b720134..9921327e7 100644 --- a/src/editor/DocumentWatcher.zig +++ b/src/editor/DocumentWatcher.zig @@ -30,7 +30,7 @@ const debounce_ns: i128 = 200 * std.time.ns_per_ms; /// Same ceiling as the text plugin's open path — guards against hashing something huge. const max_hash_bytes: usize = 64 * 1024 * 1024; -pub const have_impl = switch (builtin.os.tag) { +pub const have_impl = switch (builtin.target.os.tag) { .macos, .linux, .windows => true, else => false, }; @@ -65,7 +65,7 @@ by_path: std.StringHashMapUnmanaged(u64) = .empty, /// `.modified` for regular files — the case nightwatch's README calls out as the supported way /// to watch single files. Its directory-tree trade-off (mtime-diff scans miss pure content /// writes) doesn't apply to us: we only ever pass file paths. -const doc_variant: if (have_impl) @import("nightwatch").Variant else void = switch (builtin.os.tag) { +const doc_variant: if (have_impl) @import("nightwatch").Variant else void = switch (builtin.target.os.tag) { .macos, .freebsd, .openbsd, .netbsd, .dragonfly => .kqueuedir, else => if (have_impl) @import("nightwatch").default_variant else {}, }; diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 28138a09d..3bc656b2f 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -672,7 +672,7 @@ pub fn loadUserPlugins(editor: *Editor, config_folder: []const u8) void { const plugins_dir = std.fs.path.join(editor.app.gpa, &.{ config_folder, "plugins" }) catch return; defer editor.app.gpa.free(plugins_dir); - const ext_suffix: []const u8 = switch (builtin.os.tag) { + const ext_suffix: []const u8 = switch (builtin.target.os.tag) { .windows => ".dll", .macos => ".dylib", else => ".so", @@ -2256,7 +2256,7 @@ pub fn postInit(editor: *Editor) !void { Watch.wake.setHook(wakeEventLoop); // What a Windows build set aside to install over this executable while it ran. - if (comptime builtin.os.tag == .windows) { + if (comptime builtin.target.os.tag == .windows) { var buf: [std.fs.max_path_bytes]u8 = undefined; if (std.process.executablePath(dvui.io, &buf)) |n| ExecutableWatcher.removeSetAside(buf[0..n]) else |_| {} } @@ -2517,7 +2517,7 @@ fn fizzyIsMacOS(_: *anyopaque) bool { } fn fizzyAppliesNativeWindowOpacity(_: *anyopaque) bool { if (comptime builtin.target.cpu.arch == .wasm32) return false; - return builtin.os.tag == .macos or builtin.os.tag == .windows; + return builtin.target.os.tag == .macos or builtin.target.os.tag == .windows; } fn fizzyPanZoomScheme(ctx: *anyopaque) sdk.EditorAPI.PanZoomScheme { const editor = fizzyCtx(ctx); @@ -3894,7 +3894,7 @@ pub fn windowGlassLook(opacity: f32, size: dvui.Size.Natural) fizzy.backend.Wind /// there is none; clear at 0. Every window fizzy draws stands on it — the main window, a float's /// own window, the carry window (`Popout.base`) — so side by side they are one colour. pub fn windowBase(opacity: f32) dvui.Color { - const material = switch (builtin.os.tag) { + const material = switch (builtin.target.os.tag) { .macos, .windows => true, .linux => fizzy.editor().blurred_behind, else => false, @@ -3932,7 +3932,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // frost over a blur at the full frost, and its own colour and bend. Elsewhere — the web, // Linux, Windows, where there is no OS glass to match — the app's frosted pane (`frosted`). const opacity = @max(if (dark) editor.app.settings.window_opacity_dark else editor.app.settings.window_opacity_light, glass_look.min_opacity); - const lens = builtin.os.tag == .macos and editor.app.settings.glass_shader; + const lens = builtin.target.os.tag == .macos and editor.app.settings.glass_shader; const pane = glass_look.frosted(opacity); fizzy.core.dialogs.publishStyle(.{ .modal_dim = editor.app.settings.modal_dim, @@ -3955,7 +3955,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { fizzy.core.programs.publishHost(); // Where the main window's title bar takes presses, not the app (macOS): floating windows, // menus and tooltips are kept below it (`core.screens.mainScreen`). - fizzy.core.screens.publishMainTop(if (builtin.os.tag == .macos) fizzy.backend.titlebarStripHeight(dvui.currentWindow()) else 0); + fizzy.core.screens.publishMainTop(if (builtin.target.os.tag == .macos) fizzy.backend.titlebarStripHeight(dvui.currentWindow()) else 0); fizzy.core.LiquidField.publishEnabled(editor.app.settings.glass_shader); if (comptime builtin.target.cpu.arch == .wasm32) { // Plugins the page has finished linking since last frame register now. @@ -3983,7 +3983,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // (`backend.blurBehind`), asked for while the window is translucent at all, its corners the // frame's (square maximized, as `draw` draws it). Behind `FIZZY_BLUR_BEHIND=1` until it has been // seen on GNOME and KDE (`plans/WINDOWS_LINUX_GLASS_PLAN.md`). - if (builtin.os.tag == .linux and Popout.envSwitch("FIZZY_BLUR_BEHIND") orelse false) { + if (builtin.target.os.tag == .linux and Popout.envSwitch("FIZZY_BLUR_BEHIND") orelse false) { const win = dvui.currentWindow(); const radius: f32 = if (fizzy.backend.isMaximized(win)) 0 else Constants.linux_window_radius; editor.blurred_behind = fizzy.backend.blurBehind(win, if (editor.window_opacity_anim.value > 0 and editor.window_opacity_anim.value < 1) radius else null); @@ -3991,7 +3991,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // Translucent over the desktop's material on macOS and Windows, and on Linux where the // compositor blurs behind the window; opaque elsewhere, where the window's alpha is only its // shape (`LiquidField.publishOpaqueWindow`). - fizzy.core.LiquidField.publishOpaqueWindow(builtin.os.tag != .macos and builtin.os.tag != .windows and !editor.blurred_behind); + fizzy.core.LiquidField.publishOpaqueWindow(builtin.target.os.tag != .macos and builtin.target.os.tag != .windows and !editor.blurred_behind); // Out of a fullscreen Space, the window stops covering the desktop on AppKit's and SDL's own // time, after the transition, and nothing wakes a frame for it: it sat opaque until the mouse // moved (the user). From the moment it sets out of the Space, frames keep coming until it no @@ -4221,7 +4221,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // And on Wayland the window's shadow is fizzy's too, drawn in a margin round the frame // that the compositor knows is not the window (`frameInsets`, zero when maximized, tiled // or full screen). - const linux_windowed = builtin.os.tag == .linux and !fizzy.backend.isMaximized(dvui.currentWindow()); + const linux_windowed = builtin.target.os.tag == .linux and !fizzy.backend.isMaximized(dvui.currentWindow()); const insets = fizzy.backend.frameInsets(dvui.currentWindow()); const shadowed = insets.x > 0 or insets.y > 0 or insets.w > 0 or insets.h > 0; var overall_box = dvui.box( @@ -4243,7 +4243,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // Non-macOS: a thin strip below the top edge so the in-window title row (menu, etc.) is not flush // against the window border (complements the system caption area on Windows 11). - if (builtin.os.tag != .macos) { + if (builtin.target.os.tag != .macos) { var top_inset = dvui.box( @src(), .{ .dir = .horizontal }, @@ -4264,7 +4264,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // overlay at the top-right corner (y=0) hosts the min/max/close buttons; a drag rect is pushed // across the top so empty space (gaps between widgets) drags the window. Menu items and sidebar // buttons push themselves as interactive rects so clicks on them still reach DVUI. - if (builtin.os.tag == .windows or builtin.os.tag == .linux) { + if (builtin.target.os.tag == .windows or builtin.target.os.tag == .linux) { fizzy.backend.resetTitleBarHints(); const window_rect_natural = dvui.windowRect(); @@ -4277,7 +4277,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { (insets.y + title_strip_h) * scale, @intFromFloat(window_rect_natural.w * scale), ); - } else if (builtin.os.tag == .macos) { + } else if (builtin.target.os.tag == .macos) { // Collapse while zoomed/fullscreen (chrome overlays on hover); grow with // AppKit safe-area inset when restoring to a normal window. const title_strip_h = fizzy.backend.titlebarStripHeight(dvui.currentWindow()); @@ -4303,7 +4303,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // Top-right overlay on Windows and Linux: minimize / maximize / close, each in its // desktop's style (`caption_buttons`). Floating, so it takes no space in overall_box's // layout — the main UI below fills the entire window. - if (builtin.os.tag == .windows or builtin.os.tag == .linux) { + if (builtin.target.os.tag == .windows or builtin.target.os.tag == .linux) { const win_rect = dvui.windowRect(); const frame: dvui.Rect = .{ .x = insets.x, .y = insets.y, .w = win_rect.w - insets.x - insets.w, .h = win_rect.h - insets.y - insets.h }; caption_buttons.draw(frame, if (linux_windowed) Constants.linux_window_radius else null); @@ -5729,7 +5729,7 @@ pub fn paste(editor: *Editor) !void { /// Forwards `name`'s keybind (e.g. "copy"/"paste") as a synthetic key-down event to whichever /// widget currently holds dvui keyboard focus. On macOS, Cmd+C/Cmd+V only ever reach us as a -/// native menu action (see `Keybinds.tick`'s `builtin.os.tag != .macos` guard), and that path +/// native menu action (see `Keybinds.tick`'s `builtin.target.os.tag != .macos` guard), and that path /// otherwise only knows how to talk to the active *document* (`runActiveDocCommand`). Any other /// focused widget with its own copy/paste handling — fizzy's Output Panel, a plugin's search /// box, dvui's own text-selection widgets — never sees the raw keystroke at all, unlike on @@ -6055,7 +6055,7 @@ pub fn openInFileBrowser(editor: *Editor, path: []const u8) !void { // `environ` array captured at startup, which any `unsetenv` elsewhere in the process (SDL, // a plugin, a system framework) shrinks *in place* — leaving a NULL before the captured // length and segfaulting the whole app on the next spawn. See `darwin_spawn.zig`. - if (builtin.os.tag == .macos) { + if (builtin.target.os.tag == .macos) { // `posix_spawn` (unlike `posix_spawnp`) does not search `$PATH`, so name `open` in full. const child = fizzy.core.darwin_spawn.spawn(editor.app.gpa, .{ .argv = &.{ "/usr/bin/open", path }, @@ -6080,7 +6080,7 @@ pub fn openInFileBrowser(editor: *Editor, path: []const u8) !void { } // `start` is a cmd.exe builtin, not a standalone executable, so spawning it directly // (bypassing the shell) always fails on Windows — reveal via explorer.exe instead. - if (builtin.os.tag == .windows) { + if (builtin.target.os.tag == .windows) { const arg = try std.fmt.allocPrint(editor.app.gpa, "/select,{s}", .{path}); defer editor.app.gpa.free(arg); _ = std.process.run(editor.app.gpa, dvui.io, .{ .argv = &.{ "explorer.exe", arg } }) catch { diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index 2956f429b..cb6613d6c 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -42,7 +42,7 @@ const menu_model = @import("menu_model.zig"); /// "fizzy 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 +/// Runtime mac detection — `builtin.target.os.tag.isDarwin()` is `false` for /// wasm32-freestanding, so macOS web users would otherwise get the Windows (Ctrl) /// bindings. `fizzy.core.platform.isMacOS()` reads DVUI's `navigator.platform`-derived /// choice on web and uses `os.tag` on native. @@ -899,7 +899,7 @@ fn shadowedByHigherLayer(editor: *Editor, binding: Keymap.Binding, command_id: [ /// 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; + if (comptime builtin.target.os.tag != .macos) return; for (menu_model.flat_commands, 0..) |item, tag| { if (nativeShortcutFor(editor, item.id)) |s| { @@ -977,7 +977,7 @@ fn bestBinding(editor: *Editor, command: []const u8) ?Keymap.Binding { /// 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 (comptime builtin.target.os.tag != .macos) return false; if (!menu_model.contains(id) and !nativeMenuItemFor(editor, id)) return false; return nativeShortcutFor(editor, id) != null; } diff --git a/src/editor/caption_buttons.zig b/src/editor/caption_buttons.zig index 82ea11b09..1f2e1caa7 100644 --- a/src/editor/caption_buttons.zig +++ b/src/editor/caption_buttons.zig @@ -17,7 +17,7 @@ const Constants = @import("Constants.zig"); const Button = fizzy.backend.TitleBarButton; pub const Style = enum { windows, gnome }; -pub const style: Style = if (builtin.os.tag == .linux) .gnome else .windows; +pub const style: Style = if (builtin.target.os.tag == .linux) .gnome else .windows; /// Draws the buttons in the top-right corner of `frame`, the window's frame within it (inside the /// margin for its shadow on Linux). `corner_radius`: the window's own where fizzy rounds it diff --git a/src/editor/captures.zig b/src/editor/captures.zig index 07cd049b2..8e752e941 100644 --- a/src/editor/captures.zig +++ b/src/editor/captures.zig @@ -76,7 +76,7 @@ fn writeMeta(editor: *Editor, w: *std.Io.Writer, arena: std.mem.Allocator, why: .why = why, .seconds = seconds, .frames = frames, - .fizzy = .{ .version = fizzy.version, .optimize = @tagName(builtin.mode), .os = @tagName(builtin.os.tag), .arch = @tagName(builtin.cpu.arch) }, + .fizzy = .{ .version = fizzy.version, .optimize = @tagName(builtin.mode), .os = @tagName(builtin.target.os.tag), .arch = @tagName(builtin.target.cpu.arch) }, .window = .{ .w = px.w, .h = px.h, .scale = dvui.windowNaturalScale() }, .plugins = plugins.items, }, .{}, w); diff --git a/src/editor/dialogs/AboutFizzy.zig b/src/editor/dialogs/AboutFizzy.zig index b63491d91..c74aa5b5e 100644 --- a/src/editor/dialogs/AboutFizzy.zig +++ b/src/editor/dialogs/AboutFizzy.zig @@ -234,7 +234,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool { _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 8, .h = 8 } }); if (comptime auto_update.impl) { - if (!auto_update.installLayoutSupported(dvui.io) and builtin.os.tag == .macos) { + if (!auto_update.installLayoutSupported(dvui.io) and builtin.target.os.tag == .macos) { dvui.labelNoFmt( @src(), "In-app updates need a packaged .app (Velopack). A zig-out binary is not an app bundle.", diff --git a/src/editor/explorer/settings.zig b/src/editor/explorer/settings.zig index 762789e99..b8f03fa36 100644 --- a/src/editor/explorer/settings.zig +++ b/src/editor/explorer/settings.zig @@ -315,7 +315,7 @@ const window_items = [_]Item{ .draw = drawRestartOnRebuild, .inline_control = true, }, -}) ++ (if (builtin.os.tag != .macos or builtin.target.cpu.arch == .wasm32) [_]Item{} else [_]Item{ +}) ++ (if (builtin.target.os.tag != .macos or builtin.target.cpu.arch == .wasm32) [_]Item{} else [_]Item{ .{ .label = "Native glass", .key = "native_glass", diff --git a/src/editor/layout.zig b/src/editor/layout.zig index 6e2436426..dc733a171 100644 --- a/src/editor/layout.zig +++ b/src/editor/layout.zig @@ -35,7 +35,7 @@ pub fn layout(ctx: ?*anyopaque, f: *Layout) !dvui.App.Result { editor.infobar.draw(editor) catch dvui.log.err("Failed to draw infobar", .{}); - if (builtin.os.tag != .macos or Menu.debug_force_on_macos) { + if (builtin.target.os.tag != .macos or Menu.debug_force_on_macos) { const r = try Menu.draw(editor); if (r != .ok) return r; } From 641b76a697378811dd38f6ddbc36e2484c97624c Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:56:11 -0500 Subject: [PATCH 04/46] ci: check-examples packs without macOS AppleDouble files (#443) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changes `scripts/check-examples.sh` failed on macOS: its `pack()` tars the tree with bsdtar, which writes an AppleDouble `._name` entry beside every file carrying extended attributes — every checked-out file has `com.apple.provenance`. The fizzy tarball then held `backend/patches/sdl/._0001-….patch` and friends, and the SDL patch step (`run exe patch`) failed with `NoSections`, failing the example-app studio/minimal/endless/dvui builds. CI runs on Linux with GNU tar, so it never saw this. `pack()` now gets the same treatment `scripts/pack-sdk.sh` already applies: under bsdtar, `COPYFILE_DISABLE=1` plus `--no-xattrs --no-mac-metadata`. The flags are passed only when `tar --version` says bsdtar, so GNU tar on CI runs exactly as before. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: before, `tar -cf -` of `backend/patches/sdl` held 10 `._` entries; with the flags, 0. `EXAMPLES_JOBS=2 scripts/check-examples.sh` prints `ok` for all eight builds (plugin, studio, minimal, studio-zon, endless, dvui, replay, dvui-sdl3), exit 0. - [ ] Windows: - [ ] Linux: unchanged path (GNU tar, no flags); CI's examples job covers it. - [ ] Web: ## Follow-ups None. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- scripts/check-examples.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/check-examples.sh b/scripts/check-examples.sh index 583330f96..3cf2f7fbd 100755 --- a/scripts/check-examples.sh +++ b/scripts/check-examples.sh @@ -28,9 +28,17 @@ work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT # A directory's files as they are on disk, without build output or version control. +# macOS's bsdtar would add an AppleDouble `._name` entry beside every file carrying xattrs (every +# checkout has com.apple.provenance); unpacked, `backend/patches/sdl/._0001-….patch` fails the +# SDL patch step with NoSections. GNU tar, which CI uses, writes none (see scripts/pack-sdk.sh). +tar_flags=() +if tar --version 2>/dev/null | grep -q bsdtar; then + export COPYFILE_DISABLE=1 + tar_flags=(--no-xattrs --no-mac-metadata) +fi pack() { - tar -C "$1" -cf - --exclude=./.zig-cache --exclude=./zig-out --exclude=./zig-pkg \ - --exclude=./.git --exclude=./.jj --exclude=./.profile . + tar "${tar_flags[@]}" -C "$1" -cf - --exclude=./.zig-cache --exclude=./zig-out \ + --exclude=./zig-pkg --exclude=./.git --exclude=./.jj --exclude=./.profile . } pack "$root" | gzip > "$work/fizzy.tar.gz" From b9ba48af5941aa59fdcd4b61e829c2085cf5f3d2 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:57:35 -0500 Subject: [PATCH 05/46] build: an app lists the fizzy plugins it links (#441) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of fizzyedit/plans#13 (step 1). ## What changes An app built on fizzy chooses which of fizzy's plugins it links. `fizzy.plugins.{workbench,text,image,archive,markdown,devtools}` are entries in the list `fizzy.buildApp` takes, beside the app's own, and `fizzy.plugins.all` is fizzy's own set: ```zig try fizzy.buildApp(fizzy_dep, &.{ fizzy.plugins.workbench, fizzy.plugins.text, .{ .name = "graph", .module = graph_dep.module("plugin") }, }); ``` Fizzy's own entries have no module: `build/plugins.zig`'s `link` builds each one for every executable (native, packaged, web), wired to that executable's framework, and re-homes the app's modules the way the native and web builds each did on their own before. Fizzy's build passes `fizzy.plugins.all` through the same path, so the exe and the web build no longer keep separate copies of the list. The test build keeps its own list, because it tests fizzy itself. A plugin that isn't listed doesn't register, and fizzy's dylib for it (text, markdown, image) is neither built nor installed. Two plugins are always built, because fizzy's own code imports them as libraries. **workbench** must be listed: the editor still holds its state (`editor.workbench`, the dylib's `arg_c`), and a list without it fails the build with a message naming the plan. **markdown** is always compiled, because the store draws READMEs with it, but it only registers as a plugin when listed. **Transition:** fizzy's CI builds example-app's `main`, and example-app pins fizzy by commit, so neither repo can make the change first. Until example-app lists fizzy's plugins, a `buildApp` list that names none of them gets all of them first, as before. That fallback is three lines in `buildApp`, and step 2 removes it. fizzyedit/example-app#6 lists them explicitly (its minimal shape links only workbench and text), and it repins to this once merged. Also: - `Entry.zig` drops an unused `@import("text")`. - The `postInit` comment no longer says the bundled plugins don't ship as dylibs. They do, and it now says why. - The root `AGENTS.md` describes the list. Overlaps in files only with the Zig 0.17 drafts (#437, #438), which touch the whole build; this change touches no build step. ## SDK impact - [x] None ## Verified - [ ] macOS: `zig build`, `check-web`, `test`, `test-integration`, `test-sdk-version`, `check-fmt`. The four built-in dylibs are still installed to `zig-out//plugins/`. `scripts/check-examples.sh` passes against example-app with explicit lists (its minimal shape links only workbench and text), and against example-app's `main` through the fallback. - [ ] Windows: - [ ] Linux: CI - [ ] Web: `check-web` ## Follow-ups - Step 2 (plans#13): a link-only build, and removing the fallback. - workbench becomes optional once its state moves from the Editor into the plugin. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- AGENTS.md | 10 ++-- build.zig | 25 +++++++-- build/app.zig | 4 +- build/exe.zig | 103 +++++++----------------------------- build/plugins.zig | 119 +++++++++++++++++++++++++++++++++++++++++- build/sdk.zig | 5 +- build/web.zig | 96 +++++----------------------------- src/Entry.zig | 1 - src/editor/Editor.zig | 8 +-- 9 files changed, 189 insertions(+), 182 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dc0d5375f..4b9231d66 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,9 +62,13 @@ Full contract: **[`docs/PLUGINS.md`](docs/PLUGINS.md)**. Living reshape plan: ** Which plugins an executable links in is build data: `build/sdk.zig`'s `bundledPluginsModule` generates `bundled_plugins` (`pub const modules = .{ @import("workbench"), … }`) and the -runtime iterates it — nothing in `app/` or `src/` names a plugin except the workbench (its -state still lives on the Editor). An app built on fizzy adds its own with `defer-app` + -`fizzy.buildApp(fizzy_dep, &.{ .{ .name, .module = dep.module("plugin") } })`; see +runtime iterates it. Nothing in `app/` or `src/` registers a plugin by name, though two are +imported as libraries and so always built: workbench (its state still lives on the Editor, so +every list must include it) and markdown (the store draws READMEs with it). An app built on +fizzy lists every plugin it links with `defer-app` + `fizzy.buildApp(fizzy_dep, &.{ +fizzy.plugins.workbench, fizzy.plugins.text, .{ .name, .module = dep.module("plugin") } })`: +fizzy's own are entries with no module, built for each executable (`build/plugins.zig`'s +`link`), and only those listed register or get a dylib. See [`fizzyedit/example-app`](https://github.com/fizzyedit/example-app), which bundles `fizzyedit/example-plugin`. diff --git a/build.zig b/build.zig index d030c88be..12df859fc 100644 --- a/build.zig +++ b/build.zig @@ -63,6 +63,12 @@ var deferred: ?struct { pub const BundledPlugin = @import("build/sdk.zig").BundledPlugin; +/// Fizzy's own plugins, as entries in the list `buildApp` links: `fizzy.plugins.text`, or +/// `fizzy.plugins.all` for every one in fizzy's own order. Fizzy builds each for every executable. +/// `workbench` must be listed for now, because the editor still holds its state +/// (fizzyedit/plans#13). +pub const plugins = @import("build/plugins.zig").entries; + /// Build the application in a consumer's graph, with the plugins the consumer bundles. /// /// ```zig @@ -75,18 +81,27 @@ pub const BundledPlugin = @import("build/sdk.zig").BundledPlugin; /// .@"app-layout" = b.path("src/layout.zig"), /// }); /// try fizzy.buildApp(fizzy_dep, &.{ +/// fizzy.plugins.workbench, +/// fizzy.plugins.text, /// .{ .name = "pixi", .module = b.dependency("pixi", .{ .target = target, .optimize = optimize }).module("plugin") }, /// }); /// b.installArtifact(fizzy_dep.artifact("myapp")); /// ``` /// -/// `name` is the plugin's id (its root's `plugin_id`); `module` its static module, which the -/// plugin package exports and which fizzy wires to `dvui`, `core`, `fizzy_sdk` and `icons`. -/// Fizzy's own four are always bundled ahead of these. -pub fn buildApp(fizzy_dep: *std.Build.Dependency, plugins: []const BundledPlugin) !void { +/// `linked` is everything the app links, in registration order (the order of the sidebar's +/// views): fizzy's own (`fizzy.plugins`) and the app's. For an app's, `name` is the plugin's id +/// (its root's `plugin_id`) and `module` its static module, which the plugin package exports and +/// which fizzy wires to `dvui`, `core`, `fizzy_sdk` and `icons`. +pub fn buildApp(fizzy_dep: *std.Build.Dependency, linked: []const BundledPlugin) !void { const d = deferred orelse @panic("fizzy.buildApp: pass `.@\"defer-app\" = true` to b.dependency(\"fizzy\", …) first"); var opts = d.base; - opts.app_plugins = plugins; + // Until fizzyedit/example-app lists fizzy's plugins itself (its CI and fizzy's build each + // other's main): a list naming none of fizzy's gets all of them ahead of its own, as before. + // Goes in fizzyedit/plans#13 step 2. + const names_fizzys = for (linked) |p| { + if (p.module == null) break true; + } else false; + opts.app_plugins = if (names_fizzys) linked else try std.mem.concat(fizzy_dep.builder.allocator, BundledPlugin, &.{ &plugins.all, linked }); try @import("build/app.zig").construct(fizzy_dep.builder, d.target, d.optimize, opts, d.cfg); } diff --git a/build/app.zig b/build/app.zig index 037a0eb87..b3e4c9c17 100644 --- a/build/app.zig +++ b/build/app.zig @@ -27,8 +27,8 @@ pub const WebPluginDir = struct { }; pub const Options = struct { - /// Plugins the application bundles beyond fizzy's own — see `build.zig`'s `buildApp`. - app_plugins: []const @import("sdk.zig").BundledPlugin = &.{}, + /// Every plugin the application links, fizzy's own among them — see `build.zig`'s `buildApp`. + app_plugins: []const @import("sdk.zig").BundledPlugin = &@import("plugins.zig").entries.all, /// Dependencies (by the name in `build.zig.zon`) whose `"plugin"` module the **web** build /// links in. The browser cannot `dlopen`, so a plugin exists there only if the application /// bundles it; this is fizzy-the-app's list, resolved for the wasm target. Each is looked diff --git a/build/exe.zig b/build/exe.zig index 82a1685d5..4db12a42d 100644 --- a/build/exe.zig +++ b/build/exe.zig @@ -15,8 +15,6 @@ const workbench_plugin = plugins.workbench; const text_plugin = plugins.text; const markdown_plugin = plugins.markdown; const image_plugin = plugins.image; -const archive_plugin = plugins.archive; -const devtools_plugin = plugins.devtools; const MacosSdlPaths = common.MacosSdlPaths; /// Install stripped exe + built-in plugin dylibs for `vpk pack --packDir`. @@ -201,9 +199,10 @@ pub fn addFizzyExecutableForTarget( /// `Host.layout_ctx`. The file imports `dvui`, `app`, `core`, and `fizzy_sdk` — not /// `fizzy` / `Editor` — so the module graph does not cycle. app_layout: ?std.Build.LazyPath, - /// Plugins the application bundles beyond fizzy's own four: each a static module from a - /// plugin package (`plugin_dep.module("plugin")`), linked in and listed in the generated - /// `bundled_plugins` module under its plugin id. + /// Every plugin the application links, in registration order: fizzy's own + /// (`fizzy.plugins`, built here) and the application's, each a static module from a plugin + /// package (`plugin_dep.module("plugin")`). Listed in the generated `bundled_plugins` module + /// under its plugin id. app_plugins: []const sdk.BundledPlugin, ) !FizzyExecutable { const native = nativeDvui(b, native_backend, resolved_target, optimize, accesskit, macos_sdl_paths); @@ -292,86 +291,24 @@ pub fn addFizzyExecutableForTarget( const sdk_module = sdk.wireSdkModule(b, resolved_target, optimize, dvui_mod, proxy_bridge_host_mod, core_module, exe.root_module); const sdk_proxy_module = sdk.wireSdkModule(b, resolved_target, optimize, dvui_proxy_mod, proxy_bridge_plugin_mod, core_proxy_module, null); - const workbench_module = workbench_plugin.addStaticModule(b, resolved_target, optimize, .{ + // What this application links, as build data: a generated module whose `modules` tuple + // names every statically linked plugin, so the runtime registers, probes and falls back + // to whatever is listed here and never a plugin by name. An app built on fizzy lists its + // own (`buildApp`), fizzy's among them or not. + const linked = plugins.link(b, .{ + .target = resolved_target, + .optimize = optimize, .dvui = dvui_mod, .core = core_module, .sdk = sdk_module, .icons = icons_module, .backend = native.backend, - }, workbench_opts, exe.root_module); - const text_module = text_plugin.addStaticModule(b, resolved_target, optimize, .{ - .dvui = dvui_mod, - .core = core_module, - .sdk = sdk_module, - .icons = icons_module, - }, exe.root_module); - const image_module = image_plugin.addStaticModule(b, resolved_target, optimize, .{ - .dvui = dvui_mod, - .core = core_module, - .sdk = sdk_module, - }, exe.root_module); - const archive_module = archive_plugin.addStaticModule(b, resolved_target, optimize, .{ - .dvui = dvui_mod, - .core = core_module, - .sdk = sdk_module, - }, exe.root_module); - const devtools_module = devtools_plugin.addStaticModule(b, resolved_target, optimize, .{ - .dvui = dvui_mod, - .core = core_module, - .sdk = sdk_module, - }, exe.root_module); - const markdown_module: ?*std.Build.Module = if (resolved_target.result.cpu.arch != .wasm32) - markdown_plugin.addStaticModule(b, resolved_target, optimize, .{ - .dvui = dvui_mod, - .core = core_module, - .sdk = sdk_module, - }, exe.root_module) - else - null; - - // What this application bundles, as build data: a generated module whose `modules` tuple - // names every statically linked plugin, so the runtime registers, probes and falls back - // to whatever is listed here and never a plugin by name. An app built on fizzy lists its - // own. - var bundled_list: std.ArrayList(sdk.BundledPlugin) = .empty; - try bundled_list.appendSlice(b.allocator, &.{ - .{ .name = "workbench", .module = workbench_module }, - .{ .name = "text", .module = text_module }, - .{ .name = "image", .module = image_module }, - .{ .name = "archive", .module = archive_module }, - .{ .name = "markdown", .module = markdown_module }, - .{ .name = "devtools", .module = devtools_module }, - }); - for (app_plugins) |p| { - const src_mod = p.module orelse { - try bundled_list.append(b.allocator, p); - continue; - }; - // A copy per executable, not the plugin package's module itself: this function builds - // more than one exe (the packaged, Velopack-linked one beside the plain one), each with - // its own `dvui`/`core`/`fizzy_sdk`, and one module cannot import both sets. The copy - // keeps the plugin's own imports — its manifest options, its dependencies — and gets - // this exe's framework modules. - const m = b.createModule(.{ - .target = resolved_target, - .optimize = optimize, - .root_source_file = src_mod.root_source_file, - .link_libc = resolved_target.result.cpu.arch != .wasm32, - }); - var it = src_mod.import_table.iterator(); - while (it.next()) |kv| { - const name = kv.key_ptr.*; - if (std.mem.eql(u8, name, "dvui") or std.mem.eql(u8, name, "core") or std.mem.eql(u8, name, "fizzy_sdk") or std.mem.eql(u8, name, "icons")) continue; - m.addImport(name, kv.value_ptr.*); - } - m.addImport("dvui", dvui_mod); - m.addImport("core", core_module); - m.addImport("fizzy_sdk", sdk_module); - if (icons_module) |icons| m.addImport("icons", icons); - exe.root_module.addImport(p.name, m); - try bundled_list.append(b.allocator, .{ .name = p.name, .module = m }); - } - const bundled = sdk.bundledPluginsModule(b, resolved_target, optimize, bundled_list.items); + .workbench_opts = workbench_opts, + .consumer = exe.root_module, + .markdown = resolved_target.result.cpu.arch != .wasm32, + }, app_plugins); + const markdown_module = linked.markdown; + const bundled = sdk.bundledPluginsModule(b, resolved_target, optimize, linked.plugins); exe.root_module.addImport("bundled_plugins", bundled); const singleton_app_dep = b.dependency("dvui_singleton_app", .{ @@ -415,7 +352,7 @@ pub fn addFizzyExecutableForTarget( }, workbench_opts); } else null; - const text_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: { + const text_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32 and plugins.linksFizzys(app_plugins, "text")) blk: { break :blk text_plugin.addDylib(b, resolved_target, optimize, .{ .dvui = dvui_proxy_mod, .core = core_proxy_module, @@ -425,7 +362,7 @@ pub fn addFizzyExecutableForTarget( }); } else null; - const markdown_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: { + const markdown_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32 and plugins.linksFizzys(app_plugins, "markdown")) blk: { break :blk markdown_plugin.addDylib(b, resolved_target, optimize, .{ .dvui = dvui_proxy_mod, .core = core_proxy_module, @@ -434,7 +371,7 @@ pub fn addFizzyExecutableForTarget( }); } else null; - const image_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: { + const image_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32 and plugins.linksFizzys(app_plugins, "image")) blk: { break :blk image_plugin.addDylib(b, resolved_target, optimize, .{ .dvui = dvui_proxy_mod, .core = core_proxy_module, diff --git a/build/plugins.zig b/build/plugins.zig index 63ed36916..3ef9033f2 100644 --- a/build/plugins.zig +++ b/build/plugins.zig @@ -3,10 +3,127 @@ //! Each built-in plugin keeps its fizzy-internal static-embed glue self-contained in //! `plugins//static/integration.zig`, separate from the canonical third-party files //! at the plugin-folder root (fizzy's `@import("")` resolves to the root -//! `.zig`). Fizzy root aggregates those integration files here. +//! `.zig`). Fizzy root aggregates those integration files here, and `link` turns the list +//! an application gives into modules for one executable. +const std = @import("std"); +const BundledPlugin = @import("sdk.zig").BundledPlugin; + pub const workbench = @import("../plugins/workbench/static/integration.zig"); pub const text = @import("../plugins/text/static/integration.zig"); pub const markdown = @import("../plugins/markdown/static/integration.zig"); pub const image = @import("../plugins/image/static/integration.zig"); pub const archive = @import("../plugins/archive/static/integration.zig"); pub const devtools = @import("../plugins/devtools/static/integration.zig"); + +/// Fizzy's own plugins, as entries in the list an application links (`fizzy.plugins`). An entry +/// with no module is built by fizzy for each executable, wired to that executable's framework. +pub const entries = struct { + pub const workbench: BundledPlugin = .{ .name = "workbench" }; + pub const text: BundledPlugin = .{ .name = "text" }; + pub const image: BundledPlugin = .{ .name = "image" }; + pub const archive: BundledPlugin = .{ .name = "archive" }; + pub const markdown: BundledPlugin = .{ .name = "markdown" }; + pub const devtools: BundledPlugin = .{ .name = "devtools" }; + + /// What fizzy itself links, in its sidebar order. + pub const all = [_]BundledPlugin{ @This().workbench, @This().text, @This().image, @This().archive, @This().markdown, @This().devtools }; +}; + +/// Whether `plugins` links fizzy's own `name`, rather than leaving it out or bringing a module of +/// its own under that id. +pub fn linksFizzys(plugins: []const BundledPlugin, name: []const u8) bool { + for (plugins) |p| if (p.module == null and std.mem.eql(u8, p.name, name)) return true; + return false; +} + +/// What one executable gives the plugins it links. +pub const Wiring = struct { + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + dvui: *std.Build.Module, + core: *std.Build.Module, + sdk: *std.Build.Module, + icons: ?*std.Build.Module, + /// workbench's: the native backend, null on the web. + backend: ?*std.Build.Module, + workbench_opts: *std.Build.Step.Options, + /// The executable's root module, which imports each plugin by its name. + consumer: *std.Build.Module, + /// False where markdown does not build (the native exe for wasm). + markdown: bool = true, +}; + +pub const Linked = struct { + /// The modules for `bundledPluginsModule`, in the order the application listed them. + plugins: []const BundledPlugin, + /// Built whether or not they are listed: fizzy's own code imports both. The editor holds + /// workbench's state, and the store draws a plugin's README with markdown. A module is made + /// once for its consumer, so a listed one is this one. + workbench: *std.Build.Module, + markdown: ?*std.Build.Module, +}; + +/// Make the modules for `plugins`: fizzy's own built for this executable, an application's +/// re-homed onto it. workbench must be listed, because the editor still holds its state +/// (fizzyedit/plans#13). +pub fn link(b: *std.Build, w: Wiring, plugins: []const BundledPlugin) Linked { + if (!linksFizzys(plugins, "workbench")) std.debug.panic("fizzy: the plugin list must include `fizzy.plugins.workbench`; the editor still holds its state (fizzyedit/plans#13)", .{}); + + const workbench_mod = workbench.addStaticModule(b, w.target, w.optimize, .{ + .dvui = w.dvui, + .core = w.core, + .sdk = w.sdk, + .icons = w.icons, + .backend = w.backend, + }, w.workbench_opts, w.consumer); + const markdown_mod: ?*std.Build.Module = if (w.markdown) markdown.addStaticModule(b, w.target, w.optimize, .{ .dvui = w.dvui, .core = w.core, .sdk = w.sdk }, w.consumer) else null; + + var out: std.ArrayList(BundledPlugin) = .empty; + for (plugins) |p| { + const m: ?*std.Build.Module = if (p.module) |src| rehome(b, w, p.name, src) else if (std.mem.eql(u8, p.name, "workbench")) + workbench_mod + else if (std.mem.eql(u8, p.name, "markdown")) + markdown_mod + else if (std.mem.eql(u8, p.name, "text")) + text.addStaticModule(b, w.target, w.optimize, .{ .dvui = w.dvui, .core = w.core, .sdk = w.sdk, .icons = w.icons }, w.consumer) + else if (std.mem.eql(u8, p.name, "image")) + image.addStaticModule(b, w.target, w.optimize, .{ .dvui = w.dvui, .core = w.core, .sdk = w.sdk }, w.consumer) + else if (std.mem.eql(u8, p.name, "archive")) + archive.addStaticModule(b, w.target, w.optimize, .{ .dvui = w.dvui, .core = w.core, .sdk = w.sdk }, w.consumer) + else if (std.mem.eql(u8, p.name, "devtools")) + devtools.addStaticModule(b, w.target, w.optimize, .{ .dvui = w.dvui, .core = w.core, .sdk = w.sdk }, w.consumer) + else + std.debug.panic("fizzy: plugin '{s}' has no module and is not one of fizzy's own (`fizzy.plugins`)", .{p.name}); + // A plugin this target does not build (markdown for the native exe on wasm) is left out. + if (m) |module| out.append(b.allocator, .{ .name = p.name, .module = module }) catch @panic("OOM"); + } + return .{ .plugins = out.items, .workbench = workbench_mod, .markdown = markdown_mod }; +} + +/// A copy of an application's plugin module for this executable, not the plugin package's module +/// itself: one application builds several executables (the packaged, Velopack-linked one beside +/// the plain one, the web one), each with its own `dvui`/`core`/`fizzy_sdk`, and one module cannot +/// import both sets. The copy keeps the plugin's own imports (its manifest options, its +/// dependencies) and gets this executable's framework modules. +pub fn rehome(b: *std.Build, w: Wiring, name: []const u8, src: *std.Build.Module) *std.Build.Module { + const wasm = w.target.result.cpu.arch == .wasm32; + const m = b.createModule(.{ + .target = w.target, + .optimize = w.optimize, + .root_source_file = src.root_source_file, + .link_libc = !wasm, + .single_threaded = if (wasm) true else null, + }); + var it = src.import_table.iterator(); + while (it.next()) |kv| { + const import = kv.key_ptr.*; + if (std.mem.eql(u8, import, "dvui") or std.mem.eql(u8, import, "core") or std.mem.eql(u8, import, "fizzy_sdk") or std.mem.eql(u8, import, "icons")) continue; + m.addImport(import, kv.value_ptr.*); + } + m.addImport("dvui", w.dvui); + m.addImport("core", w.core); + m.addImport("fizzy_sdk", w.sdk); + if (w.icons) |icons| m.addImport("icons", icons); + w.consumer.addImport(name, m); + return m; +} diff --git a/build/sdk.zig b/build/sdk.zig index 836c58695..0a340532a 100644 --- a/build/sdk.zig +++ b/build/sdk.zig @@ -147,8 +147,9 @@ pub fn buildOptsModule(opts: *std.Build.Step.Options) *std.Build.Module { pub const BundledPlugin = struct { /// The plugin's import name and id — `plugin_id` in its root, `plugin.zig.zon`'s `.id`. name: []const u8, - /// Null for a plugin this target does not build (markdown on wasm); it is left out. - module: ?*std.Build.Module, + /// The plugin's static module. Null for one of fizzy's own (`fizzy.plugins`), which fizzy + /// builds for each executable. + module: ?*std.Build.Module = null, }; /// The generated `bundled_plugins` module: `pub const modules = .{ @import("a"), @import("b") … }` diff --git a/build/web.zig b/build/web.zig index 2ad8c320f..d0075f6f6 100644 --- a/build/web.zig +++ b/build/web.zig @@ -4,13 +4,6 @@ const core_mod = @import("fizzy_sdk").core_module; const plugins = @import("plugins.zig"); const sdk = @import("sdk.zig"); -const workbench_plugin = plugins.workbench; -const text_plugin = plugins.text; -const image_plugin = plugins.image; -const archive_plugin = plugins.archive; -const devtools_plugin = plugins.devtools; -const markdown_plugin = plugins.markdown; - pub fn addSteps( b: *std.Build, optimize: std.builtin.OptimizeMode, @@ -173,59 +166,28 @@ pub fn addSteps( // lazy analysis skips file-scope consts that no reachable body uses. // So no `backend` module is wired in for the web build. - const workbench_module_web = workbench_plugin.addStaticModule(b, web_target, optimize, .{ + // What the application links, as the native exe has it (`build/exe.zig`), fizzy's own built + // here for wasm and the application's re-homed: modules made for the native target get this + // build's framework modules, their own imports kept. + const wiring: plugins.Wiring = .{ + .target = web_target, + .optimize = optimize, .dvui = dvui_web, .core = core_module_web, .sdk = sdk_module_web, .icons = icons_web, .backend = null, - }, workbench_opts, web_exe.root_module); - const text_module_web = text_plugin.addStaticModule(b, web_target, optimize, .{ - .dvui = dvui_web, - .core = core_module_web, - .sdk = sdk_module_web, - .icons = icons_web, - }, web_exe.root_module); - const image_module_web = image_plugin.addStaticModule(b, web_target, optimize, .{ - .dvui = dvui_web, - .core = core_module_web, - .sdk = sdk_module_web, - }, web_exe.root_module); - const archive_module_web = archive_plugin.addStaticModule(b, web_target, optimize, .{ - .dvui = dvui_web, - .core = core_module_web, - .sdk = sdk_module_web, - }, web_exe.root_module); - const markdown_module_web = markdown_plugin.addStaticModule(b, web_target, optimize, .{ - .dvui = dvui_web, - .core = core_module_web, - .sdk = sdk_module_web, - }, web_exe.root_module); - const devtools_module_web = devtools_plugin.addStaticModule(b, web_target, optimize, .{ - .dvui = dvui_web, - .core = core_module_web, - .sdk = sdk_module_web, - }, web_exe.root_module); + .workbench_opts = workbench_opts, + .consumer = web_exe.root_module, + }; + const linked = plugins.link(b, wiring, app_plugins); + const markdown_module_web = linked.markdown; var bundled_list: std.ArrayList(sdk.BundledPlugin) = .empty; - bundled_list.appendSlice(b.allocator, &.{ - .{ .name = "workbench", .module = workbench_module_web }, - .{ .name = "text", .module = text_module_web }, - .{ .name = "image", .module = image_module_web }, - .{ .name = "archive", .module = archive_module_web }, - .{ .name = "markdown", .module = markdown_module_web }, - .{ .name = "devtools", .module = devtools_module_web }, - }) catch @panic("OOM"); - // Out-of-tree plugins the web build links in: the application's own list (`buildApp`), - // whose modules were made for the native target and are re-homed here, and the named - // dependencies resolved directly for wasm. Same re-homing the native exe does — a copy - // with this build's framework modules and the plugin's own imports kept. - for (app_plugins) |p| { - const src_mod = p.module orelse continue; - bundled_list.append(b.allocator, .{ .name = p.name, .module = rehome(b, web_target, optimize, src_mod, dvui_web, core_module_web, sdk_module_web, icons_web) }) catch @panic("OOM"); - } + bundled_list.appendSlice(b.allocator, linked.plugins) catch @panic("OOM"); + // And the named dependencies, resolved directly for wasm. for (web_plugin_deps) |dep_name| { const dep = b.lazyDependency(dep_name, .{ .target = web_target, .optimize = optimize }) orelse continue; - bundled_list.append(b.allocator, .{ .name = dep_name, .module = rehome(b, web_target, optimize, dep.module("plugin"), dvui_web, core_module_web, sdk_module_web, icons_web) }) catch @panic("OOM"); + bundled_list.append(b.allocator, .{ .name = dep_name, .module = plugins.rehome(b, wiring, dep_name, dep.module("plugin")) }) catch @panic("OOM"); } // A plugin may ship pages of its own beside the app (its `web/` directory → `plugins//`): // the far end of a popup round trip that is the plugin's, not fizzy's — a provider's @@ -387,33 +349,3 @@ fn stampStorage(b: *std.Build) std.Build.LazyPath { const stamped = std.mem.replaceOwned(u8, b.allocator, html, token, name) catch @panic("OOM"); return b.addWriteFiles().add("index.html", stamped); } - -fn rehome( - b: *std.Build, - web_target: std.Build.ResolvedTarget, - optimize: std.builtin.OptimizeMode, - src_mod: *std.Build.Module, - dvui_mod: *std.Build.Module, - core_module: *std.Build.Module, - sdk_mod: *std.Build.Module, - icons_mod: ?*std.Build.Module, -) *std.Build.Module { - const m = b.createModule(.{ - .target = web_target, - .optimize = optimize, - .root_source_file = src_mod.root_source_file, - .link_libc = false, - .single_threaded = true, - }); - var it = src_mod.import_table.iterator(); - while (it.next()) |kv| { - const name = kv.key_ptr.*; - if (std.mem.eql(u8, name, "dvui") or std.mem.eql(u8, name, "core") or std.mem.eql(u8, name, "fizzy_sdk") or std.mem.eql(u8, name, "icons")) continue; - m.addImport(name, kv.value_ptr.*); - } - m.addImport("dvui", dvui_mod); - m.addImport("core", core_module); - m.addImport("fizzy_sdk", sdk_mod); - if (icons_mod) |icons| m.addImport("icons", icons); - return m; -} diff --git a/src/Entry.zig b/src/Entry.zig index 77b578664..792624f1e 100644 --- a/src/Entry.zig +++ b/src/Entry.zig @@ -11,7 +11,6 @@ const icon = assets.files.@"icon.png"; const fizzy = @import("fizzy.zig"); const workbench = @import("workbench"); -const text = @import("text"); const auto_update = @import("app").update.auto_update; const file_assoc = @import("backend/file_assoc.zig"); const update_notify = @import("app").update.update_notify; diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 3bc656b2f..21d552036 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -2114,9 +2114,11 @@ pub fn postInit(editor: *Editor) !void { // hardcoding panes. Web-safe — the draw fns reach the same inline code the // editor tick already runs on wasm. Order = sidebar order. // Every bundled plugin: its dylib beside the exe when there is one, else the copy linked - // in. None of the bundled four ship as dylibs today, so the load "fails" and the static - // copy registers on every run; not worth logging until that changes. The workbench's - // dylib entry takes the workbench state fizzy still holds as `arg_c`. + // in. The build puts the dylibs of fizzy's own (workbench, text, markdown, image) in + // `/plugins/`, so fizzy's own features load the way a third-party plugin does + // and that path is exercised every run; a plugin with no dylib (archive, devtools, an + // app's own, every one on the web) fails the load quietly and its static copy registers. + // The workbench's dylib entry takes the workbench state fizzy still holds as `arg_c`. // What this build bundles, for anything that needs to know a plugin is compiled in rather // than installed (the store, which must not offer to uninstall one). editor.app.bundled_plugin_ids = comptime blk: { From b169188081806d73eb89d35bdd498690e52d0f0a Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:11:16 -0500 Subject: [PATCH 06/46] sdk: a plugin can open a document beside the person (#442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of fizzyedit/plans#8 · Needed by fizzyedit/agent#16 ## What changes `EditorAPI.OpenOptions` gains **`beside: bool = false`**: open the document beside the pane the person is in, leaving them that pane and their focus. It's what fizzy does for a claimed document (`showClaimed` → `Workbench.showBeside`), now available without a claim. - An open document goes beside at once (`openPath`, instead of focusing it). - One still loading carries the flag on its `Opening`, and goes beside when it lands (`Openings.land`). - `grouping` is ignored when `beside` is set. - **`Host.openBeside` / `endOpenBeside`**: while one is open, documents opened *or created* land beside the person. A sprite an agent creates (`pixi.create`) appears beside them at once, fitted to that pane, instead of opening over their document and then moving. The agent plugin opens it around each command (fizzyedit/agent#16). **Why:** an agent's commands claimed the document they edited (fizzyedit/agent#10), which showed it beside the person but also held back all their input to it for up to a quiet minute. Agents now work *with* the person (fizzyedit/pixi#31 makes a pixi edit wait for the person's open stroke or transform instead), so the hold goes (fizzyedit/agent#16), and this keeps the "shown beside you" part. `OpenOptions`' own comment anticipated it: the next answer to "where does it land" is a defaulted field. ## SDK impact - [ ] None - [ ] Core-only or additive - [x] Fingerprint moved: recorded in `sdk/src/version.zig` (`0xbd784c59f3146ea4`), `sdk_version` untouched, PR labelled `sdk`. Plugins see one new defaulted field on `OpenOptions`. - [ ] SDK release ## Verified - [x] macOS, sandbox with fizzyedit/agent#16, pixi and chat built `--fork` against this: - an agent's `pixi.draw` on a background tab moved that sprite into a pane beside the person's, while their pane and its active document stayed theirs - creating a sprite and drawing on it in one `fizzy_run` put it beside them, and a sprite created after it stayed in their pane (checked by capture) - `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build test-sdk-version`, `zig build check-web`; `test-integration` left to CI - [ ] Windows: - [ ] Linux: - [ ] Web: ## Found on the way (not fixed here) - `fizzy_screen` leaves out some tabs: after a few documents had been opened beside, a pane drawing six tabs reported two `workbench.tab:` anchors. It misled a test here into "the document vanished". 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- sdk/src/EditorAPI.zig | 5 +++++ sdk/src/Host.zig | 12 ++++++++++++ sdk/src/version.zig | 2 +- src/editor/Editor.zig | 29 +++++++++++++++++++++-------- src/editor/Openings.zig | 8 +++++++- 5 files changed, 46 insertions(+), 10 deletions(-) diff --git a/sdk/src/EditorAPI.zig b/sdk/src/EditorAPI.zig index ae51b60af..71fd4f69e 100644 --- a/sdk/src/EditorAPI.zig +++ b/sdk/src/EditorAPI.zig @@ -36,6 +36,11 @@ pub const OpenOptions = struct { /// The pane. 0 is the one the user is in. grouping: u64 = 0, mode: OpenMode = .keep, + /// Beside the pane the person is in rather than in it, leaving them that pane and their + /// focus: a document a plugin is working on, seen as it changes while the person stays where + /// they were. An open document goes there now; one still to open, once it has loaded. + /// `grouping` is ignored. + beside: bool = false, }; /// What a context menu is about, for whoever is drawing rows into it. Valid only during that diff --git a/sdk/src/Host.zig b/sdk/src/Host.zig index 1381723f7..796da3f91 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -277,6 +277,10 @@ activity_muted: bool = false, last_showing_ms: u32 = 0, /// The command whose body is running, if any: the edits made meanwhile are its (`documentEdited`). running_command: ?*const Command = null, +/// While above 0, documents opened or created land beside the pane the person is in +/// (`EditorAPI.OpenOptions.beside`), not in it: an agent's work opens where it will be seen, +/// rather than over the person's document and then moved. Nested scopes count; UI thread only. +open_beside: u32 = 0, /// Counts command runs, so one run that edits several times shows once. command_runs: u64 = 0, activity_run: u64 = 0, @@ -606,6 +610,14 @@ pub fn revealPosition(self: *Host, path: []const u8, line: u32, character: u32, return if (self.fizzy_api) |a| a.revealPosition(path, line, character, open_side) else false; } +/// Open documents beside the person until `endOpenBeside` (`open_beside`). +pub fn openBeside(self: *Host) void { + self.open_beside += 1; +} +pub fn endOpenBeside(self: *Host) void { + self.open_beside -|= 1; +} + /// Open a file — see `EditorAPI.OpenOptions`. Returns true when a new load started. pub fn openFile(self: *Host, opts: EditorAPI.OpenOptions) !bool { return if (self.fizzy_api) |a| try a.openFile(opts) else false; diff --git a/sdk/src/version.zig b/sdk/src/version.zig index 5b0ec38a5..75024ba6d 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -75,7 +75,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the /// next SDK release. -pub const recorded_sdk_shape_fingerprint: u64 = 0x0990c3ac7914768c; +pub const recorded_sdk_shape_fingerprint: u64 = 0xbd784c59f3146ea4; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 21d552036..c81a6f4d3 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -5205,7 +5205,7 @@ pub fn openFile(editor: *Editor, opts: sdk.EditorAPI.OpenOptions) !bool { else null; - const started = try editor.openPath(opts.path, opts.grouping, .{ .preview = preview, .take_slot_of = slot }); + const started = try editor.openPath(opts.path, opts.grouping, .{ .preview = preview, .take_slot_of = slot, .beside = opts.beside }); if (preview) { if (editor.docFromPath(opts.path)) |doc| editor.setDocumentPreview(doc.id, true); } @@ -5265,9 +5265,11 @@ pub fn openFilePath(editor: *Editor, path_in: []const u8, grouping: u64) !bool { } /// Where an open's placeholder tab goes (see `Openings.begin`). -const Placement = struct { +pub const Placement = struct { preview: bool = false, take_slot_of: ?[]const u8 = null, + /// `OpenOptions.beside`. + beside: bool = false, }; /// Watch this executable while `restart_on_rebuild` is on, and stop when it goes off. Once a frame; @@ -5368,7 +5370,9 @@ fn keepSessionForRestart(editor: *Editor) bool { return true; } -fn openPath(editor: *Editor, path_in: []const u8, grouping: u64, placement: Placement) !bool { +fn openPath(editor: *Editor, path_in: []const u8, grouping: u64, placement_in: Placement) !bool { + var placement = placement_in; + placement.beside = placement.beside or editor.app.host.open_beside > 0; // Last session's document, as a restart kept it: from its state, not the disk. if (comptime restart.supported) if (editor.session) |*kept| if (kept.openKept(editor, path_in, grouping) != null) return true; @@ -5378,7 +5382,9 @@ fn openPath(editor: *Editor, path_in: []const u8, grouping: u64, placement: Plac // 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.app.open_files.getIndex(doc.id)) |i| { + if (placement.beside) { + editor.workbench.showBeside(doc); + } else if (editor.app.open_files.getIndex(doc.id)) |i| { editor.workbench.setActiveDocIndex(i); } return false; @@ -5398,7 +5404,7 @@ fn openPath(editor: *Editor, path_in: []const u8, grouping: u64, placement: Plac const started = try editor.doc_io.open(path, grouping); if (started) { if (editor.app.host.pluginForExtension(std.fs.path.extension(path))) |owner| { - editor.openings.begin(editor, path, grouping, placement.preview, owner, placement.take_slot_of); + editor.openings.begin(editor, path, grouping, placement, owner); } } return started; @@ -5439,7 +5445,7 @@ fn openPath(editor: *Editor, path_in: []const u8, grouping: u64, placement: Plac job.destroy(io); return err; }; - editor.openings.begin(editor, path, grouping, placement.preview, owner, placement.take_slot_of); + editor.openings.begin(editor, path, grouping, placement, owner); return true; } @@ -5680,10 +5686,17 @@ pub fn newFile(editor: *Editor, path: []const u8, grid: sdk.EditorAPI.NewDocGrid const id = owner.documentIdFromBuffer(staging.buf.ptr); try editor.insertOpenDoc(staging.buf.ptr, owner, id); editor.documentOpened(id); - editor.workbench.setActiveDocIndex(editor.app.open_files.count() - 1); + const doc = editor.app.docById(id) orelse return error.FailedToCreateFile; + // Created beside the person (`Host.open_beside`), it lands there at once: never first over + // the document they are in, fitted to that pane, and then moved. + if (editor.app.host.open_beside > 0) { + editor.workbench.showBeside(doc); + } else { + editor.workbench.setActiveDocIndex(editor.app.open_files.count() - 1); + } editor.pending_composite_warmup = true; - return editor.app.docById(id) orelse return error.FailedToCreateFile; + return doc; } /// Dispatch a generic fizzy action to the active document owner's command (`.`). diff --git a/src/editor/Openings.zig b/src/editor/Openings.zig index 2c725b64c..52838a7e3 100644 --- a/src/editor/Openings.zig +++ b/src/editor/Openings.zig @@ -32,6 +32,8 @@ pub const Opening = struct { preview: bool, /// Why the load failed, once it has. The placeholder shows it until its tab is closed. failed: ?[]u8 = null, + /// Shown beside the person's pane once it lands (`OpenOptions.beside`). + beside: bool = false, /// Frames since the document took the tab. Null while still loading. landed: ?u8 = null, /// Asked to be shown before its pane had a region to show it in (an open at launch, before @@ -73,7 +75,9 @@ pub fn loading(self: *const Openings) bool { /// The load of `path` has started: put its placeholder in front of the user. `owner` is the /// plugin the document will belong to (the tab slot a restored session kept for it is named by /// its surface id); `take_slot_of` is a tab whose place it takes — the preview it replaces. -pub fn begin(self: *Openings, editor: *Editor, path: []const u8, grouping: u64, preview: bool, owner: *sdk.Plugin, take_slot_of: ?[]const u8) void { +pub fn begin(self: *Openings, editor: *Editor, path: []const u8, grouping: u64, placement: Editor.Placement, owner: *sdk.Plugin) void { + const preview = placement.preview; + const take_slot_of = placement.take_slot_of; if (self.entries.contains(path)) return; const o = self.gpa.create(Opening) catch return; o.* = .{ @@ -85,6 +89,7 @@ pub fn begin(self: *Openings, editor: *Editor, path: []const u8, grouping: u64, .surface_id = undefined, .grouping = grouping, .preview = preview, + .beside = placement.beside, }; o.surface_id = std.mem.concat(self.gpa, u8, &.{ surface_prefix, path }) catch { self.gpa.free(o.path); @@ -162,6 +167,7 @@ pub fn land(self: *Openings, editor: *Editor, path: []const u8, doc: sdk.DocHand } } else |_| {} if (o.preview) editor.setDocumentPreview(doc.id, true); + if (o.beside) wb.showBeside(doc); _ = wb.loading.swapRemove(o.surface_id); o.landed = 0; host.refresh(); From 09e13c18a23544349c7471f924d30a69ec4b2b65 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:32:16 -0500 Subject: [PATCH 07/46] app: an untitled document survives a crash or a restart (#447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A document that was never saved (pixi's `untitled-1`, made by `pixi.create` with no path) was lost when fizzy was killed or crashed. On 2026-10-15 a 64×64 drawing went this way: after `kill -9` and a relaunch, the log said `text: loadDocument failed: FileNotFound`, the pane showed "Couldn't open untitled-1", and `recovered/` was empty. ## Why The checkpoint (`Checkpoint`) and the restart session (`KeptDocuments`) keep each document's `captureDocumentState`, plus `documentBytes` for a dirty one. - pixi's state is its layers' pixels, not a whole document, so it can't fill an empty one. - The bytes were asked for under `untitled-1`, which names no format, so there were none. At the next launch the document was loaded empty for its state to fill, which pixi refuses. `openKept` then returned null, and `openPath` went on to open `untitled-1` from disk, routed by its missing extension to `text`. ## What changes - **Kept as a save would write it.** A dirty document that has never been saved is encoded under the name Save As offers. The new `Plugin.bytesPath(doc)` gives that name (`untitled.pixi`); for a document with a path it gives the path. The name is kept beside the bytes as `save_as` in `session.zon`. The field defaults to empty, so older sessions still read. - **Reopened through its owner, untitled.** A new optional hook, `loadUntitledDocument(title, bytes_path, bytes, out_doc)`, opens the bytes as an untitled document with its title (`untitled-1`), dirty. Its state is then restored over them. If the owner refuses that state, the document is still whole, since it opened from its own bytes. - **Never opened as a path.** If the owner has no such hook, or reopening fails, `openKept` now returns `lost` rather than null. The contents go to a recovery file named by the title plus the encoded extension (`-untitled-1.pixi`, which opens in pixi), and `openPath` does not look for a file called `untitled-1`. - `text` implements the hook. ## Plugins - **pixi:** a 14-line `loadUntitledDocument` (`fromBytes(bytes_path, …)`, then the title). It lands in fizzyedit/pixi after this releases. Until then pixi's untitled sprites go to a correctly named recovery file instead of nowhere. - **agent:** the session recorder (`src/Recording.zig`) called `documentBytes(doc, path)` and so recorded nothing for untitled documents. That's the same root cause; it switches to `bytesPath`, which also names the tape's mounted file so it replays in its owner. Both plugin changes are ready locally and go up with the repin. ## SDK Labelled `sdk`. The fingerprint moves (new optional vtable field `loadUntitledDocument`); `sdk_version` is untouched. For plugin authors: - `Plugin.bytesPath` is the path to hand `documentBytes` when you need a document's bytes outside a save: a recording, or a copy you keep. - An owner whose untitled documents should come back from a crash implements `loadUntitledDocument`. ## Verified - **Integration test** (`tests/integration.zig`, beside the restart test). An untitled, dirty text document is kept with no state, so only its bytes are left. It comes back untitled, dirty and whole. With the hook disabled, the same test fails with `text: loadDocument failed: FileNotFound`, the error from the incident. - **macOS, sandbox profile of my own** (`--profile`, with pixi built from its branch against this SDK via `--fork`): 1. A tape ran `pixi.create` (64×64, untitled) and `pixi.draw` (a rect, a line, an ellipse). 2. After a checkpoint, `kill -9`, then a relaunch. 3. `untitled-1` came back in pixi, dirty. The next checkpoint again lists it as `dirty = true, save_as = "untitled-1.pixi"`. 4. Its exported pixels match the kept `.pixi`'s layer byte for byte: 240 rect, 64 line and 72 ellipse pixels. 5. No `text:` error, nothing written to `recovered/`. - `check-fmt`, `zig build`, `test`, `test-integration` (53/53 steps), `check-web`, `test-sdk-version`, `check-tests-reached`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- docs/PLUGINS.md | 5 ++ plugins/text/plugin.zig | 9 +++ sdk/src/Plugin.zig | 28 ++++++++ sdk/src/version.zig | 2 +- src/editor/Editor.zig | 7 +- src/editor/KeptDocuments.zig | 120 ++++++++++++++++++++++++----------- tests/integration.zig | 50 +++++++++++++++ 7 files changed, 183 insertions(+), 38 deletions(-) diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 80b811a6d..e87e0cbd3 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -644,6 +644,11 @@ gets the same, and `DropZones.wheel`/`at`/`draw` give a plugin the same drop for `loadDocumentFromBytes` / `createDocument`, `saveDocument`, `reloadDocument` (optional — Fizzy's document watcher reloads clean open tabs when the file changes on disk), `closeDocument`, `isDirty`, plus opaque document-buffer management for the async load path. + `loadUntitledDocument(title, bytes_path, bytes)` (optional) opens a document that was never + saved from its unsaved bytes, as a crash checkpoint or a restart kept them: encoded under + `Plugin.bytesPath` (the name Save As offers, `untitled.pixi`), which says their format, and + given back its title (`untitled-1`), untitled and dirty. Without it, such a document comes + back only as far as `restoreDocumentState` fills an empty one, and otherwise as a recovery file. - **Document metadata at the pane boundary** — `bindDocumentToPane`, `documentGrouping` / `setDocumentGrouping` / `setDocumentGroupingOnBuffer` (the pane a document is seated in — the app stamps the target grouping on the staging buffer before the load lands, which is what diff --git a/plugins/text/plugin.zig b/plugins/text/plugin.zig index 558c02173..3cce7107c 100644 --- a/plugins/text/plugin.zig +++ b/plugins/text/plugin.zig @@ -40,6 +40,7 @@ const vtable: sdk.Plugin.VTable = .{ .loadDocument = loadDocument, .loadDocumentFromBytes = loadDocumentFromBytes, .createDocument = createDocument, + .loadUntitledDocument = loadUntitledDocument, .setDocumentGroupingOnBuffer = setDocumentGroupingOnBuffer, .documentIdFromBuffer = documentIdFromBuffer, .deinitDocumentBuffer = deinitDocumentBuffer, @@ -281,6 +282,14 @@ fn createDocument(_: *anyopaque, path: []const u8, _: sdk.EditorAPI.NewDocGrid, doc.unsaved = true; doc.pending_focus = true; } +/// A document never saved, back from its unsaved text (a crash, a restart): untitled, and dirty. +fn loadUntitledDocument(_: *anyopaque, title: []const u8, _: []const u8, bytes: []const u8, out_doc: *anyopaque) anyerror!void { + const doc = docBuf(out_doc); + doc.* = try Document.fromBytes(title, bytes); + doc.unsaved = true; + // Clean is a history at its saved id, and this one was never saved. + doc.clean_op_id = std.math.maxInt(u64); +} fn setDocumentGroupingOnBuffer(_: *anyopaque, doc: *anyopaque, grouping: u64) void { docBuf(doc).grouping = grouping; } diff --git a/sdk/src/Plugin.zig b/sdk/src/Plugin.zig index 41c54ed7a..4dfced716 100644 --- a/sdk/src/Plugin.zig +++ b/sdk/src/Plugin.zig @@ -159,6 +159,13 @@ pub const VTable = struct { /// `loadDocument`, but from in-memory bytes (browser file picker). `path` is used /// for extension detection + display name. Synchronous (web has no load worker). loadDocumentFromBytes: ?*const fn (state: *anyopaque, path: []const u8, bytes: []const u8, out_doc: *anyopaque) anyerror!void = null, + /// Open `bytes` as an untitled document named `title` (`untitled-1`), as `createDocument` + /// would have made it with these contents: never saved, and dirty. `bytes` are what + /// `documentBytes` encoded for it under `bytes_path` (`bytesPath`: the name Save As offers, + /// `untitled.pixi`), which says their format. How a crash or a restart brings back a document + /// that was never saved; without it, one comes back only as far as its state restores + /// into an empty document. Synchronous. + loadUntitledDocument: ?*const fn (state: *anyopaque, title: []const u8, bytes_path: []const u8, bytes: []const u8, out_doc: *anyopaque) anyerror!void = null, /// Size of the plugin's document type for stack/heap staging buffers (`loadDocument`, etc.). documentStackSize: ?*const fn (state: *anyopaque) usize = null, documentStackAlign: ?*const fn (state: *anyopaque) usize = null, @@ -507,6 +514,17 @@ pub fn loadDocumentFromBytes(self: Plugin, path: []const u8, bytes: []const u8, return false; } +/// `loadUntitledDocument`. Returns whether the plugin handled it (`false` = no hook). +pub fn loadUntitledDocument(self: Plugin, title: []const u8, bytes_path: []const u8, bytes: []const u8, out_doc: *anyopaque) !bool { + const prof = core.profile.begin(self.id, "loadUntitledDocument"); + defer prof.end(); + if (self.vtable.loadUntitledDocument) |f| { + f(self.state, title, bytes_path, bytes, out_doc) catch |err| return self.failed("loadUntitledDocument", err); + return true; + } + return false; +} + pub fn isDirty(self: Plugin, doc: DocHandle) bool { const prof = core.profile.begin(self.id, "isDirty"); defer prof.end(); @@ -538,6 +556,16 @@ pub fn releaseSnapshot(self: Plugin, snapshot: *anyopaque) void { if (self.vtable.releaseSnapshot) |f| f(self.state, snapshot); } +/// The path `doc`'s bytes are encoded under (`documentBytes`), owned by `allocator`: its own, or +/// for one never saved (`untitled-1`, which names no format), the name Save As offers +/// (`untitled.pixi`), so the bytes are what a save would write. +pub fn bytesPath(self: Plugin, doc: DocHandle, allocator: std.mem.Allocator) ![]const u8 { + if (!self.documentHasRecognizedSaveExtension(doc)) { + if (self.documentDefaultSaveAsFilename(doc, allocator)) |name| return name else |_| {} + } + return allocator.dupe(u8, self.documentPath(doc)); +} + /// Both halves at once, on the calling thread: for the few who need the bytes now (a crash /// recovery file, the browser's download). Null as `documentSnapshot`. pub fn documentBytes(self: Plugin, doc: DocHandle, path: []const u8, allocator: std.mem.Allocator) !?[]u8 { diff --git a/sdk/src/version.zig b/sdk/src/version.zig index 75024ba6d..d88c55561 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -75,7 +75,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the /// next SDK release. -pub const recorded_sdk_shape_fingerprint: u64 = 0xbd784c59f3146ea4; +pub const recorded_sdk_shape_fingerprint: u64 = 0xe988ac8f4b568950; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index c81a6f4d3..2a27a7e3b 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -5374,7 +5374,12 @@ fn openPath(editor: *Editor, path_in: []const u8, grouping: u64, placement_in: P var placement = placement_in; placement.beside = placement.beside or editor.app.host.open_beside > 0; // Last session's document, as a restart kept it: from its state, not the disk. - if (comptime restart.supported) if (editor.session) |*kept| if (kept.openKept(editor, path_in, grouping) != null) return true; + // One never saved that it could not bring back has no file to open instead. + if (comptime restart.supported) if (editor.session) |*kept| switch (kept.openKept(editor, path_in, grouping)) { + .opened => return true, + .lost => return false, + .not_kept => {}, + }; const path = try fizzy.core.paths.normalize(editor.app.gpa, path_in); defer editor.app.gpa.free(path); diff --git a/src/editor/KeptDocuments.zig b/src/editor/KeptDocuments.zig index 895309273..f53e42ccc 100644 --- a/src/editor/KeptDocuments.zig +++ b/src/editor/KeptDocuments.zig @@ -18,6 +18,11 @@ //! file changed since (an agent's edit, a pull) opens from disk instead; a dirty one always //! comes back as it was, since those edits are the person's. //! +//! A document never saved (`untitled-1`) has no file to fall back on, so its unsaved contents are +//! kept as its owner would save them (`Plugin.bytesPath`: under the name Save As offers), and it +//! comes back from them, untitled, through its owner (`Plugin.loadUntitledDocument`), its state +//! restored over them. It is never opened by its title as a path: no file has that name. +//! //! A new build may not read an old build's state (its format changed): the owner refuses it //! (`restoreDocumentState` fails), the document opens as it is on disk, and any unsaved contents //! are written to a recovery file the person is told about. A document with unsaved changes whose @@ -54,8 +59,11 @@ const Kept = struct { /// `captureDocumentState`, when the owner has the hook. state: ?[]u8, /// Unsaved contents (`documentBytes`), kept only for a dirty document: what a recovery file - /// holds if the new build cannot read `state`. + /// holds if the new build cannot read `state`, and what a document never saved reopens from. unsaved: ?[]u8, + /// A dirty document never saved: the name `unsaved` was encoded under (`Plugin.bytesPath`), + /// which says its format. `path` is then its title, naming no file. + save_as: ?[]const u8 = null, /// A clean document's file as it was when captured, so a restart can tell it changed since. stamp: Stamp = .{}, }; @@ -97,13 +105,17 @@ pub fn capture(editor: *Editor, plugin: ?*sdk.Plugin) CaptureError!KeptDocuments const state = owner.captureDocumentState(doc, gpa); errdefer if (state) |s| gpa.free(s); if (dirty and state == null) return error.UnsavedNotCarried; - const unsaved: ?[]u8 = if (dirty) owner.documentBytes(doc, owner.documentPath(doc), gpa) catch null else null; + // Never saved: no file to name a format, so the bytes are encoded as a save would write + // them (`bytesPath`), and that name is kept to read them back by. + const save_as: ?[]const u8 = if (dirty and !owner.documentHasRecognizedSaveExtension(doc)) try owner.bytesPath(doc, gpa) else null; + errdefer if (save_as) |a| gpa.free(a); + const unsaved: ?[]u8 = if (dirty) owner.documentBytes(doc, save_as orelse owner.documentPath(doc), gpa) catch null else null; errdefer if (unsaved) |u| gpa.free(u); const sid = sdk.document.surfaceId(editor.app.host.arena(), owner.id, path) catch return error.OutOfMemory; if (active) |a| { if (a.id == doc.id) self.active = try gpa.dupe(u8, path); } - // Last: from here `self` owns `path`, `owner_id`, `state` and `unsaved`. + // Last: from here `self` owns `path`, `owner_id`, `state`, `unsaved` and `save_as`. try self.docs.append(gpa, .{ .path = path, .owner = owner_id, @@ -113,6 +125,7 @@ pub fn capture(editor: *Editor, plugin: ?*sdk.Plugin) CaptureError!KeptDocuments .dirty = dirty, .state = state, .unsaved = unsaved, + .save_as = save_as, .stamp = if (dirty) .{} else Stamp.of(path) orelse .{}, }); } @@ -135,24 +148,26 @@ fn freeKept(gpa: std.mem.Allocator, d: Kept) void { gpa.free(d.owner); if (d.state) |s| gpa.free(s); if (d.unsaved) |u| gpa.free(u); + if (d.save_as) |a| gpa.free(a); } /// Bring each document back through `plugin` (the new build), into the tab waiting for it. pub fn reattach(self: *KeptDocuments, editor: *Editor, plugin: *sdk.Plugin) void { var host = &editor.app.host; for (self.docs.items) |d| { - const doc = load(editor, plugin, d) catch |err| { + const loaded = load(editor, plugin, d) catch |err| { dvui.log.err("reload: could not reopen {s}: {t}", .{ d.path, err }); - if (d.unsaved) |u| recover(editor, d.path, u); + recover(editor, d); // Its tab waits for nothing now. if (sdk.document.surfaceId(host.arena(), plugin.id, d.path)) |sid| editor.workbench.tabClosed(sid) else |_| {} continue; }; - const restored = if (d.state) |s| plugin.restoreDocumentState(doc, s) else true; + const doc = loaded.doc; + const restored = if (d.state) |s| plugin.restoreDocumentState(doc, s) or loaded.whole else true; if (!restored) { // The new build does not read the old one's state: the document is as on disk. dvui.log.warn("reload: {s} reopened as saved; the new build could not read its state", .{d.path}); - if (d.unsaved) |u| recover(editor, d.path, u); + recover(editor, d); // It was loaded empty for the state to fill: read it, or close it when there is // nothing on disk to read (never saved). if (!plugin.reloadDocument(doc)) { @@ -181,31 +196,41 @@ pub fn reattach(self: *KeptDocuments, editor: *Editor, plugin: *sdk.Plugin) void /// recovery files, and the tabs waiting for the documents go. pub fn abandon(self: *KeptDocuments, editor: *Editor, plugin_id: []const u8) void { for (self.docs.items) |d| { - if (d.unsaved) |u| recover(editor, d.path, u); + recover(editor, d); const sid = sdk.document.surfaceId(editor.app.host.arena(), plugin_id, d.path) catch continue; editor.workbench.tabClosed(sid); } editor.rebuildWorkspaces() catch {}; } -/// Open `d.path` through `plugin`, now, in its pane. One with state to restore starts empty, since -/// the state fills it (and an untitled one has nothing on disk to read); only one without is read -/// from disk. -fn load(editor: *Editor, plugin: *sdk.Plugin, d: Kept) !sdk.DocHandle { +const Loaded = struct { + doc: sdk.DocHandle, + /// Opened from its unsaved contents (a document never saved), so it is whole before any state + /// is restored: a state the owner refuses costs only what the bytes do not hold (the caret, + /// the scroll). + whole: bool, +}; + +/// Open `d.path` through `plugin`, now, in its pane. One never saved opens from its unsaved bytes +/// when its owner can (`loadUntitledDocument`). Otherwise one with state to restore starts empty, +/// since the state fills it (and an untitled one has nothing on disk to read); only one without is +/// read from disk. +fn load(editor: *Editor, plugin: *sdk.Plugin, d: Kept) !Loaded { return loadInto(editor, plugin, d, d.grouping); } -fn loadInto(editor: *Editor, plugin: *sdk.Plugin, d: Kept, grouping: u64) !sdk.DocHandle { +fn loadInto(editor: *Editor, plugin: *sdk.Plugin, d: Kept, grouping: u64) !Loaded { const gpa = editor.app.gpa; const staging = try sdk.document.allocStaging(plugin, gpa); defer staging.deinit(gpa); - if (d.state != null) { + const whole = if (d.save_as) |as| if (d.unsaved) |u| try plugin.loadUntitledDocument(d.path, as, u, staging.buf.ptr) else false else false; + if (whole) {} else if (d.state != null) { try sdk.document.loadBytesIntoStaging(plugin, d.path, "", staging); } else try sdk.document.loadIntoStaging(plugin, d.path, staging); const id = plugin.documentIdFromBuffer(staging.buf.ptr); plugin.setDocumentGroupingOnBuffer(staging.buf.ptr, grouping); try editor.insertOpenDoc(staging.buf.ptr, plugin, id); - return editor.app.docById(id) orelse error.DocumentNotFound; + return .{ .doc = editor.app.docById(id) orelse return error.DocumentNotFound, .whole = whole }; } /// What `save` writes as `session.zon`, beside a file per document's state and unsaved contents @@ -228,6 +253,8 @@ const Saved = struct { dirty: bool = false, has_state: bool = false, has_unsaved: bool = false, + /// `Kept.save_as`; empty for a document with a file. + save_as: []const u8 = "", size: u64 = 0, mtime_ns: i96 = 0, }; @@ -317,6 +344,7 @@ fn writeFiles(self: *const KeptDocuments, dir: []const u8, generation: u32) ![]u .dirty = d.dirty, .has_state = true, .has_unsaved = d.unsaved != null, + .save_as = d.save_as orelse "", .size = d.stamp.size, .mtime_ns = d.stamp.mtime_ns, }); @@ -387,6 +415,8 @@ fn readKept(gpa: std.mem.Allocator, dir: []const u8, generation: u32, i: usize, const state: ?[]u8 = if (d.has_state) try readBlob(gpa, dir, generation, i, "state") else null; errdefer if (state) |s| gpa.free(s); const unsaved: ?[]u8 = if (d.has_unsaved) try readBlob(gpa, dir, generation, i, "unsaved") else null; + errdefer if (unsaved) |u| gpa.free(u); + const save_as: ?[]const u8 = if (d.save_as.len > 0) try gpa.dupe(u8, d.save_as) else null; return .{ .path = path, .owner = owner, @@ -396,48 +426,62 @@ fn readKept(gpa: std.mem.Allocator, dir: []const u8, generation: u32, i: usize, .dirty = d.dirty, .state = state, .unsaved = unsaved, + .save_as = save_as, .stamp = .{ .size = d.size, .mtime_ns = d.mtime_ns }, }; } +pub const Reopened = enum { + /// The session does not hold it (or a clean document's file changed since): the caller opens + /// it from disk as any launch does. + not_kept, + opened, + /// The session held a document never saved and could not bring it back. Its unsaved contents + /// are in a recovery file; there is no file to open instead. + lost, +}; + /// Open `path` from the session, into pane `grouping`, when the session holds it: from its kept -/// state, restored, with no disk read. Null when it does not (or a clean document's file changed -/// since), and the caller opens it from disk as any launch does. A document the session held but -/// could not bring back is opened from disk too, its unsaved contents kept in a recovery file. -pub fn openKept(self: *KeptDocuments, editor: *Editor, path: []const u8, grouping: u64) ?sdk.DocHandle { +/// state, restored, with no disk read. A document with a file that the session held but could not +/// bring back is opened from disk by the caller (`not_kept`), its unsaved contents kept in a +/// recovery file. +pub fn openKept(self: *KeptDocuments, editor: *Editor, path: []const u8, grouping: u64) Reopened { const i = for (self.docs.items, 0..) |d, i| { if (std.mem.eql(u8, d.path, path)) break i; - } else return null; + } else return .not_kept; const d = self.docs.swapRemove(i); defer freeKept(self.gpa, d); + // A document never saved has no file to open in its place. + const instead: Reopened = if (d.save_as != null) .lost else .not_kept; if (!d.dirty) { - const now = Stamp.of(d.path) orelse return null; - if (now.size != d.stamp.size or now.mtime_ns != d.stamp.mtime_ns) return null; + const now = Stamp.of(d.path) orelse return .not_kept; + if (now.size != d.stamp.size or now.mtime_ns != d.stamp.mtime_ns) return .not_kept; } const plugin = editor.app.host.pluginById(d.owner) orelse { - dvui.log.warn("restart: {s} was open in '{s}', which is not loaded now; it opens from disk", .{ d.path, d.owner }); - if (d.unsaved) |u| recover(editor, d.path, u); - return null; + dvui.log.warn("restart: {s} was open in '{s}', which is not loaded now", .{ d.path, d.owner }); + recover(editor, d); + return instead; }; - const doc = loadInto(editor, plugin, d, grouping) catch |err| { + const loaded = loadInto(editor, plugin, d, grouping) catch |err| { dvui.log.err("restart: could not reopen {s}: {t}", .{ d.path, err }); - if (d.unsaved) |u| recover(editor, d.path, u); - return null; + recover(editor, d); + return instead; }; - const restored = if (d.state) |s| plugin.restoreDocumentState(doc, s) else true; + const doc = loaded.doc; + const restored = if (d.state) |s| plugin.restoreDocumentState(doc, s) or loaded.whole else true; if (!restored) { dvui.log.warn("restart: {s} reopened as saved; this build could not read its state", .{d.path}); - if (d.unsaved) |u| recover(editor, d.path, u); + recover(editor, d); // It was loaded empty for the state to fill: the caller opens it from disk instead. editor.rawCloseFileID(doc.id) catch {}; - return null; + return instead; } if (d.preview) editor.setDocumentPreview(doc.id, true); if (d.selected) self.keepSelection(plugin.id, d.path, grouping) catch {}; if (self.active) |a| if (std.mem.eql(u8, a, d.path)) { if (editor.app.open_files.getIndex(doc.id)) |idx| editor.workbench.setActiveDocIndex(idx); }; - return doc; + return .opened; } fn keepSelection(self: *KeptDocuments, owner_id: []const u8, path: []const u8, grouping: u64) !void { @@ -453,7 +497,7 @@ fn keepSelection(self: *KeptDocuments, owner_id: []const u8, path: []const u8, g /// typed is lost, written to recovery files. What is left is the panes' selections. pub fn finishDocuments(self: *KeptDocuments, editor: *Editor) void { for (self.docs.items) |d| { - if (d.unsaved) |u| recover(editor, d.path, u); + recover(editor, d); freeKept(self.gpa, d); } self.docs.clearRetainingCapacity(); @@ -466,8 +510,11 @@ pub fn applySelections(self: *KeptDocuments, editor: *Editor) void { } /// Unsaved contents the new build could not take, written beside fizzy's config so nothing typed -/// is lost, and the person told where. -fn recover(editor: *Editor, path: []const u8, unsaved: []const u8) void { +/// is lost, and the person told where. One never saved is named by its title and the extension it +/// was encoded under, so the file opens in its owner. +fn recover(editor: *Editor, d: Kept) void { + const unsaved = d.unsaved orelse return; + const path = d.path; // A page has no folder to keep them in (and no reload to lose them to: a web plugin is a // side module, never swapped). if (comptime builtin.target.cpu.arch == .wasm32) { @@ -478,7 +525,8 @@ fn recover(editor: *Editor, path: []const u8, unsaved: []const u8) void { const dir = std.fs.path.join(gpa, &.{ editor.app.config_folder, "recovered" }) catch return; defer gpa.free(dir); std.Io.Dir.cwd().createDirPath(dvui.io, dir) catch {}; - const name = std.fmt.allocPrint(gpa, "{d}-{s}", .{ std.Io.Clock.real.now(dvui.io).toSeconds(), std.fs.path.basename(path) }) catch return; + const ext = if (d.save_as) |a| std.fs.path.extension(a) else ""; + const name = std.fmt.allocPrint(gpa, "{d}-{s}{s}", .{ std.Io.Clock.real.now(dvui.io).toSeconds(), std.fs.path.basename(path), ext }) catch return; defer gpa.free(name); const out = std.fs.path.join(gpa, &.{ dir, name }) catch return; defer gpa.free(out); diff --git a/tests/integration.zig b/tests/integration.zig index 4b0e13f49..0cbe048a8 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7383,6 +7383,56 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes editor.session = null; } + // A document never saved comes back from its unsaved bytes (`Plugin.loadUntitledDocument`), + // kept as its owner would save them (`Plugin.bytesPath`), not from a file named by its title, + // which there is none of. Here it has no state (its owner might keep none): all it has is + // its bytes, and it is still untitled, dirty and whole. + { + const before = editor.activeDoc() orelse return error.TestUnexpectedResult; + try std.testing.expect(!before.owner.documentHasRecognizedSaveExtension(before)); + const path = try gpa.dupe(u8, before.owner.documentPath(before)); + defer gpa.free(path); + const grouping = before.owner.documentGrouping(before); + const dir = try fizzy.Editor.KeptDocuments.sessionDir(gpa, editor.app.config_folder); + defer gpa.free(dir); + { + var kept = try fizzy.Editor.KeptDocuments.capture(editor, null); + defer kept.deinit(); + for (kept.docs.items) |d| if (std.mem.eql(u8, d.path, path)) { + try std.testing.expect(d.save_as != null and d.unsaved != null); + }; + try kept.save(dir); + } + { + var d = try std.Io.Dir.cwd().openDir(dvui.io, dir, .{ .iterate = true }); + defer d.close(dvui.io); + var it = d.iterate(); + while (try it.next(dvui.io)) |file| { + if (std.mem.endsWith(u8, file.name, ".state")) try d.deleteFile(dvui.io, file.name); + } + const zon = try d.readFileAlloc(dvui.io, "session.zon", gpa, .unlimited); + defer gpa.free(zon); + const stateless = try std.mem.replaceOwned(u8, gpa, zon, ".has_state = true", ".has_state = false"); + defer gpa.free(stateless); + try d.writeFile(dvui.io, .{ .sub_path = "session.zon", .data = stateless }); + } + var ids: std.ArrayListUnmanaged(u64) = .empty; + defer ids.deinit(gpa); + for (editor.app.open_files.values()) |d| try ids.append(gpa, d.id); + for (ids.items) |id| editor.detachFileID(id); + + editor.session = fizzy.Editor.KeptDocuments.loadSession(gpa, dir) orelse return error.TestUnexpectedResult; + _ = try editor.app.host.openFile(.{ .path = path, .grouping = grouping }); + const after = editor.docFromPath(path) orelse return error.TestUnexpectedResult; + try std.testing.expectEqualStrings("text", after.owner.id); + try std.testing.expect(after.owner.isDirty(after)); + try std.testing.expect(!after.owner.documentHasRecognizedSaveExtension(after)); + const out = editor.app.host.callCommand("text.read", "", editor.app.host.arena()); + try std.testing.expect(std.mem.indexOf(u8, out.ok.?, ".text = \"typed\"") != null); + if (editor.session) |*kept| kept.deinit(); + editor.session = null; + } + // A checkpoint (`Checkpoint`): what a run that did not quit cleanly left is read once at the // next launch. Each write is a generation of files in the same folder (`saveGeneration`), the // last one's removed once the new `session.zon` is in place; one cut off before any From 5651df9b61dc1976aa6f21674b1ea524ba5c6de3 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:09:51 -0500 Subject: [PATCH 08/46] core: a kept frost reads again under a region's swap (#450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Glass that keeps its frost until a witness changes (`BlurBackdrop.Pane.witness`, pixi's canvas buttons through `core.dialogs.glassBubbles`) now reads again while the host draws a region's swap over the view under it. ## What was wrong Switching a pane's tab from grad.png (a blue-to-pink gradient, image plugin) to a `.pixi` sprite left the sprite's glass buttons tinted with the gradient for good. The readback was not reading an old frame: a frost reads the frame target in its deferred job, after everything beneath it has drawn this frame. What was beneath it was the host's swap. Every region swap (`Layout.drawSwapped` → `core.anim.transition`) draws the outgoing view's photograph over the incoming view and fades it out over ~0.4 s. Pixi's buttons are floating subwindows, so their frost reads the frame after that overlay has drawn. The witness (art, canvas rect, button rect, blur style) describes only the sprite, and it held still while the photograph faded, so the frost kept whatever frame of the fade it first read. In the repro log, the last capture came at fade alpha 0.95, and nothing re-read after that. ## The fix The witness can't describe this: the caller doesn't know what the host draws over its view, and a scene signature in every witness would put the host's state in every plugin's hash. Re-reading once on the frame after the frost forms doesn't fix it either, because the fade lasts many frames. Instead, the host says when it draws something no witness covers: - `BlurBackdrop.underChanged(rect)`: something no witness describes was drawn over `rect` this frame. It is kept in the shared dvui window (as `FrameTarget.want` is), so a plugin image's glass sees what the host drew. - `CrossFade.draw` marks its snapshots' rects every frame it draws one. - A kept frost whose capture rect meets a mark from this frame or the last one re-reads, in its deferred job (`FrostJob.draw`). That job runs after the overlay has reached the target. The extra frame covers the first frame after the fade, when the overlay has gone. Cost: one data-store lookup per kept frost per frame, and nothing is stored while no swap runs. During a swap, the kept frosts under it re-read every frame, as an unkept frost always does. The next thing that draws over a view in a way its witness can't see (a carried view's photo, say) calls `underChanged` the same way. ## SDK No fingerprint move. Pixi's glass gets the fix once it is rebuilt against this `core` (next SDK release + repin). Until then, the host still marks and pixi just doesn't read the marks. ## Verified macOS, sandbox profile, pixi `main` built with `--fork` against this tree. A live tape opens sprite.pixi and grad.png in one pane, shows the sprite, then shows the gradient for ~1.5 s, then switches back to the sprite. The frame was read back from fizzy's frame target after the tape (the display was asleep): - before: the dropper bubble stays blue from the gradient after the switch - after: both bubbles show the sprite's canvas `zig build`, `zig build test` (new unit tests for the marks' this-frame / next-frame / expired lifetime and overflow), `zig build check-tests-reached`, `scripts/check-fmt.sh`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- core/anim.zig | 3 ++ core/widgets/BlurBackdrop.zig | 98 ++++++++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/core/anim.zig b/core/anim.zig index 6d271cbd0..21c4d824f 100644 --- a/core/anim.zig +++ b/core/anim.zig @@ -228,6 +228,9 @@ pub const CrossFade = struct { const mode: BlitMode = if (self.opaque_snapshots) .over else .lerp; if (self.incoming) |tex| blitWith(tex, &self.incoming_frost, self.incoming_rect, self.inner, s.in_blur, s.in_alpha, mode); if (self.texture) |tex| blitWith(tex, &self.frost, self.rect, self.inner, s.out_blur, s.out_alpha, mode); + // Over the incoming view, whose glass describes only the view: it reads again. + if (self.incoming != null) BlurBackdrop.underChanged(self.incoming_rect); + if (self.texture != null) BlurBackdrop.underChanged(self.rect); // Nothing else is animating, so without this an idle app would sleep mid-fade. dvui.refresh(null, @src(), null); diff --git a/core/widgets/BlurBackdrop.zig b/core/widgets/BlurBackdrop.zig index a4af1c896..09eff8e21 100644 --- a/core/widgets/BlurBackdrop.zig +++ b/core/widgets/BlurBackdrop.zig @@ -954,7 +954,8 @@ pub const Pane = struct { /// pane moves, resizes or forms), however many frames pass. For chrome that sits over /// something the caller can describe — a button over a canvas it knows the art, zoom and pan /// of — where a re-read every frame is a blur's worth of work for a picture that has not - /// changed. + /// changed. What the caller cannot describe — the host fading one view's photograph over + /// the next as a region swaps — is the host's to say (`underChanged`), and reads it again too. witness: ?u64 = null, /// Clear glass: what is behind it unblurred — bent at its edge, tinted and lit as frost is — /// for the blur turned off where the glass program draws (`LiquidField`), so liquid glass is @@ -1067,6 +1068,7 @@ fn queuePane(id: dvui.Id, rect: Rect.Physical, corners: dvui.CornerRect, scale: .refraction = pane.refraction, .field = field, .clear = pane.clear, + .kept = pane.witness != null, }; dvui.deferRender(job, FrostJob.draw); } @@ -1106,6 +1108,94 @@ fn selfForm(id: dvui.Id, now: i128) f32 { return std.math.clamp(t, 0, 1); } +/// Fizzy addition: something no witness describes was drawn over `r` (physical) this frame — a +/// region's swap fading the outgoing view's photograph over the incoming one (`anim.CrossFade`). +/// A kept frost (`Pane.witness`) over any of it is read again, this frame and the next: the next +/// for the frame it ends, when what was drawn over is gone. Without it, the glass over a view +/// that had just come in kept the photograph of the view it replaced, faded in its frost for +/// good, since the witness, describing only the view, held still as the photograph went. +/// +/// Through the shared dvui window, so a plugin's glass hears what the host drew. Costs a lookup +/// per kept frost a frame; nothing while no one marks. +pub fn underChanged(r: Rect.Physical) void { + if (dvui.current_window == null) return; + const marks = dvui.dataGetPtrDefault(null, marks_id, marks_key, Marks, .{}); + marks.roll(dvui.currentWindow().frame_time_ns); + marks.now.add(r); +} + +/// Whether `r` is under anything `underChanged` marked this frame or the last. +fn changedUnder(r: Rect.Physical) bool { + const marks = dvui.dataGetPtr(null, marks_id, marks_key, Marks) orelse return false; + marks.roll(dvui.currentWindow().frame_time_ns); + return marks.now.hits(r) or marks.last.hits(r); +} + +const marks_id: dvui.Id = @enumFromInt(0x6669_7a7a_756e_6472); // "fizzundr" +const marks_key = "_frost_under"; + +/// The rects `underChanged` marked, this frame and the last. In the data store, so it lasts only +/// while someone marks or reads it: unread for a frame, it goes, and a frost reading after that +/// finds nothing. +const Marks = struct { + /// The frame `now` holds. + frame: i128 = 0, + now: Span = .{}, + last: Span = .{}, + + const Span = struct { + rects: [8]Rect.Physical = undefined, + len: u8 = 0, + + /// Past eight, the last one grows to take it in: a frost too many re-reads, never one + /// too few. + fn add(self: *Span, r: Rect.Physical) void { + if (self.len < self.rects.len) { + self.rects[self.len] = r; + self.len += 1; + } else { + self.rects[self.len - 1] = self.rects[self.len - 1].unionWith(r); + } + } + + fn hits(self: *const Span, r: Rect.Physical) bool { + for (self.rects[0..self.len]) |m| if (!m.intersect(r).empty()) return true; + return false; + } + }; + + /// Into frame `t`: what this frame held becomes the last frame's. + fn roll(self: *Marks, t: i128) void { + if (self.frame == t) return; + self.last = self.now; + self.now = .{}; + self.frame = t; + } +}; + +test "a kept frost reads again under a mark, that frame and the next, then not" { + var m: Marks = .{}; + const swap: Rect.Physical = .{ .x = 100, .y = 100, .w = 800, .h = 600 }; + const glass: Rect.Physical = .{ .x = 120, .y = 120, .w = 40, .h = 40 }; + const aside: Rect.Physical = .{ .x = 1000, .y = 100, .w = 40, .h = 40 }; + m.roll(1); + m.now.add(swap); + try std.testing.expect(m.now.hits(glass)); + try std.testing.expect(!m.now.hits(aside)); + // The frame after the swap's last: nothing marked, the last frame's mark still counts. + m.roll(2); + try std.testing.expect(!m.now.hits(glass) and m.last.hits(glass)); + m.roll(3); + try std.testing.expect(!m.now.hits(glass) and !m.last.hits(glass)); +} + +test "marks past the span's room widen its last rect" { + var span: Marks.Span = .{}; + for (0..10) |i| span.add(.{ .x = @floatFromInt(i * 100), .y = 0, .w = 10, .h = 10 }); + try std.testing.expectEqual(@as(u8, 8), span.len); + try std.testing.expect(span.hits(.{ .x = 905, .y = 0, .w = 2, .h = 2 })); +} + /// What `frostPane` hands to the replay. Lives in the data store under the owner's id, so the /// pointer is good until the frame ends. const FrostJob = struct { @@ -1125,6 +1215,9 @@ const FrostJob = struct { field: ?LiquidField = null, /// Clear glass (`Pane.clear`): the picture before the blur. clear: bool = false, + /// Read only when its witness changes (`Pane.witness`), or what is under it changed in a way + /// no witness sees (`underChanged`). + kept: bool = false, fn draw(ctx: ?*anyopaque) void { const self: *FrostJob = @ptrCast(@alignCast(ctx orelse return)); @@ -1138,6 +1231,9 @@ const FrostJob = struct { const prev_alpha = dvui.currentWindow().alpha; dvui.alphaSet(1); defer dvui.alphaSet(prev_alpha); + // Here, not at `queuePane`: a swap's photograph is drawn over the view after the view + // has queued its glass, and is on the target by now. + if (self.kept and changedUnder(self.backdrop.rect)) self.backdrop.markDirty(); // The capture, now that everything below this pane is on the target (again, if it was // replayed into another target already this frame: `deinit`). self.backdrop.deinit(); From 523a89f4c284c242ed354e5c249a2a5662e3c892 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:14:44 -0500 Subject: [PATCH 09/46] core: a canvas the person has not moved fits its pane; Fit to View puts it back (#444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changes **Fit mode for `CanvasWidget`** (pixi's sprite, the image viewer). - A canvas starts fitted to its pane. While it is fitted, a pane that *settles* at another size refits and recentres it: a document moved to a narrower pane beside, a window resize, the explorer opening. - The moment the person moves the view (a wheel over the canvas, a pinch, a trackpad zoom, a drag-pan, a touch pan), it stops being fitted. Nothing refits it after that, so their place is kept, as before. - New content (a different size) starts fitted again. - `fitToView()` fits it again and keeps it fitted. Before this, a canvas settled once and never refit. A sprite an agent created in the person's pane and then moved beside them kept a view fitted to the wide pane: off centre and cropped in the narrow one (reported with a screenshot). **`fizzy.fitView`:** "Fit to View", Cmd-0, in the View menu. It routes to the active document's owner as `.fitView`, the way Undo routes, through a new `fitView` document verb. The image viewer registers `image.fitView`. pixi's is fizzyedit/pixi (linked below). Detecting "the person moved it" happens at the input sites, not by comparing the viewport. The canvas's own pan-slack bookkeeping moves the scroll position every frame as layout, so a before/after comparison would misfire. **Fit to View is animated.** It moves the view eased in and out (`inOutCubic`, 450 ms at the app's motion speed) and never past the fit. The app's `settle` spring arrived early and swung past, which on a view read as a lunge. The move is a zoom about the one point on screen it leaves in place. Every point of the content moves toward or away from it by one shared factor, so x and y arrive together. Moving the middle's data point in a straight line had each axis's edges move at different rates. A move with no zoom slides. Where the view starts comes from the canvas's own state (origin + scroll position + half the box, over the scale), the inverse of where the fit places it. So a view already fitted is recognized, and Fit to View does nothing. Read off last frame's screen rects instead, the start was off by a zoomed view's scroll bar. **While fitted, the content follows the pane live.** A pane changing size refits on every frame it changes, so dragging a split or the explorer closing zooms it smoothly. Before, it waited for the pane to hold still for three frames and restored a snapshot of the old fit in the meantime, then caught up in one jump. That waiting and snapshotting still guard a view the person has placed. A fitted view is worked out afresh each frame, so there is no drift to guard against. The person's pan or zoom ends a move where it is. With motion off, a fit is the jump it was. ## SDK impact - [ ] None - [x] Core-only or additive: reaches plugins at the next SDK release (core's `CanvasWidget`; the fingerprint is unmoved). pixi's command uses it through an `@hasDecl` guard, so pixi builds on the released SDK meanwhile. - [ ] Fingerprint moved - [ ] SDK release ## Verified - [x] macOS, a profile of my own, with this change merged locally with #442 and pixi built `--fork`: - an agent created a 64×64 sprite and drew on it; it landed beside the person's sprite fitted and centred - after `pixi.view {zoom: 20}` on it (no longer fitted), toggling the explorer twice resized both panes: the zoomed sprite kept its zoom, and the untouched one stayed fitted and centred - Cmd-0 on the zoomed sprite fitted and centred it again - `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build test-sdk-version`, `zig build check-web`; `test-integration` left to CI - [ ] Windows: - [ ] Linux: - [ ] Web: Animated fit and live refit: macOS, sandbox profile, measured with a temporary per-frame log of the sprite's screen rect (removed): - **A fit from 12x on the bottom left:** each axis's share of its distance per frame matched (0.24/0.24, 0.49/0.49, 0.83/0.83 … 1.00/1.00). No frame passed the fit, and the first frame didn't jump. - **A fit on a fitted view:** nothing moved. - **A fit from 24x on the top left, then from the bottom right:** each starts from where the view was. - **Closing the explorer with the sprite fitted:** the sprite re-centred on every frame of the pane's 175 ms widening (1193 → 933 px). It didn't jump at the end. ## Follow-ups - A Fit to View glass bubble among pixi's canvas bubbles, and the corner controls as glass bubbles (asked for; next). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- core/widgets/CanvasWidget.zig | 141 +++++++++++++++++++++++++++++++++- plugins/image/plugin.zig | 19 +++++ src/editor/Editor.zig | 2 +- src/editor/Keybinds.zig | 11 +++ src/editor/menu_model.zig | 1 + 5 files changed, 169 insertions(+), 5 deletions(-) diff --git a/core/widgets/CanvasWidget.zig b/core/widgets/CanvasWidget.zig index dfdd3e842..aed80f220 100644 --- a/core/widgets/CanvasWidget.zig +++ b/core/widgets/CanvasWidget.zig @@ -43,6 +43,21 @@ first_center: bool = true, second_center: bool = true, prev_parent_rect: dvui.Rect = .{}, +/// Fitted to its pane until the person moves the view: a wheel, a pinch, a pan over the canvas. +/// While fitted, a pane that settles at another size (a document moved to a narrower pane +/// beside, a window resized) refits it; once the person has moved it, nothing does, so their +/// place is kept. `fitToView` fits it again; an owner that sets the view itself says whether it +/// left it fitted. +fit: bool = true, +/// The pane size the canvas was last fitted to. +fit_parent: dvui.Size = .{}, +/// `fitToView` asked: the next fit moves the view there rather than jumping (`fit_move`). +fit_animate: bool = false, +/// A fit on its way, from the view as it was: a zoom about the one point on screen the move leaves +/// where it is, so every point of the content moves toward or away from it in step and both axes +/// arrive together (`applyFitMove`). Null when none is; a pan or zoom of the person's ends it. +fit_move: ?FitMove = null, + /// Parent rect captured during the previous `install` call (regardless of whether /// `recenter` ran). Lets us detect "the workspace pane is mid-animation right now" so /// we can skip the pan-slack viewport/origin mutation in `processEvents` — running it @@ -334,6 +349,51 @@ pub fn recenter(self: *CanvasWidget) void { self.prev_parent_rect = parent; } +/// Fit the content to the pane again, moving the view there on the app's motion, and keep it +/// fitted until the person moves the view. +pub fn fitToView(self: *CanvasWidget) void { + self.fit = true; + self.fit_parent = .{}; + self.fit_animate = true; + dvui.refresh(null, @src(), self.id); +} + +const FitMove = struct { from_scale: f32, to_scale: f32, from_center: dvui.Point, to_center: dvui.Point }; +/// How long a fit takes to move the view there, before the app's motion speed. +const fit_move_us: i32 = 450_000; + +/// One frame of a fit on its way (`fit_move`), `t` of the way there. The scale eases on a log +/// scale, and the data point at the box's middle follows from the move's still point: the data +/// point `P`, at `G` from the box's middle, that the start and end views both put in the same +/// place. Every point of the content then moves toward or away from it by one shared factor, a +/// zoom about it, so x and y move in step. (The middle's data point moved in a straight line +/// instead, its screen edges moved at different rates on each axis.) With no zoom to speak of +/// there is no still point: the middle slides in a line. +fn applyFitMove(self: *CanvasWidget, m: FitMove, t: f32) void { + const box = dvui.parentGet().data().rect; + const data = self.init_opts.data_size; + const s = @exp(std.math.lerp(@log(m.from_scale), @log(m.to_scale), t)); + const c: dvui.Point = if (@abs(m.from_scale - m.to_scale) < 1e-3 * m.to_scale) .{ + .x = std.math.lerp(m.from_center.x, m.to_center.x, t), + .y = std.math.lerp(m.from_center.y, m.to_center.y, t), + } else c: { + const k = m.from_scale - m.to_scale; + const p: dvui.Point = .{ + .x = (m.from_center.x * m.from_scale - m.to_center.x * m.to_scale) / k, + .y = (m.from_center.y * m.from_scale - m.to_center.y * m.to_scale) / k, + }; + const g: dvui.Point = .{ .x = (p.x - m.from_center.x) * m.from_scale, .y = (p.y - m.from_center.y) * m.from_scale }; + break :c .{ .x = p.x - g.x / s, .y = p.y - g.y / s }; + }; + self.prev_scale = self.scale; + self.scale = s; + self.scroll_info.virtual_size = .{ .w = data.w * s, .h = data.h * s }; + // As `recenter` does: the scroll position zeroed with the origin, `deinit` adding the slack. + self.scroll_info.viewport.x = 0; + self.scroll_info.viewport.y = 0; + self.origin = .{ .x = c.x * s - box.w / 2, .y = c.y * s - box.h / 2 }; +} + pub fn rescale(self: *CanvasWidget) void { const parent = dvui.parentGet().data().rect; @@ -365,6 +425,7 @@ pub fn install(self: *CanvasWidget, src: std.builtin.SourceLocation, init_opts: self.second_center = true; self.fade_started = false; self.fade_pending = false; + self.fit = true; } // While still in the settle phase, force another recenter whenever the parent rect // changed since last frame: the workspace layout may still be moving (e.g. status bar @@ -397,7 +458,10 @@ pub fn install(self: *CanvasWidget, src: std.builtin.SourceLocation, init_opts: // confirmed stable frame. This freezes the canvas's pan/zoom state during animations // so mid-frame mutations (scroll container bounce-back, scrollbar visibility flips // shrinking the viewport, etc.) don't accumulate drift. - if (self.settled() and !parent_is_stable and self.has_stable_snapshot) { + // Not while fitted: a fitted view is worked out afresh from the pane each frame (`refit`), so + // there is no drift to guard against, and the snapshot froze it at the old fit for as long as + // the pane moved. + if (self.settled() and !parent_is_stable and self.has_stable_snapshot and !self.fit) { self.scroll_info.viewport.x = self.stable_viewport.x; self.scroll_info.viewport.y = self.stable_viewport.y; self.scroll_info.virtual_size = self.stable_virtual_size; @@ -420,11 +484,73 @@ pub fn install(self: *CanvasWidget, src: std.builtin.SourceLocation, init_opts: // for many frames and `center` can stay true — ignore it once the canvas has settled so // reopening the explorer does not recenter an already-panned file. const explicit_center = self.init_opts.center and !self.settled(); - if (size_changed or self.second_center or explicit_center or parent_changed_while_unsettled) { - self.rescale(); - self.recenter(); + // Still fitted, and the pane is at a size it was not fitted to: refit, every frame it changes, + // so a pane being dragged zooms the content with it live. (It waited for the pane to hold + // still, and the content caught up in a jump after the drag.) + const refit = self.fit and self.settled() and + (parent_rect_now.w != self.fit_parent.w or parent_rect_now.h != self.fit_parent.h); + if (size_changed or self.second_center or explicit_center or parent_changed_while_unsettled or refit) { + const only_refit = !(size_changed or self.second_center or explicit_center or parent_changed_while_unsettled); + if (only_refit and self.fit_animate and self.scale > 0) { + // Asked for (`fitToView`): moved there from where the view is. A pane changing size + // refits at once — a resize the fit trailed behind would lag the person's drag. + // Where the view is: the data point at the middle of the box, from the canvas's own + // state, the inverse of where `applyFitMove` puts it. Pans move the scroll position, + // pan slack moves it and the origin by opposite amounts, so their sum places it; + // a fitted view comes out at the data's exact middle. (Read off last frame's screen + // rects it was off by the scroll bar a zoomed view shows, and a view already fitted + // jumped and moved back.) + const box = dvui.parentGet().data().rect; + const from_center: dvui.Point = .{ + .x = (self.origin.x + self.scroll_info.viewport.x + box.w / 2) / self.scale, + .y = (self.origin.y + self.scroll_info.viewport.y + box.h / 2) / self.scale, + }; + const from_scale = self.scale; + self.rescale(); + const to_scale = self.scale; + const data = self.init_opts.data_size; + // Already fitted (within a pixel on screen): nothing to move, and no motion to show. + // The view stays exactly as it is: no recenter, which would reset the scroll. + const there = @abs(to_scale - from_scale) * @max(data.w, data.h) < 1 and + @abs(from_center.x - data.w / 2) * to_scale < 1 and @abs(from_center.y - data.h / 2) * to_scale < 1; + if (there) { + self.fit_move = null; + self.scale = from_scale; + } else { + self.fit_move = .{ .from_scale = from_scale, .to_scale = to_scale, .from_center = from_center, .to_center = .{ .x = data.w / 2, .y = data.h / 2 } }; + self.scale = from_scale; + // A camera move: eased in and out, never past where it is going. The app's + // `settle` arrives early and swings past and back, which on a view read as a + // lunge and an overshoot. + dvui.animation(self.id, "_fit", .{ + .start_val = 0, + .end_val = 1, + .end_time = core.motion.duration(fit_move_us), + .easing = dvui.easing.inOutCubic, + }); + } + } else { + self.fit_move = null; + self.rescale(); + self.recenter(); + } + self.fit_parent = .{ .w = parent_rect_now.w, .h = parent_rect_now.h }; dvui.refresh(null, @src(), self.id); } + self.fit_animate = false; + if (self.fit_move) |m| { + if (!self.fit) { + // The person took the view: it stays where the move had it. + self.fit_move = null; + } else if (dvui.animationGet(self.id, "_fit")) |a| { + self.applyFitMove(m, a.value()); + } else { + // Done (or motion is off): exactly the fitted view. + self.fit_move = null; + self.rescale(); + self.recenter(); + } + } // Wait one frame before starting the fade so the most-stale (frame 1) recenter is // hidden (alpha == 0), then begin fading on the next install — overlapping with any @@ -952,6 +1078,7 @@ pub fn processEvents(self: *CanvasWidget) void { // and resize handle. The scale-around-point math here is identical to the wheel // and old inline pinch paths — only the timing changed. if (self.pending_pinch_zoom != 1.0) { + self.fit = false; const zoom = self.pending_pinch_zoom; const zoomP = self.pending_pinch_zoom_p; const prevP = self.dataFromScreenPoint(zoomP); @@ -965,6 +1092,7 @@ pub fn processEvents(self: *CanvasWidget) void { self.pending_pinch_zoom = 1.0; } if (self.pending_trackpad) { + self.fit = false; const ratio = self.pending_trackpad_ratio; const cursor_phys = self.pending_trackpad_cursor; const prevP = self.dataFromScreenPoint(cursor_phys); @@ -979,6 +1107,7 @@ pub fn processEvents(self: *CanvasWidget) void { self.pending_trackpad = false; } if (self.pending_touch_pan.x != 0 or self.pending_touch_pan.y != 0) { + self.fit = false; const pdx = self.pending_touch_pan.x; const pdy = self.pending_touch_pan.y; self.scroll_info.viewport.x += pdx; @@ -1141,6 +1270,7 @@ pub fn processEvents(self: *CanvasWidget) void { const ddy = -dps.y / rs.s; self.scroll_info.viewport.x += ddx; self.scroll_info.viewport.y += ddy; + self.fit = false; pan_dx += ddx; pan_dy += ddy; pan_motion = true; @@ -1151,6 +1281,9 @@ pub fn processEvents(self: *CanvasWidget) void { } } } else if (me.action == .wheel_y or me.action == .wheel_x) { + // A wheel over the canvas moves the view, as a zoom here or a pan by the + // scroll container: either way the person has placed it. + self.fit = false; switch (self.init_opts.pan_zoom_scheme) { .mouse => { const base: f32 = if (me.mod.matchBind("shift")) 1.005 else 1.005; diff --git a/plugins/image/plugin.zig b/plugins/image/plugin.zig index a03c3c0a2..8d0cc063f 100644 --- a/plugins/image/plugin.zig +++ b/plugins/image/plugin.zig @@ -68,6 +68,25 @@ pub fn register(host: *sdk.Host) !void { try host.registerPlugin(&plugin); try host.registerFileKind(.{ .owner = &plugin, .kind = fileKind }); + try host.registerCommand(.{ + .id = sdk.Plugin.commandId("image", "fitView"), + .owner = &plugin, + .title = "Fit to View", + .run = cmdFitView, + .isEnabled = cmdFitViewEnabled, + }); +} + +/// `fizzy.fitView` on an image: fitted to its pane, and kept fitted until the person moves it. +fn cmdFitView(_: *anyopaque) anyerror!void { + const handle = sdk.host().activeDoc() orelse return; + if (handle.owner != &plugin) return; + const doc = docFrom(handle) orelse return; + doc.canvas.fitToView(); +} +fn cmdFitViewEnabled(_: *anyopaque) bool { + const handle = sdk.host().activeDoc() orelse return false; + return handle.owner == &plugin; } pub fn pluginPtr() *sdk.Plugin { diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 2a27a7e3b..9533377c1 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -5707,7 +5707,7 @@ pub fn newFile(editor: *Editor, path: []const u8, grid: sdk.EditorAPI.NewDocGrid /// Dispatch a generic fizzy action to the active document owner's command (`.`). /// No active doc, or an owner that registered no such command, is a clean no-op. This is how the /// fizzy's Edit menu / keybinds reach per-editor actions without naming any plugin. -fn runActiveDocCommand(editor: *Editor, action: []const u8) !void { +pub fn runActiveDocCommand(editor: *Editor, action: []const u8) !void { const doc = editor.activeDoc() orelse return; const id = try std.fmt.allocPrint(editor.app.arena.allocator(), "{s}.{s}", .{ doc.owner.id, action }); try editor.app.host.runCommand(id); diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index cb6613d6c..5bda67e1d 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -162,6 +162,7 @@ const fizzy_commands = [_]FizzyCommand{ .{ .id = "fizzy.toggleExplorer", .title = "Toggle Explorer", .bind = "explorer", .run = cmdToggleExplorer, .icon = icons.tvg.lucide.@"panel-left" }, .{ .id = "fizzy.resetLayout", .title = "Reset Layout", .bind = null, .run = cmdResetLayout, .icon = icons.tvg.lucide.@"rotate-ccw" }, .{ .id = "fizzy.toggleFullScreen", .title = "Toggle Full Screen", .bind = null, .run = cmdToggleFullScreen, .icon = icons.tvg.lucide.maximize }, + .{ .id = "fizzy.fitView", .title = "Fit to View", .bind = null, .run = cmdFitView, .isEnabled = cmdFitViewEnabled, .icon = icons.tvg.lucide.scan }, .{ .id = "fizzy.deleteSelection", .title = "Delete Selection", .bind = "delete_selection_contents", .run = cmdDeleteSelection, .isEnabled = cmdDeleteSelectionEnabled, .icon = icons.tvg.lucide.@"trash-2" }, .{ .id = "fizzy.accept", .title = "Accept", .bind = "activate", .run = cmdAccept, .isEnabled = cmdAcceptEnabled, .icon = icons.tvg.lucide.check }, .{ .id = "fizzy.cancel", .title = "Cancel", .bind = "cancel", .run = cmdCancel, .isEnabled = cmdCancelEnabled, .icon = icons.tvg.lucide.x }, @@ -482,6 +483,14 @@ fn cmdCancel(state: *anyopaque) anyerror!void { try editorFromState(state).cancel(); } +/// The active document's view fitted to its pane again (`.fitView`): an image or a sprite +/// the person has panned and zoomed, back where a new one opens. +fn cmdFitView(state: *anyopaque) anyerror!void { + try editorFromState(state).runActiveDocCommand("fitView"); +} +fn cmdFitViewEnabled(state: *anyopaque) bool { + return editorFromState(state).activeDocCommandEnabled("fitView"); +} fn cmdUndoEnabled(state: *anyopaque) bool { return editorFromState(state).activeDocCommandEnabled("undo"); } @@ -673,6 +682,7 @@ const vscode_defaults = [_]DefaultBind{ .{ .command = "fizzy.commandPalette", .keys = "mod+shift+p" }, // The platform's own: F11 in VSCode and browsers, ⌃⌘F in every macOS app. .{ .command = "fizzy.toggleFullScreen", .keys = "f11", .keys_mac = "ctrl+cmd+f" }, + .{ .command = "fizzy.fitView", .keys = "mod+0" }, } ++ open_folder_defaults ++ close_defaults; /// The web has no folder to open — a page cannot read a directory tree — so Open Folder does not @@ -720,6 +730,7 @@ pub const document_verbs = [_]DocumentVerb{ .{ .action = "undo", .fizzy_id = "fizzy.undo" }, .{ .action = "redo", .fizzy_id = "fizzy.redo" }, .{ .action = "deleteSelection", .fizzy_id = "fizzy.deleteSelection" }, + .{ .action = "fitView", .fizzy_id = "fizzy.fitView" }, .{ .action = "acceptEdit", .fizzy_id = "fizzy.accept" }, .{ .action = "cancelEdit", .fizzy_id = "fizzy.cancel" }, .{ .action = "transform", .fizzy_id = null }, diff --git a/src/editor/menu_model.zig b/src/editor/menu_model.zig index 2ee10cde5..080d17fa1 100644 --- a/src/editor/menu_model.zig +++ b/src/editor/menu_model.zig @@ -209,6 +209,7 @@ const view_items = [_]Item{ .{ .command = .{ .id = "fizzy.toggleExplorer", .title = .{ .dynamic = explorerTitle } } }, .{ .command = .{ .id = "fizzy.resetLayout", .title = .{ .static = "Reset Layout" }, .sf_symbol = "arrow.counterclockwise" } }, .{ .command = .{ .id = "fizzy.toggleFullScreen", .title = .{ .static = "Toggle Full Screen" }, .sf_symbol = "arrow.up.left.and.arrow.down.right" } }, + .{ .command = .{ .id = "fizzy.fitView", .title = .{ .static = "Fit to View" }, .sf_symbol = "viewfinder" } }, .{ .plugin_section = "fizzy.menu.view" }, .separator, } ++ demo_items ++ [_]Item{ From a254595581472ccb3578466299550b929873b0bd Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:39:44 -0500 Subject: [PATCH 10/46] core: viewport buttons are glass bubbles; Fit to View is one (#446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #444 (Fit to View); review that first. This PR's own diff is the top commit. ## What changes The round buttons that float over the app are glass bubbles now, the same glass as pixi's canvas controls and the demo player's transport: - **The places' corner button (Arrange)** is a 36 pt glass bubble with lucide's `layout-grid`, in place of the bordered disc with entypo's grid. Glass has no opacity, so where the disc faded, the bubble grows in from its centre, with its shadow. - **The explorer's peek collapse button** is a bubble, in place of a filled disc with a box shadow. - **The image viewer has Fit to View** as a bubble at the viewport's top left, where pixi keeps its canvas controls. It runs `fitToView` from #444. `core.widgets.glass_button` is pixi's `glass_button.zig`, moved into core so the shell and every plugin can draw it: `options`, `background`, `wash`, `witness` (a signature of what is under the glass, so the frost is read again only when that changes), and `bubble` (a viewport bubble in one call: floating, clipped to its screen, icon, tooltip; true when clicked). A toggled-on button stays glass: its icon takes the highlight (`iconColor`), where pixi's covered the glass with a highlight-filled disc. Pixi keeps its own copy until it repins onto an SDK carrying this. The image plugin now depends on `icons`, as text does: `build/plugins.zig`'s `link` passes it to the static module and `build/exe.zig` to the dylib. ## Pitfalls hit - A dvui button has a 4 pt margin by default, so a 36 pt `.rect` drew a 28 pt bubble with an icon padded nearly away. The corner button sets `.margin = .{}`. - A floating widget made inside a `CanvasWidget` takes the canvas's zoom. The Fit bubble is drawn after the canvas closes, from the viewport rect taken while it was open. ## SDK No fingerprint move. `core/` is new for plugins, and it reaches them with the next SDK release. ## Verified macOS, a sandbox profile (`--profile`), with captures through the agent plugin: - the corner bubble at 36 pt with its icon, lit under the pointer; - the image viewer's Fit bubble at the viewport's top left. The explorer's collapse button shows only while the explorer is peeking (a narrow window). It compiles but I didn't see it on screen. Gates: `check-fmt`, `zig build`, `test`, `test-integration`, `check-web`, `test-sdk-version`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- app/layout/Region.zig | 52 ++++++---- build/app.zig | 1 + build/exe.zig | 1 + build/plugins.zig | 2 +- core/widgets.zig | 2 + core/widgets/glass_button.zig | 141 +++++++++++++++++++++++++++ plugins/image/build.zig | 4 + plugins/image/build.zig.zon | 5 + plugins/image/src/ImageView.zig | 47 ++++++--- plugins/image/static/integration.zig | 2 + src/editor/explorer/Explorer.zig | 63 ++---------- 11 files changed, 230 insertions(+), 90 deletions(-) create mode 100644 core/widgets/glass_button.zig diff --git a/app/layout/Region.zig b/app/layout/Region.zig index a8febf4cc..d269ef9aa 100644 --- a/app/layout/Region.zig +++ b/app/layout/Region.zig @@ -22,6 +22,7 @@ const std = @import("std"); const dvui = @import("dvui"); const core = @import("core"); +const icons = @import("icons"); const sdk = @import("fizzy_sdk"); const Split = core.widgets.Split; const Layout = @import("Layout.zig"); @@ -650,8 +651,10 @@ pub fn init(self: *Layout, src: std.builtin.SourceLocation, init_opts: InitOptio /// How close the pointer must be to a region's top-right corner, in points, before the button /// shows. Far enough that a glance toward the corner finds it; near enough that it never /// appears while the user is working in the middle of the region: the button and a margin round it. -const corner_reach: f32 = core.widgets.round_button_size + 28; -const corner_button_size: f32 = core.widgets.round_button_size; +const corner_reach: f32 = core.widgets.glass_button.size + 28; +const corner_button_size: f32 = core.widgets.glass_button.size; +/// The button's distance in from the place's corner: room for the glass's shadow ring. +const corner_inset: f32 = 8; /// The small button in a region's top-right corner that opens the picker for it. This is the /// piece that makes placement *visual*: a user looks at the place in the window they want to @@ -736,19 +739,17 @@ fn cornerButton(self: *Layout, opts: InitOptions, keywords: []const []const u8, .label = .{ .text = "Arrange" }, .tab_index = 0, .rect = .{ - .x = content.w - corner_button_size - 4, - .y = 4, + .x = content.w - corner_button_size - corner_inset, + .y = corner_inset, .w = corner_button_size, .h = corner_button_size, }, - // Entypo's grid draws its squares well inside its own box, so the glyph is given nearly - // all of the button. - .padding = dvui.Rect.all(2), - .corners = dvui.CornerRect.all(corner_button_size / 2), - .background = true, - .color_fill = .{ .color = theme.color(.control, .fill).opacity(@max(alpha, 0.35)) }, - .border = dvui.Rect.all(1), - .color_border = .{ .color = theme.color(.control, .border).opacity(@max(alpha, 0.35)) }, + .padding = .all(0), + .margin = .{}, + .corners = .round(corner_button_size / 2), + .background = false, + .border = .all(0), + .box_shadow = null, }); defer bw.deinit(); bw.processEvents(); @@ -794,14 +795,25 @@ fn cornerButton(self: *Layout, opts: InitOptions, keywords: []const []const u8, } if (alpha > 0.01 and !dragged and !self.state.view_drag.active()) { - bw.drawBackground(); - core.icon.icon(@src(), "regions", dvui.entypo.grid, .{ - .fill_color = .{ .color = if (bw.hovered()) theme.color(.highlight, .fill) else theme.color(.control, .fill).lerp(theme.color(.control, .text), alpha) }, - // A clear stroke makes it one flat colour, which the icon cache tints rather than - // bakes: baked, every step of the fade (it comes and goes as a drag crosses a place) - // rasterized a new texture, two render-target switches a frame. - .stroke_color = .transparent, - }, .{ .expand = .both }); + // A glass bubble, as the controls over a document are (`core.widgets.glass_button`). Glass + // has no opacity, so it fades by growing in from its centre, its shadow with it. + const br = bw.data().borderRectScale(); + const grown = br.r.insetAll(br.r.w * (1 - alpha) / 2); + // An empty place's corner is its hatch, which does not change: read the frost once. A + // filled one's is the view's own content, read while the button shows. + const witness: ?u64 = if (filled) null else core.widgets.glass_button.witness(grown, 0); + core.dialogs.glassBubbles(bw.data().id, &.{.{ .rect = grown, .light = core.widgets.glass_button.light(&bw) }}, br.s, .{ .shadow = alpha, .witness = witness }); + // White, tinted by the text colour: the icon cache tints a flat colour rather than baking + // it, and every step of the fade (it comes and goes as a drag crosses a place) would + // otherwise rasterize a new texture, two render-target switches a frame. + core.icon.icon(@src(), "regions", icons.tvg.lucide.@"layout-grid", .{ .fill_color = .white, .stroke_color = .white }, .{ + .expand = .ratio, + .gravity_x = 0.5, + .gravity_y = 0.5, + .min_size_content = .{ .w = 1, .h = 1 }, + .padding = .all(corner_button_size * 0.3 * alpha + (1 - alpha) * corner_button_size / 2), + .color_text = .{ .color = theme.color(.content, .text).opacity(alpha) }, + }); } if (bw.clicked() and !dragged) { self.state.openPicker(self.gpa, opts.name, bw.data().rectScale().r.toNatural().bottomLeft()); diff --git a/build/app.zig b/build/app.zig index b3e4c9c17..7f16cc10b 100644 --- a/build/app.zig +++ b/build/app.zig @@ -930,6 +930,7 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. .dvui = dvui_testing_dep.module("dvui_testing"), .core = core_module_test, .sdk = sdk_module_test, + .icons = icons_test, }, fizzy_test_module); const devtools_module_test = plugins.devtools.addStaticModule(b, target, optimize, .{ .dvui = dvui_testing_dep.module("dvui_testing"), diff --git a/build/exe.zig b/build/exe.zig index 4db12a42d..9b750a482 100644 --- a/build/exe.zig +++ b/build/exe.zig @@ -376,6 +376,7 @@ pub fn addFizzyExecutableForTarget( .dvui = dvui_proxy_mod, .core = core_proxy_module, .sdk = sdk_proxy_module, + .icons = icons_module, .proxy_bridge = proxy_bridge_plugin_mod, }); } else null; diff --git a/build/plugins.zig b/build/plugins.zig index 3ef9033f2..2e8d484a8 100644 --- a/build/plugins.zig +++ b/build/plugins.zig @@ -87,7 +87,7 @@ pub fn link(b: *std.Build, w: Wiring, plugins: []const BundledPlugin) Linked { else if (std.mem.eql(u8, p.name, "text")) text.addStaticModule(b, w.target, w.optimize, .{ .dvui = w.dvui, .core = w.core, .sdk = w.sdk, .icons = w.icons }, w.consumer) else if (std.mem.eql(u8, p.name, "image")) - image.addStaticModule(b, w.target, w.optimize, .{ .dvui = w.dvui, .core = w.core, .sdk = w.sdk }, w.consumer) + image.addStaticModule(b, w.target, w.optimize, .{ .dvui = w.dvui, .core = w.core, .sdk = w.sdk, .icons = w.icons }, w.consumer) else if (std.mem.eql(u8, p.name, "archive")) archive.addStaticModule(b, w.target, w.optimize, .{ .dvui = w.dvui, .core = w.core, .sdk = w.sdk }, w.consumer) else if (std.mem.eql(u8, p.name, "devtools")) diff --git a/core/widgets.zig b/core/widgets.zig index 9c49003d2..ac6f1c45d 100644 --- a/core/widgets.zig +++ b/core/widgets.zig @@ -54,6 +54,8 @@ pub const PopupWidget = @import("widgets/menu/Popup.zig"); /// round that is tapped or clicked. Big enough to hit with a finger, small enough not to crowd /// what it sits over, so one size serves touch and mouse alike. pub const round_button_size: f32 = 32; +/// Round buttons in liquid glass, and the viewport bubbles that float over a document. +pub const glass_button = @import("widgets/glass_button.zig"); /// A dropdown whose list is the menus' frosted surface — use this, not `dvui.DropdownWidget`. pub const DropdownWidget = @import("widgets/Dropdown.zig"); diff --git a/core/widgets/glass_button.zig b/core/widgets/glass_button.zig new file mode 100644 index 000000000..69a2a656f --- /dev/null +++ b/core/widgets/glass_button.zig @@ -0,0 +1,141 @@ +//! Round buttons in liquid glass: the controls that float over a document or a pane. +//! +//! Each is a glass bubble (`dialogs.glassBubbles`), the same glass as the demo player's transport +//! and pixi's canvas controls, refracting what is behind it along its own outline, with the +//! floating surfaces' shadow as a ring round it (never a box shadow, which the frost would read +//! back and blur). Hover and press light the glass; a toggled-on button stays glass, its icon in +//! the highlight. With the blur off the glass is the plain dialog fill. +const std = @import("std"); +const dvui = @import("dvui"); +const dialogs = @import("../dialogs.zig"); +const screens = @import("../screens.zig"); +const icon_draw = @import("../gfx/icon.zig"); +const widgets = @import("../widgets.zig"); + +/// The size of a viewport bubble, in points: pixi's canvas controls are this size. +pub const size: f32 = 36; + +/// How lit a button's glass is for its state (`dialogs.Bubble.light`). +pub fn light(btn: *dvui.ButtonWidget) f32 { + return if (btn.pressed()) dialogs.bubble_press else if (btn.hovered()) dialogs.bubble_hover else 0; +} + +/// The wash over a button inside a pane of glass (pixi's edit pill), as a menu's rows take: the +/// highlight when toggled on, else the menus' press and hover colours, else nothing. +pub fn wash(btn: *dvui.ButtonWidget, active: bool) void { + const color: ?dvui.Color = if (active) + highlight(btn) + else if (btn.pressed()) + dialogs.rowPress() + else if (btn.hovered()) + dialogs.rowHover() + else + null; + if (color) |c| fillCircle(btn, c); +} + +/// A toggled-on button's tint. +fn highlight(btn: *dvui.ButtonWidget) dvui.Color { + return dvui.themeGet().color(.highlight, .fill).opacity(if (btn.hovered()) 1 else 0.85); +} + +fn fillCircle(btn: *dvui.ButtonWidget, c: dvui.Color) void { + const rs = btn.data().borderRectScale(); + rs.r.fill(.round(@min(rs.r.w, rs.r.h) / 2), .{ .color = .{ .color = c }, .fade = 1 }); +} + +/// A round button's whole background: its own disc of glass, lit under the pointer. Call after +/// `processEvents`, in place of `drawBackground`, with the button made by `options`. A toggled-on +/// button is still glass: its icon takes the highlight (`iconColor`). +/// +/// `under_witness` is a signature of what lies under the glass (`witness`); the frost is read again only +/// when it changes. Null re-reads it every frame the app draws. +pub fn background(btn: *dvui.ButtonWidget, under_witness: ?u64) void { + const rs = btn.data().borderRectScale(); + dialogs.glassBubbles(btn.data().id, &.{.{ .rect = rs.r, .light = light(btn) }}, rs.s, .{ .witness = under_witness }); +} + +/// The icon's colour on a glass button: the highlight when toggled on, the text colour otherwise; +/// disabled, most of the way to the content fill rather than see-through (at partial alpha a +/// stroked icon's overlapping caps and joins drew darker than its lines). +pub fn iconColor(active: bool, enabled: bool) dvui.Color { + const theme = dvui.themeGet(); + const color = if (active) theme.color(.highlight, .fill) else theme.color(.content, .text); + return if (enabled) color else color.lerp(theme.color(.content, .fill), 0.65); +} + +/// A witness for glass at `r` (physical) over something whose look `under` signs (a canvas's art, +/// zoom and pan; a pane's scroll; 0 for a plain fill): what is under it, where the glass is, and +/// how glass is drawn. +pub fn witness(r: dvui.Rect.Physical, under: u64) u64 { + var h = std.hash.Wyhash.init(under); + h.update(std.mem.asBytes(&.{ r.x, r.y, r.w, r.h })); + h.update(std.mem.asBytes(&.{ dialogs.style().blur, dvui.themeGet().color(.content, .fill) })); + return h.final(); +} + +/// Button options for a round glass button of `diameter` points: no fill, border or shadow of its +/// own; `background` draws them. +pub fn options(diameter: f32, over: dvui.Options) dvui.Options { + const base: dvui.Options = .{ + .min_size_content = .{ .w = diameter, .h = diameter }, + .expand = .none, + .background = false, + .border = .all(0), + .corners = .round(diameter / 2), + .color_border = .transparent, + .box_shadow = null, + .padding = .all(0), + .margin = .{}, + }; + return base.override(over); +} + +pub const BubbleOptions = struct { + /// What it does, as its tooltip says and a screen reader reads it. + label: []const u8, + enabled: bool = true, + active: bool = false, + /// `witness` of what lies under it; null re-reads it every frame. + witness_under: ?u64 = null, + /// How much of the bubble the icon fills. + icon_share: f32 = 0.34, + id_extra: usize = 0, +}; + +/// A viewport bubble: a round glass button floating at `rect` (natural; `size` square, or smaller +/// while it grows in) with `icon` (tvg) in it and a tooltip. Floats over whatever is drawn there, +/// clipped to the screen it is on (the main window, or a float popped out of it). True on the +/// frame it is clicked. +pub fn bubble(src: std.builtin.SourceLocation, rect: dvui.Rect, icon: []const u8, opts: BubbleOptions) bool { + var fw: dvui.FloatingWidget = undefined; + fw.init(src, .{}, .{ + .rect = rect, + .expand = .none, + .background = false, + .id_extra = opts.id_extra, + }); + defer fw.deinit(); + dvui.clipSet(screens.pixelsFor(.cast(fw.data().rect))); + + var btn: dvui.ButtonWidget = undefined; + btn.init(@src(), .{}, options(@min(rect.w, rect.h), .{ .expand = .both, .label = .{ .text = opts.label } })); + defer btn.deinit(); + if (opts.enabled) btn.processEvents(); + const r = btn.data().borderRectScale().r; + background(&btn, if (opts.witness_under) |u| witness(r, u) else null); + + const color = iconColor(opts.active, opts.enabled); + // White, tinted by the text colour: the icon cache tints a flat colour rather than baking it. + icon_draw.icon(@src(), opts.label, icon, .{ .stroke_color = .white, .fill_color = .white }, .{ + .color_text = .{ .color = color }, + .expand = .ratio, + .gravity_x = 0.5, + .gravity_y = 0.5, + .min_size_content = .{ .w = 1, .h = 1 }, + .padding = .all(@min(rect.w, rect.h) * opts.icon_share), + }); + + widgets.tooltip(@src(), .{ .active_rect = btn.data().rectScale().r }, "{s}", .{opts.label}, .{}); + return opts.enabled and btn.clicked(); +} diff --git a/plugins/image/build.zig b/plugins/image/build.zig index 55caa20ba..2e9045a86 100644 --- a/plugins/image/build.zig +++ b/plugins/image/build.zig @@ -11,5 +11,9 @@ pub fn build(b: *std.Build) void { const optimize = b.standardOptimizeOption(.{}); const plugin = fizzy.plugin.create(b, .{ .target = target, .optimize = optimize }); + + if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| { + plugin.module.addImport("icons", dep.module("icons")); + } fizzy.plugin.install(b, plugin.lib, .{}); } diff --git a/plugins/image/build.zig.zon b/plugins/image/build.zig.zon index d3e0c6229..8427f7b61 100644 --- a/plugins/image/build.zig.zon +++ b/plugins/image/build.zig.zon @@ -15,5 +15,10 @@ .fizzy = .{ .path = "../../sdk", }, + .icons = .{ + .url = "https://github.com/fizzyedit/icons/archive/41e7d2162b121e3798438a7da25ff4648b204dc7.tar.gz", + .hash = "icons-0.1.0-iJxA-Z0TNABnFm_tPfx46ZnvojHweT6xON9ay91XZ4Xo", + .lazy = true, + }, }, } diff --git a/plugins/image/src/ImageView.zig b/plugins/image/src/ImageView.zig index 04bc9caf8..d51372c39 100644 --- a/plugins/image/src/ImageView.zig +++ b/plugins/image/src/ImageView.zig @@ -3,6 +3,7 @@ const std = @import("std"); const dvui = @import("dvui"); const core = @import("core"); const sdk = @import("fizzy_sdk"); +const icons = @import("icons"); const Document = @import("Document.zig"); const CanvasWidget = core.widgets.CanvasWidget; @@ -24,19 +25,39 @@ pub fn draw(doc: *Document) !void { // background, at the app's content opacity, the same as under pixi's canvas). Its edge // shadows are the canvas widget's own. - doc.canvas.install(@src(), .{ - .id = doc.canvas.id, - .data_size = .{ .w = image_rect.w, .h = image_rect.h }, - .pan_zoom_scheme = canvasPanZoomScheme(), - }, .{ .expand = .both, .background = false, .color_fill = .{ .color = .transparent } }); - defer doc.canvas.deinit(); - - drawShadow(&doc.canvas, image_rect); - drawFill(image_rect); - try drawCheckerboard(doc, image_rect); - try drawImage(doc); - drawOutline(&doc.canvas); - doc.canvas.processEvents(); + const view = view: { + doc.canvas.install(@src(), .{ + .id = doc.canvas.id, + .data_size = .{ .w = image_rect.w, .h = image_rect.h }, + .pan_zoom_scheme = canvasPanZoomScheme(), + }, .{ .expand = .both, .background = false, .color_fill = .{ .color = .transparent } }); + defer doc.canvas.deinit(); + + drawShadow(&doc.canvas, image_rect); + drawFill(image_rect); + try drawCheckerboard(doc, image_rect); + try drawImage(doc); + drawOutline(&doc.canvas); + doc.canvas.processEvents(); + break :view doc.canvas.scroll_container.data().contentRectScale().r.toNatural(); + }; + // Outside the canvas: a floating widget made inside it takes the canvas's zoom. + drawFitButton(doc, view); +} + +/// Fit to View as a glass bubble at the viewport's top left, where pixi keeps its canvas controls. +fn drawFitButton(doc: *Document, view: dvui.Rect.Natural) void { + const margin: f32 = 10; + const size = core.widgets.glass_button.size; + if (view.w < size + 2 * margin or view.h < size + 2 * margin) return; + // What is under it: the image, where it is and how big, and which of its frames shows. + var under = std.hash.Wyhash.init(0); + under.update(std.mem.asBytes(&.{ doc.canvas.screen_rect_scale.r.x, doc.canvas.screen_rect_scale.r.y, doc.canvas.screen_rect_scale.s })); + under.update(std.mem.asBytes(&.{ doc.width, doc.height, doc.frame })); + const rect: dvui.Rect = .{ .x = view.x + margin, .y = view.y + margin, .w = size, .h = size }; + if (core.widgets.glass_button.bubble(@src(), rect, icons.tvg.lucide.scan, .{ .label = "Fit to View", .witness_under = under.final() })) { + doc.canvas.fitToView(); + } } fn drawShadow(canvas: *CanvasWidget, image_rect: dvui.Rect) void { diff --git a/plugins/image/static/integration.zig b/plugins/image/static/integration.zig index 1253914b8..74981c730 100644 --- a/plugins/image/static/integration.zig +++ b/plugins/image/static/integration.zig @@ -14,6 +14,7 @@ pub const ModuleImports = struct { core: *std.Build.Module, sdk: *std.Build.Module, proxy_bridge: ?*std.Build.Module = null, + icons: ?*std.Build.Module = null, }; fn applyImports(module: *std.Build.Module, imports: ModuleImports) void { @@ -21,6 +22,7 @@ fn applyImports(module: *std.Build.Module, imports: ModuleImports) void { module.addImport("core", imports.core); module.addImport("fizzy_sdk", imports.sdk); if (imports.proxy_bridge) |proxy_bridge| module.addImport("proxy_bridge", proxy_bridge); + if (imports.icons) |icons| module.addImport("icons", icons); } /// Static `@import("image")` module for exe / web / tests. diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig index 32ed14bc4..4b3225c66 100644 --- a/src/editor/explorer/Explorer.zig +++ b/src/editor/explorer/Explorer.zig @@ -170,11 +170,9 @@ pub fn draw( } fn drawCollapseButton(explorer: *Explorer, editor: *fizzy.Editor) void { - // Styled to match the floating Edit pill (see `Workspace.drawEditPill`): circular - // background, same content.fill / content.text color pair, same drop shadow. - const button_size: f32 = fizzy.core.widgets.round_button_size; - const btn_radius: f32 = button_size / 2; - const margin: f32 = 8; + // A glass bubble, as the controls over a document are (`core.widgets.glass_button`). + const button_size = fizzy.core.widgets.glass_button.size; + const margin: f32 = 12; const wr = dvui.windowRect(); const r = editor.regionFor(fizzy.sdk.keywords.ide.sidebar) orelse return; @@ -196,57 +194,10 @@ fn drawCollapseButton(explorer: *Explorer, editor: *fizzy.Editor) void { const sized = button_size * s; if (sized < 0.5) return; - var fw: dvui.FloatingWidget = undefined; - fw.init(@src(), .{ .mouse_events = true }, .{ - .rect = .{ - .x = wr.w - margin - sized, - .y = wr.h - margin - sized, - .w = sized, - .h = sized, - }, - }); - defer fw.deinit(); - - var bw: dvui.ButtonWidget = undefined; - bw.init(@src(), .{}, .{ - .label = .{ .text = "Collapse Explorer" }, - .expand = .both, - .corners = dvui.CornerRect.all(btn_radius), - .background = true, - .color_fill = .{ .color = dvui.themeGet().color(.content, .fill) }, - .color_fill_hover = .{ .color = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0) }, - .color_border = .transparent, - .padding = .all(0), - .margin = .all(margin), - .min_size_content = .{ .w = button_size, .h = button_size }, - .box_shadow = .{ - .color = .black, - .alpha = 0.2, - .fade = 4, - .offset = .{ .x = 0, .y = 2 }, - .corners = dvui.CornerRect.all(btn_radius), - }, - }); - defer bw.deinit(); - bw.processEvents(); - bw.drawBackground(); - - const icon_color = dvui.themeGet().color(.content, .text); - fizzy.core.icon.icon( - @src(), - "collapse_explorer", - icons.tvg.lucide.@"panel-left-close", - .{ .stroke_color = .{ .color = icon_color }, .fill_color = .{ .color = icon_color } }, - .{ - .expand = .ratio, - .gravity_x = 0.5, - .gravity_y = 0.5, - .min_size_content = .{ .w = 1.0, .h = 1.0 }, - .padding = .all(6), - }, - ); - - if (bw.clicked()) { + // Grows from its own centre. + const centre: dvui.Point = .{ .x = wr.w - margin - button_size / 2, .y = wr.h - margin - button_size / 2 }; + const rect: dvui.Rect = .{ .x = centre.x - sized / 2, .y = centre.y - sized / 2, .w = sized, .h = sized }; + if (fizzy.core.widgets.glass_button.bubble(@src(), rect, icons.tvg.lucide.@"panel-left-close", .{ .label = "Collapse Explorer" })) { explorer.peekClose(editor); } } From 1265f6078e96a60bb32a96ca511793f5b8ab5d3f Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:40:11 -0500 Subject: [PATCH 11/46] sdk: the file tree keeps its own folders; files.reveal shows new files (#449) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of fizzyedit/plans#16 (step 1). ## What changes The file tree keeps what is the tree's own, and stops being reached into through the SDK. Three `Host`/`EditorAPI` entries that existed only for the workbench go. - **Expanded folders are the tree's.** `Explorer.open_branches` moves onto the workbench's `Workbench` instance (`open_branches`, `setBranchOpen`), and `explorerBranchIsOpen`/`setExplorerBranchOpen` go. The instance is shared by both copies of the plugin (static and dylib), which is why the state lives there and not in a module global. Fizzy's demo snapshots read and restore it on `editor.workbench`; plans#16 step 2 moves that behind something the workbench offers. - **A new file is revealed through the `files` service.** `setExplorerNewFilePath` goes. The `files` service (now `service_version = 2`) gains `reveal(path)`: "show this just-made path as a new file". Fizzy's implementation drops the listing cache, because the caller usually wrote the file itself, and emits `fizzy.files.reveal` (declared, `Path` payload). The workbench subscribes and queues the reveal, so the row opens selected, its name in inline rename, with the closing dialog aimed at it, as before. Any other file view could do the same. The workbench's own New Folder sets its pending path directly. - **The tree's stash id is made in its own draw.** `file_tree_data_id` was set and read within one draw, so fizzy clearing it whenever Files was hidden (`resetFileTreeWhenFilesHidden` → `clearFileTreeDataId`) did nothing. It is a local now, and both functions go. Event delivery is queued until the next frame, so a reveal lands one frame later than the direct write did. The tree already waits for the row to be laid out, so nothing visible changes. **Store plugins:** pixi (`src/dialogs/NewFile.zig`) and atlas (`plugin.zig`, New Note) called `setExplorerNewFilePath`; both now call `files.reveal`. Those are local jj changes in their repos, built against this tree's SDK (`zig build --fork=/sdk`), to push with the repin to the release that carries this. No other store plugin, example-app or example-plugin uses the removed entries or the `files` service. **Overlap:** #446 also touches `src/editor/Explorer.zig` (the collapse button). This change only removes `open_branches` and the hidden-tree check there. ## SDK impact - [x] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` For plugin authors: `Host.setExplorerNewFilePath(path)` → `host.getServiceTyped(sdk.services.files.Api)` then `.reveal(path)`. `explorerBranchIsOpen`/`setExplorerBranchOpen` are gone, with no replacement: the tree's open folders are the workbench's own state. A plugin built against `files` service version 1 gets null from `getServiceTyped` on this host (versions are refused, not cast), so it degrades rather than misbehaving. ## Verified - [x] macOS: - **Gates:** `zig build`, `test` (91/91 steps), `test-integration` (53/53 steps), `check-tests-reached`, `test-sdk-version`, `check-web`, `check-fmt`. - **New integration check** (in the whole-editor test): a file written outside the service and passed to `files.reveal` is heard by the tree, then taken once its row is laid out. Pointing the workbench's subscription at another event name makes it fail at `expect(heard)`, so it tests the round trip. - **Plugins:** pixi and atlas build against this SDK. - [ ] Windows: - [ ] Linux: CI - [x] Web: `check-web` Not looked at by eye: the dialog flying into the revealed row. The test covers the tree taking the path, which is the step that re-aims the dialog. ## Follow-ups - plans#16 step 2: the Explorer's file parts move into the workbench, Explorer becomes `SidebarPane`, and demos reach the tree's state through the workbench. - Push pixi's and atlas's changes with the repin PRs after the next SDK release. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- docs/PLUGINS.md | 2 +- plugins/workbench/plugin.zig | 13 ++++++++++ plugins/workbench/src/Workbench.zig | 14 ++++++++--- plugins/workbench/src/files.zig | 32 +++++++++++------------ sdk/src/EditorAPI.zig | 17 ------------- sdk/src/Host.zig | 12 --------- sdk/src/events.zig | 4 +++ sdk/src/services/files.zig | 10 +++++++- sdk/src/version.zig | 2 +- src/editor/Demo.zig | 10 ++++---- src/editor/Editor.zig | 39 +++++------------------------ src/editor/FilesService.zig | 14 +++++++++++ src/editor/SettingsTree.zig | 2 +- src/editor/explorer/Explorer.zig | 17 ------------- tests/integration.zig | 24 ++++++++++++++++++ 15 files changed, 105 insertions(+), 107 deletions(-) diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index e87e0cbd3..bbe1ca89a 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -1174,7 +1174,7 @@ The SDK ships definitions for the services in this ecosystem, in | Service | Provider | What it's for | |---|---|---| -| `"files"` | the application (fizzy: `src/editor/FilesService.zig`) | `createFile` / `createDir` / `rename` / `delete` / `move` on disk, with open documents rewritten or closed in step. Absent on web | +| `"files"` | the application (fizzy: `src/editor/FilesService.zig`) | `createFile` / `createDir` / `rename` / `delete` / `move` on disk, with open documents rewritten or closed in step; `reveal` shows a path just made as a new file (the tree hears `fizzy.files.reveal`), for a plugin that wrote it itself. Absent on web | | `"workbench"` | `workbench` | Only what the thing drawing tabs is the authority on: `currentGrouping` / `newGrouping` (which pane a new document lands in — pass as `host.openFile`'s `grouping`) and `registerBranchDecorator` (draw on every file-tree row) | | `"markdown"` | `markdown` | Render a markdown byte slice into the current dvui parent (native only — absent on web) | | `"wikilink"` | any indexer (e.g. `brain`) | Resolve `[[Note]]` to a file, plus completion candidates and index state | diff --git a/plugins/workbench/plugin.zig b/plugins/workbench/plugin.zig index 8a6b8bc83..117c99eea 100644 --- a/plugins/workbench/plugin.zig +++ b/plugins/workbench/plugin.zig @@ -69,6 +69,11 @@ pub fn register(host: *sdk.Host) !void { .keywords = sdk.keywords.ide.main, .draw = drawCenter, }); + if (comptime has_file_tree) try host.subscribe(.{ + .event = "fizzy.files.reveal", + .owner = &plugin, + .handler = revealNewPath, + }); try host.declareEvent(.{ .name = "workbench.pane.closed", .owner = &plugin, @@ -84,6 +89,14 @@ fn deinit(_: *anyopaque) void { files.deinitCaches(); } +/// A path just made, from a plugin's New flow or the app: the tree reveals it, selected, with its +/// inline rename open, once its row exists. +fn revealNewPath(_: *anyopaque, event: sdk.events.Event) void { + const reveal = sdk.events.parse(sdk.events.Path, runtime.host().arena(), event) catch return; + runtime.workbench().setPendingNewFilePath(reveal.path) catch |err| + dvui.log.err("workbench: could not queue {s} to reveal: {t}", .{ reveal.path, err }); +} + fn drawFiles(_: ?*anyopaque) anyerror!dvui.App.Result { try files.draw(); return .ok; diff --git a/plugins/workbench/src/Workbench.zig b/plugins/workbench/src/Workbench.zig index b522eabd2..e0e195b16 100644 --- a/plugins/workbench/src/Workbench.zig +++ b/plugins/workbench/src/Workbench.zig @@ -48,7 +48,9 @@ grouping_id_counter: u64 = 0, /// was (a hash of its surface id): the gap a strip opens for it while it is carried over one. carried_tab_w: f32 = 0, carried_tab_key: u64 = 0, -file_tree_data_id: ?dvui.Id = null, +/// The file tree's expanded folders, by branch id. Instance state for the same two-copies reason +/// as `pending_new_file_path` below; fizzy's demos save and restore it with the rest of a scene. +open_branches: std.AutoHashMapUnmanaged(dvui.Id, void) = .empty, /// Branch id of the last root the file tree opened by default. A new root starts expanded once; /// after that its open state is the user's (`files.drawRoot`). Instance state for the same /// two-copies reason as `pending_new_file_path` below. @@ -107,6 +109,7 @@ pub fn init(allocator: std.mem.Allocator) Workbench { } pub fn deinit(self: *Workbench) void { + self.open_branches.deinit(self.allocator); self.loading.deinit(self.allocator); self.decorators.deinit(self.allocator); self.clearPendingNewFilePath(); @@ -209,8 +212,13 @@ pub fn newGroupingID(self: *Workbench) u64 { return self.grouping_id_counter; } -pub fn clearFileTreeDataId(self: *Workbench) void { - self.file_tree_data_id = null; +/// Expand or collapse the tree's folder `branch_id`. +pub fn setBranchOpen(self: *Workbench, branch_id: dvui.Id, open: bool) void { + if (open) { + self.open_branches.put(self.allocator, branch_id, {}) catch {}; + } else { + _ = self.open_branches.remove(branch_id); + } } /// A document closed by the user: its tab leaves every pane. Not called at shutdown, where the diff --git a/plugins/workbench/src/files.zig b/plugins/workbench/src/files.zig index 9aabcd2b6..df4ee2784 100644 --- a/plugins/workbench/src/files.zig +++ b/plugins/workbench/src/files.zig @@ -115,14 +115,16 @@ pub fn draw() !void { // closes it; the table answers them all the same way. const folder: ?[]const u8 = runtime.host().folder(); const path = folder orelse { - runtime.workbench().file_tree_data_id = null; runtime.workbench().file_tree_root_opened = null; if (comptime builtin.target.cpu.arch == .wasm32) try drawWebEmpty() else drawNativeEmpty(); return; }; + // Where the tree's per-row stash lives (`dvui.dataSet` under it): made here, each frame it + // draws, so nothing outside the tree has to drop it when the Files view is hidden. + const unique_id = dvui.parentGet().extendId(@src(), 0); const filter_text = try drawFilter(tree); - try drawRoot(path, tree, filter_text); + try drawRoot(path, tree, unique_id, filter_text); } fn drawNativeEmpty() void { @@ -201,7 +203,6 @@ fn drawFilter(tree: *core.widgets.TreeWidget) ![]const u8 { // last frame's draw invalidated while it was still reading them. files.releaseRetired(); - runtime.workbench().file_tree_data_id = dvui.parentGet().extendId(@src(), 0); _ = tree; // Right margin keeps the entry clear of the overlay scrollbar that draws over the pane's right edge. @@ -215,8 +216,7 @@ fn drawFilter(tree: *core.widgets.TreeWidget) ![]const u8 { /// The explorer's one root: the open folder, drawn as an ordinary folder row (see `drawRow`'s /// `root`), then the blank space below the tree. -fn drawRoot(path: []const u8, tree: *core.widgets.TreeWidget, filter_text: []const u8) !void { - const unique_id = runtime.workbench().file_tree_data_id orelse return; +fn drawRoot(path: []const u8, tree: *core.widgets.TreeWidget, unique_id: dvui.Id, filter_text: []const u8) !void { const root_branch_id = tree.data().id.update(path); // A folder the tree hasn't shown yet opens expanded, once. After that its open state is the @@ -224,7 +224,7 @@ fn drawRoot(path: []const u8, tree: *core.widgets.TreeWidget, filter_text: []con const wb = runtime.workbench(); if (wb.file_tree_root_opened != root_branch_id.asUsize()) { wb.file_tree_root_opened = root_branch_id.asUsize(); - runtime.host().setExplorerBranchOpen(root_branch_id, true); + runtime.workbench().setBranchOpen(root_branch_id, true); } carryBack(tree, unique_id); @@ -869,7 +869,7 @@ pub fn recurseFiles(root_directory: []const u8, root_label: []const u8, outer_tr var expanded = false; const expanded_indent: f32 = 14.0; - if (runtime.host().explorerBranchIsOpen(branch_id)) { + if (runtime.workbench().open_branches.contains(branch_id)) { expanded = true; } @@ -974,7 +974,7 @@ pub fn recurseFiles(root_directory: []const u8, root_label: []const u8, outer_tr if (branch.dropInto() and entry.kind == .directory) { try applyFileMove(inner_unique_id, tree, abs_path); // Expand the folder so the dropped item is visible - runtime.host().setExplorerBranchOpen(branch_id, true); + runtime.workbench().setBranchOpen(branch_id, true); } { // Add right click context menu for item options @@ -1161,7 +1161,7 @@ pub fn recurseFiles(root_directory: []const u8, root_label: []const u8, outer_tr // .alpha = 0.15 * t, // }, })) { - runtime.host().setExplorerBranchOpen(branch_id, true); + runtime.workbench().setBranchOpen(branch_id, true); try search( abs_path, tree, @@ -1173,13 +1173,13 @@ pub fn recurseFiles(root_directory: []const u8, root_label: []const u8, outer_tr if (root) |r| r.ranked else null, ); } else { - if (runtime.host().explorerBranchIsOpen(branch_id)) { - runtime.host().setExplorerBranchOpen(branch_id, false); + if (runtime.workbench().open_branches.contains(branch_id)) { + runtime.workbench().setBranchOpen(branch_id, false); } } // Keep open_branches in sync so hover-expand and drop-into expand persist next frame if (branch.expanded) { - runtime.host().setExplorerBranchOpen(branch_id, true); + runtime.workbench().setBranchOpen(branch_id, true); } color_id.* = color_id.* + 1; }, @@ -1292,7 +1292,7 @@ fn appendRowOrder( const abs = core.paths.join(arena, directory, e.name) catch continue; const branch_id = tree_id.update(abs); out.append(arena, .{ .id = branch_id.asUsize(), .path = abs }) catch return; - if (e.kind == .directory and runtime.host().explorerBranchIsOpen(branch_id)) { + if (e.kind == .directory and runtime.workbench().open_branches.contains(branch_id)) { appendRowOrder(arena, tree_id, abs, out); } } @@ -1667,9 +1667,9 @@ fn applyFileMove(unique_id: dvui.Id, tree: *core.widgets.TreeWidget, target_dir: /// "New Folder..." from either context menu (the project row and a folder row run the same /// code): create the folder, then hand the tree its path so the row that appears next frame -/// opens its inline rename editor with the name selected — the same handoff a New Document -/// dialog makes through `EditorAPI.setExplorerNewFilePath`. Both now land on the shared -/// `Workbench` instance, so it no longer matters which copy of this module makes the call. +/// opens its inline rename editor with the name selected — what a plugin's New flow asks for +/// through the `files` service's `reveal`, heard here as `fizzy.files.reveal`. Both land on the +/// shared `Workbench` instance, so it does not matter which copy of this module makes the call. /// A folder that could not be created focuses nothing. pub fn createFolderInteractive(parent: []const u8) void { const arena = dvui.currentWindow().arena(); diff --git a/sdk/src/EditorAPI.zig b/sdk/src/EditorAPI.zig index 71fd4f69e..6ded8415c 100644 --- a/sdk/src/EditorAPI.zig +++ b/sdk/src/EditorAPI.zig @@ -350,9 +350,6 @@ pub const VTable = struct { /// `fizzy.files.changed` can be relied on to be emitted. False with no folder open, /// on a platform with no watcher backend, or when starting one failed. folderWatchActive: *const fn (ctx: *anyopaque) bool, - /// Explorer tree branch expanded state. - explorerBranchIsOpen: *const fn (ctx: *anyopaque, branch_id: dvui.Id) bool, - setExplorerBranchOpen: *const fn (ctx: *anyopaque, branch_id: dvui.Id, open: bool) void, /// Draw workspace panes (center region); `index` is the root pane (usually 0). drawWorkspaces: *const fn (ctx: *anyopaque, index: usize) anyerror!dvui.App.Result, /// Native open-folder dialog (no-op on web). @@ -376,8 +373,6 @@ pub const VTable = struct { allocUntitledPath: *const fn (ctx: *anyopaque) anyerror![]u8, /// Create and open a new document at `path` (path ownership transfers to fizzy). createDocument: *const fn (ctx: *anyopaque, path: []const u8, grid: NewDocGrid) anyerror!DocHandle, - /// Hint the files tree to scroll/highlight a path just created (e.g. New File dialog). - setExplorerNewFilePath: *const fn (ctx: *anyopaque, path: []const u8) anyerror!void, // ---- saving ---- /// How a document's saving stands, as fizzy runs it (`SaveState`): for a tab's or a file @@ -687,14 +682,6 @@ pub fn folderWatchActive(self: EditorAPI) bool { return self.vtable.folderWatchActive(self.ctx); } -pub fn explorerBranchIsOpen(self: EditorAPI, branch_id: dvui.Id) bool { - return self.vtable.explorerBranchIsOpen(self.ctx, branch_id); -} - -pub fn setExplorerBranchOpen(self: EditorAPI, branch_id: dvui.Id, open: bool) void { - self.vtable.setExplorerBranchOpen(self.ctx, branch_id, open); -} - pub fn drawWorkspaces(self: EditorAPI, index: usize) !dvui.App.Result { return self.vtable.drawWorkspaces(self.ctx, index) catch |err| self.failed("drawWorkspaces", err); } @@ -733,10 +720,6 @@ pub fn createDocument(self: EditorAPI, path: []const u8, grid: NewDocGrid) !DocH return self.vtable.createDocument(self.ctx, path, grid) catch |err| self.failed("createDocument", err); } -pub fn setExplorerNewFilePath(self: EditorAPI, path: []const u8) !void { - return self.vtable.setExplorerNewFilePath(self.ctx, path) catch |err| self.failed("setExplorerNewFilePath", err); -} - pub fn saveState(self: EditorAPI, doc_id: u64) SaveState { return self.vtable.saveState(self.ctx, doc_id); } diff --git a/sdk/src/Host.zig b/sdk/src/Host.zig index 796da3f91..beb299509 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -721,14 +721,6 @@ pub fn folderWatchActive(self: *Host) bool { return if (self.fizzy_api) |a| a.folderWatchActive() else false; } -pub fn explorerBranchIsOpen(self: *Host, branch_id: dvui.Id) bool { - return if (self.fizzy_api) |a| a.explorerBranchIsOpen(branch_id) else false; -} - -pub fn setExplorerBranchOpen(self: *Host, branch_id: dvui.Id, open: bool) void { - if (self.fizzy_api) |a| a.setExplorerBranchOpen(branch_id, open); -} - pub fn drawWorkspaces(self: *Host, index: usize) !dvui.App.Result { return if (self.fizzy_api) |a| try a.drawWorkspaces(index) else .ok; } @@ -783,10 +775,6 @@ pub fn createDocument(self: *Host, path: []const u8, grid: EditorAPI.NewDocGrid) return if (self.fizzy_api) |a| try a.createDocument(path, grid) else error.FizzyApiNotInstalled; } -pub fn setExplorerNewFilePath(self: *Host, path: []const u8) !void { - return if (self.fizzy_api) |a| try a.setExplorerNewFilePath(path) else error.FizzyApiNotInstalled; -} - /// How a document's saving stands (`EditorAPI.SaveState`): for an indicator. Not saving and /// never saved when fizzy isn't installed. pub fn saveState(self: *Host, doc_id: u64) EditorAPI.SaveState { diff --git a/sdk/src/events.zig b/sdk/src/events.zig index 1ea8aff7c..8b282f5ed 100644 --- a/sdk/src/events.zig +++ b/sdk/src/events.zig @@ -72,6 +72,10 @@ pub fn parse(comptime T: type, arena: std.mem.Allocator, event: Event) !T { // `doc.owner.documentBytes(doc, path, allocator)`. // // `fizzy.files.changed`: files under the project folder changed on disk, `FilesChanged`. +// +// `fizzy.files.reveal`: a path was just made and should be shown the way a new file is: revealed +// in whatever lists the project's files, selected, its name open for editing. `Path`. Emitted by +// fizzy's `files` service (`reveal`), for a plugin's New flow or its own. /// The payload of fizzy's folder and document events. pub const Path = struct { diff --git a/sdk/src/services/files.zig b/sdk/src/services/files.zig index 896bd3a24..85f7c50c8 100644 --- a/sdk/src/services/files.zig +++ b/sdk/src/services/files.zig @@ -24,7 +24,7 @@ const std = @import("std"); pub const Api = struct { /// Bump whenever this struct's layout changes: `getServiceTyped` refuses a provider whose /// version differs rather than reinterpreting one shape as another across `dlopen`. - pub const service_version: u32 = 1; + pub const service_version: u32 = 2; pub const service_name = "files"; ctx: *anyopaque, @@ -45,6 +45,11 @@ pub const Api = struct { /// Move `path` into `target_dir`, keeping its basename. False when it is already there — /// a drop onto the folder a file is already in is a no-op, not a failure. move: *const fn (ctx: *anyopaque, path: []const u8, target_dir: []const u8) anyerror!bool, + /// Show `path`, just made, the way a new file is shown: whatever lists the project's files + /// reveals it, selected, with its name open for editing (fizzy emits `fizzy.files.reveal`). + /// For a path the caller wrote itself rather than through this service, too: the listing + /// is refreshed first, so the row exists to reveal. + reveal: *const fn (ctx: *anyopaque, path: []const u8) void, }; pub fn createFile(self: Api, path: []const u8) !void { @@ -62,4 +67,7 @@ pub const Api = struct { pub fn move(self: Api, path: []const u8, target_dir: []const u8) !bool { return self.vtable.move(self.ctx, path, target_dir); } + pub fn reveal(self: Api, path: []const u8) void { + self.vtable.reveal(self.ctx, path); + } }; diff --git a/sdk/src/version.zig b/sdk/src/version.zig index d88c55561..d2df627b7 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -75,7 +75,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the /// next SDK release. -pub const recorded_sdk_shape_fingerprint: u64 = 0xe988ac8f4b568950; +pub const recorded_sdk_shape_fingerprint: u64 = 0x92a6bd46850d47c3; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/src/editor/Demo.zig b/src/editor/Demo.zig index df1fb3ee2..1ca73df43 100644 --- a/src/editor/Demo.zig +++ b/src/editor/Demo.zig @@ -449,7 +449,7 @@ fn keyframe(ctx: *anyopaque, kf: *const automation.Tape.Keyframe) void { }; // The tree as if never seen: folders closed, the root opening on its own. - editor.explorer.open_branches.clearRetainingCapacity(); + editor.workbench.open_branches.clearRetainingCapacity(); editor.workbench.file_tree_root_opened = null; editor.setProjectFolder(kf.root) catch |err| dvui.log.err("demo: could not open {s}: {t}", .{ kf.root, err }); editor.app.host.setSelectionFor(sdk.keywords.ide.sidebar, Editor.workbench_files_view); @@ -738,8 +738,8 @@ fn fill(self: *Demo, snap: *Snapshot, root: []const u8, mem: *core.vfs.Mem) !boo } snap.docs = docs.items; - const branches = try a.alloc(dvui.Id, editor.explorer.open_branches.count()); - var it = editor.explorer.open_branches.keyIterator(); + const branches = try a.alloc(dvui.Id, editor.workbench.open_branches.count()); + var it = editor.workbench.open_branches.keyIterator(); var i: usize = 0; while (it.next()) |id| : (i += 1) branches[i] = id.*; snap.branches = branches; @@ -798,8 +798,8 @@ fn restore(ctx: *anyopaque, raw: *anyopaque) bool { if (snap.explorer_closed != editor.explorer.closed) { if (snap.explorer_closed) editor.explorer.close(editor) else editor.explorer.open(editor); } - editor.explorer.open_branches.clearRetainingCapacity(); - for (snap.branches) |id| editor.explorer.open_branches.put(id, {}) catch {}; + editor.workbench.open_branches.clearRetainingCapacity(); + for (snap.branches) |id| editor.workbench.setBranchOpen(id, true); editor.workbench.file_tree_root_opened = snap.root_opened; for (snap.settings) |saved| { diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 9533377c1..248f2d50a 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -625,7 +625,7 @@ pub fn init( fizzy.backend.setTitlebarColor(dvui.currentWindow(), dvui.themeGet().color(.content, .fill).opacity(if (dvui.themeGet().dark) editor.app.settings.window_opacity_dark else editor.app.settings.window_opacity_light)); - editor.explorer.* = .init(); + editor.explorer.* = .{}; editor.app.open_files = .empty; try editor.workbench.initDefaultWorkspace(); @@ -2083,6 +2083,11 @@ pub fn postInit(editor: *Editor) !void { .description = "Files under the project folder changed on disk (coalesced, ignore rules applied); truncated means rescan.", .payload = ".{ .events = .{ .{ .path = \"/abs\", .kind = .created, .object = .file, .old_path = \"\" } }, .truncated = false }", }) catch {}; + editor.app.host.declareEvent(.{ + .name = "fizzy.files.reveal", + .description = "A path was just made: show it as a new file, revealed and selected with its name open for editing.", + .payload = ".{ .path = \"/absolute/path\" }", + }) catch {}; // Publish the shared file set. Same reason as the commands above: `env.ctx` is this // editor's final address. The table answers three questions it can't itself — where the @@ -2370,8 +2375,6 @@ const fizzy_api_vtable: sdk.EditorAPI.VTable = .{ .openInFileBrowser = fizzyOpenInFileBrowser, .isPathIgnored = fizzyIsPathIgnored, .folderWatchActive = fizzyFolderWatchActive, - .explorerBranchIsOpen = fizzyExplorerBranchIsOpen, - .setExplorerBranchOpen = fizzySetExplorerBranchOpen, .drawWorkspaces = fizzyDrawWorkspaces, .showOpenFolderDialog = fizzyShowOpenFolderDialog, .showOpenFileDialog = fizzyShowOpenFileDialog, @@ -2380,7 +2383,6 @@ const fizzy_api_vtable: sdk.EditorAPI.VTable = .{ .refresh = fizzyRefresh, .allocUntitledPath = fizzyAllocUntitledPath, .createDocument = fizzyCreateDocument, - .setExplorerNewFilePath = fizzySetExplorerNewFilePath, .saveState = fizzySaveState, .logLine = fizzyLogLine, .panic = fizzyPanic, @@ -2656,17 +2658,6 @@ fn fizzyIsPathIgnored( ) bool { return fizzyCtx(ctx).ignore.isIgnored(project_root, abs_path, name, kind); } -fn fizzyExplorerBranchIsOpen(ctx: *anyopaque, branch_id: dvui.Id) bool { - return fizzyCtx(ctx).explorer.open_branches.contains(branch_id); -} -fn fizzySetExplorerBranchOpen(ctx: *anyopaque, branch_id: dvui.Id, open: bool) void { - const editor = fizzyCtx(ctx); - if (open) { - editor.explorer.open_branches.put(branch_id, {}) catch {}; - } else { - _ = editor.explorer.open_branches.remove(branch_id); - } -} fn fizzyDrawWorkspaces(ctx: *anyopaque, index: usize) anyerror!dvui.App.Result { return fizzyCtx(ctx).workbench.drawWorkspaces(index); } @@ -2714,17 +2705,6 @@ fn fizzyAllocUntitledPath(ctx: *anyopaque) anyerror![]u8 { fn fizzyCreateDocument(ctx: *anyopaque, path: []const u8, grid: sdk.EditorAPI.NewDocGrid) anyerror!sdk.DocHandle { return fizzyCtx(ctx).newFile(path, grid); } -fn fizzySetExplorerNewFilePath(ctx: *anyopaque, path: []const u8) anyerror!void { - const editor = fizzyCtx(ctx); - try editor.workbench.setPendingNewFilePath(path); - // A plugin reaching this has just written `path` with its own save routine, not through - // `files.createFilePath`, so nothing has dropped the shared listing cache and that cache - // still predates the file. Left stale, the row never appears on the next frame: the match in - // `files.zig` never runs, no inline rename opens, and the dialog still closing over the top - // of it has no row to fly into. This is the only thing standing between "the file exists" - // and "the user can see and rename it". - editor.app.file_table.invalidateAll(); -} fn fizzySaveState(ctx: *anyopaque, doc_id: u64) sdk.EditorAPI.SaveState { return fizzyCtx(ctx).doc_io.state(doc_id); } @@ -2988,12 +2968,6 @@ pub fn activeDoc(editor: *Editor) ?sdk.DocHandle { return editor.workbench.activeDoc(); } -/// Files sidebar inactive — drop the tree's dvui stash id, which the tree sets again each frame -/// it draws. -pub fn resetFileTreeWhenFilesHidden(editor: *Editor) void { - editor.workbench.clearFileTreeDataId(); -} - /// Draws whichever center provider is active, blur-fading when that changes. /// /// Swapping providers replaces the entire center subtree, and each provider paints its own pane @@ -6325,7 +6299,6 @@ pub fn deinit(editor: *Editor) !void { } editor.app.settings.deinit(editor.app.gpa); - editor.explorer.deinit(); editor.app.gpa.destroy(editor.explorer); PluginStore.deinit(); diff --git a/src/editor/FilesService.zig b/src/editor/FilesService.zig index f7bb00085..096da184f 100644 --- a/src/editor/FilesService.zig +++ b/src/editor/FilesService.zig @@ -40,6 +40,7 @@ const vtable: sdk.services.files.Api.VTable = .{ .rename = rename, .delete = delete, .move = move, + .reveal = reveal, }; /// Create an empty file at `path`. @@ -227,6 +228,19 @@ fn move(ctx: *anyopaque, path: []const u8, target_dir: []const u8) anyerror!bool return true; } +/// Show `path` the way a new file is shown, through whatever lists the project's files: the file +/// tree hears `fizzy.files.reveal` and reveals it, selected, with its name open for editing. +/// +/// A plugin calling this has usually just written `path` with its own save routine, not through +/// `createFile`, so nothing has dropped the shared listing cache and it still predates the file. +/// Left stale, the row never appears: the tree has nothing to reveal, no rename opens, and a dialog +/// still closing over it has no row to fly into. So the listing goes first. +fn reveal(ctx: *anyopaque, path: []const u8) void { + const self = hostOf(ctx); + if (self.files) |files| files.invalidateAll(); + self.emitZon("fizzy.files.reveal", sdk.events.Path{ .path = path }); +} + /// Whether `child` sits strictly below `ancestor`, comparing whole path components so that /// `src/files2` is not treated as living inside `src/files`. fn isStrictPathDescendant(child: []const u8, ancestor: []const u8) bool { diff --git a/src/editor/SettingsTree.zig b/src/editor/SettingsTree.zig index c37e21718..33d302eda 100644 --- a/src/editor/SettingsTree.zig +++ b/src/editor/SettingsTree.zig @@ -46,7 +46,7 @@ const fizzy_branch_title = @import("app").AppInfo.current.display_name; /// made a collapsed plugin flash open on first draw. Second, `Branch` rewrites its own /// `_expanded` dvui data every frame, so force-expanding during a search would destroy the user's /// hand-picked state with nothing left to restore; a separate map (the same thing the file tree -/// does with `Explorer.open_branches`) lets a search pass `expanded = true` freely as long as it +/// does with `Workbench.open_branches`) lets a search pass `expanded = true` freely as long as it /// doesn't *write back*. A path key also survives rows being reordered by relevance. var open_branches: std.AutoHashMapUnmanaged(u64, void) = .empty; diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig index 4b3225c66..9bbe3f280 100644 --- a/src/editor/explorer/Explorer.zig +++ b/src/editor/explorer/Explorer.zig @@ -27,22 +27,10 @@ scroll_info: dvui.ScrollInfo = .{ }, rect: dvui.Rect = .{}, rect_screen: dvui.Rect.Physical = .{}, -open_branches: std.AutoHashMap(dvui.Id, void) = undefined, animations_ratio: f32 = 0.5, closed: bool = false, collapse_btn_anim_started: bool = false, -pub fn init() Explorer { - return .{ - .open_branches = .init(fizzy.entry().allocator), - }; -} - -pub fn deinit(self: *Explorer) void { - // TODO: Free memory - self.open_branches.deinit(); -} - pub fn close(explorer: *Explorer, editor: *fizzy.Editor) void { explorer.closed = true; if (editor.regionFor(fizzy.sdk.keywords.ide.sidebar)) |r| r.close(); @@ -132,11 +120,6 @@ pub fn draw( // place actually shows, which is the only reading that can never say that. const shown = f.selected(keywords); - if (comptime workbench.has_file_tree) { - const showing_files = if (shown) |s| std.mem.eql(u8, s.id, fizzy.Editor.workbench_files_view) else false; - if (!showing_files) editor.resetFileTreeWhenFilesHidden(); - } - if (shown) |surface| { // Through the layout's swap: choosing another view on the rail blurs from one body to // the next, as every region does. The body's own box, so the capture of the outgoing diff --git a/tests/integration.zig b/tests/integration.zig index 0cbe048a8..85cea4d10 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7480,6 +7480,30 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try expectEmptyFolder(dir); } + // A plugin's New flow writes its file itself and asks the `files` service to show it + // (`reveal`): fizzy emits `fizzy.files.reveal`, and the tree, which hears it, reveals the row + // with its name open for editing. The tree holds the path until the row is laid out, then + // takes it, so heard-then-taken is the whole round trip. + { + try editor.setProjectFolder(project); + editor.explorer.open(editor); + editor.app.host.setSelectionFor(sdk.keywords.ide.sidebar, fizzy.Editor.workbench_files_view); + try tmp.dir.writeFile(dvui.io, .{ .sub_path = "project/made-by-a-plugin.md", .data = "" }); + const made = try std.fs.path.join(gpa, &.{ project, "made-by-a-plugin.md" }); + defer gpa.free(made); + const files_api = editor.app.host.getServiceTyped(sdk.services.files.Api) orelse return error.TestUnexpectedResult; + files_api.reveal(made); + var heard = false; + for (0..200) |_| { + _ = try dvui.testing.step(headlessFrame); + if (editor.workbench.pending_new_file_path) |pending| { + if (std.mem.eql(u8, pending, made)) heard = true; + } else if (heard) break; + } + try std.testing.expect(heard); + try std.testing.expect(editor.workbench.pending_new_file_path == null); + } + // A quit straight after a pane slid shut (`snapshotClosingPane`): the pane still holds its // picture, and the quit comes after the last frame, when nothing can be freed at a frame's end. { From 760ea136ae9129fc4b9e33bcd0cecb50a4e66314 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:40:27 -0500 Subject: [PATCH 12/46] core: a row carried out of a tree is the OS's glass, and stays still (#451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No plan issue: a bug report from pixi's layer list (2026-10-15). Dragging a layer row out of the list kept something animating for as long as it was held, and the carried row was not the glass a tab or a file is when it is carried. ## What changes **The loop.** The slot a lifted tree row leaves behind kept the row's width from the moment it was lifted. While a row is dragged, pixi's list adds a drop row below the last one. That makes the list taller, its vertical scroll bar comes in and narrows the list, and the slot overflows it sideways. The horizontal bar that follows changes the list's height. The layers/palettes split, which fits that height with a slide, slides, the vertical bar goes, and round again. The slot now takes the list's width where the row did (`expand` horizontal) and keeps only the row's height. **The glass.** `TreeWidget`'s floating row always drew the app's frost (`dialogs.carriedGlass`). Every other carried thing goes through `ViewDrag`, which on macOS 26 is the OS's Liquid Glass in fizzy's carry overlay. The new `dialogs.carriedCard` gives a carried thing drawn in a floating widget of its own the same treatment: - Where the OS offers its glass outside a view drag (`native_glass.offered`, as pixi's dropper magnifier already uses), the card is declared to it and the layer goes over the glass (`screens.markCarried`). - Elsewhere it falls back to `carriedGlass`, as before. pixi draws nothing of its own for the carried layer; the row it carries is core's. So the fix reaches pixi, with no change in pixi, when the next SDK release repins it. ## SDK impact - [ ] None - [x] Core-only or additive: reaches plugins at the next SDK release (the fingerprint does not move; `test-sdk-version` passes) - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS (26, Debug): a sandbox profile running this build, with pixi built `--fork` against it. A sprite with three layers (`pixi.create`, `pixi.addLayer` ×2); a layer row dragged out over the canvas and held still for 3 s, measured with fizzy's profiler (`fizzy_profile` through the agent plugin): - before: the docking split's fit slide (`DockingWidget.zig:542`) asked for every one of 202 frames, the list's scroll-drag (`ScrollContainerWidget.zig:418`) and overscroll bounce (`:209`) about 50 each, plus ~100 min-size refreshes. Captures 0.4 s apart showed the scroll bars coming and going and the rows shifting sideways. - after: the only frames left come from the test driver holding the pointer (`LiveDriver`), and the captures are byte-identical. - glass: a screen capture mid-drag shows the carried row as the OS's Liquid Glass capsule, refracting what is under it, with the row's content over it. Released, the row drops back into the list. - `check-fmt`, `zig build test`, `test-integration` (53/53 steps), `check-web`, `test-sdk-version`. - [ ] Windows: not run. There is no `native_glass.offered` there, so `carriedCard` is `carriedGlass` as before. - [ ] Linux: not run (CI). Same as Windows. - [ ] Web: `check-web` builds; not run in a browser. Same path as Windows. ## Follow-ups - `ReorderWidget`'s floating item still carries a plain box shadow rather than a carried card. It is a candidate for `carriedCard` too; left out to keep this change to the tree. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- core/dialogs.zig | 16 ++++++++++++++++ core/widgets/TreeWidget.zig | 16 ++++++++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/core/dialogs.zig b/core/dialogs.zig index f67fca439..2ce2a2c12 100644 --- a/core/dialogs.zig +++ b/core/dialogs.zig @@ -23,6 +23,7 @@ const Snapshot = @import("replay").Snapshot; const anim = @import("anim.zig"); const draw = @import("draw.zig"); const screens = @import("screens.zig"); +const native_glass = @import("native_glass.zig"); /// Core-owned dialog chrome state, set by the dialog framework and read by /// fizzy so core stays decoupled from the editor. When a modal is open fizzy @@ -188,6 +189,21 @@ pub fn carriedGlass(id: dvui.Id, r: dvui.Rect.Physical, scale: f32) void { glassShadow(r, corners, scale, surfaceShadow(), 1); } +/// A carried thing's glass where it is drawn in a floating widget of its own (`layer`, its id): +/// the OS's where the OS draws glass declared outside a view drag (`native_glass.offered`: macOS +/// 26), as a view's card carried there is, and what the layer holds goes over that glass in the +/// OS's overlay (`screens.markCarried`), drawn in none of the app's own windows; elsewhere +/// `carriedGlass`. A row lifted out of a list carried this way is the same glass a tab or a file +/// is when it is carried. +pub fn carriedCard(layer: dvui.Id, r: dvui.Rect.Physical, scale: f32) void { + if (native_glass.offered()) { + native_glass.add(.{ .rect = r, .radius = @min(rounding.scaled(rounding.card) * scale, @min(r.w, r.h) / 2) }); + screens.markCarried(layer); + return; + } + carriedGlass(layer, r, scale); +} + /// Whether carried things are shown in windows of their own this run (fizzy's float windows on /// macOS): a carried view's photograph is taken without the place's background, its content over /// the window's material — set by the app before anything draws. diff --git a/core/widgets/TreeWidget.zig b/core/widgets/TreeWidget.zig index cdc236063..2c83af172 100644 --- a/core/widgets/TreeWidget.zig +++ b/core/widgets/TreeWidget.zig @@ -487,8 +487,16 @@ pub const Branch = struct { // Reserve the original slot so tree shape does not collapse; row chrome is drawn only // in the floating subwindow below (same for primary and secondary multi-drag rows). + // As tall as the row was, and as wide as the list is now where the row took the + // list's width: held at the row's width when it was lifted, a list that narrowed + // under the drag (its scroll bar coming in for the drop row below the last) was + // pushed wider than itself, and its scroll bars and a pane fitting it chased each + // other for as long as the row was held. self.wd = WidgetData.init(self.wd.src, .{}, wrapOuter(self.options).override(.{ - .min_size_content = drag_min, + .min_size_content = .{ + .w = if (self.options.expandGet().isHorizontal()) 0 else drag_min.w, + .h = drag_min.h, + }, })); self.wd.register(); dvui.parentSet(self.widget()); @@ -504,8 +512,8 @@ pub const Branch = struct { var npt = dp.plus(dvui.dragOffset().plus(.{ .x = 5, .y = 5 })).toNatural(); npt.y += @as(f32, @floatFromInt(stack_i)) * (drag_min.h + row_gap); - // Carried, a row is glass, as a tab carried along its strip is - // (`dialogs.carriedGlass`). (A list whose rows another drag can carry hands them + // Carried, a row is glass, as a tab or a view carried is + // (`dialogs.carriedCard`: the OS's glass where it is offered). (A list whose rows another drag can carry hands them // to it as they are lifted, and reads them back with `carriedOver`: fizzy's file // tree, a file to the app's view drag.) As big as the row was when it was lifted: it is // held where it was grabbed (`dragOffset`), and sized to its name instead, a short @@ -527,7 +535,7 @@ pub const Branch = struct { // window: carried in a float popped out of it, it was clipped away. dvui.clipSet(screens.pixelsFor(.cast(fd.rect))); const frs = fd.borderRectScale(); - dialogs.carriedGlass(fd.id, frs.r, frs.s); + dialogs.carriedCard(fd.id, frs.r, frs.s); } else if (source or self.tree.carried_depth > 0) { // Carried back over the tree: the row stays in its place, faded, and neither it // nor anything inside it takes the drop. From 0dfdb017bb11153b97f92015be9c96af1d1849c7 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:40:46 -0500 Subject: [PATCH 13/46] markdown: an inline render no longer shrinks for a frame as it changes (#454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chat streaming a reply in (fizzyedit/chat) jittered: the conversation dropped by a line and came back, many times a turn. The cause is in the markdown service's inline render (`drawInline`, which a chat bubble uses). `renderTopLevel` pins a block's emitted height to its height-table entry. That keeps a virtualized preview still while blocks come back from spacers. `drawInline` passes no viewport, so nothing is virtualized and every block is laid out every frame, but the pin still applied. A streamed reply changes its text on every chunk, and the chat's type-in changes it on every frame. Each change re-parses, which clears the table: 1. Frame A: the edited block has no entry and is laid out at its real height. If its text layout is new (a paragraph that just started), the measurement is filed `.deferred`, which keeps the estimate. 2. Frame B: the block is pinnable (`.deferred`), so the pin emits the estimate, which is a line or so short. 3. Frame C: real again. The caller sees H, then H minus a line, then H. With the chat following the bottom, the whole conversation moved down by that line and back. The fix gates both pins on `virtualize`, so a render with no viewport emits what it lays out. The preview pane, which always has a viewport, is unchanged. How it was seen: fizzy from main (Debug, macOS) in a sandbox profile, with this markdown dylib in place of the bundled one. A temporary driver in the chat plugin streamed 8 scripted turns, in 4 to 40 byte chunks every 20 to 140 ms. A per-frame log recorded each message row's rect, min size and the scroll position. | over 20 s of streaming | before | after | |---|---|---| | frames the conversation moved back down | 15 | 0 | | content-height changes that were shrinks | 22 of 78 | 0 of 36 | | frames a message's row was a frame behind its own size | 75 | 35 (only growth, one frame late, as dvui lays out) | Example, one reply's row height across three frames with the same text: 98.1, then 80.0, then 98.1 before; 98.1 throughout after. Test: the headless integration test's inline-render check now streams a reply in 9 bytes a frame and fails if the height ever drops. Without the fix it fails ("shrank from 36.4 to 19.6 at byte 18"); with it, `zig build test-integration` passes 53/53 steps, and so do `zig build test` and `scripts/check-fmt.sh`. `check-web`, `test-sdk-version` and the full app build were left to CI: no SDK or build file changes. No plan step; a fix found while measuring the chat. The chat's own resize fixes are fizzyedit/chat#14 and don't depend on this. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- plugins/markdown/src/md/render_ast.zig | 12 ++++++++++-- tests/integration.zig | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/plugins/markdown/src/md/render_ast.zig b/plugins/markdown/src/md/render_ast.zig index 8a13d0fd7..795dca39a 100644 --- a/plugins/markdown/src/md/render_ast.zig +++ b/plugins/markdown/src/md/render_ast.zig @@ -1949,8 +1949,16 @@ fn renderTopLevel(doc_node: ast.Node, ids: *IdGen, ctx: RenderContext) void { // everything below jumps, and next frame's anchor "corrects" it — the flash that // only happens when scrolling up. Pin the *emitted* height to the table; `record` still // learns the real size from the unconstrained inner box (see `measured` below). - const pin_for_above = known_h > 0 and !width_in_flux and y <= anchor_y; - const pin_h: ?f32 = if (known_h > 0 and !width_in_flux and + // + // Only a virtualized render pins. With no viewport (`drawInline`: a chat bubble, a + // tooltip) every block is laid out every frame, so no block comes back from a spacer and + // there is nothing for a pin to hide. There a pin does harm: an edit re-parses, which + // leaves the edited block with an estimate (biased low) or the deferred height its first + // frame filed, and next frame's pin emits that in place of its real height. The caller + // sees the block shrink by a line and grow back; a chat streaming a reply re-parses on + // every chunk, so its conversation bounced by a line each time. + const pin_for_above = virtualize and known_h > 0 and !width_in_flux and y <= anchor_y; + const pin_h: ?f32 = if (virtualize and known_h > 0 and !width_in_flux and (pin_for_above or (frozen and block_state != .estimated) or pinnable)) known_h else null; diff --git a/tests/integration.zig b/tests/integration.zig index 85cea4d10..4654316d8 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7281,6 +7281,24 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes for (0..3) |_| _ = try dvui.testing.step(inlineMarkdownFrame); try std.testing.expect(inline_md_height > one_para * 2); inline_md.?.release(0x1d1e); + + // Streamed in a few bytes a frame, as a chat reply arrives, it only ever grows. Each + // re-parse left the edited block with an estimate that a pin then emitted for a frame, so + // a streaming reply shrank by a line and grew back, over and over. + const reply = "Sure. Let me look at how the view lays out a message while it streams, because that is where the motion comes from.\n\nThe **scroll area** sizes its content from last frame's sizes, and a message that grows by a line is a frame late.\n\n- one point\n- and a second that goes on long enough to wrap at this width, and then some more"; + var last: f32 = 0; + var n: usize = 0; + while (n < reply.len) { + n = @min(reply.len, n + 9); + inline_md_text = reply[0..n]; + _ = try dvui.testing.step(inlineMarkdownFrame); + if (inline_md_height + 0.5 < last) { + std.debug.print("inline markdown shrank from {d:.1} to {d:.1} at byte {d}\n", .{ last, inline_md_height, n }); + return error.TestUnexpectedResult; + } + last = inline_md_height; + } + inline_md.?.release(0x1d1e); } // The `plugins` service: what each plugin's loading did. A built-in is loaded; a reload that From c45686c77926bb3a76cbd99c3a5e45dc03a7d0bf Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:41:29 -0500 Subject: [PATCH 14/46] ci: compiled shaders are built in CI and checked against their HLSL (#405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compiles the SPIR-V and DXIL that fizzy's native backend embeds (Vulkan, D3D12) from their HLSL in CI, and fails when what is committed is not what the HLSL compiles to, uploading the fresh files as an artifact to commit. **Why.** Nothing noticed when an HLSL changed and its compiled files did not: Windows and Linux quietly drew the previous glass after #227 (fixed by hand in #242), and again since #373, whose shader changes are in the HLSL but not in the DXIL or SPIR-V. Its first run here found worse: main's glass HLSL has not compiled since #373 (`float3(x)` from one scalar is Metal and GLSL, not HLSL). Fixed here (`(float3)x`). **The toolchain** is SDL_shadercross at `1ff05bec`, with the DirectXShaderCompiler release it pins itself (Microsoft's prebuilt v1.9.2602, by SHA-256), built as shadercross's own CI builds it: only SPIRV-Cross and shadercross, a few minutes, cached by the pin. With it the backend's default shaders, unchanged since they were compiled, reproduce byte for byte, SPIR-V and DXIL: the committed files were made with this compiler. (An earlier run built shadercross's vendored DXC from source instead, half an hour: its SPIR-V matched, its DXIL differed in DXC's version string.) **The glass** SPIR-V and DXIL are compiled again from #373's HLSL (with the fix above). The DXIL's interface (input and output signatures, pipeline state, feature flags) is identical to the old one's, so the backend's pipeline for it is unchanged. **First step of making the glass one source.** Next, in its own PR: the glass written once, in HLSL, its Metal and WebGL GLSL generated from it by SPIRV-Cross and checked by this same job, so the forms cannot drift apart as they did here. **Not seen on Windows or Linux yet**: the new glass on D3D12 and Vulkan wants a look on those machines. `zig build` and `zig build check-web` pass on macOS. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/shaders.yml | 144 ++++++++++++++++++ .../compiled/dxil/liquid_glass.fragment.dxil | Bin 8444 -> 9072 bytes .../compiled/spv/liquid_glass.fragment.spv | Bin 13412 -> 15488 bytes core/gfx/shaders/liquid_glass.fragment.hlsl | 2 +- 4 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/shaders.yml diff --git a/.github/workflows/shaders.yml b/.github/workflows/shaders.yml new file mode 100644 index 000000000..b6e740a3e --- /dev/null +++ b/.github/workflows/shaders.yml @@ -0,0 +1,144 @@ +# The SPIR-V and DXIL fizzy's native backend embeds (Vulkan, D3D12), compiled from their HLSL here +# rather than on someone's machine, and checked against what is committed. +# +# Each `X.hlsl` beside a `compiled/spv/X.spv` and `compiled/dxil/X.dxil` is compiled with +# SDL_shadercross and compared byte for byte. A difference fails the job and uploads the fresh +# files as the `compiled-shaders` artifact, laid out as the repository has them: download it and +# commit them. Before this, nothing noticed when the HLSL changed and the compiled files did not, +# and Windows and Linux quietly drew the previous glass (#242, #373). +# +# The toolchain: SDL_shadercross, pinned, with the DirectXShaderCompiler release it pins itself +# (Microsoft's prebuilt v1.9.2602, by SHA-256), as shadercross's own CI builds it. Nothing heavy is +# built here: SPIRV-Cross and shadercross, a few minutes, cached by the pin. The shaders that had +# not changed since they were compiled (the backend's defaults) reproduce with it byte for byte, +# SPIR-V and DXIL: it is the toolchain they were made with. + +name: Shaders + +on: + pull_request: + paths: + - "**/*.hlsl" + - "**/shaders/compiled/**" + - ".github/workflows/shaders.yml" + push: + branches: [main] + paths: + - "**/*.hlsl" + - "**/shaders/compiled/**" + - ".github/workflows/shaders.yml" + workflow_dispatch: + +env: + SHADERCROSS_REPO: libsdl-org/SDL_shadercross + # Its build-scripts/download-prebuilt-DirectXShaderCompiler.cmake pins DXC v1.9.2602. + SHADERCROSS_SHA: 1ff05bec573988a98ef9e0260b4da44f512b8367 + +jobs: + compile: + runs-on: ubuntu-24.04 + timeout-minutes: 150 + steps: + - uses: actions/checkout@v4 + + - name: Cached shadercross + id: cache + uses: actions/cache@v4 + with: + path: ~/shadercross + key: shadercross-${{ env.SHADERCROSS_SHA }}-prebuilt-dxc-1 + + - name: Toolchain + run: | + sudo apt-get update -y + sudo apt-get install -y ninja-build cmake spirv-tools + + # Every run: shadercross links SDL, cached or not (setup-sdl caches its own build). + - name: SDL + id: sdl + uses: libsdl-org/setup-sdl@main + with: + version: 3-latest + cmake-generator: Ninja + install-linux-dependencies: true + + - name: Build shadercross (prebuilt DXC) + if: steps.cache.outputs.cache-hit != 'true' + run: | + git clone --filter=blob:none "https://github.com/$SHADERCROSS_REPO" sc + git -C sc checkout "$SHADERCROSS_SHA" + git -C sc submodule update --init --depth 1 external/SPIRV-Cross + # Microsoft's release of DXC, as shadercross pins it (version and SHA-256). + cmake -P sc/build-scripts/download-prebuilt-DirectXShaderCompiler.cmake + dxc_root="$PWD/sc/external/DirectXShaderCompiler-binaries" + cmake -S sc/external/SPIRV-Cross -B spirv_cross_build -GNinja \ + -DCMAKE_BUILD_TYPE=Release -DSPIRV_CROSS_SHARED=ON -DSPIRV_CROSS_STATIC=ON \ + -DCMAKE_INSTALL_PREFIX="$HOME/shadercross" + cmake --build spirv_cross_build + cmake --install spirv_cross_build + cmake -S sc -B sc/build -GNinja \ + -DCMAKE_BUILD_TYPE=Release \ + -DSDLSHADERCROSS_VENDORED=OFF \ + -DSDLSHADERCROSS_CLI=ON \ + -DSDLSHADERCROSS_SHARED=ON \ + -DSDLSHADERCROSS_STATIC=OFF \ + -DSDLSHADERCROSS_WERROR=OFF \ + -DSDLSHADERCROSS_INSTALL=ON \ + -DSDLSHADERCROSS_INSTALL_RUNTIME=ON \ + -DDirectXShaderCompiler_ROOT="$dxc_root" \ + -Dspirv_cross_c_shared_ROOT="$HOME/shadercross:$HOME/shadercross/share/spirv_cross_c_shared" \ + -DCMAKE_INSTALL_PREFIX="$HOME/shadercross" + cmake --build sc/build --parallel + cmake --install sc/build + # The compiler shadercross loads at run time, beside it. + find "$dxc_root" -name 'lib*.so*' -exec cp -L {} "$HOME/shadercross/lib/" \; + ls "$HOME/shadercross/lib" + + - name: Compile and compare + run: | + export PATH="$HOME/shadercross/bin:$PATH" + export LD_LIBRARY_PATH="$HOME/shadercross/lib:${{ steps.sdl.outputs.prefix }}/lib:$LD_LIBRARY_PATH" + shadercross --help > /dev/null 2>&1 || { echo "shadercross does not run"; ldd "$HOME/shadercross/bin/shadercross"; exit 1; } + stale=0 + failed=0 + mkdir -p out + while IFS= read -r hlsl; do + dir=$(dirname "$hlsl"); name=$(basename "$hlsl" .hlsl) + for form in spv dxil; do + committed="$dir/compiled/$form/$name.$form" + [ -f "$committed" ] || continue + fresh="out/$committed" + mkdir -p "$(dirname "$fresh")" + # One that does not compile is reported, and the rest still go. + if ! (cd "$dir" && shadercross "$name.hlsl" -o "$GITHUB_WORKSPACE/$fresh"); then + echo "::error file=$hlsl::$hlsl does not compile to $form" + failed=1 + continue + fi + if [ "$form" = spv ] && ! spirv-val "$fresh"; then + echo "::error file=$hlsl::$fresh does not validate" + failed=1 + fi + if cmp -s "$committed" "$fresh"; then + echo "ok $committed" + else + echo "::error file=$hlsl::$committed is not what $hlsl compiles to: download the compiled-shaders artifact and commit it" + echo " committed $(stat -c %s "$committed") bytes, compiled $(stat -c %s "$fresh"); first differences (offset, committed, compiled):" + cmp -l "$committed" "$fresh" | head -8 || true + stale=1 + fi + done + done < <(git ls-files '*.hlsl') + echo "stale=$stale" >> "$GITHUB_ENV" + echo "failed=$failed" >> "$GITHUB_ENV" + + - name: Upload the fresh compiled shaders + if: env.stale == '1' || env.failed == '1' + uses: actions/upload-artifact@v4 + with: + name: compiled-shaders + path: out/ + + - name: Fail on stale or broken compiled shaders + if: env.stale == '1' || env.failed == '1' + run: exit 1 diff --git a/core/gfx/shaders/compiled/dxil/liquid_glass.fragment.dxil b/core/gfx/shaders/compiled/dxil/liquid_glass.fragment.dxil index e8a82f9af2fac2f23dcd403ae2a1e93bab673e91..24e69dce5d757cbc2759632ffedf993c985066f3 100644 GIT binary patch delta 5924 zcmbtYdt6gjwmwNt4u?GO0D=h*IROm_5t8r-QcaUUQ>~#z6vQdL69_LygsAvv#pX#2 z8YPHYDn3B0qP1-htD>FT1W*Ekf{zZi)&j#=Y?)r5v;$V}%nqFN_v`)l=8v4S_qW&D zYp?ySwbwo`q~r2{lq^|t!L9{Ct$2s(*V7)B(8HTPAVCnM4yz6bpvKW4D6M(fkEF#6 z6oQnXpF)P9l&n<>2n@nNd&&cXWXbj?90mkEZ&^iNLP5IErZH)6*X*K3Xqv!?0E)sY zC;N5?@b}#1#v!;--MZ=#2K&OY4{_`^l7lCp zu#{9%qL=UkO%x6n%^7dzAKG2lSchZ1uuh$TrnGQ4hbuUUNu8x=sOWNRUWrh1O*+nT zW}9y+=ENDAfxdq@j_k>3{piELc75KPikS=b5=kJIdpa`Lre!+frF@D2S1tNi!s{Zx zuaM46?o|>;cz}bD@z@G~uE+14Y~evRLLR`2zeQwZeyN_1C?)5*m-ddOe!BUmj}Jyz zqKoyC6kz%>9J`Oeo`!vxp@8AkC=JRZ4wDX|#Ma`d9Z|MR&PW4_(Fe+Qe!U|=f!$Fu z65!2}(@S~12ha@fulnxS%rB~VB+#GiH7xOZs`WA@;Ujjh z1h^e(xRAi31a;_w21&t;GQLx~uo(MEGOt##Vb7Y1t`kT4Q#BZ^R4+*jGc-NENW&Ebvf_^^w;8R8M_m-md5!`CGSbEG=0}KJCdqx&Iwj z-8&yXK3w0FZ2Dff*I%B={o9m0^H#!QNw;;V)p?}%uMfKp01G|8cmv3my{pcbmXws1 z<&*&x+t$LdS%xH+&W-sySLLk*-Q4`O`UID<^r!Oh4?KQ3`cy6wEygOM!6;Sf_L4F% z`&x2V%L?@0q$DU{Wm{^=_ix93Kc-kEESl$HIg35VdO7k()$Oa&i`APcvTQ{%1GbUa(Pw9=t9*onl6jl;YUCS}whl+3$}6@wQRuqY zvXb9=D?P>6n0pML|G=cJ#pgeS8*E%O<+8N4N7*jZN?B$H%j!r&jI84hlX3u8+&ASz z20mtp!wotHB1khZ$pIHhHA^%f$6VmIwg|6#%C@9p=tcwouMSg1k%6x^2rxqf0~Kfu zOleRtYe3?qWmf#F*MY3dQMGK^wkj-YTVtIMoZ%eL9_t=?dhf~Ovv7*9aQT88{Dd3A z)+M?%dl`50y_Q-oDn5O7;G$*o8`i^|_HETsb-O>V35=?%^C{Zy9Oq<@-Ru78kn`+l z+i3S$I6GLl`~#sVsdahN)i}}h)+LUh7BTu0$vX#!pb8j*eBt*nI(W)zHh70^ogL8x zh6Chb>G4uv6}kX~s!-xlyf8hJ(^Y+-2@L8#tw zg;osF&Rc0a`MFjIN0Gp&%evr;ZIOcxvy`mGS;e0WB0FugkF6erhSs(~GdaqpUua>u z6zI0lO9pA5NNAe~1xWN4j;L#G3vGk~Rx9N3**NWNA2O#vIVuaXlX+7*O(3D6nZ6aH zH>+u-O4;HNdeC&X#OhsJ&NB~gBT2&$4`87F8(nLZufR9DwhZ0tdfXb4u&c4 z&>D$>2A{}BFDZ{oSWT?w!Ov-hRGEGo!-BCzDqPZI^6|cy{6y*@cogkE-P)t z0P$NRmkf6D4opY=5hD8zlVHN?M-<+19k3c|^pq(#;vJp$B<8zCGJ%E5YBM`DB;ejS zP7YnCyq&03b__xijB!chq$Z+rU$Qbz3LZ#KoQ@@DctNnEURwEI_(5+21(NcM(%TlG zd22Wd1jO=A_i4@(y1YIsh_#@dV_x^hjnluQ#XA!u%y9HKZG{=8A}t} z)YPFUPTvNT4wW-}fKh|bD)+3k!t6QKP2sXo8@0NMsxP-lM^q%vsSWzv#NjQ#j)v9> z<#o~mR?g9S;LTbcjznsZeB4Z|=icd%rj5jUKA+yJT|lgP!+wqJy&M9#0(stEpuk2{ zdOU;mMXz!|120c1NxBFNQwGpCqh`yD0e1`Q1gUtL~wTs*z zX4^UvryNBxsK*D_Q2Y$)KBM5aHR7h(;o6M6?GrvvAzGuSl)t*3v5p~Y^L6st+i+q# zhbk0j+9NDnxQ$1OO%tH>4H5U5y^*{YxL0x$=O?TIB0d!Vo06M@{5)5KW|(aV-dm#( zS@kh1TNrm@53qt(+c$da?I6M{aW_Z{bV{jD zh_7{eK-V}IRPES>oY+>hlY6nLHc|5k81*qOt_rK8dN!;kl2IMSveh@MxB_8O%W6;5~~|+ zw3rc<4shW5bdS$EiXSDp`et03fIokO0=P=r)e#ruea5xy>`Zr3Z@S}Au6}erj;=i?xz`}2BV=3UT$CxYd4h3lj?IANwf$)^X z;6pxf74-edA%O8j5) z;y4fy9?z<$(Z95tL&1h1AWdSwm=C}S@C1psHH5uh{1_Q)4{wH6dCyzE9J4eahI?GCIp}%6P z8MjtZpK?H|Cw=nPF;vLLML9w+e-%wthMVTJxL&lqj zn1iYv5|0VNa+_f~Ymu{akyOkbB$X@rC~gWjM!p*Au}yi5=~v=g!|ZyN{X89|L~di}V<)5}gqEcn`-O zOG6tyZ}D&Uz5APB&Nb{$ovZfsbQj6^L?oS!D!UZAQwYwhO!{Rk1^X!# z`ybbpD-tO)S=^3JAxGQ-e@CqCe-1?|4@Eb6l9V}okI7~U*soGN#P^9r%rydcp%f4| z~@Tdi&HLRrL569g>?`E$LT~{ZN+jyFWB| z29`y7f|!OIJ0^@=8arD$=q`X+94yAV2t7-irC~8%SAQB>?uRDbmLLUK?bWDBB64_z zPhZO@Dv0Bt#V9z(qMe;^wzbN$2{yF@q}MdFWs_u~mgFF|tnyz>x4Vbj%HG z{Cf=fvjH|f(Htw83n>kXFu&1S2BQeyUQTtQUlP7O@QuZSMH9dm%r118@NMQd7a7;4 zXmf#5EdQkEuqq+Pck;s$^0A z6Kj(k1H}5mUOne~_yF}}xng`oWe7h-q|&k1xBo!iOyslHkgq%++yb8yhE;`p1dxhl zm}Uu_o%^KX0tU$AAXO#@*gH*e+N%Cu-w?WRCd=E|Nt22*8E%qm6vkbJCBGKBjuT&_ zGA;*``nS>%1+TUzaiO?>g}}B0><8GaL(E8_XeUfj*~o z20jy;t8^uRHH`C^;Fy-yrcO~OwXNUo+vM=t-BB2)V;VP@;?fK`aSeX(E1LH`#QqNs zx=)*X4GUkafjxJK1gbtG8@ew81(fpL>IJR@>fWYZ9Wtq4bsvy2*pVw&k*~zka-Py- zW?=a}6O6fz<9PCZ#{@~}Z6bQwwPA2cplVWm!IY5279 zW?WabVsAMkazxdShtxz&_Xw|%f-c|XwFh>P7FxJwqp}>5k2gaW_hS2_s?(JudRJ$5 zgt>}oDCZ@Ps2n&bv=fQ5wdv>&qw{$Z>de5B`f9gx+ZHdEQGLlXSrTAO{Glr9Cis-b zB{Rx`6Rl4q^C+U)e5+wuS_MHid*#8r2tUPM{d8^QwQ6u?jH(=7UG8Am@yl#bJO2Th-n~-1LoO{ZX3dstH>fKPRSl8Z zU7cl8jk$_S-b2(H`@*-;cHEXi=QeKPC((vgq-A}b zx}ASp>#=h@xzSTCtUV6cf>*g6xMt-))0I>FHzqZ% zedUoBfLSg+1$ex9Q0pcF|Ft)D z5kxKTyY}gP>mh0r1WpH%IH8{GE;`PiFAI3neXXfWKSw&^j^6Xz1JX5a&J9=Yas%&0oEHBx<4&N|&uHOjRNVB&&o_0`0 zhC>2zcX`sDiN{ahBXZYfzx4JP!WVzf42{>`CxFuU^t40*psZi~n#B!l$xnxuX$T~2 zefVpK-N?3zbmKC%BW6-?`p+XO%n3xb@C;;TNEchQ%0t5ko z8t*5Q0U%SgJO{uBA$Z$Q1Aq*L`z{X#fXUY7Z`Om&`?nZnX)LPuCWiD`%z4ok6!mISDx>+dPk8J|D;uUW`I&lWPYDqPs8By{MjaY~Cknt&+BLZ67bJ7hZ7D$;?~? z+GLdFaFBR-UItv3f|(e`7eE~cB*VRGBL;Bv3`#Y)$;HbvG1~Ru--YsYQ_28xmbB-b zD@_u+W~oZ?+;gilS>}u*SmbSsz7~rdq@EZPLx<2(6_jqJEU2X{M1RsdD2oOVm}73b z<$|{&v~7A?nXuo3(cW~)&r_DDMMXa^jpDztRw)Wi)IuV&O9GS<9TBC_l z=}ts#Fu*U73?ZUM@8#le?!BX2drv7zfSMe`Z3Bm{^qu^yEthWP7nUc#t|@)JXxsC~ z`f&DpJu}y#`G+^$;N-J!9|YzqEI-7)DE`;lITCz_3I2K2!)^WFdk%%PwS9K2@)|xx zUoNv(&G;!W=gC8N5~_3Y?nAlsTh3T~vh2g0V;g3FxxS8bXYimb@&Z1}_Ssi$*YMSI z>1tA|9QuQ_uwgYaG#J_UQ{!)si%k`yn*$1OjGk6}Q^@~SyaVqKD*i-B>sz5KD7xbR zr_O6Jq!-i(6Wx8eh_$#~AKyXc!9w!1#4lUSs#*&gVWc2#u<(5Clw|cq94q8HOZT!%Ipi(43gd5iuMb-DqBM*p zOP1rBt=eSF%j1v>jCpHVW^WyaTmv;C6C;cSYA{eM0~KtfU@OQmYD~ghP7Mk6%HZpI z7!;u$GKLr`ae+CfnlCkaM|EFxkMW_-XXV)Y4DVx*6J5{nx*3;cutkJ*0O4ZlYc7qN zvH%cA-j&ced6oP6PWL73Ywq2*Wd*W^*S@_a>A;cfo!gH@Ah})s4fnkR_q#_=QI)eq zi{D*vR(M9#c1m(abZ)MEwFSPq^mOTik5+DJF5R-i`BlI+JL%Jz*|A6J!y1x~L~PxG zPtZL$HahOSdwp>1-YM#K+{6DQ9mr!+89z|~fJNPggi!(DF9$SCuI___URZWBYBl?s zPze|@=npSy$ol6-HmIcvIlu0dD1}WIZ9BEp`HYW0ktiLl_h#}dE2g?_o8=UHdLbcSL&6j_b zD0P7~b*X@sT5|f)J+_Uh$DomK@k|TEfpoyAc1?ODv|atQSA+!)0E58WTs>*lV>!3k zW0jNj7+UH?z*qbC$JAw1TaHP2Nm;pVSIA(B;CJ%pC8=4w7-$LXPfLpXaqv(i&y``4%j!m~J*y zotUpnUI>BnESJvT$IwRf!Dzin?JTH0&=YPnR1L38UK{`yv^@@hcSlA7plWZ=i~uM| z&I^E_pU>n4EJtE1aj<-EPh4Ya{%7oL~KD^N3WkU^glCTb=)_@rm0)~0Q#*b_-G4gEaOq@;m^D~+d=-eO~Qo`W? z)q@>*d#&E2PU8VemRX&qL6j`3reg?@A#luYYsd(ik`5qL(>l&YiS=oLja_(w_Ft-s$9y4##?0it5yRE*K1}0FTyZO zgK$~o33wTGw@y~k3h!gW%OIz$;s&G4AL;z_trtC$S#& zhX%Bf?Z?L$zl-h@P$Kh*eSY7WGe8mG`}7FpY4|=Je_PxNUq85SS~QoR5m(P>#rFxd zQg7gkX6!Sy z8GL=rc}qSZbNh@WX*BFl`Oa#OQdZ=kV_LHOI3ckwOwWdXIF=!e#x>yFdsQHRHZMgO znV&<6qA036ajJCa!JM3SraW-$_M(t>rWb!JQ>lZ%P`eKbHE{bsk1=F1_XGgya+fGu&W`d0(Kif zX8_c*^>c90Qw)d$;89W+fP-bABMb+J*lxaSI=s8qzP~sxZmE(*R!^sdI&`kxEw&}% z4p9fv#ui`1^r|{x$=9QQH9jUH+mf3Rud)wY)k_Y(TBEsPoOYw?ZlhD36QSSFG)@qz2Inoj&fP7- zV@OPW+#*{#N^n^Hfq6L*1i4L*p&hZr@f*AC)VV|^xcPyZzQ@sBk^$KGkm0Zu;ifWq z(gu2^uuiRmTj68haEUl>3$1I;YQp2Vod|nZJ^wFWK7Ny_KT(H0Op!vPT2ivxXO#tM z9_v0r^$`L2={Vo8zc#wxR~~N-2I-~PIV5{9GLy<8Cb~+9M)8Kazxu$76n0irF-9;l*waMaK`_F&2>?2yx_@-< zhfq0z?7MWa%7G&@@bs%5-uuzu@?#L`xS#%6|K8@&E7KHuO1zkjj|4e-T9EW+eNqG6 zgWvX!Du16yFGs1w3gDq zHu^+X4?1{-a7AKV@=1|sx~OpB%S6~bvdUO1X=kNpD|Jqi_eF!V6)seRhBq(75)sw) z68~?irA11X(QT1QqsMR!_J>T?>O;pH;JZ{##6$Z#+%->PG6r*!$fCAWfa0 z@o~_JsgGkPs*aH&xQi(9K^0i{eFhB?4E7BS?nROb(jB>%pzjDE_vYAM84gTQyDzuP z8g?G@>DrF=Tz#7JxuYk*ExH&_yoMLVaf;-T4EV=)k~J*x9|g$`bc3)b4=mQ>Mc$P4 zvD_<1v)hC{7n*wpY`I;||1drzo^v4i2jk3tTu&}~UL4mjWt^=BDYHzgz-A#B&W6k2 zOm?Fe6&4EnQ*>EH`fx2)?UDI_Lxu%5QYOB})ZN?0=x)xw6}Wr1W~%3VC3j@=Uq=WM zw32z-X}=aMlW}iT3{ngHm_1$SC|})hkMVnFon9)Yk$3g7$5QKtXx^=vh{!AH*$S2* zHol%LyaR~-J~80-_`q;!O=@n1;QXjoC+Bflp_2o7_#j9s&h)qNjD#KvFPS8Zwl5;; z!3MfXSSN#(4nu`4kur&D!10t3cvK6MxLRleRk(ePbEVOd4_mMqxUy#B(=B$`d6Wv6 zJ*4ddj^|s_@=Pv*rkvfXA2x5;n8rh!b@3~vZ^w)D5iKc}%R(jx^j#ZkWDNXjiDL-T z@bcA`=IY1GSK)PchKXsS;zeP(tm};xrnEK7b`ZrDlXsgg^Q~O=XWof7FEWhvid>!; zcV9_H2`r8w?Goh83f}F78N;jr=A!Wyoy>7nK;qy*=#A+*8q+H}Yv7#qmXNclHyF*S zG3D9}?07y|g73mC?q48w4MIbybyP`C7RJ#@k-jQFF=#1k8jo`i~bJDqCUjtHpGpV9fMVUZbr2fN2<&<>yv1{gC zNa=jPVyg5QVvK6D0Iq^Mfr{K4Yk+W^-0v6y{8&2FCUFf0*uCrChO;|Wmp>?}v^)te zVpo;8f~$`=R$6`yYTdq}%M%2Cfh^E(1YofjSOM0?f>BRHpTS_TGni5vU1mL(o@pTEB|HSc6%)pO}fxmwP#U^xn diff --git a/core/gfx/shaders/compiled/spv/liquid_glass.fragment.spv b/core/gfx/shaders/compiled/spv/liquid_glass.fragment.spv index f9d667b32a85e7185a2275b93ff279ba27d1b113..4a36459a94f4db2d1388a82807b16918a1127b58 100644 GIT binary patch literal 15488 zcmZ{q2bh)B)kZJXhA3h~5C^b}SP(@K6%av11|3A~jtYoKAMDDApxAqF*kihB8e^g{ zJ<%klnQBZk-NYEXCjVOBeDC}x^CTyCKj+=`?7dI9cQ8G=4_m9z=+NmE*;j9A)s>WDo??W(?SqlZ(a+n}*_qbFF``K@yoC|9ej7qlUqIu-%- zsAMw{^dfd|WNqgxnl)ngQ%B65-#TX&d@nq;J!r|orEL}8n-yxirLFT9oiS?(Y~{ZW zb7j|3%6m4}Mc#5+>ykya-Fo5w!>)Iu5AtgLBNxn_y>Q9=rSQFptlY8ez}B`_=3e}7 zedfZfTHiY7E*NoU>yi-@nx`}$0gHUCs`gqwed5ds&CN#~SoKYvJz2P{t#WFaapLsW zC39xAp|$R^huZeVw#u)g?#?Dn!q&A1Lk@ZSE$>dsyH2UMr$ZsvP%l3Oa>(1uQZHZX z?P;l(SD*FlajBOt_4ZG_dApu`8Gi$K&%=h$WU|j541;$}!c$pyXcPRkYl2*Vr<(5v zc7)#utbS8``!hE|>cksEjmGi`;Ww%H#{Ti$6yCMMZw5B^@SB6}dH4ZfdmerZu=U8h z-ayC^eh_&3c?+g)hJH)1HpUyy&d9rW`jn*YR>-t9x8bZP-vq^OV>CnHcO+Y9d!C%^ zrF{#(HP~~IoVEdP_So~I!*2`UXteL2b+&_3CdFoZ@VYMy8|$9z0B(XD>g9K=xc|4` z3I4ffr&N8mj^WU2Jx4v2HFmCL%Cp7@aHFxN(`OerV(b?^*QYUZ^8J4?%Juvsj;r z%*NDaAUfYO>r;0SvpQ?_Tu-X8F~`7Lqi540KbF}y$q~J8kRy8E9EZI1_{O+~`~l3y z?pE{G<9!Q19_;!OF_mC%`K&W1k4Gd>kjq^K}yBKKu4d#`nCnLMK77I~g3i)TrGlkh<8NTG%CD z?PfvhVmG_6^SdGI&H<~7-D!oL-x;x+3sx7q(+j%+h21=`y4amj*!dlkc=N&P4zKrR z0a#zp)Dy@&OA8_8epkHB{x4$o95}zuZF7$oLxUjC#u8{L6kQuQx@CpV^L8fG1nGAc z6hC!mLrsvnbD-$#>$%WiNZol*sXM>WT~O%ygD-@V%knO|_Cj}27u||NcX1cp%0joQ z(0QgWfjrmdc`0O`Z*bOD*O;m5>1E9J)VZC$>w7t*uV?qsdM~a38&jQk^h##OJH%9X z6{O5q;`m(+)^8Z(9>&JmeR7|#frdinpuPUaRemjG&c+q5taH2$>^U}e^1dFtHKZ=* z?*{Ptkatae&fkq-b@9Cktlwc!d~XKFSABeM0jrDetzdnpLh-!~9AEYEy&bHsnc6*T zcR=PMIl4g?Q=_^&p%zG+*xved;^22JAY%qxxtw4V~xv zUT94h(ofxeQ2gwHwpr(Xu=zT2e;)uV_x^fj9%Od7x9T2(9){doabkS{Y%JGGtVh7g z6YGOuhq2Us2>LK&EOBCe1Z>{+#Pg@0HrmGKqu|t5+Gv}%0~<^7`&a)bA#FQ!9q;_- z;d4$NVV(Z)Ux1WPhPH)L>r>$L_HkkkfqxoOZg1oNMex&5{FBo&$c(K%F~0;hFGtSX zm%+-jUtdwlaKF@j6;kG2h!g8;U}M>%#QHi|xv}hd{G9U!egixddJxjynBJLhGCN#X zn{PqSLar;$zB~tZ&54lf`#Z&ce;ZO~-L7pseT|WvzXR@?^LLS1zdG}Hp4nj@>b?hk zA2OCW{rv&h{-##_w9z&;KdklcfjPy-*?yS!k04_iTYLS>{H-IlFMzGne&}bM)b?X= zYRmig6J+*k8f1;yME6s$x@Jf_>riJ-KZD|D%qGbGdtQGIsT&Vzm*1Pe02@=icSYM5 zA>|!+qvH(!vX-x8w%;!?JM6c*UqQcy?4{UVyj1hba~FOCHvS4Icj32SWqCJbUvicb)vI37en?y zoolJf+RMQ9Vhv>M)bR(f@{&@=1<2J~hifL*DzNtU%Jp(y{s`8#2b7-v39Nh*$i4Y9 zv%|en_ZR4|kb5IetXIINK;5AHZSgm7PpBW{Zvt!nJES~*uYzxc962-p0Bff%XX`bv z^1BP!Kf&54i|^}T<@zU&H^Anx5tKaM1S?M-Z-LFj{1fYKuy*Q_$2(x<_Y|^s!P+Q` z?|We7`seq?`(W!ZN8@OdyZA4#YkL=C6WzZHT~|My;%5wPvtBn9UC8`Am)-S)%qjLg z;N8cPR@e4D(dna&?|?CSf%#PLhWhlfH&~q`vbAcN;`DND7;V+XZXK|`-3z;Q;k8v4 zyY;}@d3F=854^VOvJdNnjqf^<^#yBVyu8yJfVEW@*@j?aO7>wZ*6Ih=w$jz_;YRR$ zs=s-(&EL)a!S+&p*4`MbTnFD1@0WMUJ5}<^^X6}Yt?PQ@KGoHHSvK82Rq~vlAw#v=Ln91cLaM>4gS&Gb> zo!hBzL*`4NOCaA2Z5APAV`@#5Whz>v9f%hPXhby_k;3%J{g(voauwX%FNGo4got{N8O>2GGmML z9XJJSZXt8E=hKmCt50kX1AD$pvJZYYPDReAF#__p*twAQ`EF?jyJwO;d@3K` zEZ8~#SM}zrP{_5j@4A`DHa#oIoSL9hyZw;Q6_#B5UeRL0x*O=iRE@VCd z{zS-q5hw0RU_MnJ+n5(a?orNsD|i7i?XuR%;Q7d`Fa13Q8K3Ij=%Y<^r`9^(dF{+Q zeVGMs@3qNYnO)ncOP+JUe5(4@cdh?4Wb@FwXUTmoGUdjO>~yel`xV(duyWUk>S7lT`|k<8z|nuqb1pwma2 zoc*O>K27z|*z5vXps#$##xNFVUo;UtNuzTlO z_pI2D=$3=s!>&4OYe#N>)w!mAzMH+#T?Af>S$=Ki74YjY&!8`TnJg~baH)<8KxyOupVExU(8nyM^xrW)1cjsDoWyTZdOkM}}4Pol6 z{dySX_Q-YP=j^&eklz3v%4{y$8`E`fWOlf&Ha9`aTvwd_-VApC?XPRZ#yM-;0(K48 z)n0$sxRu%A8rs|jDRT{R?$+&K*VrE0)O!bf>W$r<$h1|L`*s)D`=%~;YKpcpS)xDA?xaMKQca*kNWuJ%sqgtyblk;m-pcz zcx{yD%smWNZa!V_!v~P5ccgER)Uxus=7Y#I^ZP`-e>dd&t=Q;i?*pWBo3HZ-Jo?~(9eiB^1DW8Wg-;^)F zYok1MKLu9)-}d)ucnz_H~%eub=ZyaqB z%C&W*&ac6jb$%Vbtn(Z2+9*$*-vlfFZ~OZ#spCeXwhJKJ+oRYvuiO zj_nV?uH&1kUm53z@ZK+Vo=tV*xLpU4`;WluGyAv77WM2Nd0$^Zr#|oakHO0FJ^2%` z!!xb!r_j$J`ye(~ZRC~b?ED;TPVP%-qpkJ0_AkKxW;KrX`Zx0nU=p$y!TL)Mr*hwa zS?kJue+ij(o^SQ;`!wDlp@k-3)o{ z?wMy%`{dIdY(B>Kcac6l^nui6A9{jkq0>Ei_d=%pIE41MH`tN>t_8m~WPiohrj5Mv z#90SCgBVh2qiyP67wn!GNBh!$J$Q4`CNcYfm0LshaD6Z#s{K>%*|7H1-4~fX(k}hk z0IZF&*lh^5N9z0?XwUkA^;Z|Wjlgrz+1uFlN2aa1^mb#ga_@N7-2~os)g|7hVE0d5 z;%x@jSKW>E-#VLvr(w4<{`q%-0mu+lZ-#nn&%eoR0k15!0}ESq+9uaQ@SX#v&R%Z` zquhT}%D=Y^hIc*xUDMc}kFA&;IUh~%$~+I^tT6=a?{}uo_hTrGa&z&F#Lw9?G6eZB z@K9#YjrPWL-L07&uB**9(6*54inH!^;H;Z7vpq8F@ZRqN?Fw02_Gt&O`=njY-i}~n zsY{P{0`Eqk^msVDaec$BNqua02CGlJ5n%g!2zL4Hy$if{>eBOF!NxS6`uOh#HYfG= z&VE?CJ&4WjVAnVp-{dh8z9Z+3e?#2^KD}@aeY8n0MuF{xc8R+ucz0y#%C*fQcY80e zIl33xo0t1Gn%R-_vN!xbkhzPqZ)3pou=D<8-F=ZM_sxiGEWC1M*@yk$t;u~*Z!YHP z+OFezC9gbr?+@1BTb&#* zMmn(O^-T{Zg0*!-c2F&I?Z_t8GEMXSF&RdG?bRFK{vXWjNdFIkKNPYrVr`}rHr}nn zpsA2H;;h>Y?s~VTAu~7c)=0>Hc(>Az!@>G#=N`D0agAT{%Cjd&fc0~2|2si!twmq+ z&wXhDyT1Px<@=a*r-S9yM|LFG9DN((KLZ?p<*_{qtiAf|*-Wr;)y3{;@F-%ci`_9` z<$EHx#^mjs8jl5Ar*&&@P42~U%#OTs$HSihS+_X%`b2Q~7M%od9eIn?$F>!$KJU)S zU}alE>BA{t{k;R~&D&gD*Y(ZqRAxuc(k%GdwT(DEnFAgK9g1yar`59ZZJLX$e49>( zHwVv>G4#u^10QQ_}m)HxzuCLD8t<~DCFMV7D zE_=QhzU;aB*e(I9PtTWvm6?Bf-Ud$3)tBqKe$L7=u=&|zeaxxcANMJ?XM){J&x(G= zNsVWL)qD2b&$F2w+0S#}&xNd8>>Aq0D^HyB!2Vm^(Ig+)`N)(HfJ#68liLMg&zG^a zH)ih4g<$j3#@v=OJIw7>)@g^o2r?IO-lP>^|F*D?uB)Fm+LpZXwu!eA zJe6Qfow==o(MMhEE&=-+s{FpX6q&!H)uk`1!OE>KwO*zkQkOG(IoKNfyF|Ttc}DYh z?iI+i3FSNLN@SC3!n}$BTdn1H&s8wm=-3GA8Qp_&0+aVYKo6(np)1?G z7dms&Hvd+03s`;f%x~*kk+rbSm}>ow|KH(l$Xwrl*HWMTyB$0lnflz@I|^I%c~|ZP ztMl*U)Or`ZziS+cc{eg`)kSs>Sh@LT&)0yJhqCAQBC|HhVctT|mwSF6j5hjb&+i9o zukL^C`2*;T?i1dO~}`R~@w-Yw78L+GcVKNQm5d$%8_#51PueO4F^ F{U5)H+_V4y literal 13412 zcmZ{o37pnd6^1{yK><-jL_lx`6j5+g6hx72SW|Qm)W}R3Vinmz#SPp_vs}t0%gXj_ zw@ot(vwhFXY|l2^Z83MPp6BoX9dGlq%XfbFJ?}a9+;h)ezHjK!eN^v8qg$iV=--&v zosKr3(Y<2&Hkzt_?)*jbTQ_f6)_UZG!?gEr^suXJyEk@e^aML}ZO7_$@>=D3A$y={ zV-ir0idziPi`Y$YTefXj-a39+>*}=~E0?49Vx+!vHm%>hrP7<2q4wL{v3A4R%QvA` z<9DU6{1%mb&qg2kMXNeCZK(bFM*knbrba*bYW`E!tzNNy)7s7GO+=QrZJpJzrGvf~ z|8}D<+^YFq=jwH>=X7jpo!LIWeIY7*?`rKefAZ|bGuzu2&Z_FB&6=#=x}_Sl=+rY$ z?%1?)`4+6MyR4zU{qe2xdw$)WMVgDRbB{o5>egG`om6+8(r!&hBF>>*y#=wUTg%d} zUfQi`X;;^t`K)niS1;|>PrG?LpL!X8cXZFg9>_eh&mN3Ix6NgwTz6y;drR|u|?k(JZ8_s%h;=(``BzUDTL8P%JS`0a(I1^r;MwYTQU z$y!>s==*~`2g&IG@ZdY|pA>yGdZV#pTIM+rO-_l=7;v8lM@?~04gxnLHtp&MSK15y zW6>Y&IbmmFj;nmNzu_?){+<4WL=o8Sdo4s>#@|}pTkNgmBO%uI2bwk{fG<91u zbvWvISosdc%lAxvQq8M7pY=K#(SKib?}TSTZbW_lf>XzbXj?m3@@q_*m_5p!}q zbLfqg^~?pgAkN|X=GD6SOk^f&B(_rD~C&=h{dfPK#&;NWxfBPB4 za_$olbN7uhhIyQb$X|hvdV9_JR$Wqa3u@l?Y8~TT*GY(cV*5p8Y~QfNUIdm;?32NA zW$aVX<;&QoqRY?VBuuA&DdIl))+)yLyevkv#qVX{_+^dyorY+O-^&ZX zqVO9G&b()UwZ-qu!p}E4eoMgG;@1IAenY_VTME_|zgHH1zUlE>2G({`y)Vnbj`d94 z4d+=}fyfU-o?`!3(t8fyRdoQ)*{V_7Bxv*_1Z05NYNiOGfv7KAk&g){^R@lz(V!NQQU0B#W(-$G0 zYxBGqG0(?2Yujr~SJiX}y*0JpLERmD3F27K?j7}BTnaX(Ht*3Cz*jUb$Shs=Y6YK3@ zo3U=Ae+TkT#8~3QdKcKdt%>K)ar)>RpLc_^w$ex6ye01e8_U@GJ6>PcWIWfAc<%*w z&HsIH^2z^pu+99ny&w4iVk~iDeGqJJ?w|8HP9J^a^C7T4w$ex6eYM>M*FygZ zBc`KQ{fl( z>z>N`{C;WsEF$M#h!g8`U}IUM#M%j#H$Q8harSuw?*)%UZbkGrrg!G^^fu4Qz4Z4X zUqGB!oPGHs*g1WJoZsIm*859{HrMUk#&fJOlJl3rU32~loORJ=9$%%mnTNKoAzw#~ zB~E?60k*zbtK;<1H$LC2?e2j&#mC-ynD@63V;Nh2$CvrLj`)5X>^iN7EE?auwHbR8vKi6lJh54u^AT&H&AGH? z?z6$x!gUxs>-c5OYl~zZTi~?24(Cj)3&HwYE9c93c?hg;4faM*ZJpKwck3Es(@d#KxdHfA*9_F7|e+TQQEqVL{EPqqM{S&N@T*f{M zmUn!9Z~P1FI?T~H`s6M?26patBtEe{Uf8;hd!n!zL*LBzBv^YX=I6P53ej#(@qZfZ zK9+K2?f(qCWAyPIFvhcpIS)g$r-h0_&?STyL;373;7cF?Ip#t1a*0u3$dZ-#q%} z@8&*WYpFeR_XW$lC%z}%FYl6fs?_E4=J&(jdA)DG|N7?b*$v*9+8(HFxy$|GwVw;` zUEUpR^Db-K0~vrgzu5WpQI}7gJ;BD=%2;bNkA5J!yy81!ZT14oo9|lmyg!4`%NXT6 z&X;wrW6XNQT#RK7S?4+Mu0xygv?Z6l!LCP1E*tQbHy2|jm-E17UCiYyIM-~SzjH1I zUxfH(=(CbupY8NHgM*3fn`g^i-3N{jx$!p}-~jsU{Sb8D-)%_F&d}Q5_epzvhk=cy zJ+b!%^QmHM_uhFfeOHI0Yj;e}-w3e05^g`R{2<0;f1AU=O9pXz%+dwj=&okM%_8VBZ6)meL4=R?r7mwC0K%PYzMP_TSi=flv=DfjVk zbenb7HXf0)&f?^F1lT$o(>lk;K6O44tliq{@A#}|0+>%#=hP*6PQ+lI<$8`nmoMu) z3Eeo_lGo8-*XJJe<9|;*hsS{VRR2e>-P)P^J)F}P`pIzm$a~g~1>3BFwke35H4r~b zG_l+aV$8vJD%f|w{9Q2(PCjRPI#|y9oM#5u<~-Wk5IJLu^Bp)7Y;F;AwdTje>Fb#I z&H{VBOR)~a@tqCNr_qXRMm8e)=euPN*gaDmXTo8=p--*$4gw)4R5L-zG0 zU_RA3HDB%VJr1lrZ`JW&-zvXX^JdJ4(@$IO$q8US)%vw({E1-4Tlb7_hjYBPj9&oe zQ;pZ2@h5?;i7jVkA-dF9M7wM7tYpk0xYW@-JXvR&d$@xB6!cRO_eGqzF9q|d>bQn} z72+P{%r6Enhtn@}y$rkz&h@3fr@`^5?u}#giS6aJ&39fu^G;n(N4NI+jv7E_vpbfjxKH%vEkV z__*4Z|BG7zw-`>bp?3POL~)Eh@m~dwzxi8N^DzEuY>v?~jVO-M zC;pqj@$b6k&Db2HPu9Ez%%@tj_GWO`HM{n_@ms;}ooC&%Vm)Fz2kahpwYj!);jOPW z=X9KZ2Q^_k58RtxeHZ#|=)2ONN?rQXpO3DO{Nnm=+6%z)#&w*$$3Amh19lGQ)!*^XaV@>gIrO;>k#i1l?$-5S z=NN-;*86(&tT%pdfYVo7?%NGu@0+&Vl^enT@2=d0%`y7qe%=grPqpR#yb;W&xjX1MY`+=^b_hqs{XBcC((R{Jl zh-W~Y{N4{P@8bv1%lr62bbaKL--p2RFIJlm!)v!CzmK5Hdlzk)`=jXP+;^atbAJq7 zANkDvaj^W0)#elM+RNH}5?$ULy+}m3wC&-%o>`$GhyfGS1!To*`}StF{@Opc&-;8SrlO{ze{J_tw(ik@sNJp7-Ok zV7a`{p99-G588Gj_afFoY_9sK%jZ4#JlLFk`%52vU5|6$2lls;arAe5JKsEQa9;pB zUa{F($Hf0)Z8Ls;hkXf7KYxd5_wVfm+%w~S8C`qC-)Hh)fpaeHiTzcuye-_D;2f{NcGs6ReG}b1RBU$Tn!Z)r$~Ao(PCwVAyRUTU(R7XTh{k$u)kC3I@j?V6!}Gn@9%Fl(yW#1 z{vGCeV0qVIExfaT2J@-D zGqqbs*PeC%1+Q#o6V12cvw*Lgn zdv`MLqv+17E%E*ZcK@^`-eX|LYI6@Br?+JfoM@=T$GfH*80r2x;av1_z`U#7;Gn8ENV|KU|6AyeE*IY(g|9Y!^Zx-oYny+E7&G1-pRV)&1_q$$r!8yT6KqW5X;1D0!P?Br zGrAYOEoXEP`re3VRP5gCqb{E~gTel8U%({c_JNak56W?l&)YEsY);13-&<@b2j_lxH++keV>7(|+Rf29jAz_Zmru?uV8=P9 zx$B#`M}lV)pRRKqqfi{9Eq?oh-M8|N9{}fjtu6VD2FqKE)a5|1F}3CW90PW}V-fA< z)?=UdYeGH7y>`57 z&3E-gu=849{YKCqj^uqi3eNxAnTX`hO{&NEH-+}B{b=wJaN6U0OyR3N@A+h~Hh=S^ z?#H4pMYkp96gYjg#cwLuyvs8-4P9Stsrz)Wd?anT%;e^mlKkvU Date: Fri, 16 Oct 2026 10:43:05 -0500 Subject: [PATCH 15/46] app: a link-only build, which loads no plugin at runtime (#445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of fizzyedit/plans#13 (step 2). Builds on #441. ## What changes An app built on fizzy can run with linked plugins only: ```zig const fizzy_dep = b.dependency("fizzy", .{ …, .@"defer-app" = true, .@"dynamic-plugins" = false }); ``` (or `zig build -Ddynamic-plugins=false`). In that build: - **No plugin dylib is built or installed.** fizzy's four bundled ones are skipped, as are the packaged copies. - **Nothing loads at runtime.** One comptime constant, `App.loads_plugins`, turns off: - the bundled-dylib probe in `postInit` - the user plugins directory scan (`loadUserPlugins`) - loading a plugin by id (the store's and settings' enable path) - a web page's `?plugin=` request - **No store.** `PluginStore.register` still sets the `PluginManager` (settings name plugins through it), then returns before the catalog fetch and the store's view. `tick` returns at once. Every gate is comptime, as wasm's are, so a dynamic build checks nothing at runtime. Most other dynamic paths go quiet on their own when nothing is `dlopen`ed: the rebuilt-plugin watcher walks only the loaded libraries. CI compiles the link-only build on Linux (`-Ddynamic-plugins=false -Dno-emit`, about 10 s locally), because its gates are comptime and the default build never compiles that branch. ## SDK impact - [x] None ## Verified - [x] macOS: - **Build:** `zig build -Ddynamic-plugins=false` installs only the executable, with no `plugins/` directory. - **Runtime:** a `text.dylib` planted beside the executable and in the profile's user plugins directory is not mapped into the link-only process (`vmmap`). The default build, run with the same profile, maps all four of fizzy's dylibs. - **Screenshots:** the link-only window has no store icon in the rail, and the default build has it above the settings cog. - **Gates on the default build:** `zig build`, `test` (91/91 steps), `test-integration` (53/53 steps), `check-tests-reached`, `test-sdk-version`, `check-fmt`. - **Web:** `check-web` with and without the flag. - [ ] Windows: - [ ] Linux: CI (including the new link-only compile) - [x] Web: compiles link-only; not run in a browser Not exercised: opening Settings in the link-only build. Its plugin rows read through the `PluginManager`, which is set as before. ## Follow-ups - **Removing the `buildApp` fallback** (a list naming none of fizzy's plugins gets all of them) waits until fizzyedit/example-app#6 is on example-app's `main`, because fizzy's CI builds that `main`. It's a small PR after the repin. - **example-app: a link-only build** in its CI and in `check-examples.sh`, after this merges, for the same ordering reason. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 ++++++ AGENTS.md | 5 ++++- app/App.zig | 9 +++++++-- app/store/PluginStore.zig | 5 +++++ build/app.zig | 13 +++++++++++-- build/exe.zig | 10 ++++++---- src/editor/Editor.zig | 6 +++--- 7 files changed, 42 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 829d1da78..2d4506360 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -211,6 +211,12 @@ jobs: if: matrix.name == 'macOS' run: bash scripts/ci-retry.sh zig build -Dnative-backend=sdl3 -Dno-emit --summary all + # A link-only app (`-Ddynamic-plugins=false`): its gates on loading plugins at runtime are + # comptime, so only a build with it off shows that build still compiles. + - name: Compile a link-only app + if: matrix.name == 'Linux' + run: bash scripts/ci-retry.sh zig build -Ddynamic-plugins=false -Dno-emit --summary all + # The suites for SDL's patches on macOS (FizzyGPU's Metal tests; the rest are Linux's), when # SDL's patches or pins changed: see the `sdl` job. Is there a Metal device? The probe says, # and the suite then requires Metal, so a runner with one can't skip the Metal tests. (On diff --git a/AGENTS.md b/AGENTS.md index 4b9231d66..fb191062e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,10 @@ every list must include it) and markdown (the store draws READMEs with it). An a fizzy lists every plugin it links with `defer-app` + `fizzy.buildApp(fizzy_dep, &.{ fizzy.plugins.workbench, fizzy.plugins.text, .{ .name, .module = dep.module("plugin") } })`: fizzy's own are entries with no module, built for each executable (`build/plugins.zig`'s -`link`), and only those listed register or get a dylib. See +`link`), and only those listed register or get a dylib. `.@"dynamic-plugins" = false` (or +`-Ddynamic-plugins=false`) makes an app link-only: no plugin dylib is built, and nothing loads +at runtime (fizzy's dylibs, the user plugins directory, the store, a web page's `?plugin=`), +all decided at comptime (`App.loads_plugins`). See [`fizzyedit/example-app`](https://github.com/fizzyedit/example-app), which bundles `fizzyedit/example-plugin`. diff --git a/app/App.zig b/app/App.zig index 276a6cbb5..b9b815f54 100644 --- a/app/App.zig +++ b/app/App.zig @@ -1141,12 +1141,17 @@ pub fn syncLoadedPluginRenderBridge(app: *App) void { } } +/// Whether this build loads plugins at runtime (`-Ddynamic-plugins`, default on): fizzy's own +/// dylibs, the user plugins directory, the store, and on the web a page's `?plugin=`. Off, the +/// app runs exactly the plugins it links. +pub const loads_plugins = if (@hasDecl(build_opts, "dynamic_plugins")) build_opts.dynamic_plugins else true; + /// Whether a bundled plugin should be loaded from `{exe_dir}/plugins//` rather than the -/// copy linked in: never on wasm, not when the build pinned it static +/// copy linked in: never on wasm or in a link-only build, not when the build pinned it static /// (`build_opts.static_`), and not when `FIZZY_STATIC_=1` is set — the bisection /// switch for dylib loading trouble. pub fn bundledDylibEnabled(gpa: std.mem.Allocator, comptime id: []const u8) bool { - if (comptime builtin.target.cpu.arch == .wasm32) return false; + if (comptime builtin.target.cpu.arch == .wasm32 or !loads_plugins) return false; if (comptime @hasDecl(build_opts, "static_" ++ id)) { if (comptime @field(build_opts, "static_" ++ id)) return false; } diff --git a/app/store/PluginStore.zig b/app/store/PluginStore.zig index 9410d5d96..f19bf176f 100644 --- a/app/store/PluginStore.zig +++ b/app/store/PluginStore.zig @@ -23,6 +23,7 @@ const store = @import("registry/store.zig"); const Page = @import("PluginPage.zig"); const PluginLoader = @import("PluginLoader.zig"); const PluginManager = @import("PluginManager.zig"); +const App = @import("../App.zig"); /// Pretend two installed plugins have store updates waiting, so the update flow (the app's /// offer, or the silent path's logging) can be exercised without an actually out-of-date /// plugin. A build-time flag: flip it, run, flip it back. @@ -406,6 +407,9 @@ const app = &PluginManager.current; pub fn register(manager: PluginManager) !void { PluginManager.current = manager; + // A link-only build has no store: nothing to fetch, nowhere to install to. The manager is + // still set, because settings name plugins through it (`displayName`). + if (comptime !App.loads_plugins) return; const host = manager.host; const url = resolveRegistryUrl(); const fp_hex = try std.fmt.allocPrint(manager.gpa, "0x{x}", .{dylib.abi_fingerprint}); @@ -1333,6 +1337,7 @@ fn offerPendingUpdates() void { /// before the Host-registry iterations, so a freshly-registered or unloaded plugin never /// mutates a list mid-iteration. pub fn tick() void { + if (comptime !App.loads_plugins) return; if (comptime builtin.target.cpu.arch == .wasm32) { if (catalog) |*c| c.pump(web_fetch); StoreIcon.pump(); diff --git a/build/app.zig b/build/app.zig index 7f16cc10b..3d2b47aed 100644 --- a/build/app.zig +++ b/build/app.zig @@ -83,6 +83,7 @@ pub const Config = struct { static_workbench: bool, static_text: bool, static_image: bool, + dynamic_plugins: bool, workbench_opts: *std.Build.Step.Options, /// Null on the configure pass that fetches Velopack (see `readConfig`). msvcup_before_compile: ?*std.Build.Step.Run, @@ -227,6 +228,11 @@ pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options build_opts.addOption([]const u8, "app_repo_url", app_repo_url); build_opts.addOption([]const u8, "app_repo_url_fallback", app_repo_url_fallback); build_opts.addOption(bool, "velopack_enabled", velopack_enabled); + // Off, the app runs only the plugins it links (`buildApp`'s list): no plugin dylib is built, + // and nothing loads at runtime — not fizzy's own dylibs, not the user plugins directory, not + // the store, not a web page's `?plugin=`. Comptime, so none of it is checked per frame. + const dynamic_plugins = b.option(bool, "dynamic-plugins", "Load plugins at runtime: fizzy's own dylibs, the user plugins directory, the store (default: true)") orelse true; + build_opts.addOption(bool, "dynamic_plugins", dynamic_plugins); // A consumer that wants its own shape passes `-Dapp-layout=` (a LazyPath to // `pub fn layout(?*anyopaque, *Layout)`). Fizzy itself uses `src/editor/layout.zig`. @@ -303,6 +309,7 @@ pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options .static_workbench = static_workbench, .static_text = static_text, .static_image = static_image, + .dynamic_plugins = dynamic_plugins, .workbench_opts = workbench_opts, .msvcup_before_compile = msvcup_before_compile, .accesskit = accesskit, @@ -354,6 +361,7 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. const static_workbench = cfg.static_workbench; const static_text = cfg.static_text; const static_image = cfg.static_image; + const dynamic_plugins = cfg.dynamic_plugins; const workbench_opts = cfg.workbench_opts; const msvcup_before_compile = cfg.msvcup_before_compile; const accesskit = cfg.accesskit; @@ -377,7 +385,7 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. web.addSteps(b, optimize, build_opts, workbench_opts, assets_module, opts.app_plugins, opts.web_plugin_deps, opts.web_plugin_dirs); - const main_fizzy = try fizzy_exe.addFizzyExecutableForTarget(b, vz, target, optimize, accesskit, native_backend, build_opts, workbench_opts, assets_module, macos_sdl_paths, velopack_enabled, app_name, app_layout_path, opts.app_plugins); + const main_fizzy = try fizzy_exe.addFizzyExecutableForTarget(b, vz, target, optimize, accesskit, native_backend, build_opts, workbench_opts, assets_module, macos_sdl_paths, velopack_enabled, app_name, app_layout_path, opts.app_plugins, dynamic_plugins); const exe = main_fizzy.exe; const package_fizzy: FizzyExecutable = package_blk: { @@ -399,8 +407,9 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. pack_opts.addOption(bool, "static_workbench", static_workbench); pack_opts.addOption(bool, "static_text", static_text); pack_opts.addOption(bool, "static_image", static_image); + pack_opts.addOption(bool, "dynamic_plugins", dynamic_plugins); pack_opts.addOption(bool, "has_app_layout", app_layout_path != null); - break :package_blk try fizzy_exe.addFizzyExecutableForTarget(b, vz, target, optimize, accesskit, native_backend, pack_opts, workbench_opts, assets_module, macos_sdl_paths, true, app_name, app_layout_path, opts.app_plugins); + break :package_blk try fizzy_exe.addFizzyExecutableForTarget(b, vz, target, optimize, accesskit, native_backend, pack_opts, workbench_opts, assets_module, macos_sdl_paths, true, app_name, app_layout_path, opts.app_plugins, dynamic_plugins); }; const exe_for_package = package_fizzy.exe; diff --git a/build/exe.zig b/build/exe.zig index 9b750a482..22305d6ea 100644 --- a/build/exe.zig +++ b/build/exe.zig @@ -204,6 +204,8 @@ pub fn addFizzyExecutableForTarget( /// package (`plugin_dep.module("plugin")`). Listed in the generated `bundled_plugins` module /// under its plugin id. app_plugins: []const sdk.BundledPlugin, + /// False for a link-only app (`-Ddynamic-plugins=false`): no plugin dylib is built. + dynamic_plugins: bool, ) !FizzyExecutable { const native = nativeDvui(b, native_backend, resolved_target, optimize, accesskit, macos_sdl_paths); const dvui_dep = native.dep; @@ -341,7 +343,7 @@ pub fn addFizzyExecutableForTarget( exe.root_module.addImport("app_layout", app_layout_mod); } - const workbench_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: { + const workbench_dylib: ?*std.Build.Step.Compile = if (dynamic_plugins and resolved_target.result.cpu.arch != .wasm32) blk: { break :blk workbench_plugin.addDylib(b, resolved_target, optimize, .{ .dvui = dvui_proxy_mod, .core = core_proxy_module, @@ -352,7 +354,7 @@ pub fn addFizzyExecutableForTarget( }, workbench_opts); } else null; - const text_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32 and plugins.linksFizzys(app_plugins, "text")) blk: { + const text_dylib: ?*std.Build.Step.Compile = if (dynamic_plugins and resolved_target.result.cpu.arch != .wasm32 and plugins.linksFizzys(app_plugins, "text")) blk: { break :blk text_plugin.addDylib(b, resolved_target, optimize, .{ .dvui = dvui_proxy_mod, .core = core_proxy_module, @@ -362,7 +364,7 @@ pub fn addFizzyExecutableForTarget( }); } else null; - const markdown_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32 and plugins.linksFizzys(app_plugins, "markdown")) blk: { + const markdown_dylib: ?*std.Build.Step.Compile = if (dynamic_plugins and resolved_target.result.cpu.arch != .wasm32 and plugins.linksFizzys(app_plugins, "markdown")) blk: { break :blk markdown_plugin.addDylib(b, resolved_target, optimize, .{ .dvui = dvui_proxy_mod, .core = core_proxy_module, @@ -371,7 +373,7 @@ pub fn addFizzyExecutableForTarget( }); } else null; - const image_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32 and plugins.linksFizzys(app_plugins, "image")) blk: { + const image_dylib: ?*std.Build.Step.Compile = if (dynamic_plugins and resolved_target.result.cpu.arch != .wasm32 and plugins.linksFizzys(app_plugins, "image")) blk: { break :blk image_plugin.addDylib(b, resolved_target, optimize, .{ .dvui = dvui_proxy_mod, .core = core_proxy_module, diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 248f2d50a..30c93b7e9 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -667,7 +667,7 @@ pub const view_settings = "fizzy.settings"; pub const workbench_files_view = workbench_mod.view_files; pub fn loadUserPlugins(editor: *Editor, config_folder: []const u8) void { - if (comptime builtin.target.cpu.arch == .wasm32) return; + if (comptime builtin.target.cpu.arch == .wasm32 or !App.loads_plugins) return; const plugins_dir = std.fs.path.join(editor.app.gpa, &.{ config_folder, "plugins" }) catch return; defer editor.app.gpa.free(plugins_dir); @@ -1424,7 +1424,7 @@ pub const UnloadError = error{ NotUnloadable, DirtyDocuments }; /// dvui/render-bridge sync path as `loadUserPlugins`. Caller ensures `id` is not already /// registered. On success the lib is appended to `loaded_plugin_libs`. pub fn loadUserPluginById(editor: *Editor, id: []const u8) !void { - if (comptime builtin.target.cpu.arch == .wasm32) return error.NotUnloadable; + if (comptime builtin.target.cpu.arch == .wasm32 or !App.loads_plugins) return error.NotUnloadable; const path = try App.userPluginPath(editor.app.gpa, &editor.app, id); const opened = PluginLoader.open(editor.app.gpa, path, id) catch |err| { editor.userPluginLoadFailed(id, path, err); @@ -1632,7 +1632,7 @@ const WebPluginRequest = struct { /// The page asks for a plugin: `?plugin=` on the URL (`plugins//.wasm` beside /// the app), or one it remembered from a store install (by its URL). export fn FizzyWebPluginRequest(id_ptr: [*]const u8, id_len: usize, url_ptr: [*]const u8, url_len: usize) void { - if (comptime builtin.target.cpu.arch != .wasm32) return; + if (comptime builtin.target.cpu.arch != .wasm32 or !App.loads_plugins) return; const editor = web_editor orelse return; const id = id_ptr[0..id_len]; // The id reaches this from the page's query string, and with no URL it is interpolated From 21e4ccbf773a2ea84f6bd3d759671d92ae7af406 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:50:56 -0500 Subject: [PATCH 16/46] tape: steps of a document's history, and a deflated binary form (#453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 1: the tape format. ## What changes A tape can carry **a document's own history**, not just input: - **`step` op** (`Tape.Step{ file, effect, motion }`): applies one change's effect, in its owner's encoding, to a file of the keyframe before it, shown being made over the op's `ms`. Effects replay exactly at any window size and in any layout; input only replays in the window it was recorded in. Steps land with **no frame between them**, so a seek across an hour of drawing is one sequencer call. - **Files carry bytes and an owner**: `File.text` may be a binary document's bytes as on disk (ZON escapes them; round-trip tested), and `File.owner` / `File.format` say which plugin applies its steps and in which of its encodings. - **`Check.steps`**: the app says whose steps, in which format, it applies. Without it, a tape with steps is refused (`error.StepsUnsupported`). A misspelled chord shows when pressed; a step nobody applied would play a document that never changes and look fine. - **`Stage.step`** (optional): the replay player hands each step to the stage, with `ms = 0` while a seek or catch-up runs. **Fizzy's stage does not apply steps yet**: its `Input.check` has no `steps`, so fizzy refuses such tapes for now. That lands with the tracks (plan steps 3–6). **Binary form version 3:** - Steps; files' owner and format; a **blob table** for effects and motion. Not interned: effects almost never repeat, and hashing every byte to find out costs more than it saves. - An optional **deflated** body (`binary.Options{ .deflate = true }`): the inflated length, then one zlib stream. - Versions 1 and 2 still read. The v2 test uses a real file written by `main`'s codec (`sdk/tape/testdata/everything-v2.tape`), not a patched version number, since v3's layout differs. - **std 0.16's inflate has two defects** a shared file must not reach. It asserts past the end of its input on a stream cut short (`tossBitsShort` adds consumed bits where it should subtract them), and it reads the zlib checksum but never compares it. So the reader feeds it zeros without end past the stream, caps the output at the header's length (+1, to catch a stream that runs on), and checks Adler-32 itself. Worth re-checking at the 0.17 move. **Moved from the plan's step 1:** - Keyframe "documents as state" became file bytes plus an owner. A shared tape must create its document from nothing, and `captureDocumentState` only restores in place. - The idle-gap rule is deferred to the step that plays tracks. Nothing plays steps until then, and it is the player's rule, not the format's. **API:** `binary.write` / `binary.encode` take an `Options` argument (call sites updated; nothing outside this repo calls them, checked in example-app, example-plugin, agent and pixi). Measured, `zig build bench-tape -Doptimize=ReleaseFast`, synthetic strokes shaped as pixi's effect will be (pixi's real encoding is plan step 4): | Steps | Plain | Plain load | Deflated | Deflated write | Deflated load | |---:|---:|---:|---:|---:|---:| | 2,000 (≈ 76 min of drawing) | 275 KB | 0.04 ms | 100 KB | 8.7 ms | 0.86 ms | | 20,000 | 2.7 MB | 0.25 ms | 873 KB | 87 ms | 7.9 ms | | 100,000 | 13.3 MB | 1.5 ms | 4.2 MB | 432 ms | 40 ms | ## SDK impact - [ ] None - [x] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag The fingerprint doesn't move (`test-sdk-version` passes): tapes cross the plugin boundary as bytes. The `tape` and `replay` libraries ship in the SDK tarball, so a plugin that calls `tape.binary.encode` gets the new `Options` argument at the next release (none does today). ## Verified - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build check-web`, `zig build test-sdk-version` and `zig build check-tests-reached` all pass. - `zig build test`: 557/558 (1 skipped). `zig build test-integration`: 53/53 steps, 525/525 tests. - New tape tests: steps' validation and ZON round-trip, the sequencer landing steps together, binary v3 round-trip plain and deflated, v1/v2 reading, truncation at every length and 4,000 random flips per form. A one-off 100,000 flips per form under ReleaseSafe passed. - [ ] Windows: not run (std-only format code). - [ ] Linux: CI. - [ ] Web: `check-web` builds. Nothing plays steps there yet. ## Follow-ups - Plan step 2: `encodeStep` / `applyStep` on the document vtable, text's implementation. - The idle-gap playback rule, with the step that plays tracks. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- docs/AUTOMATION.md | 28 +- sdk/replay/Player.zig | 7 + sdk/replay/Stage.zig | 9 + sdk/tape/Sequencer.zig | 42 +++ sdk/tape/Tape.zig | 99 +++++- sdk/tape/binary.zig | 494 +++++++++++++++++++-------- sdk/tape/testdata/everything-v2.tape | Bin 0 -> 1192 bytes tests/bench/bench_tape.zig | 89 ++++- tests/integration.zig | 6 +- 9 files changed, 620 insertions(+), 154 deletions(-) create mode 100644 sdk/tape/testdata/everything-v2.tape diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index f78c504f7..0472208a7 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -286,17 +286,29 @@ one. | `type: "text"` | Type over `ms`; `\n` is Enter, `\t` is Tab. | | `command: .{ .id, .args }` | Run a command: with no `args`, as its menu row or shortcut would (one that needs arguments asks for them); with `args`, ZON keyed by its parameters (`".{ .line = 1 }"`), as the palette does once they are given. A script writes `s.command(id)` or `s.commandWith(id, .{ .line = 1 })`. | | `wait: .{ .until, .timeout }` | Hold demo time until `.idle`, `.shown = "tag"` or `.gone = "tag"`; give up after `timeout` ms of wall time. | +| `step: .{ .file, .effect, .motion }` | Apply one step of a document's history to `file` of the keyframe before it, shown being made over `ms`: `effect` is what the step did and `motion` how it was made (optional), both in the file's `owner`'s encoding. Steps land together, with no frame between them, so a seek across an hour of drawing is one call. | Key chords are the app's spelling: the `tape` library carries them as text, and a `Tape.Check` the app passes to `parse`, `load` and `Script` says which it accepts (fizzy's is `automation.Input.check`, the keymap's parser). +**Steps** record what a document became, not the input that made it, so they replay exactly at +any window size and in any layout (`plans/RECORDING_PLAN.md`). A file a step changes names the +plugin that applies it and its encoding: `.{ .path = "sprite.pixi", .text = , +.owner = "pixi", .format = 1 }`. A file's `text` is its bytes as on disk, a binary document's +included (ZON escapes them). `Check.steps` says whose steps, in which format, the app can apply; +without it a tape with steps is refused (`error.StepsUnsupported`), since a step nobody applied +would play a document that never changes. The app applies them through its stage +(`Stage.step`). Fizzy does not apply steps yet. + ### The binary form (`.tape`) -`tape.binary` writes and reads the same `Tape` as a few flat tables: a 60-byte header (`FZTP`, -a version, the counts), a string table that holds each distinct string once (anchors repeat -constantly), then fixed-size records — ops, keyframes and their files, captions, chapters — -that point into it by index. Reading is one bounds-checked pass that builds the `Tape` in a +`tape.binary` writes and reads the same `Tape` as a few flat tables: a header (`FZTP`, a +version, the counts), a string table that holds each distinct string once (anchors repeat +constantly), a blob table of the steps' effects and motion, then fixed-size records — ops, +keyframes and their files, captions, chapters — that point into them by index. With +`.{ .deflate = true }` everything after the version and flags is one zlib stream, for a tape that +is sent somewhere. Reading is one bounds-checked pass that builds the `Tape` in a single arena, with every offset, index and enum checked: a damaged or truncated file is an error, never a crash. It is lossless both ways, so a tape converts freely between the forms. @@ -309,6 +321,14 @@ compare (`zig build bench-tape -Doptimize=ReleaseFast`; recording-shaped tapes, | 10,000 (about ten minutes recorded) | 1.5 MB | 17 ms | 353 KB | 0.29 ms | | 100,000 | 15 MB | 179 ms | 3.4 MB | 3.9 ms | +A recording of drawing, plain against deflated (steps shaped as a pixi stroke's effect, with the +pen's path; the same bench, ReleaseFast): + +| Steps | Plain | Plain load | Deflated | Deflated write | Deflated load | +|---:|---:|---:|---:|---:|---:| +| 2,000 (about an hour of drawing) | 275 KB | 0.04 ms | 100 KB | 8.7 ms | 0.86 ms | +| 20,000 | 2.7 MB | 0.25 ms | 873 KB | 87 ms | 7.9 ms | + ## Live tapes A demo cuts to a keyframe and plays at its authored pace. A **live tape** plays on the app as it diff --git a/sdk/replay/Player.zig b/sdk/replay/Player.zig index ce0b75488..aff26ea4b 100644 --- a/sdk/replay/Player.zig +++ b/sdk/replay/Player.zig @@ -933,6 +933,7 @@ const sink_vtable: Sequencer.Sink.VTable = .{ .keyframe = keyframe, .holds = holds, .timedOut = timedOut, + .step = step, }; fn from(ctx: *anyopaque) *Player { @@ -969,6 +970,12 @@ fn command(ctx: *anyopaque, cmd: Tape.Command) void { from(ctx).stage.command(cmd.id, cmd.args); } +fn step(ctx: *anyopaque, st: Tape.Step, ms: u32) void { + const self = from(ctx); + // A seek, or a frame run unseen to catch up, lands it at once. + self.stage.step(st, if (self.state == .seeking or self.catching_up) 0 else ms); +} + fn keyframe(ctx: *anyopaque, kf: *const Tape.Keyframe) void { const self = from(ctx); self.input.releaseHeld(); diff --git a/sdk/replay/Stage.zig b/sdk/replay/Stage.zig index 9a4ec9808..0df975936 100644 --- a/sdk/replay/Stage.zig +++ b/sdk/replay/Stage.zig @@ -48,6 +48,12 @@ pub const VTable = struct { /// Replaying fast (a seek): turn animation off so every replayed frame lands where it is /// going, and back on after. fastForward: ?*const fn (ctx: *anyopaque, on: bool) void = null, + /// Apply a step to a file of the keyframe the demo cut to, as its owner encoded it + /// (`Tape.Step`), shown being made over `ms`: 0 while a seek catches up, when it lands at once. + /// An app that sets this puts a keyframe's files in place opened by their `owner`, and says + /// whose steps it can apply (`Tape.Check.steps`); one that leaves it null refuses tapes with + /// steps at load. + step: ?*const fn (ctx: *anyopaque, step: Tape.Step, ms: u32) void = null, /// The app's model now, as something `restore` can put back — or null when this is not a /// moment to keep (work in flight, a popup open, a document whose owner cannot say what it @@ -91,6 +97,9 @@ pub fn commandTitle(self: Stage, id: []const u8) ?[]const u8 { pub fn fastForward(self: Stage, on: bool) void { if (self.vtable.fastForward) |f| f(self.ctx, on); } +pub fn step(self: Stage, st: Tape.Step, ms: u32) void { + if (self.vtable.step) |f| f(self.ctx, st, ms); +} pub fn snapshots(self: Stage) bool { return self.vtable.capture != null and self.vtable.restore != null and self.vtable.release != null; } diff --git a/sdk/tape/Sequencer.zig b/sdk/tape/Sequencer.zig index 7bfbe8e7b..1a2b9c7fb 100644 --- a/sdk/tape/Sequencer.zig +++ b/sdk/tape/Sequencer.zig @@ -21,6 +21,8 @@ //! path a point per `path_step_ms`, the frames live play would have drawn, so whatever it //! crosses sees the pointer pass — a text field it leaves forgets its click count, as it did //! live — and typing still arrives a keystroke at a time. +//! * **Steps land together.** A `step` is a document's own change, not input: it needs no frame +//! drawn before the next op, so a seek over an hour of drawing applies it all in one call. //! * **Waits hold time still.** A `wait` that does not hold yet stops the clock at the wait until //! it does (or its timeout passes, in wall time), so the ops after it land on an app that has //! caught up — at any speed, on any machine. @@ -88,6 +90,9 @@ pub const Sink = struct { holds: *const fn (ctx: *anyopaque, until: Tape.Until) bool, /// A wait gave up. The sink says so where a person will see it. timedOut: *const fn (ctx: *anyopaque, until: Tape.Until) void, + /// Apply a step to its file, shown being made over `ms` (the op's). Null for a sink that + /// plays only live tapes, which have none (`Tape.validate`). + step: ?*const fn (ctx: *anyopaque, step: Tape.Step, ms: u32) void = null, }; }; @@ -200,6 +205,10 @@ pub fn advance(self: *Sequencer, until: f64, wall_ms: f64, sink: Sink) Progress self.typing = .{ .op = self.cursor }; self.cursor += 1; }, + .step => |st| { + self.cursor += 1; + if (sink.vtable.step) |f| f(sink.ctx, st, op.ms); + }, .keyframe => |i| { self.cursor += 1; sink.vtable.keyframe(sink.ctx, &self.tape.keyframes[i]); @@ -429,6 +438,7 @@ const Log = struct { .keyframe = keyframe, .holds = holds, .timedOut = timedOut, + .step = step, } }; } fn from(ctx: *anyopaque) *Log { @@ -470,6 +480,9 @@ const Log = struct { fn timedOut(ctx: *anyopaque, _: Tape.Until) void { from(ctx).print("timeout", .{}); } + fn step(ctx: *anyopaque, st: Tape.Step, ms: u32) void { + from(ctx).print("step {s} {s} {d}", .{ st.file, st.effect, ms }); + } }; const test_keyframes = [_]Tape.Keyframe{ .{ .root = "demo://a" }, .{ .root = "demo://b" } }; @@ -534,6 +547,35 @@ test "a click lands a frame after the glide that brought the pointer to it" { try testing.expect(seq.done()); } +test "steps land together, with no frame between them, each at its own time" { + const ops = [_]Tape.Op{ + .{ .at = 0, .do = .{ .keyframe = 0 } }, + .{ .at = 100, .ms = 40, .do = .{ .step = .{ .file = "a.pixi", .effect = "one" } } }, + .{ .at = 200, .do = .{ .step = .{ .file = "a.pixi", .effect = "two" } } }, + .{ .at = 300, .ms = 90, .do = .{ .step = .{ .file = "a.pixi", .effect = "three" } } }, + .{ .at = 400, .do = .{ .key = "a" } }, + }; + const tape: Tape = .{ .name = "t", .ops = &ops, .keyframes = &test_keyframes }; + var log: Log = .{}; + defer log.deinit(); + + var seq: Sequencer = .init(&tape); + try testing.expectEqual(Progress.yielded, seq.advance(0, 16, log.sink())); + // Played live, each lands when it is due. + try testing.expectEqual(Progress.reached, seq.advance(150, 16, log.sink())); + try testing.expectEqualStrings("keyframe demo://a\nstep a.pixi one 40\n", log.out.items); + // Sought past, the rest land in the one call, up to the key that needs a frame of its own. + try testing.expectEqual(Progress.yielded, seq.advance(1000, 16, log.sink())); + try testing.expectEqualStrings( + \\keyframe demo://a + \\step a.pixi one 40 + \\step a.pixi two 0 + \\step a.pixi three 90 + \\key a + \\ + , log.out.items); +} + test "a glide covered in one call still passes along its path, a frame's worth at a time" { const ops = [_]Tape.Op{ .{ .at = 0, .do = .{ .keyframe = 0 } }, diff --git a/sdk/tape/Tape.zig b/sdk/tape/Tape.zig index 6a0ad70bc..19b6c76b8 100644 --- a/sdk/tape/Tape.zig +++ b/sdk/tape/Tape.zig @@ -19,6 +19,12 @@ //! app's own spelling (fizzy's: `"mod+shift+p"`, `"enter"`); what a spelling means, and whether //! one is right, is the app's to say (`Check`), so the format carries no keymap of its own. //! +//! **Steps** carry a document's own history rather than input: the effect of one change (the +//! pixels a stroke set, the text a transaction inserted) in its owner's encoding, applied to a +//! file the keyframe declared. Input replays only in a window laid out as it was recorded; an +//! effect replays exactly anywhere. What a step's bytes mean is the owner's, versioned by the file's +//! `format`, and whether the app can apply them is the app's to say (`Check.steps`). +//! //! Everything here is plain data with no pointers into anything but its own strings, so a tape //! round-trips through ZON (`parse`, `write`): the same thing a script builds (`Script`) is what //! a recorder would write and what a web page can fetch and play. @@ -89,6 +95,10 @@ pub const Action = union(enum) { /// Hold demo time until a condition holds. Waits are how a tape stays deterministic over an /// app that does things on its own time (loading a file, animating a pane open). wait: Wait, + /// Apply one step of a document's history to a file of the keyframe before it, shown being + /// made over `Op.ms` (the time it took, as recorded). Lands with no frame drawn between it and + /// the next: an effect needs no layout to land on, so a seek applies thousands a frame. + step: Step, /// Where a `move` or `trace` takes the pointer. pub fn target(self: Action) ?Target { @@ -113,6 +123,20 @@ pub const Command = struct { args: []const u8 = "", }; +/// One step of a document's history, as its owner encoded it. +pub const Step = struct { + /// The file it changes, as the keyframe before it names it (`File.path`). The file names its + /// owner, whose step it is. + file: []const u8, + /// What the step did to the document, in the owner's encoding (`File.format`): exact, so it + /// lands the same at any window size, in any layout. + effect: []const u8, + /// How it was made, when the owner recorded it (a pen's path and timing, in the document's + /// own coordinates), so a replay shows it made as it was. Empty: the owner shows the effect + /// made its own way. + motion: []const u8 = "", +}; + pub const Scroll = struct { x: f32 = 0, y: f32 = 0, @@ -194,6 +218,12 @@ pub const Keyframe = struct { /// put in place at the cut whatever the viewer has chosen, and given back when the demo ends. settings: []const Setting = &.{}, + /// The file at `path`, or null when it has none. + pub fn file(self: Keyframe, path: []const u8) ?*const File { + for (self.files) |*f| if (std.mem.eql(u8, f.path, path)) return f; + return null; + } + /// How the window is arranged at the cut. The stage's to interpret, like the rest. pub const Layout = enum { /// As it is. @@ -220,7 +250,13 @@ pub const Setting = struct { pub const File = struct { /// Relative to the keyframe's `root`: `src/main.zig`. path: []const u8, + /// Its bytes, as on disk: text, or a binary document's (a `.pixi`), which ZON escapes. text: []const u8, + /// The plugin that opens it and applies its steps (`pixi`). Empty: whichever the app would + /// open it with, and the file takes no steps. + owner: []const u8 = "", + /// The owner's encoding of its steps (`Step.effect`), which the owner numbers. + format: u32 = 0, }; // ---- reading ------------------------------------------------------------------------------- @@ -356,7 +392,7 @@ pub fn pointerShown(self: Tape, applied: usize, t: f64, fade_ms: f64) f32 { .move, .trace, .press, .release, .scroll => .pointer, // A keyframe is a fresh start. .keyframe => break, - .wait => continue, + .wait, .step => continue, }; const at: f64 = @floatFromInt(op.at); if (moved_at) |m| { @@ -386,6 +422,10 @@ pub const Error = error{ ChaptersOutOfOrder, /// A live tape (`Check.live`) plays on the app as it is, so it never cuts to a keyframe. KeyframeInLiveTape, + /// A step names no file of the keyframe before it, or one with no owner to apply it. + StepWithoutFile, + /// The app cannot apply a file's steps: no `Check.steps`, or it said no to the owner's format. + StepsUnsupported, }; /// What only the app can say about a tape: whether a key chord is spelled right, in its spelling. @@ -396,6 +436,10 @@ pub const Check = struct { /// test, a macro, an automation client — rather than a demo that cuts to the state it /// declares. It has no keyframe anywhere, where a demo opens on one. live: bool = false, + /// Whether the app applies `owner`'s steps written in `format`. Left out, a tape with steps + /// is refused: unlike a misspelled chord, which shows when it is pressed, a step nobody + /// applies plays a document that never changes and looks like a tape that works. + steps: ?*const fn (owner: []const u8, format: u32) bool = null, }; /// Check what `Sequencer` relies on, once, at load — a tape that fails here would misplay rather @@ -403,6 +447,8 @@ pub const Check = struct { pub fn validate(self: Tape, check: Check) Error!void { if (!check.live and (self.ops.len == 0 or self.ops[0].at != 0 or self.ops[0].do != .keyframe)) return error.NoKeyframeAtStart; var prev: u32 = 0; + // The keyframe the ops are under: a step's file is one of its. + var scene: ?*const Keyframe = null; for (self.ops) |op| { if (op.at < prev) return error.OpsOutOfOrder; prev = op.at; @@ -410,6 +456,13 @@ pub fn validate(self: Tape, check: Check) Error!void { .keyframe => |i| { if (check.live) return error.KeyframeInLiveTape; if (i >= self.keyframes.len) return error.BadKeyframeIndex; + scene = &self.keyframes[i]; + }, + .step => |st| { + const f = (scene orelse return error.StepWithoutFile).file(st.file) orelse return error.StepWithoutFile; + if (f.owner.len == 0) return error.StepWithoutFile; + const ok = check.steps orelse return error.StepsUnsupported; + if (!ok(f.owner, f.format)) return error.StepsUnsupported; }, .key => |k| if (check.key) |ok| { if (!ok(k)) return error.BadChord; @@ -671,3 +724,47 @@ test "a hand-written tape parses with every default left out" { try testing.expect(owned.tape.ops[2].do.wait.until == .idle); try testing.expectEqual(@as(u32, 10_000), owned.tape.ops[2].do.wait.timeout); } + +/// An app that applies `pixi`'s steps in its format 1, and nobody else's. +pub const test_steps: Check = .{ .steps = struct { + fn ok(owner: []const u8, format: u32) bool { + return std.mem.eql(u8, owner, "pixi") and format == 1; + } +}.ok }; + +test "a step changes a file of the keyframe before it, whose owner the app can apply" { + const sprite: [5]u8 = .{ 0x89, 'P', 0, 0xff, '\n' }; + const files = [_]File{ .{ .path = "a.pixi", .text = &sprite, .owner = "pixi", .format = 1 }, .{ .path = "notes.md", .text = "# hi" } }; + const kf = [_]Keyframe{ .{ .root = "demo://s", .files = &files, .open = &.{"a.pixi"} }, .{ .root = "demo://s" } }; + const ops = [_]Op{ + .{ .at = 0, .do = .{ .keyframe = 0 } }, + .{ .at = 100, .ms = 250, .do = .{ .step = .{ .file = "a.pixi", .effect = "\x01\x02", .motion = "\x00" } } }, + .{ .at = 400, .do = .{ .step = .{ .file = "a.pixi", .effect = "\x03" } } }, + }; + const tape: Tape = .{ .name = "s", .ops = &ops, .keyframes = &kf }; + try tape.validate(test_steps); + // An app that says nothing of steps, or no to this owner's format, refuses the tape. + try testing.expectError(error.StepsUnsupported, tape.validate(.{})); + const other_format = [_]File{.{ .path = "a.pixi", .text = &sprite, .owner = "pixi", .format = 2 }}; + try testing.expectError(error.StepsUnsupported, (Tape{ .name = "s", .ops = &ops, .keyframes = &.{.{ .root = "demo://s", .files = &other_format }} }).validate(test_steps)); + // A file that names its owner and takes no steps needs nothing of the app. + try (Tape{ .name = "s", .ops = ops[0..1], .keyframes = &.{.{ .root = "demo://s", .files = &other_format }} }).validate(.{}); + + // Its file must be the keyframe's, and one with an owner to apply it. + const elsewhere = [_]Op{ ops[0], .{ .at = 50, .do = .{ .keyframe = 1 } }, ops[1] }; + try testing.expectError(error.StepWithoutFile, (Tape{ .name = "s", .ops = &elsewhere, .keyframes = &kf }).validate(test_steps)); + const ownerless = [_]Op{ ops[0], .{ .at = 50, .do = .{ .step = .{ .file = "notes.md", .effect = "x" } } } }; + try testing.expectError(error.StepWithoutFile, (Tape{ .name = "s", .ops = &ownerless, .keyframes = &kf }).validate(test_steps)); + // A live tape has no keyframe, so nothing to step. + try testing.expectError(error.StepWithoutFile, (Tape{ .name = "s", .ops = ops[1..] }).validate(.{ .live = true, .steps = test_steps.steps })); + + // Through ZON, the binary file and the steps come back byte for byte. + var out: std.Io.Writer.Allocating = .init(testing.allocator); + defer out.deinit(); + try tape.write(&out.writer); + const source = try testing.allocator.dupeZ(u8, out.written()); + defer testing.allocator.free(source); + var back = try parse(testing.allocator, source, test_steps); + defer back.deinit(); + try testing.expectEqualDeep(tape, back.tape); +} diff --git a/sdk/tape/binary.zig b/sdk/tape/binary.zig index 6d37c2af6..bc8a6f73c 100644 --- a/sdk/tape/binary.zig +++ b/sdk/tape/binary.zig @@ -10,6 +10,9 @@ //! how many of everything follow; //! * the string table: each distinct string once — every anchor, every typed text, every file — //! as `count + 1` offsets into the bytes that follow them; +//! * the blob table, the same shape: each step's effect and motion, in order and never interned +//! (an effect almost never repeats, and hashing every byte of an hour of them to find out +//! would cost more than it saves); //! * the ops, one fixed-size record each (`op_size` bytes): time, length, kind, and the kind's //! payload as string indices and numbers. Fixed size is what lets a reader index op `i`, or //! binary-search them by time, without decoding the ones before it; @@ -21,15 +24,22 @@ //! slice of that one copy, so there is one allocation for the bytes and one per list, never one //! per string. A truncated or tampered file is an error, never a crash or a read out of bounds. //! +//! **Deflated** (`Options.deflate`, the header's flag `flag_deflated`): after the magic, version +//! and flags come the length of what would otherwise follow them, then one zlib stream of it. For +//! a recording that is shared — its effects are mostly runs of the same few bytes. Reading +//! inflates it into the one copy, exactly that long, then reads as ever. +//! //! **Versions.** `version` is bumped for any change a reader of the old one would misread; a //! reader refuses a version it does not know (`error.UnsupportedVersion`) rather than guess, and //! reads every older one it does. 2 gave a `command` op its arguments, in the record's second -//! string; a version-1 command has none. +//! string; a version-1 command has none. 3 added steps, files' owner and format, the blob table +//! and the deflated form. const std = @import("std"); +const flate = std.compress.flate; const Tape = @import("Tape.zig"); pub const magic = "FZTP".*; -pub const version: u16 = 2; +pub const version: u16 = 3; /// The oldest version `read` still reads. pub const oldest_version: u16 = 1; @@ -52,14 +62,32 @@ pub const Header = struct { captions: u32, clear: u32, chapters: u32, + // Version 3 on. + blobs: u32 = 0, + blob_bytes: u32 = 0, + + /// How many of its fields a tape of version `v` has on disk. + fn fields(v: u16) usize { + return if (v >= 3) std.meta.fields(Header).len else 13; + } }; -/// The header's size on disk: magic, version, flags, then `Header`'s fields. -pub const header_size = 4 + 2 + 2 + 13 * 4; +/// What comes before the header: magic, version, flags. +const prefix_size = 4 + 2 + 2; + +/// The header's flags. +pub const flag_deflated: u16 = 1 << 0; +const known_flags = flag_deflated; +/// The most a deflated tape inflates to: past it, the stream is taken for corrupt rather than +/// allocated (a few bytes of tampered stream can claim gigabytes). +pub const max_inflated = 256 << 20; /// The size of each record on disk. pub const op_size = 36; const keyframe_size = 32; -const file_size = 8; +/// A file's record: path and text, then from version 3 its owner and format. +fn fileSize(v: u16) usize { + return if (v >= 3) 16 else 8; +} const open_size = 4; const setting_size = 12; const caption_size = 32; @@ -67,16 +95,50 @@ const clear_size = 4; const chapter_size = 8; /// An op's kind on disk. Appended to, never reordered. -const Kind = enum(u8) { keyframe, move, press, release, scroll, key, type, command, wait, trace }; +const Kind = enum(u8) { keyframe, move, press, release, scroll, key, type, command, wait, trace, step }; /// A wait's condition on disk. const UntilKind = enum(u8) { idle, shown, gone }; // ---- writing ------------------------------------------------------------------------------- -/// Write `tape` in the binary form. `gpa` is for the string table while it is built. -pub fn write(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer) !void { +pub const Options = struct { + /// One zlib stream after the header's flags: smaller, for a tape that is sent somewhere, and + /// slower to write and read (`zig build bench-tape`). + deflate: bool = false, +}; + +/// Write `tape` in the binary form. `gpa` is for the string table while it is built, and for the +/// uncompressed form while it is deflated. +pub fn write(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer, opts: Options) !void { + try w.writeAll(&magic); + try w.writeInt(u16, version, .little); + try w.writeInt(u16, if (opts.deflate) flag_deflated else 0, .little); + if (!opts.deflate) return writeBody(gpa, tape, w); + + var body: std.Io.Writer.Allocating = .init(gpa); + defer body.deinit(); + try writeBody(gpa, tape, &body.writer); + // The compressor wants room to write into of its own, whatever `w` has. + var out: std.Io.Writer.Allocating = try .initCapacity(gpa, 64); + defer out.deinit(); + const window = try gpa.alloc(u8, flate.max_window_len); + defer gpa.free(window); + const c = try gpa.create(flate.Compress); + defer gpa.destroy(c); + c.* = try .init(&out.writer, window, .zlib, .default); + try c.writer.writeAll(body.written()); + try c.finish(); + try w.writeInt(u32, count(body.written().len), .little); + try w.writeAll(out.written()); +} + +/// Everything after the flags. +fn writeBody(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer) !void { var table: Strings = .{}; defer table.deinit(gpa); + var blobs: std.ArrayList([]const u8) = .empty; + defer blobs.deinit(gpa); + var blob_bytes: usize = 0; // Every string, interned, in the order first met — so the table is deterministic. const name = try table.intern(gpa, tape.name); @@ -93,6 +155,11 @@ pub fn write(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer) !void { .shown, .gone => |s| _ = try table.intern(gpa, s), .idle => {}, }, + .step => |st| { + _ = try table.intern(gpa, st.file); + try blobs.appendSlice(gpa, &.{ st.effect, st.motion }); + blob_bytes += st.effect.len + st.motion.len; + }, .keyframe, .press, .release, .scroll => {}, }; var n_files: u32 = 0; @@ -103,6 +170,7 @@ pub fn write(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer) !void { for (kf.files) |f| { _ = try table.intern(gpa, f.path); _ = try table.intern(gpa, f.text); + _ = try table.intern(gpa, f.owner); } for (kf.open) |o| _ = try table.intern(gpa, o); for (kf.settings) |s| { @@ -138,27 +206,23 @@ pub fn write(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer) !void { .captions = count(tape.captions.len), .clear = n_clear, .chapters = count(tape.chapters.len), + .blobs = count(blobs.items.len), + .blob_bytes = count(blob_bytes), }; - try w.writeAll(&magic); - try w.writeInt(u16, version, .little); - try w.writeInt(u16, 0, .little); inline for (std.meta.fields(Header)) |f| try w.writeInt(u32, @field(h, f.name), .little); - var at: u32 = 0; - for (table.list.items) |s| { - try w.writeInt(u32, at, .little); - at += count(s.len); - } - try w.writeInt(u32, at, .little); - for (table.list.items) |s| try w.writeAll(s); - try w.splatByteAll(0, pad4(table.bytes)); + try writeTable(w, table.list.items, table.bytes); + try writeTable(w, blobs.items, blob_bytes); + // The next blob of each step's, in the order the steps were met above. + var blob: u32 = 0; for (tape.ops) |op| { var kind: Kind = undefined; var small: u8 = 0; var a: u32 = 0; var b: u32 = 0; - var xy: [4]f32 = @splat(0); + // The record's last four words: a pointer's floats, bit for bit, or a step's motion. + var xy: [4]u32 = @splat(0); switch (op.do) { .keyframe => |i| { kind = .keyframe; @@ -167,7 +231,7 @@ pub fn write(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer) !void { .move, .trace => |m| { kind = if (op.do == .move) .move else .trace; a = table.index(m.tag); - xy = .{ m.x, m.y, m.dx, m.dy }; + xy = .{ @bitCast(m.x), @bitCast(m.y), @bitCast(m.dx), @bitCast(m.dy) }; }, .press, .release => |btn| { kind = if (op.do == .press) .press else .release; @@ -175,7 +239,7 @@ pub fn write(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer) !void { }, .scroll => |s| { kind = .scroll; - xy = .{ s.x, s.y, 0, 0 }; + xy = .{ @bitCast(s.x), @bitCast(s.y), 0, 0 }; }, .key => |s| { kind = .key; @@ -190,6 +254,13 @@ pub fn write(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer) !void { a = table.index(c.id); b = table.index(c.args); }, + .step => |st| { + kind = .step; + a = table.index(st.file); + b = blob; + xy[0] = blob + 1; + blob += 2; + }, .wait => |wt| { kind = .wait; b = wt.timeout; @@ -213,7 +284,7 @@ pub fn write(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer) !void { try w.writeInt(u16, 0, .little); try w.writeInt(u32, a, .little); try w.writeInt(u32, b, .little); - for (xy) |v| try w.writeInt(u32, @bitCast(v), .little); + for (xy) |v| try w.writeInt(u32, v, .little); } var files_at: u32 = 0; @@ -230,8 +301,8 @@ pub fn write(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer) !void { settings_at += count(kf.settings.len); } for (tape.keyframes) |kf| for (kf.files) |f| { - try w.writeInt(u32, table.index(f.path), .little); - try w.writeInt(u32, table.index(f.text), .little); + for ([_]u32{ table.index(f.path), table.index(f.text), table.index(f.owner), f.format }) |v| + try w.writeInt(u32, v, .little); }; for (tape.keyframes) |kf| for (kf.open) |o| try w.writeInt(u32, table.index(o), .little); for (tape.keyframes) |kf| for (kf.settings) |s| { @@ -256,11 +327,23 @@ pub fn write(gpa: std.mem.Allocator, tape: Tape, w: *std.Io.Writer) !void { } } +/// A table of `list` as `count + 1` offsets into their `bytes` bytes, then the bytes, padded. +fn writeTable(w: *std.Io.Writer, list: []const []const u8, bytes: usize) !void { + var at: u32 = 0; + for (list) |s| { + try w.writeInt(u32, at, .little); + at += count(s.len); + } + try w.writeInt(u32, at, .little); + for (list) |s| try w.writeAll(s); + try w.splatByteAll(0, pad4(bytes)); +} + /// `write` into a new allocation. -pub fn encode(gpa: std.mem.Allocator, tape: Tape) ![]u8 { +pub fn encode(gpa: std.mem.Allocator, tape: Tape, opts: Options) ![]u8 { var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); - write(gpa, tape, &out.writer) catch |err| switch (err) { + write(gpa, tape, &out.writer, opts) catch |err| switch (err) { error.WriteFailed => return error.OutOfMemory, else => |e| return e, }; @@ -324,25 +407,47 @@ pub fn read(gpa: std.mem.Allocator, bytes: []const u8, check: Tape.Check) ReadEr var arena: std.heap.ArenaAllocator = .init(gpa); errdefer arena.deinit(); const a = arena.allocator(); - const own = try a.dupe(u8, bytes); - var r: Reader = .{ .bytes = own }; - _ = try r.take(magic.len); - const tape_version = try r.int(u16); + var pre: Reader = .{ .bytes = bytes }; + _ = try pre.take(magic.len); + const tape_version = try pre.int(u16); if (tape_version < oldest_version or tape_version > version) return error.UnsupportedVersion; - _ = try r.int(u16); // flags: none yet - var h: Header = undefined; - inline for (std.meta.fields(Header)) |f| @field(h, f.name) = try r.int(u32); + const flags = try pre.int(u16); + if (flags & ~known_flags != 0) return error.Corrupt; + // The one copy every string and blob of the tape is a slice of. + const own = if (flags & flag_deflated == 0) try a.dupe(u8, bytes[prefix_size..]) else blk: { + if (tape_version < 3) return error.Corrupt; + const len = try pre.int(u32); + if (len > max_inflated) return error.Corrupt; + var in_buffer: [4096]u8 = undefined; + var in: Endless = .init(bytes[prefix_size + 4 ..], &in_buffer); + const window = try gpa.alloc(u8, flate.max_window_len); + defer gpa.free(window); + var d: flate.Decompress = .init(&in.reader, .zlib, window); + // One byte more than it should come to, so a stream that runs on is caught; a stream cut + // short runs on into the zeros, or stops early, or fails its checksum. + const inflated = d.reader.allocRemaining(a, .limited(@as(usize, len) + 1)) catch |err| return switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.ReadFailed, error.StreamTooLong => error.Corrupt, + }; + if (inflated.len != len or d.state != .end) return error.Corrupt; + // std's inflate reads the stream's checksum and leaves it unchecked. + if (std.hash.Adler32.hash(inflated) != d.container_metadata.zlib.adler) return error.Corrupt; + break :blk inflated; + }; + var r: Reader = .{ .bytes = own }; + + var h: Header = std.mem.zeroes(Header); + inline for (std.meta.fields(Header), 0..) |f, i| { + if (i < Header.fields(tape_version)) @field(h, f.name) = try r.int(u32); + } - // The string table: offsets, then the bytes they index. - const offsets = try r.take(try sizeOf(@as(usize, h.strings) + 1, 4)); - const string_bytes = try r.take(h.string_bytes); - try r.align4(); - const strings: Strings2 = .{ .offsets = offsets, .bytes = string_bytes, .count = h.strings }; - try strings.check(); + const strings = try Table.take(&r, h.strings, h.string_bytes); + const blobs: Table = if (tape_version >= 3) try Table.take(&r, h.blobs, h.blob_bytes) else .empty; const op_bytes = try r.take(try sizeOf(h.ops, op_size)); const kf_bytes = try r.take(try sizeOf(h.keyframes, keyframe_size)); + const file_size = fileSize(tape_version); const file_bytes = try r.take(try sizeOf(h.files, file_size)); const open_bytes = try r.take(try sizeOf(h.open, open_size)); const setting_bytes = try r.take(try sizeOf(h.settings, setting_size)); @@ -360,8 +465,9 @@ pub fn read(gpa: std.mem.Allocator, bytes: []const u8, check: Tape.Check) ReadEr _ = try f.int(u16); const ia = try f.int(u32); const ib = try f.int(u32); - var xy: [4]f32 = undefined; - for (&xy) |*v| v.* = @bitCast(try f.int(u32)); + var words: [4]u32 = undefined; + for (&words) |*v| v.* = try f.int(u32); + const xy: [4]f32 = @bitCast(words); op.* = .{ .at = at, .ms = ms, .do = switch (kind) { .keyframe => .{ .keyframe = std.math.cast(u16, ia) orelse return error.Corrupt }, .move => .{ .move = .{ .tag = try strings.get(ia), .x = xy[0], .y = xy[1], .dx = xy[2], .dy = xy[3] } }, @@ -380,6 +486,7 @@ pub fn read(gpa: std.mem.Allocator, bytes: []const u8, check: Tape.Check) ReadEr .shown => .{ .shown = try strings.get(ia) }, .gone => .{ .gone = try strings.get(ia) }, } } }, + .step => .{ .step = .{ .file = try strings.get(ia), .effect = try blobs.get(ib), .motion = try blobs.get(words[0]) } }, } }; } @@ -387,6 +494,10 @@ pub fn read(gpa: std.mem.Allocator, bytes: []const u8, check: Tape.Check) ReadEr for (files, 0..) |*file, i| { var f: Reader = .{ .bytes = file_bytes[i * file_size ..][0..file_size] }; file.* = .{ .path = try strings.get(try f.int(u32)), .text = try strings.get(try f.int(u32)) }; + if (tape_version >= 3) { + file.owner = try strings.get(try f.int(u32)); + file.format = try f.int(u32); + } } const open = try a.alloc([]const u8, h.open); for (open, 0..) |*o, i| { @@ -457,6 +568,32 @@ pub fn read(gpa: std.mem.Allocator, bytes: []const u8, check: Tape.Check) ReadEr return .{ .arena = arena, .tape = tape }; } +/// A deflated stream's bytes, then zeros without end. std's inflate (0.16) misreads a stream that +/// ends inside its last four bytes (`tossBitsShort` adds the bits it has used where it should take +/// them away) and asserts past the end of its input — a crash on a tape cut short. Never letting +/// its input end keeps it off that path; the inflated length, known from the header, bounds what a +/// stream cut short can run on into. ZIG017: fixed in 0.17's inflate, so this goes with the move; +/// 0.17 still leaves the checksum unchecked, so `read`'s Adler-32 check stays. +const Endless = struct { + rest: []const u8, + reader: std.Io.Reader, + + fn init(bytes: []const u8, buffer: []u8) Endless { + return .{ .rest = bytes, .reader = .{ .vtable = &.{ .stream = stream }, .buffer = buffer, .seek = 0, .end = 0 } }; + } + + fn stream(r: *std.Io.Reader, w: *std.Io.Writer, limit: std.Io.Limit) std.Io.Reader.StreamError!usize { + const self: *Endless = @alignCast(@fieldParentPtr("reader", r)); + const dest = limit.slice(try w.writableSliceGreedy(1)); + const n = @min(dest.len, self.rest.len); + @memcpy(dest[0..n], self.rest[0..n]); + @memset(dest[n..], 0); + self.rest = self.rest[n..]; + w.advance(dest.len); + return dest.len; + } +}; + /// `n` records of `size` bytes, or `error.Corrupt` when that is more than a tape could hold. fn sizeOf(n: usize, size: usize) ReadError!usize { return std.math.mul(usize, n, size) catch error.Corrupt; @@ -492,18 +629,31 @@ const Reader = struct { } }; -/// The string table as read: offsets into `bytes`, checked once, then indexed. -const Strings2 = struct { +/// A table as read — the strings, or the blobs: offsets into `bytes`, checked once, then indexed. +const Table = struct { offsets: []const u8, bytes: []const u8, count: u32, - fn offset(self: Strings2, i: usize) u32 { + /// No entries: what a tape from before version 3 has of blobs. + const empty: Table = .{ .offsets = &.{ 0, 0, 0, 0 }, .bytes = "", .count = 0 }; + + /// The table at `r`: `n + 1` offsets, then `len` bytes and their padding. + fn take(r: *Reader, n: u32, len: u32) ReadError!Table { + const offsets = try r.take(try sizeOf(@as(usize, n) + 1, 4)); + const bytes = try r.take(len); + try r.align4(); + const t: Table = .{ .offsets = offsets, .bytes = bytes, .count = n }; + try t.check(); + return t; + } + + fn offset(self: Table, i: usize) u32 { return std.mem.readInt(u32, self.offsets[i * 4 ..][0..4], .little); } /// Offsets start at 0, never go backwards, and end at the end of the bytes. - fn check(self: Strings2) ReadError!void { + fn check(self: Table) ReadError!void { var prev: u32 = 0; if (self.offset(0) != 0) return error.Corrupt; for (1..@as(usize, self.count) + 1) |i| { @@ -515,7 +665,7 @@ const Strings2 = struct { } /// String `i`. Empty, it is the empty string a tape's defaults are, as `slice`'s lists. - fn get(self: Strings2, i: u32) ReadError![]const u8 { + fn get(self: Table, i: u32) ReadError![]const u8 { if (i >= self.count) return error.Corrupt; const lo = self.offset(i); const hi = self.offset(@as(usize, i) + 1); @@ -528,47 +678,56 @@ const Strings2 = struct { const testing = std.testing; -/// A tape with something of everything in it. -fn everything() Tape { - const S = struct { - const files = [_]Tape.File{ .{ .path = "a.md", .text = "# A\n" }, .{ .path = "b.md", .text = "" } }; - const settings = [_]Tape.Setting{.{ .owner = "markdown", .key = "default_md_view", .value = ".split" }}; - const keyframes = [_]Tape.Keyframe{ - .{ .root = "demo://t", .files = &files, .open = &.{ "a.md", "b.md" }, .settings = &settings, .layout = .reset }, - .{ .root = "demo://t" }, - }; - const ops = [_]Tape.Op{ - .{ .at = 0, .do = .{ .keyframe = 0 } }, - .{ .at = 0, .do = .{ .wait = .{ .until = .idle } } }, - .{ .at = 0, .do = .{ .wait = .{ .until = .{ .shown = "text.end:demo://t/a.md" }, .timeout = 2500 } } }, - .{ .at = 100, .ms = 400, .do = .{ .move = .{ .tag = "text.end:demo://t/a.md", .x = 0.25, .y = 0.75, .dx = -3.5, .dy = 8 } } }, - .{ .at = 500, .do = .{ .press = .right } }, - .{ .at = 560, .do = .{ .release = .right } }, - .{ .at = 600, .do = .{ .scroll = .{ .x = 1, .y = -3 } } }, - .{ .at = 700, .ms = 300, .do = .{ .type = "héllo\n\tworld" } }, - .{ .at = 1100, .do = .{ .key = "mod+k mod+c" } }, - .{ .at = 1200, .do = .{ .command = .{ .id = "fizzy.toggleExplorer" } } }, - .{ .at = 1250, .do = .{ .command = .{ .id = "text.goToLine", .args = ".{ .line = 12, .column = 3 }" } } }, - .{ .at = 1300, .do = .{ .wait = .{ .until = .{ .gone = "fizzy.palette" } } } }, - .{ .at = 1400, .do = .{ .keyframe = 1 } }, - .{ .at = 1500, .ms = 200, .do = .{ .move = .{} } }, - .{ .at = 1700, .ms = 80, .do = .{ .trace = .{ .tag = "pixi.canvas:demo://t/a.pixi", .x = 0.4, .y = 0.6 } } }, - }; - const captions = [_]Tape.Caption{ - .{ .at = 0, .ms = 900, .title = "Hi", .text = "Welcome.", .place = .middle }, - .{ .at = 900, .ms = 600, .text = "Beside it.", .near = "text.end:demo://t/a.md", .clear = &.{ "text.body:demo://t/a.md", "text.preview:demo://t/a.md" } }, - }; - const chapters = [_]Tape.Chapter{ .{ .at = 0, .title = "One" }, .{ .at = 1400, .title = "Two" } }; +const Everything = struct { + const files = [_]Tape.File{ .{ .path = "a.md", .text = "# A\n" }, .{ .path = "b.md", .text = "" } }; + const settings = [_]Tape.Setting{.{ .owner = "markdown", .key = "default_md_view", .value = ".split" }}; + const keyframe = Tape.Keyframe{ .root = "demo://t", .files = &files, .open = &.{ "a.md", "b.md" }, .settings = &settings, .layout = .reset }; + /// A binary document, by its owner, and its steps. + const sprite = [_]Tape.File{.{ .path = "a.pixi", .text = &.{ 0x89, 'P', 0, 0xff, '\n', 0 }, .owner = "pixi", .format = 1 }}; + const ops_v2 = [_]Tape.Op{ + .{ .at = 0, .do = .{ .keyframe = 0 } }, + .{ .at = 0, .do = .{ .wait = .{ .until = .idle } } }, + .{ .at = 0, .do = .{ .wait = .{ .until = .{ .shown = "text.end:demo://t/a.md" }, .timeout = 2500 } } }, + .{ .at = 100, .ms = 400, .do = .{ .move = .{ .tag = "text.end:demo://t/a.md", .x = 0.25, .y = 0.75, .dx = -3.5, .dy = 8 } } }, + .{ .at = 500, .do = .{ .press = .right } }, + .{ .at = 560, .do = .{ .release = .right } }, + .{ .at = 600, .do = .{ .scroll = .{ .x = 1, .y = -3 } } }, + .{ .at = 700, .ms = 300, .do = .{ .type = "héllo\n\tworld" } }, + .{ .at = 1100, .do = .{ .key = "mod+k mod+c" } }, + .{ .at = 1200, .do = .{ .command = .{ .id = "fizzy.toggleExplorer" } } }, + .{ .at = 1250, .do = .{ .command = .{ .id = "text.goToLine", .args = ".{ .line = 12, .column = 3 }" } } }, + .{ .at = 1300, .do = .{ .wait = .{ .until = .{ .gone = "fizzy.palette" } } } }, + .{ .at = 1400, .do = .{ .keyframe = 1 } }, + .{ .at = 1500, .ms = 200, .do = .{ .move = .{} } }, + .{ .at = 1700, .ms = 80, .do = .{ .trace = .{ .tag = "pixi.canvas:demo://t/a.pixi", .x = 0.4, .y = 0.6 } } }, }; - return .{ - .name = "t", - .title = "Everything", - .home = "region:Main", - .ops = &S.ops, - .keyframes = &S.keyframes, - .captions = &S.captions, - .chapters = &S.chapters, + const ops = ops_v2 ++ [_]Tape.Op{ + .{ .at = 1800, .ms = 250, .do = .{ .step = .{ .file = "a.pixi", .effect = "\x00\x01\xff", .motion = "\x02\x00" } } }, + // The same effect again is stored again; one with no motion stores an empty one. + .{ .at = 2100, .do = .{ .step = .{ .file = "a.pixi", .effect = "\x00\x01\xff" } } }, }; + const captions = [_]Tape.Caption{ + .{ .at = 0, .ms = 900, .title = "Hi", .text = "Welcome.", .place = .middle }, + .{ .at = 900, .ms = 600, .text = "Beside it.", .near = "text.end:demo://t/a.md", .clear = &.{ "text.body:demo://t/a.md", "text.preview:demo://t/a.md" } }, + }; + const chapters = [_]Tape.Chapter{ .{ .at = 0, .title = "One" }, .{ .at = 1400, .title = "Two" } }; + + fn tape(ops_: []const Tape.Op, keyframes: []const Tape.Keyframe) Tape { + return .{ .name = "t", .title = "Everything", .home = "region:Main", .ops = ops_, .keyframes = keyframes, .captions = &captions, .chapters = &chapters }; + } +}; + +/// A tape with something of everything in it. +fn everything() Tape { + const keyframes = [_]Tape.Keyframe{ Everything.keyframe, .{ .root = "demo://t", .files = &Everything.sprite } }; + return Everything.tape(&Everything.ops, &keyframes); +} + +/// `everything` as version 2 could hold it, before steps: what `testdata/everything-v2.tape` is, +/// written by the version-2 codec. +fn everythingV2() Tape { + const keyframes = [_]Tape.Keyframe{ Everything.keyframe, .{ .root = "demo://t" } }; + return Everything.tape(&Everything.ops_v2, &keyframes); } /// The two tapes say exactly the same, field for field — and write the same ZON. @@ -583,19 +742,24 @@ fn expectSame(want: Tape, got: Tape) !void { try testing.expectEqualStrings(a.written(), b.written()); } -test "a tape round-trips through the binary form, field for field" { - const tape = everything(); - try tape.validate(.{}); - const bytes = try encode(testing.allocator, tape); - defer testing.allocator.free(bytes); - try testing.expect(sniff(bytes)); +const steps_check = Tape.test_steps; - var owned = try read(testing.allocator, bytes, .{}); - defer owned.deinit(); - try expectSame(tape, owned.tape); - // Floats exactly, not near enough. - try testing.expectEqual(@as(f32, -3.5), owned.tape.ops[3].do.move.dx); - try testing.expectEqualStrings("héllo\n\tworld", owned.tape.ops[7].do.type); +test "a tape round-trips through the binary form, field for field, deflated or not" { + const tape = everything(); + try tape.validate(steps_check); + for ([_]bool{ false, true }) |deflate| { + const bytes = try encode(testing.allocator, tape, .{ .deflate = deflate }); + defer testing.allocator.free(bytes); + try testing.expect(sniff(bytes)); + + var owned = try read(testing.allocator, bytes, steps_check); + defer owned.deinit(); + try expectSame(tape, owned.tape); + // Floats exactly, not near enough; bytes as they were. + try testing.expectEqual(@as(f32, -3.5), owned.tape.ops[3].do.move.dx); + try testing.expectEqualStrings("héllo\n\tworld", owned.tape.ops[7].do.type); + try testing.expectEqualSlices(u8, &.{ 0x89, 'P', 0, 0xff, '\n', 0 }, owned.tape.keyframes[1].files[0].text); + } } test "binary and ZON are the same tape, converted either way" { @@ -605,76 +769,115 @@ test "binary and ZON are the same tape, converted either way" { try tape.write(&zon.writer); const source = try testing.allocator.dupeZ(u8, zon.written()); defer testing.allocator.free(source); - var from_zon = try Tape.parse(testing.allocator, source, .{}); + var from_zon = try Tape.parse(testing.allocator, source, steps_check); defer from_zon.deinit(); - const bytes = try encode(testing.allocator, from_zon.tape); + const bytes = try encode(testing.allocator, from_zon.tape, .{}); defer testing.allocator.free(bytes); - var back = try read(testing.allocator, bytes, .{}); + var back = try read(testing.allocator, bytes, steps_check); defer back.deinit(); try expectSame(tape, back.tape); } -test "each string is stored once" { +test "each string is stored once, each blob as often as it comes" { const tape = everything(); - const bytes = try encode(testing.allocator, tape); + const bytes = try encode(testing.allocator, tape, .{}); defer testing.allocator.free(bytes); // The anchor is named by two ops, a wait and a caption: once in the file. try testing.expectEqual(@as(usize, 1), std.mem.count(u8, bytes, "text.end:demo://t/a.md")); + try testing.expectEqual(@as(usize, 2), std.mem.count(u8, bytes, "\x00\x01\xff")); +} + +test "a deflated tape is smaller" { + // Steps the shape of a recording's: runs of a few colours. + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + const ops = try arena.allocator().alloc(Tape.Op, 201); + ops[0] = .{ .at = 0, .do = .{ .keyframe = 1 } }; + for (ops[1..], 1..) |*op, i| { + const effect = try arena.allocator().alloc(u8, 64); + @memset(effect, @intCast(i % 4)); + op.* = .{ .at = @intCast(i * 100), .do = .{ .step = .{ .file = "a.pixi", .effect = effect } } }; + } + const tape = Everything.tape(ops, &.{ Everything.keyframe, .{ .root = "demo://t", .files = &Everything.sprite } }); + const plain = try encode(testing.allocator, tape, .{}); + defer testing.allocator.free(plain); + const deflated = try encode(testing.allocator, tape, .{ .deflate = true }); + defer testing.allocator.free(deflated); + try testing.expect(deflated.len * 4 < plain.len); } test "a reader holds nothing of the bytes it read" { - const bytes = try encode(testing.allocator, everything()); - var owned = try read(testing.allocator, bytes, .{}); - defer owned.deinit(); - @memset(bytes, 0xaa); - testing.allocator.free(bytes); - try testing.expectEqualStrings("region:Main", owned.tape.home); + for ([_]bool{ false, true }) |deflate| { + const bytes = try encode(testing.allocator, everything(), .{ .deflate = deflate }); + var owned = try read(testing.allocator, bytes, steps_check); + defer owned.deinit(); + @memset(bytes, 0xaa); + testing.allocator.free(bytes); + try testing.expectEqualStrings("region:Main", owned.tape.home); + try testing.expectEqualStrings("\x02\x00", owned.tape.ops[15].do.step.motion); + } } test "what is not a binary tape, or not a whole one, is refused" { - try testing.expectError(error.NotATape, read(testing.allocator, ".{ .name = \"zon\" }", .{})); - - const bytes = try encode(testing.allocator, everything()); - defer testing.allocator.free(bytes); - // Cut short anywhere, it is truncated — or, cut inside the string table, corrupt — and never - // read past its end. - for (0..bytes.len) |n| { - if (read(testing.allocator, bytes[0..n], .{})) |owned| { - var o = owned; - o.deinit(); - return error.TestUnexpectedResult; - } else |err| switch (err) { - error.NotATape, error.Truncated, error.Corrupt => {}, - else => return err, + try testing.expectError(error.NotATape, read(testing.allocator, ".{ .name = \"zon\" }", steps_check)); + + for ([_]bool{ false, true }) |deflate| { + const bytes = try encode(testing.allocator, everything(), .{ .deflate = deflate }); + defer testing.allocator.free(bytes); + // Cut short anywhere, it is truncated — or, cut inside a table or the stream, corrupt — + // and never read past its end. + for (0..bytes.len) |n| { + if (read(testing.allocator, bytes[0..n], steps_check)) |owned| { + var o = owned; + o.deinit(); + return error.TestUnexpectedResult; + } else |err| switch (err) { + error.NotATape, error.Truncated, error.Corrupt => {}, + else => return err, + } } - } - var newer = try testing.allocator.dupe(u8, bytes); - defer testing.allocator.free(newer); - std.mem.writeInt(u16, newer[4..6], version + 1, .little); - try testing.expectError(error.UnsupportedVersion, read(testing.allocator, newer, .{})); + var newer = try testing.allocator.dupe(u8, bytes); + defer testing.allocator.free(newer); + std.mem.writeInt(u16, newer[4..6], version + 1, .little); + try testing.expectError(error.UnsupportedVersion, read(testing.allocator, newer, steps_check)); + // A flag no reader knows is not guessed at. + std.mem.writeInt(u16, newer[4..6], version, .little); + std.mem.writeInt(u16, newer[6..8], 1 << 7, .little); + try testing.expectError(error.Corrupt, read(testing.allocator, newer, steps_check)); + } } test "flipped bytes are an error, never a crash" { - const bytes = try encode(testing.allocator, everything()); - defer testing.allocator.free(bytes); - const copy = try testing.allocator.dupe(u8, bytes); - defer testing.allocator.free(copy); var prng: std.Random.DefaultPrng = .init(0x7a9e); const r = prng.random(); - for (0..4000) |_| { - @memcpy(copy, bytes); - for (0..1 + r.uintLessThan(usize, 4)) |_| copy[r.uintLessThan(usize, copy.len)] ^= @as(u8, 1) << r.int(u3); - if (read(testing.allocator, copy, .{})) |owned| { - var o = owned; - o.deinit(); - } else |_| {} + for ([_]bool{ false, true }) |deflate| { + const bytes = try encode(testing.allocator, everything(), .{ .deflate = deflate }); + defer testing.allocator.free(bytes); + const copy = try testing.allocator.dupe(u8, bytes); + defer testing.allocator.free(copy); + for (0..4000) |_| { + @memcpy(copy, bytes); + for (0..1 + r.uintLessThan(usize, 4)) |_| copy[r.uintLessThan(usize, copy.len)] ^= @as(u8, 1) << r.int(u3); + if (read(testing.allocator, copy, steps_check)) |owned| { + var o = owned; + o.deinit(); + } else |_| {} + } } } +const v2_fixture = @embedFile("testdata/everything-v2.tape"); + +test "a version-2 tape still reads, field for field" { + var owned = try read(testing.allocator, v2_fixture, .{}); + defer owned.deinit(); + try expectSame(everythingV2(), owned.tape); +} + test "a version-1 tape still reads, its commands without arguments" { - const bytes = try encode(testing.allocator, everything()); + const bytes = try testing.allocator.dupe(u8, v2_fixture); defer testing.allocator.free(bytes); std.mem.writeInt(u16, bytes[4..6], 1, .little); var owned = try read(testing.allocator, bytes, .{}); @@ -685,14 +888,19 @@ test "a version-1 tape still reads, its commands without arguments" { std.mem.writeInt(u16, bytes[4..6], 0, .little); try testing.expectError(error.UnsupportedVersion, read(testing.allocator, bytes, .{})); + // Deflating came with version 3. + std.mem.writeInt(u16, bytes[4..6], 2, .little); + std.mem.writeInt(u16, bytes[6..8], flag_deflated, .little); + try testing.expectError(error.Corrupt, read(testing.allocator, bytes, .{})); } -test "a recording's key chords are checked against the app's spelling, as in ZON" { - const bytes = try encode(testing.allocator, everything()); +test "a recording's key chords and steps are checked against the app, as in ZON" { + const bytes = try encode(testing.allocator, everything(), .{}); defer testing.allocator.free(bytes); - try testing.expectError(error.BadChord, read(testing.allocator, bytes, .{ .key = struct { + try testing.expectError(error.BadChord, read(testing.allocator, bytes, .{ .steps = steps_check.steps, .key = struct { fn ok(c: []const u8) bool { return std.mem.indexOfScalar(u8, c, ' ') == null; } }.ok })); + try testing.expectError(error.StepsUnsupported, read(testing.allocator, bytes, .{})); } diff --git a/sdk/tape/testdata/everything-v2.tape b/sdk/tape/testdata/everything-v2.tape new file mode 100644 index 0000000000000000000000000000000000000000..66a1e105ed0ffe1da85332a0700767913d7e53a8 GIT binary patch literal 1192 zcmb7^ziSjh6vxNJn9KPqYWxu!7ey>`%=x48z?g^;i-;)>3^s9dGuaH8-C6b~_ah=i zuohdbZPICBAt(e?u!xrEMc*BocezUkWrSndxk#pX3c@aMJlNDjwk&sLNsj1=k`BVa^leoyU!}&><3Z)IP$kfAoUD& z{8CM(Sf6BU>N;Z&HW5$%q9Sa-`7G)kFVX`A8~lf6-f8w`Qa7$+pQ+6}^$wZ}*4T$c zuP#?GHtPyQND=+)mD3EPNNbVbY2OPPI>$c8!^YeOCG=H>rr$Yt3Qg>G``gfb#(b(5 zdcnOjBDI%jbPiY42fdGEU$(X;UpycGqd9jKlv12Ih|u literal 0 HcmV?d00001 diff --git a/tests/bench/bench_tape.zig b/tests/bench/bench_tape.zig index d142f0658..af897eef1 100644 --- a/tests/bench/bench_tape.zig +++ b/tests/bench/bench_tape.zig @@ -1,4 +1,5 @@ -//! `zig build bench-tape` — how long a tape takes to save and load, ZON against the binary form. +//! `zig build bench-tape` — how long a tape takes to save and load, ZON against the binary form, +//! and a recording of drawing's size and speed plain against deflated. //! //! Prints timings rather than asserting them: compare runs at the same `-Doptimize` //! (`-Doptimize=ReleaseFast` for numbers worth quoting). The tapes are recording-shaped — glides @@ -63,14 +64,96 @@ fn zonRead(source: [:0]const u8) !void { fn binWrite(t: Tape, out: *std.Io.Writer.Allocating) !void { out.clearRetainingCapacity(); - try binary.write(std.testing.allocator, t, &out.writer); + try binary.write(std.testing.allocator, t, &out.writer, .{}); } fn binRead(bytes: []const u8) !void { - var owned = try binary.read(std.testing.allocator, bytes, .{}); + var owned = try binary.read(std.testing.allocator, bytes, steps_check); owned.deinit(); } +const steps_check = Tape.test_steps; + +/// `n` steps of drawing a small sprite, shaped as a pixi stroke's effect will be: the layer, the +/// pixels' indices as deltas (neighbours, so mostly one byte), their colours before and after in +/// runs, and the pen's path. Not pixi's encoding — that is pixi's (step 4 of +/// plans/RECORDING_PLAN.md measures the real one) — but its size and its sameness. +fn drawing(arena: std.mem.Allocator, n: usize) !Tape { + var prng: std.Random.DefaultPrng = .init(0x51e9); + const r = prng.random(); + const palette = [_][4]u8{ .{ 0, 0, 0, 0 }, .{ 34, 32, 52, 255 }, .{ 69, 40, 60, 255 }, .{ 172, 50, 50, 255 }, .{ 223, 113, 38, 255 }, .{ 251, 242, 54, 255 } }; + const ops = try arena.alloc(Tape.Op, n + 1); + ops[0] = .{ .at = 0, .do = .{ .keyframe = 0 } }; + var t: u32 = 0; + for (ops[1..]) |*op| { + const pixels = 4 + r.uintLessThan(usize, 56); + var effect: std.ArrayList(u8) = .empty; + try effect.appendSlice(arena, &.{ 1, @intCast(pixels) }); + for (0..pixels) |_| try effect.append(arena, if (r.uintLessThan(u8, 8) == 0) 64 else 1); + // Before: what was there, in a run or three; after: the pen's colour. + for (0..1 + r.uintLessThan(usize, 3)) |_| { + try effect.append(arena, @intCast(1 + r.uintLessThan(usize, pixels))); + try effect.appendSlice(arena, &palette[r.uintLessThan(usize, palette.len)]); + } + try effect.append(arena, @intCast(pixels)); + try effect.appendSlice(arena, &palette[1 + r.uintLessThan(usize, palette.len - 1)]); + // The pen's path: a point every other pixel, a small step and a few ms each. + const motion = try arena.alloc(u8, (pixels / 2) * 3); + for (motion, 0..) |*b, i| b.* = if (i % 3 == 2) 8 + r.uintLessThan(u8, 8) else r.uintLessThan(u8, 3); + const ms: u32 = @intCast(pixels * 12); + t += 400 + r.uintLessThan(u32, 3000); + op.* = .{ .at = t, .ms = ms, .do = .{ .step = .{ .file = "sprite.pixi", .effect = effect.items, .motion = motion } } }; + t += ms; + } + // The sprite as it opened: a small file, already packed, so it barely deflates. + const sprite = try arena.alloc(u8, 6 * 1024); + r.bytes(sprite); + const files = try arena.alloc(Tape.File, 1); + files[0] = .{ .path = "sprite.pixi", .text = sprite, .owner = "pixi", .format = 1 }; + const kf = try arena.alloc(Tape.Keyframe, 1); + kf[0] = .{ .root = "demo://drawing", .files = files, .open = &.{"sprite.pixi"} }; + return .{ .name = "drawing", .ops = ops, .keyframes = kf }; +} + +fn binWriteWith(t: Tape, out: *std.Io.Writer.Allocating, opts: binary.Options) !void { + out.clearRetainingCapacity(); + try binary.write(std.testing.allocator, t, &out.writer, opts); +} + +test "bench tape: a recording of drawing, plain against deflated" { + std.debug.print("\n{s:>9} {s:>10} | {s:>9} {s:>9} {s:>9} | {s:>9} {s:>9} {s:>9} | {s:>7}\n", .{ + "steps", "drawing", "plain KB", "write ms", "read ms", "zlib KB", "write ms", "read ms", "smaller", + }); + for ([_]usize{ 2_000, 20_000, 100_000 }) |n| { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const t = try drawing(arena.allocator(), n); + try t.validate(steps_check); + + var plain: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer plain.deinit(); + const plain_write = try best(binWriteWith, .{ t, &plain, binary.Options{} }); + const plain_read = try best(binRead, .{plain.written()}); + var deflated: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer deflated.deinit(); + const deflated_write = try best(binWriteWith, .{ t, &deflated, binary.Options{ .deflate = true } }); + const deflated_read = try best(binRead, .{deflated.written()}); + + const minutes = @as(f64, @floatFromInt(t.duration())) / std.time.ms_per_min; + std.debug.print("{d:>9} {d:>7.0} min | {d:>9} {d:>9.3} {d:>9.3} | {d:>9} {d:>9.3} {d:>9.3} | {d:>6.1}x\n", .{ + n, + minutes, + plain.written().len / 1024, + plain_write, + plain_read, + deflated.written().len / 1024, + deflated_write, + deflated_read, + @as(f64, @floatFromInt(plain.written().len)) / @as(f64, @floatFromInt(deflated.written().len)), + }); + } +} + test "bench tape: ZON against the binary form" { std.debug.print("\n{s:>9} | {s:>9} {s:>9} {s:>10} | {s:>9} {s:>9} {s:>10} | {s:>7} {s:>7}\n", .{ "ops", "ZON KB", "write ms", "read ms", "bin KB", "write ms", "read ms", "smaller", "faster", diff --git a/tests/integration.zig b/tests/integration.zig index 4654316d8..cf6757d18 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -5147,7 +5147,7 @@ test "demo: every bundled demo builds into a valid tape" { try std.testing.expect(std.mem.endsWith(u8, owned.tape.keyframes[0].root, e.name)); // And it travels as a `.tape` (what a site hosts) unchanged. - const bytes = try automation.binary.encode(std.testing.allocator, owned.tape); + const bytes = try automation.binary.encode(std.testing.allocator, owned.tape, .{}); defer std.testing.allocator.free(bytes); var back = try automation.Tape.load(std.testing.allocator, bytes, automation.Input.check); defer back.deinit(); @@ -5188,7 +5188,7 @@ test "demo: the hand-written sample tape parses and round-trips" { // `Tape.load` takes the source as it is and the binary form alike. var loaded = try automation.Tape.load(std.testing.allocator, source, automation.Input.check); defer loaded.deinit(); - const bytes = try automation.binary.encode(std.testing.allocator, owned.tape); + const bytes = try automation.binary.encode(std.testing.allocator, owned.tape, .{}); defer std.testing.allocator.free(bytes); var from_binary = try automation.Tape.load(std.testing.allocator, bytes, automation.Input.check); defer from_binary.deinit(); @@ -7687,7 +7687,7 @@ test "automation service: tape bytes in, a ticket back, the outcome by ticket" { // The binary form plays the same; the first tape's outcome is still there to ask for. var owned = try liveTape(); defer owned.deinit(); - const encoded = try automation.binary.encode(std.testing.allocator, owned.tape); + const encoded = try automation.binary.encode(std.testing.allocator, owned.tape, .{}); defer std.testing.allocator.free(encoded); const second = try started(api.play(encoded)); try std.testing.expect(second != ticket); From 357a0634308b578fed5f86651df3415ae14f239f Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:07:39 -0500 Subject: [PATCH 17/46] shaders: the glass written once, its Metal and GLSL generated (#424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Stacked on #405** (its commits show here until it merges). The glass written once, in HLSL; its Metal and WebGL GLSL generated from it and checked by #405's job, instead of three programs kept in step by hand. Keeping them in step is where the shader bugs came from: #373's `float3(x)` was valid Metal and GLSL and broke the HLSL, and the compiled forms went stale twice. ## What changes - **`scripts/compile-shaders.sh`**: every `X.hlsl` with compiled forms committed beside it, to each of them. SPIR-V, DXIL and Metal by shadercross (the Metal SDL_GPU takes, as the backend's own default shaders already shipped). GLSL ES 3.00 for WebGL2 by spirv-cross from the SPIR-V, with NaN checks relaxed (HLSL's `min`/`max` otherwise come out wrapped in `isnan` tests at every pixel), float precision high, and the names the web binds (`vColor`, `vTextureCoord`, `uSampler`, `uTex1`, `uData`). The workflow runs it and compares every form. - **The glass loads the generated Metal and GLSL** (`LiquidField`); the hand-written `liquid_glass.metal` and `liquid_glass.glsl` are gone. The HLSL is the one source and carries the explanations the GLSL had. - **The web compiles a complete generated source as it is** (`web/web.js`, a `#version` line), on WebGL2 only: on WebGL1 the program fails and the glass falls back to its meshes (generated loops are not WebGL1's constant-bounded ones). - `program_example`'s committed Metal, hand-written to show the interface, is now generated like the rest (same interface). ## Verified - **CI's compile check**: the backend's default shaders reproduce byte for byte in all three of SPIR-V, DXIL and Metal with this toolchain. - **macOS**: the same in-app About dialog captured through the agent with the hand-written Metal (#405's build) and the generated one (this build): plain glass differs by a mean of 0.18 of 255, at most 2 (the dither); elsewhere only edges, where the dialog's contents sat a fraction of a pixel apart between the two captures. - **Web (Chromium, WebGL2)**: the generated GLSL compiles and links against the web's own vertex shader, and `uData`, `uSampler`, `uTex1` and `uMatrix` all resolve; this build's menus draw their glass through it, no program failed, and it looks as main's did. - `zig build`, `zig build test` (91/91 steps), `zig build test-integration` (53/53 steps), `zig build check-web`. - Not seen: Windows (D3D12) and Linux (Vulkan), as for #405; Firefox and Safari on the web. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/shaders.yml | 51 +- .../compiled/msl/program_example.fragment.msl | 28 +- core/gfx/LiquidField.zig | 17 +- core/gfx/glass_look.zig | 2 +- core/gfx/programs.zig | 5 + .../compiled/glsl/liquid_glass.fragment.glsl | 477 +++++++++++++++++ .../compiled/msl/liquid_glass.fragment.msl | 484 ++++++++++++++++++ core/gfx/shaders/liquid_glass.fragment.hlsl | 36 +- core/gfx/shaders/liquid_glass.glsl | 224 -------- core/gfx/shaders/liquid_glass.metal | 217 -------- scripts/compile-shaders.sh | 59 +++ web/web.js | 13 +- 12 files changed, 1109 insertions(+), 504 deletions(-) create mode 100644 core/gfx/shaders/compiled/glsl/liquid_glass.fragment.glsl create mode 100644 core/gfx/shaders/compiled/msl/liquid_glass.fragment.msl delete mode 100644 core/gfx/shaders/liquid_glass.glsl delete mode 100644 core/gfx/shaders/liquid_glass.metal create mode 100755 scripts/compile-shaders.sh diff --git a/.github/workflows/shaders.yml b/.github/workflows/shaders.yml index b6e740a3e..11c7017ab 100644 --- a/.github/workflows/shaders.yml +++ b/.github/workflows/shaders.yml @@ -1,8 +1,8 @@ # The SPIR-V and DXIL fizzy's native backend embeds (Vulkan, D3D12), compiled from their HLSL here # rather than on someone's machine, and checked against what is committed. # -# Each `X.hlsl` beside a `compiled/spv/X.spv` and `compiled/dxil/X.dxil` is compiled with -# SDL_shadercross and compared byte for byte. A difference fails the job and uploads the fresh +# Each `X.hlsl` beside compiled forms of it (`compiled/{spv,dxil,msl,glsl}/X.*`) is compiled by +# `scripts/compile-shaders.sh` and compared byte for byte. A difference fails the job and uploads the fresh # files as the `compiled-shaders` artifact, laid out as the repository has them: download it and # commit them. Before this, nothing noticed when the HLSL changed and the compiled files did not, # and Windows and Linux quietly drew the previous glass (#242, #373). @@ -20,12 +20,14 @@ on: paths: - "**/*.hlsl" - "**/shaders/compiled/**" + - "scripts/compile-shaders.sh" - ".github/workflows/shaders.yml" push: branches: [main] paths: - "**/*.hlsl" - "**/shaders/compiled/**" + - "scripts/compile-shaders.sh" - ".github/workflows/shaders.yml" workflow_dispatch: @@ -99,36 +101,23 @@ jobs: export PATH="$HOME/shadercross/bin:$PATH" export LD_LIBRARY_PATH="$HOME/shadercross/lib:${{ steps.sdl.outputs.prefix }}/lib:$LD_LIBRARY_PATH" shadercross --help > /dev/null 2>&1 || { echo "shadercross does not run"; ldd "$HOME/shadercross/bin/shadercross"; exit 1; } - stale=0 + command -v spirv-cross failed=0 - mkdir -p out - while IFS= read -r hlsl; do - dir=$(dirname "$hlsl"); name=$(basename "$hlsl" .hlsl) - for form in spv dxil; do - committed="$dir/compiled/$form/$name.$form" - [ -f "$committed" ] || continue - fresh="out/$committed" - mkdir -p "$(dirname "$fresh")" - # One that does not compile is reported, and the rest still go. - if ! (cd "$dir" && shadercross "$name.hlsl" -o "$GITHUB_WORKSPACE/$fresh"); then - echo "::error file=$hlsl::$hlsl does not compile to $form" - failed=1 - continue - fi - if [ "$form" = spv ] && ! spirv-val "$fresh"; then - echo "::error file=$hlsl::$fresh does not validate" - failed=1 - fi - if cmp -s "$committed" "$fresh"; then - echo "ok $committed" - else - echo "::error file=$hlsl::$committed is not what $hlsl compiles to: download the compiled-shaders artifact and commit it" - echo " committed $(stat -c %s "$committed") bytes, compiled $(stat -c %s "$fresh"); first differences (offset, committed, compiled):" - cmp -l "$committed" "$fresh" | head -8 || true - stale=1 - fi - done - done < <(git ls-files '*.hlsl') + bash scripts/compile-shaders.sh out || failed=1 + stale=0 + while IFS= read -r fresh; do + committed=${fresh#out/} + if cmp -s "$committed" "$fresh"; then + echo "ok $committed" + else + echo "::error file=$committed::$committed is not what its HLSL compiles to: download the compiled-shaders artifact and commit it" + echo " committed $(stat -c %s "$committed") bytes, compiled $(stat -c %s "$fresh")" + stale=1 + fi + case $fresh in + *.spv) spirv-val "$fresh" || { echo "::error file=$committed::$fresh does not validate"; failed=1; } ;; + esac + done < <(find out -type f | sort) echo "stale=$stale" >> "$GITHUB_ENV" echo "failed=$failed" >> "$GITHUB_ENV" diff --git a/backend/src/shaders/compiled/msl/program_example.fragment.msl b/backend/src/shaders/compiled/msl/program_example.fragment.msl index b32c867b6..dee14ac30 100644 --- a/backend/src/shaders/compiled/msl/program_example.fragment.msl +++ b/backend/src/shaders/compiled/msl/program_example.fragment.msl @@ -3,30 +3,26 @@ using namespace metal; -// `program_example.fragment.hlsl` in Metal, to the interface shadercross gives it: the entry -// point is `main0`, the inputs are the default vertex shader's `[[user(locnN)]]` outputs, -// textures and samplers are numbered from the draw's own (0), and the uniforms are buffer 0. - -struct main0_in +struct type_Uniforms { - float4 color [[user(locn0)]]; - float2 uv [[user(locn1)]]; + float4 data[2]; }; struct main0_out { - float4 color [[color(0)]]; + float4 out_var_SV_Target0 [[color(0)]]; }; -fragment main0_out main0( - main0_in in [[stage_in]], - texture2d Texture0 [[texture(0)]], sampler Sampler0 [[sampler(0)]], - texture2d Texture1 [[texture(1)]], sampler Sampler1 [[sampler(1)]], - constant float4 *data [[buffer(0)]]) +struct main0_in +{ + float4 in_var_COLOR [[user(locn0)]]; + float2 in_var_TEXCOORD0 [[user(locn1)]]; +}; + +fragment main0_out main0(main0_in in [[stage_in]], constant type_Uniforms& Uniforms [[buffer(0)]], texture2d Texture0 [[texture(0)]], texture2d Texture1 [[texture(1)]], sampler Sampler0 [[sampler(0)]], sampler Sampler1 [[sampler(1)]]) { main0_out out = {}; - float4 base = Texture0.sample(Sampler0, in.uv) * in.color * data[0]; - float4 extra = Texture1.sample(Sampler1, in.uv * data[1].x); - out.color = mix(base, extra, data[1].y); + out.out_var_SV_Target0 = mix((Texture0.sample(Sampler0, in.in_var_TEXCOORD0) * in.in_var_COLOR) * Uniforms.data[0], Texture1.sample(Sampler1, (in.in_var_TEXCOORD0 * Uniforms.data[1].x)), float4(Uniforms.data[1].y)); return out; } + diff --git a/core/gfx/LiquidField.zig b/core/gfx/LiquidField.zig index 79e934434..45eb62d10 100644 --- a/core/gfx/LiquidField.zig +++ b/core/gfx/LiquidField.zig @@ -1,5 +1,5 @@ //! One layer of liquid glass: rounded boxes that run together where they come close, drawn as -//! frosted, refracting glass in one pass at every pixel (`shaders/liquid_glass.glsl`). +//! frosted, refracting glass in one pass at every pixel (`shaders/liquid_glass.fragment.hlsl`). //! //! **What it is.** Each shape is a rounded box — a circle is a square rounded all the way, a //! capsule a rect rounded across its short side. Their outlines are joined by a smooth minimum @@ -148,7 +148,7 @@ pub fn clusters(self: *const LiquidField) Clusters { pub const uniform_vec4s = header_vec4s + shape_vec4s * max_shapes; -/// What the program reads, `uData` in `shaders/liquid_glass.glsl`. +/// What the program reads, `uData` in `shaders/liquid_glass.fragment.hlsl`. pub const Uniforms = extern struct { /// Where the frost starts, and 1 / its size: physical pixels to its uv. frost_map: [4]f32, @@ -231,17 +231,18 @@ const rim_line_width: f32 = 0.8; // ── Drawing ───────────────────────────────────────────────────────────────────────────────────── -/// The program for the native backend on Vulkan and D3D12, compiled by shadercross from -/// `shaders/liquid_glass.fragment.hlsl` (the commands are at its top). Copied out of the embed to -/// be aligned: Vulkan takes SPIR-V as 32-bit words. +/// The program, written once in `shaders/liquid_glass.fragment.hlsl` and compiled from it for every +/// backend (`scripts/compile-shaders.sh`, checked in CI): SPIR-V and DXIL for the native backend on +/// Vulkan and D3D12, Metal for it on macOS, GLSL ES 3.00 for the web. The binary forms are copied +/// out of the embed to be aligned: Vulkan takes SPIR-V as 32-bit words. const compiled = struct { const spirv align(8) = @embedFile("shaders/compiled/spv/liquid_glass.fragment.spv").*; const dxil align(8) = @embedFile("shaders/compiled/dxil/liquid_glass.fragment.dxil").*; }; var program: programs.Program = .from(.{ - .glsl = @embedFile("shaders/liquid_glass.glsl"), - .msl = @embedFile("shaders/liquid_glass.metal"), + .glsl = @embedFile("shaders/compiled/glsl/liquid_glass.fragment.glsl"), + .msl = @embedFile("shaders/compiled/msl/liquid_glass.fragment.msl"), .spirv = &compiled.spirv, .dxil = &compiled.dxil, }, .{ .textures = 1, .uniform_vec4s = uniform_vec4s }); @@ -431,7 +432,7 @@ pub const Sample = struct { light: f32, }; -/// `shaders/liquid_glass.glsl`'s field, line for line, over every shape: for hit tests and tests. +/// `shaders/liquid_glass.fragment.hlsl`'s field, line for line, over every shape: for hit tests and tests. pub fn sample(self: *const LiquidField, p: dvui.Point.Physical) Sample { const big = std.math.floatMax(f32); var d1: f32 = big; diff --git a/core/gfx/glass_look.zig b/core/gfx/glass_look.zig index 5207160dd..577e017a0 100644 --- a/core/gfx/glass_look.zig +++ b/core/gfx/glass_look.zig @@ -216,7 +216,7 @@ pub fn window(opacity: f32) Window { // ── The app's glass ───────────────────────────────────────────────────────────────────────────── -/// The app's glass at the opacity, as the glass program (`shaders/liquid_glass.glsl`) reads it. Shaped +/// The app's glass at the opacity, as the glass program (`shaders/liquid_glass.fragment.hlsl`) reads it. Shaped /// after Apple's lens, measured over a striped pattern: the backdrop pulled *inward* from a band /// near the rim — `bevel` of the shape's shorter half, at most `bevel_cap` points — by up to `bend` /// times that band, which magnifies it and, past 1, folds it into a mirrored, darker band just diff --git a/core/gfx/programs.zig b/core/gfx/programs.zig index b7c2f2c11..73b527c77 100644 --- a/core/gfx/programs.zig +++ b/core/gfx/programs.zig @@ -53,6 +53,11 @@ //! `MAX_VEC4` for the length of its uniforms, `uniform vec4 uData[MAX_VEC4]`. Inputs are //! `vColor` and `vTextureCoord`; samplers are `uSampler` (unit 0, the draw's own texture), //! `uTex1` and `uTex2` (the extra textures `begin` binds). +//! +//! **Or generated.** A complete GLSL ES 3.00 source — one starting with its `#version` line — is +//! compiled as it is, on WebGL2 only. That is what `scripts/compile-shaders.sh` makes from a +//! program written once in HLSL, with the names above; fizzy's glass is one +//! (`shaders/liquid_glass.fragment.hlsl`). const std = @import("std"); const dvui = @import("dvui"); diff --git a/core/gfx/shaders/compiled/glsl/liquid_glass.fragment.glsl b/core/gfx/shaders/compiled/glsl/liquid_glass.fragment.glsl new file mode 100644 index 000000000..3c3f45f42 --- /dev/null +++ b/core/gfx/shaders/compiled/glsl/liquid_glass.fragment.glsl @@ -0,0 +1,477 @@ +#version 300 es +precision highp float; +precision highp int; + +uniform highp vec4 uData[55]; +uniform highp sampler2D uSampler; +uniform highp sampler2D uTex1; + +in highp vec4 vColor; +in highp vec2 vTextureCoord; +layout(location = 0) out highp vec4 out_var_SV_Target0; + +void main() +{ + highp vec4 _678; + do + { + int _90 = int((vColor.y * 255.0) + 0.5); + int _95 = int((vColor.z * 255.0) + 0.5); + bool _111 = uData[5].y > 0.0; + highp float _113; + highp float _116; + highp vec2 _118; + highp vec2 _120; + highp vec4 _122; + highp vec4 _124; + highp float _126; + highp float _128; + highp float _130; + highp float _132; + _113 = 1000000000.0; + _116 = 1000000000.0; + _118 = vec2(0.0); + _120 = vec2(0.0); + _122 = vec4(0.0); + _124 = vec4(0.0); + _126 = 1000000000.0; + _128 = 1000000000.0; + _130 = 1000000000.0; + _132 = 1000000000.0; + highp float _117; + highp vec2 _121; + highp vec4 _125; + highp float _129; + highp float _133; + highp float _114; + highp vec2 _119; + highp vec4 _123; + highp float _127; + highp float _131; + int _134 = _90; + for (;;) + { + bool _143; + if (_134 < (_90 + _95)) + { + _143 = _134 < 16; + } + else + { + _143 = false; + } + if (_143) + { + int _145 = 3 * _134; + int _146 = 7 + _145; + int _152 = _145 + 9; + highp vec2 _156 = vTextureCoord - uData[_146 * 1 + 0].xy; + highp float _160 = min(uData[_146 * 1 + 0].z, uData[_146 * 1 + 0].w); + highp vec4 _162 = min(uData[(_145 + 8) * 1 + 0], vec4(_160)); + highp float _163 = _156.x; + highp float _184; + if (_163 > 0.0) + { + highp float _175; + if (_156.y > 0.0) + { + _175 = _162.z; + } + else + { + _175 = _162.y; + } + _184 = _175; + } + else + { + highp float _183; + if (_156.y > 0.0) + { + _183 = _162.w; + } + else + { + _183 = _162.x; + } + _184 = _183; + } + highp vec2 _188 = (abs(_156) - uData[_146 * 1 + 0].zw) + vec2(_184); + highp float _190 = (_163 < 0.0) ? (-1.0) : 1.0; + highp float _193 = (_156.y < 0.0) ? (-1.0) : 1.0; + highp float _195 = _188.x; + bool _201; + if (_195 > 0.0) + { + _201 = _188.y > 0.0; + } + else + { + _201 = false; + } + highp vec2 _215; + if (_201) + { + _215 = normalize(_188) * vec2(_190, _193); + } + else + { + highp vec2 _214; + if (_195 > _188.y) + { + _214 = vec2(_190, 0.0); + } + else + { + _214 = vec2(0.0, _193); + } + _215 = _214; + } + highp float _222 = (min(max(_195, _188.y), 0.0) + length(max(_188, vec2(0.0)))) - _184; + bool _223 = _222 < _132; + if (_223) + { + _114 = _116; + _123 = _124; + _131 = _132; + } + else + { + bool _227 = _222 < _130; + bvec4 _229 = bvec4(_227); + _114 = _227 ? _160 : _113; + _123 = vec4(_229.x ? uData[_152 * 1 + 0].x : _122.x, _229.y ? uData[_152 * 1 + 0].y : _122.y, _229.z ? uData[_152 * 1 + 0].z : _122.z, _229.w ? uData[_152 * 1 + 0].w : _122.w); + _131 = _227 ? _222 : _130; + } + _117 = _223 ? _160 : _116; + bvec4 _232 = bvec4(_223); + _125 = vec4(_232.x ? uData[_152 * 1 + 0].x : _124.x, _232.y ? uData[_152 * 1 + 0].y : _124.y, _232.z ? uData[_152 * 1 + 0].z : _124.z, _232.w ? uData[_152 * 1 + 0].w : _124.w); + _133 = _223 ? _222 : _132; + highp vec2 _277; + highp float _278; + if (uData[_152 * 1 + 0].w < 0.5) + { + highp float _243 = (vTextureCoord.x - uData[_146 * 1 + 0].x) + uData[_146 * 1 + 0].z; + highp float _245 = (uData[_146 * 1 + 0].x + uData[_146 * 1 + 0].z) - vTextureCoord.x; + highp float _250 = (vTextureCoord.y - uData[_146 * 1 + 0].y) + uData[_146 * 1 + 0].w; + highp float _252 = (uData[_146 * 1 + 0].y + uData[_146 * 1 + 0].w) - vTextureCoord.y; + highp float _256 = min(min(_243, _245), min(_250, _252)); + highp vec4 _261 = exp((vec4(_256) - vec4(_243, _245, _250, _252)) / vec4(uData[1].y)); + highp float _262 = _261.x; + highp float _263 = _261.y; + highp float _265 = _261.z; + highp float _267 = _261.w; + highp float _268 = ((_262 + _263) + _265) + _267; + _277 = vec2(_263 - _262, _267 - _265) / vec2(_268); + _278 = (uData[1].y * log(_268)) - _256; + } + else + { + _277 = _215; + _278 = _222; + } + bool _279 = _278 < _128; + if (_279) + { + _119 = _120; + _127 = _128; + } + else + { + bool _283 = _278 < _126; + bvec2 _284 = bvec2(_283); + _119 = vec2(_284.x ? _277.x : _118.x, _284.y ? _277.y : _118.y); + _127 = _283 ? _278 : _126; + } + bvec2 _287 = bvec2(_279); + _121 = vec2(_287.x ? _277.x : _120.x, _287.y ? _277.y : _120.y); + _129 = _279 ? _278 : _128; + _113 = _114; + _116 = _117; + _118 = _119; + _120 = _121; + _122 = _123; + _124 = _125; + _126 = _127; + _128 = _129; + _130 = _131; + _132 = _133; + _134++; + continue; + } + else + { + break; + } + } + highp float _289 = max(uData[1].x, 9.9999997473787516355514526367188e-05); + highp float _294 = max(_289 - abs(_132 - _130), 0.0) / _289; + highp float _295 = _294 * _294; + highp float _296 = _295 * 0.5; + highp float _301; + if (_130 < _132) + { + _301 = 1.0 - _296; + } + else + { + _301 = _296; + } + highp float _305 = min(_132, _130) - ((_295 * _289) * 0.25); + highp float _310 = max(_289 - abs(_128 - _126), 0.0) / _289; + highp float _311 = _310 * _310; + highp float _312 = _311 * 0.5; + highp float _317; + if (_126 < _128) + { + _317 = 1.0 - _312; + } + else + { + _317 = _312; + } + highp float _323 = clamp(0.5 - _305, 0.0, 1.0); + if (_323 <= 0.0) + { + discard; + } + if (vColor.x < 0.5) + { + _678 = vec4(0.0, 0.0, 0.0, _323); + break; + } + highp vec4 _334 = mix(_124, _122, vec4(_301)); + highp vec2 _336 = mix(_120, _118, vec2(_317)); + highp float _338 = max(0.0, -(min(_128, _126) - ((_311 * _289) * 0.25))); + highp float _339 = -_338; + highp float _342 = exp(_339 / uData[1].z); + highp float _348 = _334.y; + highp float _382; + highp float _383; + highp vec2 _384; + if (_111) + { + highp float _356 = max(min(uData[5].y * mix(_116, _113, _301), uData[6].w), 1.0); + highp float _359 = clamp(1.0 - (_338 / _356), 0.0, 1.0); + highp float _364 = (((uData[5].z * _356) * _348) * _359) * _359; + _382 = _359; + _383 = _364; + _384 = clamp(((vTextureCoord - (_336 * _364)) - uData[0].xy) * uData[0].zw, vec2(0.0), vec2(1.0)); + } + else + { + _382 = 0.0; + _383 = 0.0; + _384 = clamp(((vTextureCoord + (_336 * ((uData[2].x * _348) * _342))) - uData[0].xy) * uData[0].zw, vec2(0.0), vec2(1.0)); + } + highp vec4 _388 = textureLod(uSampler, _384, 0.0); + bool _390 = uData[4].w > 0.5; + highp vec4 _398; + if (_390) + { + _398 = textureLod(uTex1, _384, 0.0); + } + else + { + _398 = _388; + } + bool _403; + if (_111) + { + _403 = uData[6].y > 0.0; + } + else + { + _403 = false; + } + bool _407; + if (_403) + { + _407 = _383 > 0.5; + } + else + { + _407 = false; + } + highp vec4 _468; + highp vec4 _469; + if (_407) + { + highp vec2 _415 = (_336 * ((_383 * 0.039999999105930328369140625) * uData[6].y)) * uData[0].zw; + highp vec2 _417 = clamp(_384 + _415, vec2(0.0), vec2(1.0)); + highp vec2 _419 = clamp(_384 - _415, vec2(0.0), vec2(1.0)); + highp vec4 _423 = textureLod(uSampler, _417, 0.0); + highp float _428 = _388.w; + highp float _429 = (_423.x / max(_423.w, 9.9999997473787516355514526367188e-05)) * _428; + highp vec4 _430 = _388; + _430.x = _429; + highp vec4 _434 = textureLod(uSampler, _419, 0.0); + highp float _439 = (_434.z / max(_434.w, 9.9999997473787516355514526367188e-05)) * _428; + _430.z = _439; + highp vec4 _467; + if (_390) + { + highp vec4 _447 = textureLod(uTex1, _417, 0.0); + highp vec4 _454 = _398; + _454.x = (_447.x / max(_447.w, 9.9999997473787516355514526367188e-05)) * _398.w; + highp vec4 _458 = textureLod(uTex1, _419, 0.0); + _454.z = (_458.z / max(_458.w, 9.9999997473787516355514526367188e-05)) * _398.w; + _467 = _454; + } + else + { + highp vec4 _465 = _398; + _465.x = _429; + _465.z = _439; + _467 = _465; + } + _468 = _467; + _469 = _430; + } + else + { + _468 = _398; + _469 = _388; + } + highp float _477; + if (uData[6].x > 0.5) + { + _477 = 1.0; + } + else + { + _477 = _469.w; + } + highp vec4 _486 = vec4(_469.xyz / vec3(max(_469.w, 9.9999997473787516355514526367188e-05)), 1.0); + highp vec4 _495 = vec4(_468.xyz / vec3(max(_468.w, 9.9999997473787516355514526367188e-05)), 1.0); + bool _497 = uData[4].z > 0.5; + highp float _502; + if (_497) + { + _502 = uData[4].x; + } + else + { + _502 = 0.0; + } + highp float _511; + if (_111) + { + _511 = _502 * (1.0 - ((_382 * _382) * (1.0 - smoothstep(0.800000011920928955078125, 1.0, _502)))); + } + else + { + _511 = _502; + } + highp float _514 = 1.0 - _511; + highp float _522; + if (_111) + { + _522 = _382; + } + else + { + _522 = _342 * _342; + } + highp float _525 = clamp(((uData[2].y * _348) * _522) * _514, 0.0, 1.0); + highp vec4 _531 = ((_495 * _525) + ((mix(_495, _486, vec4(clamp(_334.x, 0.0, 1.0))) * _514) * (1.0 - _525))) + (uData[3] * _511); + highp float _536 = dot(_336, vec2(-0.707106769084930419921875)); + highp float _537 = max(_536, 0.0); + highp float _539 = max(-_536, 0.0); + highp float _542 = abs(_536); + bool _547; + if (_111) + { + _547 = uData[6].z > 0.0; + } + else + { + _547 = false; + } + highp float _567; + if (_547) + { + _567 = (uData[6].z * (1.0 - smoothstep(0.20000000298023223876953125, 0.699999988079071044921875, _542))) * exp((-max(0.0, -_305)) / (uData[2].z * 2.5)); + } + else + { + _567 = 0.85000002384185791015625 * (1.0 - smoothstep(0.0, uData[2].z * 0.625, -_305)); + } + highp float _575; + if (_111) + { + _575 = max((uData[5].w * _382) * _382, _567); + } + else + { + _575 = 0.0; + } + highp float _578 = _477 * (1.0 - _575); + highp vec4 _584 = vec4((_531 + (_486 * (1.0 - _531.w))).xyz * _578, _578 + _575); + highp float _603; + if (_111) + { + highp float _594; + if (_547) + { + _594 = uData[6].z * smoothstep(0.75, 1.0, _542); + } + else + { + _594 = 0.25; + } + _603 = _594; + } + else + { + _603 = (0.449999988079071044921875 * ((_537 * sqrt(_537)) + ((0.75 * _539) * sqrt(_539)))) + 0.0599999986588954925537109375; + } + highp float _630; + if (_547) + { + _630 = exp((-max(0.0, -_305)) / (uData[2].z * 1.25)); + } + else + { + highp float _629; + if (_111) + { + highp float _617 = -_305; + _629 = smoothstep(uData[2].z * 0.3125, uData[2].z * 0.625, _617) * (1.0 - smoothstep(uData[2].z * 1.5, uData[2].z * 2.0, _617)); + } + else + { + _629 = exp((-max(0.0, -_305)) / uData[2].z); + } + _630 = _629; + } + highp float _637; + if (_111) + { + _637 = 0.0; + } + else + { + _637 = (0.039999999105930328369140625 * exp(_339 / uData[1].w)) * _537; + } + highp float _643; + if (_497) + { + _643 = uData[4].y; + } + else + { + _643 = 0.0; + } + highp vec3 _653 = _584.xyz + vec3(clamp((_643 + _334.z) + ((_348 * uData[2].w) * ((_630 * _603) + _637)), 0.0, 1.0)); + highp vec4 _655 = clamp(vec4(_653.x, _653.y, _653.z, _584.w), vec4(0.0), vec4(1.0)); + highp float _662 = max(_655.w, max(_655.x, max(_655.y, _655.z))); + highp vec4 _663 = _655; + _663.w = _662; + highp vec3 _674 = _663.xyz + vec3(((fract(52.98291778564453125 * fract(dot(vTextureCoord, vec2(0.067110560834407806396484375, 0.005837149918079376220703125)))) - 0.5) * uData[5].x) * _662); + _678 = clamp(vec4(_674.x, _674.y, _674.z, _663.w), vec4(0.0), vec4(1.0)) * _323; + break; + } while(false); + out_var_SV_Target0 = _678; +} + diff --git a/core/gfx/shaders/compiled/msl/liquid_glass.fragment.msl b/core/gfx/shaders/compiled/msl/liquid_glass.fragment.msl new file mode 100644 index 000000000..bce3c888d --- /dev/null +++ b/core/gfx/shaders/compiled/msl/liquid_glass.fragment.msl @@ -0,0 +1,484 @@ +#include +#include + +using namespace metal; + +struct type_Uniforms +{ + float4 uData[55]; +}; + +struct main0_out +{ + float4 out_var_SV_Target0 [[color(0)]]; +}; + +struct main0_in +{ + float4 in_var_COLOR [[user(locn0)]]; + float2 in_var_TEXCOORD0 [[user(locn1)]]; +}; + +fragment main0_out main0(main0_in in [[stage_in]], constant type_Uniforms& Uniforms [[buffer(0)]], texture2d Frost [[texture(0)]], texture2d Sharp [[texture(1)]], sampler FrostSampler [[sampler(0)]], sampler SharpSampler [[sampler(1)]]) +{ + main0_out out = {}; + float4 _678; + do + { + int _90 = int((in.in_var_COLOR.y * 255.0) + 0.5); + int _95 = int((in.in_var_COLOR.z * 255.0) + 0.5); + bool _111 = Uniforms.uData[5].y > 0.0; + float _113; + float _116; + float2 _118; + float2 _120; + float4 _122; + float4 _124; + float _126; + float _128; + float _130; + float _132; + _113 = 1000000000.0; + _116 = 1000000000.0; + _118 = float2(0.0); + _120 = float2(0.0); + _122 = float4(0.0); + _124 = float4(0.0); + _126 = 1000000000.0; + _128 = 1000000000.0; + _130 = 1000000000.0; + _132 = 1000000000.0; + float _117; + float2 _121; + float4 _125; + float _129; + float _133; + float _114; + float2 _119; + float4 _123; + float _127; + float _131; + int _134 = _90; + for (;;) + { + bool _143; + if (_134 < (_90 + _95)) + { + _143 = _134 < 16; + } + else + { + _143 = false; + } + if (_143) + { + int _145 = 3 * _134; + int _146 = 7 + _145; + int _152 = _145 + 9; + float2 _156 = in.in_var_TEXCOORD0 - Uniforms.uData[_146].xy; + float _160 = precise::min(Uniforms.uData[_146].z, Uniforms.uData[_146].w); + float4 _162 = precise::min(Uniforms.uData[_145 + 8], float4(_160)); + float _163 = _156.x; + float _184; + if (_163 > 0.0) + { + float _175; + if (_156.y > 0.0) + { + _175 = _162.z; + } + else + { + _175 = _162.y; + } + _184 = _175; + } + else + { + float _183; + if (_156.y > 0.0) + { + _183 = _162.w; + } + else + { + _183 = _162.x; + } + _184 = _183; + } + float2 _188 = (abs(_156) - Uniforms.uData[_146].zw) + float2(_184); + float _190 = (_163 < 0.0) ? (-1.0) : 1.0; + float _193 = (_156.y < 0.0) ? (-1.0) : 1.0; + float _195 = _188.x; + bool _201; + if (_195 > 0.0) + { + _201 = _188.y > 0.0; + } + else + { + _201 = false; + } + float2 _215; + if (_201) + { + _215 = fast::normalize(_188) * float2(_190, _193); + } + else + { + float2 _214; + if (_195 > _188.y) + { + _214 = float2(_190, 0.0); + } + else + { + _214 = float2(0.0, _193); + } + _215 = _214; + } + float _222 = (precise::min(precise::max(_195, _188.y), 0.0) + length(precise::max(_188, float2(0.0)))) - _184; + bool _223 = _222 < _132; + if (_223) + { + _114 = _116; + _123 = _124; + _131 = _132; + } + else + { + bool _227 = _222 < _130; + _114 = _227 ? _160 : _113; + _123 = select(_122, Uniforms.uData[_152], bool4(_227)); + _131 = _227 ? _222 : _130; + } + _117 = _223 ? _160 : _116; + _125 = select(_124, Uniforms.uData[_152], bool4(_223)); + _133 = _223 ? _222 : _132; + float2 _277; + float _278; + if (Uniforms.uData[_152].w < 0.5) + { + float _243 = (in.in_var_TEXCOORD0.x - Uniforms.uData[_146].x) + Uniforms.uData[_146].z; + float _245 = (Uniforms.uData[_146].x + Uniforms.uData[_146].z) - in.in_var_TEXCOORD0.x; + float _250 = (in.in_var_TEXCOORD0.y - Uniforms.uData[_146].y) + Uniforms.uData[_146].w; + float _252 = (Uniforms.uData[_146].y + Uniforms.uData[_146].w) - in.in_var_TEXCOORD0.y; + float _256 = precise::min(precise::min(_243, _245), precise::min(_250, _252)); + float4 _261 = exp((float4(_256) - float4(_243, _245, _250, _252)) / float4(Uniforms.uData[1].y)); + float _262 = _261.x; + float _263 = _261.y; + float _265 = _261.z; + float _267 = _261.w; + float _268 = ((_262 + _263) + _265) + _267; + _277 = float2(_263 - _262, _267 - _265) / float2(_268); + _278 = (Uniforms.uData[1].y * log(_268)) - _256; + } + else + { + _277 = _215; + _278 = _222; + } + bool _279 = _278 < _128; + if (_279) + { + _119 = _120; + _127 = _128; + } + else + { + bool _283 = _278 < _126; + _119 = select(_118, _277, bool2(_283)); + _127 = _283 ? _278 : _126; + } + _121 = select(_120, _277, bool2(_279)); + _129 = _279 ? _278 : _128; + _113 = _114; + _116 = _117; + _118 = _119; + _120 = _121; + _122 = _123; + _124 = _125; + _126 = _127; + _128 = _129; + _130 = _131; + _132 = _133; + _134++; + continue; + } + else + { + break; + } + } + float _289 = precise::max(Uniforms.uData[1].x, 9.9999997473787516355514526367188e-05); + float _294 = precise::max(_289 - abs(_132 - _130), 0.0) / _289; + float _295 = _294 * _294; + float _296 = _295 * 0.5; + float _301; + if (_130 < _132) + { + _301 = 1.0 - _296; + } + else + { + _301 = _296; + } + float _305 = precise::min(_132, _130) - ((_295 * _289) * 0.25); + float _310 = precise::max(_289 - abs(_128 - _126), 0.0) / _289; + float _311 = _310 * _310; + float _312 = _311 * 0.5; + float _317; + if (_126 < _128) + { + _317 = 1.0 - _312; + } + else + { + _317 = _312; + } + float _323 = fast::clamp(0.5 - _305, 0.0, 1.0); + if (_323 <= 0.0) + { + discard_fragment(); + } + if (in.in_var_COLOR.x < 0.5) + { + _678 = float4(0.0, 0.0, 0.0, _323); + break; + } + float4 _334 = mix(_124, _122, float4(_301)); + float2 _336 = mix(_120, _118, float2(_317)); + float _338 = precise::max(0.0, -(precise::min(_128, _126) - ((_311 * _289) * 0.25))); + float _339 = -_338; + float _342 = exp(_339 / Uniforms.uData[1].z); + float _348 = _334.y; + float _382; + float _383; + float2 _384; + if (_111) + { + float _356 = precise::max(precise::min(Uniforms.uData[5].y * mix(_116, _113, _301), Uniforms.uData[6].w), 1.0); + float _359 = fast::clamp(1.0 - (_338 / _356), 0.0, 1.0); + float _364 = (((Uniforms.uData[5].z * _356) * _348) * _359) * _359; + _382 = _359; + _383 = _364; + _384 = fast::clamp(((in.in_var_TEXCOORD0 - (_336 * _364)) - Uniforms.uData[0].xy) * Uniforms.uData[0].zw, float2(0.0), float2(1.0)); + } + else + { + _382 = 0.0; + _383 = 0.0; + _384 = fast::clamp(((in.in_var_TEXCOORD0 + (_336 * ((Uniforms.uData[2].x * _348) * _342))) - Uniforms.uData[0].xy) * Uniforms.uData[0].zw, float2(0.0), float2(1.0)); + } + float4 _388 = Frost.sample(FrostSampler, _384, level(0.0)); + bool _390 = Uniforms.uData[4].w > 0.5; + float4 _398; + if (_390) + { + _398 = Sharp.sample(SharpSampler, _384, level(0.0)); + } + else + { + _398 = _388; + } + bool _403; + if (_111) + { + _403 = Uniforms.uData[6].y > 0.0; + } + else + { + _403 = false; + } + bool _407; + if (_403) + { + _407 = _383 > 0.5; + } + else + { + _407 = false; + } + float4 _468; + float4 _469; + if (_407) + { + float2 _415 = (_336 * ((_383 * 0.039999999105930328369140625) * Uniforms.uData[6].y)) * Uniforms.uData[0].zw; + float2 _417 = fast::clamp(_384 + _415, float2(0.0), float2(1.0)); + float2 _419 = fast::clamp(_384 - _415, float2(0.0), float2(1.0)); + float4 _423 = Frost.sample(FrostSampler, _417, level(0.0)); + float _428 = _388.w; + float _429 = (_423.x / precise::max(_423.w, 9.9999997473787516355514526367188e-05)) * _428; + float4 _430 = _388; + _430.x = _429; + float4 _434 = Frost.sample(FrostSampler, _419, level(0.0)); + float _439 = (_434.z / precise::max(_434.w, 9.9999997473787516355514526367188e-05)) * _428; + _430.z = _439; + float4 _467; + if (_390) + { + float4 _447 = Sharp.sample(SharpSampler, _417, level(0.0)); + float4 _454 = _398; + _454.x = (_447.x / precise::max(_447.w, 9.9999997473787516355514526367188e-05)) * _398.w; + float4 _458 = Sharp.sample(SharpSampler, _419, level(0.0)); + _454.z = (_458.z / precise::max(_458.w, 9.9999997473787516355514526367188e-05)) * _398.w; + _467 = _454; + } + else + { + float4 _465 = _398; + _465.x = _429; + _465.z = _439; + _467 = _465; + } + _468 = _467; + _469 = _430; + } + else + { + _468 = _398; + _469 = _388; + } + float _477; + if (Uniforms.uData[6].x > 0.5) + { + _477 = 1.0; + } + else + { + _477 = _469.w; + } + float4 _486 = float4(_469.xyz / float3(precise::max(_469.w, 9.9999997473787516355514526367188e-05)), 1.0); + float4 _495 = float4(_468.xyz / float3(precise::max(_468.w, 9.9999997473787516355514526367188e-05)), 1.0); + bool _497 = Uniforms.uData[4].z > 0.5; + float _502; + if (_497) + { + _502 = Uniforms.uData[4].x; + } + else + { + _502 = 0.0; + } + float _511; + if (_111) + { + _511 = _502 * (1.0 - ((_382 * _382) * (1.0 - smoothstep(0.800000011920928955078125, 1.0, _502)))); + } + else + { + _511 = _502; + } + float _514 = 1.0 - _511; + float _522; + if (_111) + { + _522 = _382; + } + else + { + _522 = _342 * _342; + } + float _525 = fast::clamp(((Uniforms.uData[2].y * _348) * _522) * _514, 0.0, 1.0); + float4 _531 = ((_495 * _525) + ((mix(_495, _486, float4(fast::clamp(_334.x, 0.0, 1.0))) * _514) * (1.0 - _525))) + (Uniforms.uData[3] * _511); + float _536 = dot(_336, float2(-0.707106769084930419921875)); + float _537 = precise::max(_536, 0.0); + float _539 = precise::max(-_536, 0.0); + float _542 = abs(_536); + bool _547; + if (_111) + { + _547 = Uniforms.uData[6].z > 0.0; + } + else + { + _547 = false; + } + float _567; + if (_547) + { + _567 = (Uniforms.uData[6].z * (1.0 - smoothstep(0.20000000298023223876953125, 0.699999988079071044921875, _542))) * exp((-precise::max(0.0, -_305)) / (Uniforms.uData[2].z * 2.5)); + } + else + { + _567 = 0.85000002384185791015625 * (1.0 - smoothstep(0.0, Uniforms.uData[2].z * 0.625, -_305)); + } + float _575; + if (_111) + { + _575 = precise::max((Uniforms.uData[5].w * _382) * _382, _567); + } + else + { + _575 = 0.0; + } + float _578 = _477 * (1.0 - _575); + float4 _584 = float4((_531 + (_486 * (1.0 - _531.w))).xyz * _578, _578 + _575); + float _603; + if (_111) + { + float _594; + if (_547) + { + _594 = Uniforms.uData[6].z * smoothstep(0.75, 1.0, _542); + } + else + { + _594 = 0.25; + } + _603 = _594; + } + else + { + _603 = (0.449999988079071044921875 * ((_537 * sqrt(_537)) + ((0.75 * _539) * sqrt(_539)))) + 0.0599999986588954925537109375; + } + float _630; + if (_547) + { + _630 = exp((-precise::max(0.0, -_305)) / (Uniforms.uData[2].z * 1.25)); + } + else + { + float _629; + if (_111) + { + float _617 = -_305; + _629 = smoothstep(Uniforms.uData[2].z * 0.3125, Uniforms.uData[2].z * 0.625, _617) * (1.0 - smoothstep(Uniforms.uData[2].z * 1.5, Uniforms.uData[2].z * 2.0, _617)); + } + else + { + _629 = exp((-precise::max(0.0, -_305)) / Uniforms.uData[2].z); + } + _630 = _629; + } + float _637; + if (_111) + { + _637 = 0.0; + } + else + { + _637 = (0.039999999105930328369140625 * exp(_339 / Uniforms.uData[1].w)) * _537; + } + float _643; + if (_497) + { + _643 = Uniforms.uData[4].y; + } + else + { + _643 = 0.0; + } + float3 _653 = _584.xyz + float3(fast::clamp((_643 + _334.z) + ((_348 * Uniforms.uData[2].w) * ((_630 * _603) + _637)), 0.0, 1.0)); + float4 _655 = fast::clamp(float4(_653.x, _653.y, _653.z, _584.w), float4(0.0), float4(1.0)); + float _662 = precise::max(_655.w, precise::max(_655.x, precise::max(_655.y, _655.z))); + float4 _663 = _655; + _663.w = _662; + float3 _674 = _663.xyz + float3(((fract(52.98291778564453125 * fract(dot(in.in_var_TEXCOORD0, float2(0.067110560834407806396484375, 0.005837149918079376220703125)))) - 0.5) * Uniforms.uData[5].x) * _662); + _678 = fast::clamp(float4(_674.x, _674.y, _674.z, _663.w), float4(0.0), float4(1.0)) * _323; + break; + } while(false); + out.out_var_SV_Target0 = _678; + return out; +} + diff --git a/core/gfx/shaders/liquid_glass.fragment.hlsl b/core/gfx/shaders/liquid_glass.fragment.hlsl index 42bf83ad9..31f1d22f2 100644 --- a/core/gfx/shaders/liquid_glass.fragment.hlsl +++ b/core/gfx/shaders/liquid_glass.fragment.hlsl @@ -1,13 +1,19 @@ -// `liquid_glass.glsl` in HLSL, for fizzy's native backend on Vulkan and D3D12: the same program -// line for line as it and `liquid_glass.metal` — change all three together, and -// `LiquidField.sample` with them. shadercross compiles it to the two forms `LiquidField` embeds: +// Liquid glass (`core/gfx/LiquidField.zig`): rounded boxes run together by a smooth minimum, +// frosted and refracting, in one pass at every pixel. // -// shadercross liquid_glass.fragment.hlsl -o compiled/spv/liquid_glass.fragment.spv -// shadercross liquid_glass.fragment.hlsl -o compiled/dxil/liquid_glass.fragment.dxil +// The one source of the program: written here, in HLSL, and generated from it for every backend +// by `scripts/compile-shaders.sh` — SPIR-V (Vulkan) and DXIL (D3D12) for fizzy's native backend, +// Metal for it on macOS, GLSL ES 3.00 for the web — into `compiled/`, which CI checks against this +// (`.github/workflows/shaders.yml`). Never edit those by hand: change this, and commit what CI +// compiles. `LiquidField.sample` mirrors the field on the CPU, line for line; change it with this. +// +// Each quad's vertex colour says what it draws: r the pass (0 punches the glass's coverage out +// of what is there, 1 adds the glass), g and b the first of its shapes and how many. // // To the interface the native backend's programs take (`program_example.fragment.hlsl`): the // default vertex shader's outputs as inputs, the draw's own texture (the frost) at slot 0, the -// sharp picture at 1, the uniforms as one array. +// sharp picture at 1, the uniforms as one array. On the web the same, by the names +// `core.gfx.programs` gives them (the generator renames them). #define G 7 #define MAX_SHAPES 16 @@ -32,6 +38,8 @@ cbuffer Uniforms : register(b0, space3) float4 uData[G + 3 * MAX_SHAPES]; }; +// Signed distance to a box of half-size `b` at the origin, its corners rounded `r` +// (top-left, top-right, bottom-right, bottom-left; y down), and which way is out. float sdRoundBox(float2 p, float2 b, float4 r, out float2 g) { float rr = p.x > 0.0 ? (p.y > 0.0 ? r.z : r.y) : (p.y > 0.0 ? r.w : r.x); @@ -47,6 +55,8 @@ float sdRoundBox(float2 p, float2 b, float4 r, out float2 g) return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - rr; } +// `liquid_glass.fieldAt`: a soft minimum of the distances to the box's four sides, so the way +// out turns smoothly round a corner and never folds along a diagonal. Negative inside. float softBox(float2 p, float4 s, float k, out float2 o) { float4 d = float4(p.x - s.x + s.z, s.x + s.z - p.x, p.y - s.y + s.w, s.y + s.w - p.y); @@ -57,6 +67,7 @@ float softBox(float2 p, float4 s, float k, out float2 o) return k * log(sum) - m; } +// iq's quadratic smooth minimum, and how much of `b` is in it. float smin(float a, float b, float k, out float m) { float h = max(k - abs(a - b), 0.0) / k; @@ -82,8 +93,11 @@ float4 main(PSInput input) : SV_Target0 float4 g4 = uData[4]; float4 g5 = uData[5]; // dither; the lens: its band's share of the shape, its bend, its shade float4 g6 = uData[6]; // opaque window; the lens: dispersion, a drop's diagonal light (0: a window's hairline), the band's cap + // Apple's lens (`glass_look.inApp`): the picture pulled inward from a band near the rim, + // magnified and, past a bend of 1, folded there. Off, the earlier glass: pulled from outside. bool inward = g5.y > 0.0; + // The two nearest shapes, for the outline and for the way out. float d1 = 1e9; float d2 = 1e9; float4 m1 = float4(0.0, 0.0, 0.0, 0.0); float4 m2 = float4(0.0, 0.0, 0.0, 0.0); float h1 = 1e9; float h2 = 1e9; float f1 = 1e9; float f2 = 1e9; float2 o1 = float2(0.0, 0.0); float2 o2 = float2(0.0, 0.0); @@ -122,6 +136,10 @@ float4 main(PSInput input) : SV_Target0 // Level 0 explicitly: the frost has no mips, and an implicit level after `discard` is a // derivative in divergent control flow, which Metal and GLSL let pass and HLSL does not. +// What the glass shows here. Apple's lens: from further in, within the band near the rim — `t` +// 1 at the edge, 0 a band in — by `bend` bands at the edge, falling off as t², so the picture +// magnifies toward the rim and folds where the slope passes 1. The earlier glass: further out +// along the way out, as steep as it is (`seen`). float t = 0.0; float disp = 0.0; float2 uv; @@ -166,6 +184,8 @@ float4 main(PSInput input) : SV_Target0 frost = float4(frost.rgb / max(frost.a, 1e-4), 1.0); sharp = float4(sharp.rgb / max(sharp.a, 1e-4), 1.0); +// The frost at its share of a frost / tint mix, the blur coming in from sharp. The window's +// colour comes in whatever the blur: the opacity is the colour, under clear glass too. float mixv = g4.z > 0.5 ? g4.x : 0.0; // Apple's lens: the window's colour fades out across the band, as an OS window's does // (`glass_look.window`, its feather: strong most of the way, gone at the edge), so the edge stays @@ -173,8 +193,11 @@ float4 main(PSInput input) : SV_Target0 // too (`glass_look.shine_start`, 0.8 to 1). if (inward) mixv *= 1.0 - t * t * (1.0 - smoothstep(0.8, 1.0, mixv)); float4 c = lerp(sharp, frost, b) * (1.0 - mixv); +// The rim's clearer glass over it (`drawClear`): for Apple's lens, the band, so the rim stays +// clear glass while the middle frosts. float a = clamp(g2.y * lens * (inward ? t : steepR * steepR) * (1.0 - mixv), 0.0, 1.0); c = sharp * a + c * (1.0 - sharp.a * a); +// The tint (`addTint`). c += g3 * mixv; // A translucent tint lies over the frost, not over what is behind the window: the glass is // opaque here, and `under` alone says how much of the desktop's material shows through it. @@ -199,6 +222,7 @@ float4 main(PSInput input) : SV_Target0 : 0.85 * (1.0 - smoothstep(0.0, 0.5 * px, -D)); float shade = inward ? max(g5.w * t * t, edge_dark) : 0.0; float4 o = float4(c.rgb * (under * (1.0 - shade)), under * (1.0 - shade) + shade); +// The lift and the rim's light (`drawLift`), brightest facing the top left. float spec = inward ? (diagonal ? g6.z * smoothstep(0.75, 1.0, af) : 0.25) : 0.45 * (toward * sqrt(toward) + 0.75 * away * sqrt(away)) + 0.06; float line_ = diagonal ? exp(-max(0.0, -D) / px) diff --git a/core/gfx/shaders/liquid_glass.glsl b/core/gfx/shaders/liquid_glass.glsl deleted file mode 100644 index 1b2c605e0..000000000 --- a/core/gfx/shaders/liquid_glass.glsl +++ /dev/null @@ -1,224 +0,0 @@ -// Liquid glass (`core/gfx/LiquidField.zig`): rounded boxes run together by a smooth minimum, -// frosted and refracting, in one pass at every pixel. Written against the program prelude -// (`core/gfx/programs.zig`): VARYING, TEX, FRAG_COLOR, MAX_VEC4. -// -// Mirrors `LiquidField.sample` line for line, as `liquid_glass.metal` and -// `liquid_glass.fragment.hlsl` (compiled to SPIR-V and DXIL) mirror it; change them all together. -// -// Each quad's vertex colour says what it draws: r the pass (0 punches the glass's coverage out -// of what is there, 1 adds the glass), g and b the first of its shapes and how many. - -#define G 7 -#define MAX_SHAPES 16 - -VARYING vec4 vColor; -VARYING vec2 vTextureCoord; - -uniform sampler2D uSampler; // the frost -uniform sampler2D uTex1; // the picture before the blur -uniform vec4 uData[MAX_VEC4]; - -// Signed distance to a box of half-size `b` at the origin, its corners rounded `r` -// (top-left, top-right, bottom-right, bottom-left; y down), and which way is out. -float sdRoundBox(vec2 p, vec2 b, vec4 r, out vec2 g) { - float rr = p.x > 0.0 ? (p.y > 0.0 ? r.z : r.y) : (p.y > 0.0 ? r.w : r.x); - vec2 q = abs(p) - b + rr; - vec2 s = vec2(p.x < 0.0 ? -1.0 : 1.0, p.y < 0.0 ? -1.0 : 1.0); - if (q.x > 0.0 && q.y > 0.0) { - g = normalize(q) * s; - } else if (q.x > q.y) { - g = vec2(s.x, 0.0); - } else { - g = vec2(0.0, s.y); - } - return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - rr; -} - -// `liquid_glass.fieldAt`: a soft minimum of the distances to the box's four sides, so the way -// out turns smoothly round a corner and never folds along a diagonal. Negative inside. -float softBox(vec2 p, vec4 s, float k, out vec2 o) { - vec4 d = vec4(p.x - s.x + s.z, s.x + s.z - p.x, p.y - s.y + s.w, s.y + s.w - p.y); - float m = min(min(d.x, d.y), min(d.z, d.w)); - vec4 w = exp((m - d) / k); - float sum = w.x + w.y + w.z + w.w; - o = vec2(w.y - w.x, w.w - w.z) / sum; - return k * log(sum) - m; -} - -// iq's quadratic smooth minimum, and how much of `b` is in it. -float smin(float a, float b, float k, out float m) { - float h = max(k - abs(a - b), 0.0) / k; - m = h * h * 0.5; - if (b < a) m = 1.0 - m; - return min(a, b) - h * h * k * 0.25; -} - -float noise(vec2 p) { - return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); -} - -void main() { - int first = int(vColor.g * 255.0 + 0.5); - int count = int(vColor.b * 255.0 + 0.5); - vec2 p = vTextureCoord; - vec4 g0 = uData[0]; // the frost: where it starts, and 1 / its size - vec4 g1 = uData[1]; // merge k, softness, refraction depth, light depth - vec4 g2 = uData[2]; // reach, clarity, rim line width, light - vec4 g3 = uData[3]; // tint, premultiplied - vec4 g4 = uData[4]; // mix, lift, has tint, has sharp - vec4 g5 = uData[5]; // dither; the lens: its band's share of the shape, its bend, its shade - vec4 g6 = uData[6]; // opaque window; the lens: dispersion, a drop's diagonal light (0: a window's hairline), the band's cap - // Apple's lens (`glass_look.inApp`): the picture pulled inward from a band near the rim, - // magnified and, past a bend of 1, folded there. Off, the earlier glass: pulled from outside. - bool inward = g5.y > 0.0; - - // The two nearest shapes, for the outline and for the way out. - float d1 = 1e9; float d2 = 1e9; vec4 m1 = vec4(0.0); vec4 m2 = vec4(0.0); - float h1 = 1e9; float h2 = 1e9; - float f1 = 1e9; float f2 = 1e9; vec2 o1 = vec2(0.0); vec2 o2 = vec2(0.0); - for (int i = 0; i < MAX_SHAPES; i++) { - if (i < first) continue; - if (i >= first + count) break; - vec4 s0 = uData[G + 3 * i]; - vec4 s1 = uData[G + 3 * i + 1]; - vec4 s2 = uData[G + 3 * i + 2]; - vec2 g; - float d = sdRoundBox(p - s0.xy, s0.zw, min(s1, vec4(min(s0.z, s0.w))), g); - float hs = min(s0.z, s0.w); - if (d < d1) { d2 = d1; m2 = m1; h2 = h1; d1 = d; m1 = s2; h1 = hs; } else if (d < d2) { d2 = d; m2 = s2; h2 = hs; } - vec2 o; - float f = d; - if (s2.w < 0.5) { - f = softBox(p, s0, g1.y, o); - } else { - o = g; - } - if (f < f1) { f2 = f1; o2 = o1; f1 = f; o1 = o; } else if (f < f2) { f2 = f; o2 = o; } - } - float k = max(g1.x, 0.0001); - float wm; float wf; - float D = smin(d1, d2, k, wm); - float F = smin(f1, f2, k, wf); - float cov = clamp(0.5 - D, 0.0, 1.0); - if (cov <= 0.0) discard; - if (vColor.r < 0.5) { - FRAG_COLOR = vec4(0.0, 0.0, 0.0, cov); - return; - } - - vec4 mat = mix(m1, m2, wm); // blur, lens, light, field - vec2 outv = mix(o1, o2, wf); - float soft = max(0.0, -F); - float steepR = exp(-soft / g1.z); - float steepL = exp(-soft / g1.w); - float b = clamp(mat.x, 0.0, 1.0); - float lens = mat.y; - - // What the glass shows here. Apple's lens: from further in, within the band near the rim — `t` - // 1 at the edge, 0 a band in — by `bend` bands at the edge, falling off as t², so the picture - // magnifies toward the rim and folds where the slope passes 1. The earlier glass: further out - // along the way out, as steep as it is (`seen`). - float t = 0.0; - float disp = 0.0; - vec2 uv; - if (inward) { - float W = max(min(g5.y * mix(h1, h2, wm), g6.w), 1.0); - t = clamp(1.0 - soft / W, 0.0, 1.0); - disp = g5.z * W * lens * t * t; - uv = clamp((p - outv * disp - g0.xy) * g0.zw, 0.0, 1.0); - } else { - uv = clamp((p + outv * (g2.x * lens * steepR) - g0.xy) * g0.zw, 0.0, 1.0); - } - vec4 frost = TEX(uSampler, uv); - vec4 sharp = g4.w > 0.5 ? TEX(uTex1, uv) : frost; - // A little colour fringe where the band bends hardest: red from a touch less far in, blue a - // touch further. - // The colour fringe: red and blue from either side, each un-premultiplied by its own alpha and - // put back at the middle's. By the middle's alone, at the edge of a clear part of the window - // the two alphas differ widely, and the fringe came out as red and blue specks. - if (inward && g6.y > 0.0 && disp > 0.5) { - vec2 du = outv * (disp * 0.04 * g6.y) * g0.zw; - vec2 uvr = clamp(uv + du, 0.0, 1.0); - vec2 uvb = clamp(uv - du, 0.0, 1.0); - vec4 frost_r = TEX(uSampler, uvr); - frost.r = frost_r.r / max(frost_r.a, 1e-4) * frost.a; - vec4 frost_b = TEX(uSampler, uvb); - frost.b = frost_b.b / max(frost_b.a, 1e-4) * frost.a; - if (g4.w > 0.5) { - vec4 sharp_r = TEX(uTex1, uvr); - sharp.r = sharp_r.r / max(sharp_r.a, 1e-4) * sharp.a; - vec4 sharp_b = TEX(uTex1, uvb); - sharp.b = sharp_b.b / max(sharp_b.a, 1e-4) * sharp.a; - } else { - sharp.r = frost.r; - sharp.b = frost.b; - } - } - // Worked out over an opaque picture of what the glass covers, then made exactly as - // see-through as that is (`under`): over a translucent window (vibrancy, Acrylic) the - // desktop's material shows through the glass as much as through the window round it. Laid - // over the translucent picture as it was, the rim — where the clear glass lies over the - // frost — came out more opaque than the face, and the material lit the face and not the rim. - // Opaque where the window is (`LiquidField.publishOpaqueWindow`): its alpha is only its shape. - float under = uData[6].x > 0.5 ? 1.0 : frost.a; - frost = vec4(frost.rgb / max(frost.a, 1e-4), 1.0); - sharp = vec4(sharp.rgb / max(sharp.a, 1e-4), 1.0); - - // The frost at its share of a frost / tint mix, the blur coming in from sharp. The window's - // colour comes in whatever the blur: the opacity is the colour, under clear glass too. - float mixv = g4.z > 0.5 ? g4.x : 0.0; - // Apple's lens: the window's colour fades out across the band, as an OS window's does - // (`glass_look.window`, its feather: strong most of the way, gone at the edge), so the edge stays - // the lens bending what is behind it — unless the colour is near whole, when it covers the edge - // too (`glass_look.shine_start`, 0.8 to 1). - if (inward) mixv *= 1.0 - t * t * (1.0 - smoothstep(0.8, 1.0, mixv)); - vec4 c = mix(sharp, frost, b) * (1.0 - mixv); - // The rim's clearer glass over it (`drawClear`): for Apple's lens, the band, so the rim stays - // clear glass while the middle frosts. - float a = clamp(g2.y * lens * (inward ? t : steepR * steepR) * (1.0 - mixv), 0.0, 1.0); - c = sharp * a + c * (1.0 - sharp.a * a); - // The tint (`addTint`). - c += g3 * mixv; - // A translucent tint lies over the frost, not over what is behind the window: the glass is - // opaque here, and `under` alone says how much of the desktop's material shows through it. - c += frost * (1.0 - c.a); - // The way the edge faces, against a light from the top left. - float facing = dot(outv, vec2(-0.70710678, -0.70710678)); - float toward = max(facing, 0.0); - float away = max(-facing, 0.0); - // Apple's lens has two edges, measured beside the OS's (`g6.z`, `glass_look.InApp.diagonal`): - // - a drop's (> 0): lit along the diagonal the light runs, top left and bottom right, about - // three pixels in and only near the diagonal; darkened across the other, top right and - // bottom left; nothing at the sides square to the light; - // - a window's (0): a dark hairline at the very edge and a bright one, two pixels, just inside - // it, the same on every side (the OS window's own border, as Liquid Glass windows have). - // Nothing across the band in either. Shade is the glass's own, not the picture's: a dark layer - // over it, as whole where the picture is see-through as where it is not. `px` is a point, in - // pixels (the rim line's width is 0.8 of one, `LiquidField.rim_line_width`). - float px = g2.z * 1.25; - float af = abs(facing); - bool diagonal = inward && g6.z > 0.0; - float edge_dark = diagonal ? g6.z * (1.0 - smoothstep(0.2, 0.7, af)) * exp(-max(0.0, -D) / (2.0 * px)) - : 0.85 * (1.0 - smoothstep(0.0, 0.5 * px, -D)); - float shade = inward ? max(g5.w * t * t, edge_dark) : 0.0; - vec4 o = vec4(c.rgb * (under * (1.0 - shade)), under * (1.0 - shade) + shade); - // The lift and the rim's light (`drawLift`), brightest facing the top left. - float spec = inward ? (diagonal ? g6.z * smoothstep(0.75, 1.0, af) : 0.25) - : 0.45 * (toward * sqrt(toward) + 0.75 * away * sqrt(away)) + 0.06; - float line = diagonal ? exp(-max(0.0, -D) / px) - : inward ? smoothstep(0.25 * px, 0.5 * px, -D) * (1.0 - smoothstep(1.2 * px, 1.6 * px, -D)) - : exp(-max(0.0, -D) / g2.z); - float lit = line * spec + (inward ? 0.0 : 0.04 * steepL * toward); - float lift = (g4.z > 0.5 ? g4.y : 0.0) + mat.z; - // The glass's own light, added whole. Scaled with the picture, it went wherever the picture - // was see-through — over the explorer, which is the window's OS glass — and the glass all but - // vanished there, rim and all, where Liquid Glass keeps its bright rim over anything. - o.rgb += vec3(clamp(lift + lens * g2.w * lit, 0.0, 1.0)); - o = clamp(o, 0.0, 1.0); - // Light over a see-through picture carries its own alpha: brighter than its alpha, a pixel is - // no colour premultiplied, and the frost captured from this frame next (`rgb / a`) blew it up - // into bright specks wherever the window is clear — over the explorer. Opaque, alpha is 1. - o.a = max(o.a, max(o.r, max(o.g, o.b))); - o.rgb += (noise(gl_FragCoord.xy) - 0.5) * g5.x * o.a; - FRAG_COLOR = clamp(o, 0.0, 1.0) * cov; -} diff --git a/core/gfx/shaders/liquid_glass.metal b/core/gfx/shaders/liquid_glass.metal deleted file mode 100644 index 994ecd444..000000000 --- a/core/gfx/shaders/liquid_glass.metal +++ /dev/null @@ -1,217 +0,0 @@ -// `liquid_glass.glsl` in Metal, for fizzy's native backend (`backend/`): the same -// program line for line — change it, `liquid_glass.fragment.hlsl` and `LiquidField.sample` -// together. To the interface the native backend's programs take: entry point `main0`, the -// default vertex shader's outputs as inputs, the draw's own texture (the frost) at 0, the sharp -// picture at 1, the uniforms as buffer 0. - -#include -using namespace metal; - -#define G 7 -#define MAX_SHAPES 16 - -struct main0_in -{ - float4 color [[user(locn0)]]; - float2 uv [[user(locn1)]]; -}; - -struct main0_out -{ - float4 color [[color(0)]]; -}; - -static float sdRoundBox(float2 p, float2 b, float4 r, thread float2 &g) -{ - float rr = p.x > 0.0 ? (p.y > 0.0 ? r.z : r.y) : (p.y > 0.0 ? r.w : r.x); - float2 q = abs(p) - b + rr; - float2 s = float2(p.x < 0.0 ? -1.0 : 1.0, p.y < 0.0 ? -1.0 : 1.0); - if (q.x > 0.0 && q.y > 0.0) { - g = normalize(q) * s; - } else if (q.x > q.y) { - g = float2(s.x, 0.0); - } else { - g = float2(0.0, s.y); - } - return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - rr; -} - -static float softBox(float2 p, float4 s, float k, thread float2 &o) -{ - float4 d = float4(p.x - s.x + s.z, s.x + s.z - p.x, p.y - s.y + s.w, s.y + s.w - p.y); - float m = min(min(d.x, d.y), min(d.z, d.w)); - float4 w = exp((m - d) / k); - float sum = w.x + w.y + w.z + w.w; - o = float2(w.y - w.x, w.w - w.z) / sum; - return k * log(sum) - m; -} - -static float smin(float a, float b, float k, thread float &m) -{ - float h = max(k - abs(a - b), 0.0) / k; - m = h * h * 0.5; - if (b < a) m = 1.0 - m; - return min(a, b) - h * h * k * 0.25; -} - -static float noise(float2 p) -{ - return fract(52.9829189 * fract(dot(p, float2(0.06711056, 0.00583715)))); -} - -fragment main0_out main0( - main0_in in [[stage_in]], - float4 frag [[position]], - texture2d Frost [[texture(0)]], sampler FrostSampler [[sampler(0)]], - texture2d Sharp [[texture(1)]], sampler SharpSampler [[sampler(1)]], - constant float4 *uData [[buffer(0)]]) -{ - main0_out out = {}; - int first = int(in.color.g * 255.0 + 0.5); - int count = int(in.color.b * 255.0 + 0.5); - float2 p = in.uv; - float4 g0 = uData[0]; - float4 g1 = uData[1]; - float4 g2 = uData[2]; - float4 g3 = uData[3]; - float4 g4 = uData[4]; - float4 g5 = uData[5]; // dither; the lens: its band's share of the shape, its bend, its shade - float4 g6 = uData[6]; // opaque window; the lens: dispersion, a drop's diagonal light (0: a window's hairline), the band's cap - bool inward = g5.y > 0.0; - - float d1 = 1e9; float d2 = 1e9; float4 m1 = float4(0.0); float4 m2 = float4(0.0); - float h1 = 1e9; float h2 = 1e9; - float f1 = 1e9; float f2 = 1e9; float2 o1 = float2(0.0); float2 o2 = float2(0.0); - for (int i = first; i < first + count && i < MAX_SHAPES; i++) { - float4 s0 = uData[G + 3 * i]; - float4 s1 = uData[G + 3 * i + 1]; - float4 s2 = uData[G + 3 * i + 2]; - float2 g; - float d = sdRoundBox(p - s0.xy, s0.zw, min(s1, float4(min(s0.z, s0.w))), g); - float hs = min(s0.z, s0.w); - if (d < d1) { d2 = d1; m2 = m1; h2 = h1; d1 = d; m1 = s2; h1 = hs; } else if (d < d2) { d2 = d; m2 = s2; h2 = hs; } - float2 o; - float f = d; - if (s2.w < 0.5) { - f = softBox(p, s0, g1.y, o); - } else { - o = g; - } - if (f < f1) { f2 = f1; o2 = o1; f1 = f; o1 = o; } else if (f < f2) { f2 = f; o2 = o; } - } - float k = max(g1.x, 0.0001); - float wm; float wf; - float D = smin(d1, d2, k, wm); - float F = smin(f1, f2, k, wf); - float cov = clamp(0.5 - D, 0.0, 1.0); - if (cov <= 0.0) discard_fragment(); - if (in.color.r < 0.5) { - out.color = float4(0.0, 0.0, 0.0, cov); - return out; - } - - float4 mat = mix(m1, m2, wm); - float2 outv = mix(o1, o2, wf); - float soft = max(0.0, -F); - float steepR = exp(-soft / g1.z); - float steepL = exp(-soft / g1.w); - float b = clamp(mat.x, 0.0, 1.0); - float lens = mat.y; - - float t = 0.0; - float disp = 0.0; - float2 uv; - if (inward) { - float W = max(min(g5.y * mix(h1, h2, wm), g6.w), 1.0); - t = clamp(1.0 - soft / W, 0.0, 1.0); - disp = g5.z * W * lens * t * t; - uv = clamp((p - outv * disp - g0.xy) * g0.zw, 0.0, 1.0); - } else { - uv = clamp((p + outv * (g2.x * lens * steepR) - g0.xy) * g0.zw, 0.0, 1.0); - } - float4 frost = Frost.sample(FrostSampler, uv); - float4 sharp = g4.w > 0.5 ? Sharp.sample(SharpSampler, uv) : frost; - // The colour fringe: red and blue from either side, each un-premultiplied by its own alpha and - // put back at the middle's. By the middle's alone, at the edge of a clear part of the window - // the two alphas differ widely, and the fringe came out as red and blue specks. - if (inward && g6.y > 0.0 && disp > 0.5) { - float2 du = outv * (disp * 0.04 * g6.y) * g0.zw; - float2 uvr = clamp(uv + du, 0.0, 1.0); - float2 uvb = clamp(uv - du, 0.0, 1.0); - float4 frost_r = Frost.sample(FrostSampler, uvr); - frost.r = frost_r.r / max(frost_r.a, 1e-4) * frost.a; - float4 frost_b = Frost.sample(FrostSampler, uvb); - frost.b = frost_b.b / max(frost_b.a, 1e-4) * frost.a; - if (g4.w > 0.5) { - float4 sharp_r = Sharp.sample(SharpSampler, uvr); - sharp.r = sharp_r.r / max(sharp_r.a, 1e-4) * sharp.a; - float4 sharp_b = Sharp.sample(SharpSampler, uvb); - sharp.b = sharp_b.b / max(sharp_b.a, 1e-4) * sharp.a; - } else { - sharp.r = frost.r; - sharp.b = frost.b; - } - } - // Worked out over an opaque picture of what the glass covers, then made exactly as - // see-through as that is (`under`): over a translucent window (vibrancy, Acrylic) the - // desktop's material shows through the glass as much as through the window round it. Laid - // over the translucent picture as it was, the rim — where the clear glass lies over the - // frost — came out more opaque than the face, and the material lit the face and not the rim. - // Opaque where the window is (`LiquidField.publishOpaqueWindow`): its alpha is only its shape. - float under = uData[6].x > 0.5 ? 1.0 : frost.a; - frost = float4(frost.rgb / max(frost.a, 1e-4), 1.0); - sharp = float4(sharp.rgb / max(sharp.a, 1e-4), 1.0); - - float mixv = g4.z > 0.5 ? g4.x : 0.0; - // Apple's lens: the window's colour fades out across the band, as an OS window's does - // (`glass_look.window`, its feather: strong most of the way, gone at the edge), so the edge stays - // the lens bending what is behind it — unless the colour is near whole, when it covers the edge - // too (`glass_look.shine_start`, 0.8 to 1). - if (inward) mixv *= 1.0 - t * t * (1.0 - smoothstep(0.8, 1.0, mixv)); - float4 c = mix(sharp, frost, b) * (1.0 - mixv); - float a = clamp(g2.y * lens * (inward ? t : steepR * steepR) * (1.0 - mixv), 0.0, 1.0); - c = sharp * a + c * (1.0 - sharp.a * a); - c += g3 * mixv; - // A translucent tint lies over the frost, not over what is behind the window: the glass is - // opaque here, and `under` alone says how much of the desktop's material shows through it. - c += frost * (1.0 - c.a); - // The way the edge faces, against a light from the top left. - float facing = dot(outv, float2(-0.70710678, -0.70710678)); - float toward = max(facing, 0.0); - float away = max(-facing, 0.0); - // Apple's lens has two edges, measured beside the OS's (`g6.z`, `glass_look.InApp.diagonal`): - // - a drop's (> 0): lit along the diagonal the light runs, top left and bottom right, about - // three pixels in and only near the diagonal; darkened across the other, top right and - // bottom left; nothing at the sides square to the light; - // - a window's (0): a dark hairline at the very edge and a bright one, two pixels, just inside - // it, the same on every side (the OS window's own border, as Liquid Glass windows have). - // Nothing across the band in either. Shade is the glass's own, not the picture's: a dark layer - // over it, as whole where the picture is see-through as where it is not. `px` is a point, in - // pixels (the rim line's width is 0.8 of one, `LiquidField.rim_line_width`). - float px = g2.z * 1.25; - float af = abs(facing); - bool diagonal = inward && g6.z > 0.0; - float edge_dark = diagonal ? g6.z * (1.0 - smoothstep(0.2, 0.7, af)) * exp(-max(0.0, -D) / (2.0 * px)) - : 0.85 * (1.0 - smoothstep(0.0, 0.5 * px, -D)); - float shade = inward ? max(g5.w * t * t, edge_dark) : 0.0; - float4 o = float4(c.rgb * (under * (1.0 - shade)), under * (1.0 - shade) + shade); - float spec = inward ? (diagonal ? g6.z * smoothstep(0.75, 1.0, af) : 0.25) - : 0.45 * (toward * sqrt(toward) + 0.75 * away * sqrt(away)) + 0.06; - float line = diagonal ? exp(-max(0.0, -D) / px) - : inward ? smoothstep(0.25 * px, 0.5 * px, -D) * (1.0 - smoothstep(1.2 * px, 1.6 * px, -D)) - : exp(-max(0.0, -D) / g2.z); - float lit = line * spec + (inward ? 0.0 : 0.04 * steepL * toward); - float lift = (g4.z > 0.5 ? g4.y : 0.0) + mat.z; - // The glass's own light, added whole. Scaled with the picture, it went wherever the picture - // was see-through — over the explorer, which is the window's OS glass — and the glass all but - // vanished there, rim and all, where Liquid Glass keeps its bright rim over anything. - o.rgb += float3(clamp(lift + lens * g2.w * lit, 0.0, 1.0)); - o = clamp(o, 0.0, 1.0); - // Light over a see-through picture carries its own alpha: brighter than its alpha, a pixel is - // no colour premultiplied, and the frost captured from this frame next (`rgb / a`) blew it up - // into bright specks wherever the window is clear — over the explorer. Opaque, alpha is 1. - o.a = max(o.a, max(o.r, max(o.g, o.b))); - o.rgb += (noise(frag.xy) - 0.5) * g5.x * o.a; - out.color = clamp(o, 0.0, 1.0) * cov; - return out; -} diff --git a/scripts/compile-shaders.sh b/scripts/compile-shaders.sh new file mode 100755 index 000000000..7f890b65f --- /dev/null +++ b/scripts/compile-shaders.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Compile every HLSL shader that has compiled forms committed beside it, into OUT (same layout): +# +# X.hlsl → compiled/spv/X.spv SPIR-V (Vulkan) shadercross +# compiled/dxil/X.dxil DXIL (D3D12) shadercross +# compiled/msl/X.msl Metal (SDL_GPU, macOS) shadercross +# compiled/glsl/X.glsl GLSL ES 3.00 (WebGL2) spirv-cross, from the SPIR-V +# +# Only the forms that exist in the repository are made: a shader opts into one by committing it +# (an empty file to begin with). The HLSL is the one source; the rest are generated and checked +# (`.github/workflows/shaders.yml`), never edited by hand. +# +# The GLSL is complete source, for the web backend's programs (`core.gfx.programs`): its inputs +# are named as the web's vertex shader writes them (`vColor`, `vTextureCoord`), its samplers by +# unit (`uSampler` 0, `uTex1` 1), its uniforms `uData`, the names `web/web.js` binds. +# +# Needs `shadercross` and `spirv-cross` on PATH. Exits non-zero if any shader does not compile. +set -euo pipefail +OUT=${1:?usage: compile-shaders.sh OUT} +# Absolute: each shader compiles from its own directory (its `#include`s are relative). +mkdir -p "$OUT"; OUT=$(cd "$OUT" && pwd) +failed=0 +while IFS= read -r hlsl; do + dir=$(dirname "$hlsl"); name=$(basename "$hlsl" .hlsl) + for form in spv dxil msl glsl; do + [ -f "$dir/compiled/$form/$name.$form" ] || continue + dest="$OUT/$dir/compiled/$form/$name.$form" + mkdir -p "$(dirname "$dest")" + case $form in + spv | dxil | msl) + if ! (cd "$dir" && shadercross "$name.hlsl" -o "$dest"); then + echo "::error file=$hlsl::$hlsl does not compile to $form"; failed=1 + fi ;; + glsl) + spv="$OUT/$dir/compiled/spv/$name.spv" + [ -f "$spv" ] || (cd "$dir" && shadercross "$name.hlsl" -o "$spv.tmp" && mv "$spv.tmp" "$spv") + # NaN checks relaxed: HLSL's `min` and `max` came out wrapped in `isnan` tests, a cost the + # web's frame pays at every pixel for nothing the program relies on. + if ! spirv-cross "$spv" --es --version 300 --flatten-ubo --relax-nan-checks \ + --rename-interface-variable in 0 vColor \ + --rename-interface-variable in 1 vTextureCoord \ + --output "$dest.raw"; then + echo "::error file=$hlsl::$hlsl does not cross-compile to GLSL"; failed=1; continue + fi + # The names the web binds: the samplers by unit (spirv-cross names a texture and sampler it + # combines after both), the uniforms `uData` (it names a flattened block for its type). And + # floats high precision by default, as the web's own programs are: a program reads pixel + # positions. + sed -E \ + -e 's/\bSPIRV_Cross_CombinedFrostFrostSampler\b/uSampler/g' \ + -e 's/\bSPIRV_Cross_CombinedSharpSharpSampler\b/uTex1/g' \ + -e 's/\btype_Uniforms\b/uData/g' \ + -e 's/^precision mediump float;$/precision highp float;/' \ + "$dest.raw" > "$dest" + rm "$dest.raw" ;; + esac + done +done < <(git ls-files '*.hlsl') +exit $failed diff --git a/web/web.js b/web/web.js index 4fb689b72..acef9caf7 100644 --- a/web/web.js +++ b/web/web.js @@ -654,7 +654,18 @@ export class Dvui { }, wasm_programCreate: (ptr, len, textures, uniform_vec4s) => { const gl = this.gl; - const src = this.programPrelude(uniform_vec4s) + this.stringFromPointer(ptr, len); + const body = this.stringFromPointer(ptr, len); + // A complete source — one generated from a program's HLSL, GLSL ES 3.00 + // (`scripts/compile-shaders.sh`) — is compiled as it is, on WebGL2 only. On WebGL1 + // it fails, and whoever drew with it falls back (the glass to its meshes). + const complete = body.startsWith("#version"); + if (complete && !this.webgl2) { + const id = this.newProgramId; + this.newProgramId += 1; + this.programs.set(id, { prog: null, fs: null, loc: null, status: 0 }); + return id; + } + const src = complete ? body : this.programPrelude(uniform_vec4s) + body; const vs = gl.createShader(gl.VERTEX_SHADER); gl.shaderSource(vs, this.webgl2 ? vertexShaderSource_webgl2 : vertexShaderSource_webgl); gl.compileShader(vs); From 18ce8bb0b300398a2489a8f95eb40ad2ea0d7c9c Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:26:48 -0500 Subject: [PATCH 18/46] docs: Fizzy is MIT again; licensing and trademark policy (#440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fizzy returns to the MIT license it had from 2022 until May 2026, and writes down what that promises. **Do not merge until Aryan-982 agrees to relicense #327** (4 lines in `build/app.zig` and `tests/bench/bench_replay.zig`, the only outside contribution made under GPL-3.0). `LICENSING.md`'s History section says that consent was given. ## Why GPL-3.0 worked against what Fizzy is: plugins are `dlopen`'d into the editor's address space, which makes a closed or paid plugin legally murky, and every app built with `fizzy.buildApp` would have to be GPL. The goal is the opposite: the editor and framework free forever, with plugins and applications free to be commercial. ## What changes - `LICENSE`: MIT, `Copyright (c) 2022-2026 Colton Franklin and Fizzy contributors`. - `LICENSING.md`: every release stays MIT; Fizzy itself never goes paid, open-core or "community edition"; the SDK never needs a key, account or payment to load a plugin; plugins and apps may be closed and paid; the store may list paid plugins; contributions are inbound = outbound, no CLA. - `TRADEMARKS.md`: what MIT doesn't cover. "a plugin for Fizzy" and unmodified redistribution are fine without asking. A modified build needs its own name and icon. - `CONTRIBUTING.md`: a short "Licensing contributions" section, plus a row in "Where knowledge lives". - `readme.md`: a License section. - `scripts/pack-sdk.sh`: the SDK tarball now includes `LICENSE` and lists it in `.paths`, because plugins vendor the SDK's source and MIT asks that copies keep the notice. This takes effect at the next SDK release and doesn't touch released tarballs. Sole trademark holder for now: Colton Franklin. Moving the trademark to a fiscal host or foundation later is the natural next step if the project wants that. ## How it was verified - `scripts/pack-sdk.sh` produces a tarball containing `LICENSE`, with `"core"` and `"LICENSE"` in `.paths`. - `zig fetch` on the tarball hashes it as the `fizzy-0.2.27` package. - No Zig source changed. ## SDK No boundary change; the fingerprint and `sdk_version` are untouched. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- CONTRIBUTING.md | 7 + LICENSE | 695 ++------------------------------------------ LICENSING.md | 36 +++ TRADEMARKS.md | 27 ++ readme.md | 5 +- scripts/pack-sdk.sh | 14 +- 6 files changed, 104 insertions(+), 680 deletions(-) create mode 100644 LICENSING.md create mode 100644 TRADEMARKS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a1f9a628..2ca1a9eb9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -150,6 +150,13 @@ maintainer makes the SDK **live** on the site (below). | Plans | private, in [`fizzyedit/plans`](https://github.com/fizzyedit/plans): issues, and a long design as `plans/_PLAN.md` opening with a status line (`Status: proposed`, `in progress, fizzyedit/fizzy#N`, or `done`) | | The forks fizzy builds on | `docs/DEPENDENCIES.md` | | Releases | `RELEASING.md` | +| Licensing, and the name | `LICENSING.md`, `TRADEMARKS.md` | + +## Licensing contributions + +Fizzy is MIT (`LICENSING.md`), and a contribution comes in under the license it goes out under: +opening a PR offers its change under MIT. There is no contributor license agreement. Code +copied from elsewhere must carry a license compatible with MIT, and keeps its notice. ## Habits reviewers ask for diff --git a/LICENSE b/LICENSE index f288702d2..d71a44d82 100644 --- a/LICENSE +++ b/LICENSE @@ -1,674 +1,21 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. +MIT License + +Copyright (c) 2022-2026 Colton Franklin and Fizzy contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LICENSING.md b/LICENSING.md new file mode 100644 index 000000000..ce80ec63e --- /dev/null +++ b/LICENSING.md @@ -0,0 +1,36 @@ +# Licensing + +## Fizzy is free, forever + +Fizzy is MIT-licensed (`LICENSE`): the application, the framework in `app/`, the plugin SDK in +`sdk/`, `core/`, and the bundled plugins in `plugins/`. Every version ever released stays MIT. +That grant cannot be withdrawn, by the maintainers or by anyone who comes after them. + +## Build what you want on it, commercial work included + +- **Plugins** may carry any license, closed-source and paid included. A plugin meets Fizzy only + through the SDK, and the SDK asks nothing of it beyond keeping the MIT notice with any copy of + the SDK's own source it ships. +- **Applications** built on Fizzy as a library (`fizzy.buildApp`) may be commercial and + closed-source, under the same condition. +- **The plugin store** may list free and paid plugins alike; each listing states its license. + +## What the project commits to + +- Fizzy itself, the editor and the framework, will never become paid, open-core, or split into + a "community edition". No feature of Fizzy is held back for a paid tier; paid work belongs in + plugins, made by whoever wants to make them, the maintainers included. +- The SDK will never require a license key, an account, or a payment to load a plugin. +- Contributions come in under the license they go out under: MIT, with no contributor license + agreement. No single party collects the right to relicense other people's work. + +## The name + +"Fizzy" and the Fizzy logo are trademarks of Colton Franklin. Forks are welcome under another +name; what you may call a build of Fizzy, and how, is in `TRADEMARKS.md`. + +## History + +Fizzy was MIT from 2022 until May 2026, GPL-3.0 from then until this file was added, and MIT +again since. Code in the GPL-3.0 releases was written by Colton Franklin, apart from one +contribution whose author agreed to its relicensing, so the same code is offered under MIT too. diff --git a/TRADEMARKS.md b/TRADEMARKS.md new file mode 100644 index 000000000..c576d077f --- /dev/null +++ b/TRADEMARKS.md @@ -0,0 +1,27 @@ +# Trademarks + +The code is MIT (`LICENSE`); the name is not. "Fizzy" and the Fizzy logo are trademarks of +Colton Franklin. This policy exists so that a person who downloads something called Fizzy gets +Fizzy, and it is meant to stay out of the way of everyone building on it. + +## Fine without asking + +- Saying what your work is for: "a plugin for Fizzy", "built with Fizzy", "Fizzy theme pack". +- Redistributing an unmodified Fizzy build under its own name and logo. +- Packaging Fizzy for an operating system or package manager, with only the changes needed to + build and install it there. +- Writing, teaching, streaming and talking about Fizzy, logo included. +- Naming a plugin `fizzy-` or `-for-fizzy`. + +## Ask first + +- Calling a modified build "Fizzy", or shipping it with the Fizzy logo or icon. +- A product, company or domain name that contains "Fizzy" or could be mistaken for it. +- Using the logo in a way that suggests the project endorses or sponsors you. + +A fork is welcome; give it a name and icon of its own and say it is based on Fizzy. + +## Asking + +Open an issue on [`fizzyedit/fizzy`](https://github.com/fizzyedit/fizzy/issues). Reasonable +requests get a yes. diff --git a/readme.md b/readme.md index af713a1c5..ff4f468f7 100644 --- a/readme.md +++ b/readme.md @@ -44,5 +44,8 @@ By default, when you install fizzy, only builtin plugins are included. This give - [prime31](https://github.com/prime31) for all the help. - Any and all contributors +## License - +Fizzy is [MIT](LICENSE), and will stay free. Plugins and applications built on it may be +commercial and closed-source. What that promises, and what it asks, is in +[LICENSING.md](LICENSING.md); the name and logo are covered by [TRADEMARKS.md](TRADEMARKS.md). diff --git a/scripts/pack-sdk.sh b/scripts/pack-sdk.sh index c9d17166c..ba6b05c65 100755 --- a/scripts/pack-sdk.sh +++ b/scripts/pack-sdk.sh @@ -38,8 +38,10 @@ mkdir -p "$pkg" # Package root = the sdk/ build surface plus its own src/ (no app-only deps). cp -R sdk/. "$pkg/" -# The one module source that lives outside the package in-repo. +# The one module source that lives outside the package in-repo, and the license every copy of +# it carries. cp -R core "$pkg/core" +cp LICENSE "$pkg/LICENSE" # Drop build/editor-only noise if any leaked into the copy (none expected). rm -rf "$pkg/core/.zig-cache" "$pkg/src/.zig-cache" 2>/dev/null || true @@ -58,12 +60,14 @@ text = open(path).read() paths = re.search(r'\.paths = \.\{\n(.*?)\n \},', text, re.S) if not paths: sys.exit("pack-sdk: no .paths list in the package's build.zig.zon") -if '"core"' not in paths.group(1): - text = text[:paths.end(1)] + '\n "core",' + text[paths.end(1):] +for entry in ("core", "LICENSE"): + if f'"{entry}"' not in paths.group(1): + text = text[:paths.end(1)] + f'\n "{entry}",' + text[paths.end(1):] + paths = re.search(r'\.paths = \.\{\n(.*?)\n \},', text, re.S) text = re.sub(r'\.version = "[^"]*"', f'.version = "{version}"', text, count=1) paths = re.search(r'\.paths = \.\{\n(.*?)\n \},', text, re.S) -if '"core",' not in paths.group(1): - sys.exit("pack-sdk: could not add core to the package's .paths") +if '"core",' not in paths.group(1) or '"LICENSE",' not in paths.group(1): + sys.exit("pack-sdk: could not add core and LICENSE to the package's .paths") open(path, "w").write(text) PY From 2180f219b857a34f3eb9f4e5c9187c5c436e88cf Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:31:58 -0500 Subject: [PATCH 19/46] sdk: owners report a document's steps, and apply recorded ones (#456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 2: owners' steps, with text's implementation. ## What changes A document's owner can report each change to its history as it happens, and apply recorded ones. - **`sdk.Step`**: `{ kind, id, effect, motion, ms }`. The kinds are: - `added`: a new history entry. - `joined`: more of the entry on top, such as a run of typing. Only the new part goes in `effect`. - `undone`: an entry taken back. Its effect is the entry's reverse. - `redone`: an entry put back. - `restarted`: the history began again (a reload or a restore). Every effect applies **forward**, so a replay is effects in order, and the tape format (#453) needs no direction. - **`Host.recording(doc_id)` / `Host.recordStep(doc_id, step)`**: the owner asks first, so a document nobody records encodes nothing. **`Host.recorder`** is the app's seam: plan step 3's tracks install it. Nothing installs it yet, so this PR changes no behaviour at runtime. - **`Plugin.step_format`** (the owner's encoding, 0 = none) and **`VTable.applyStep(doc, effect, motion, ms)`**: lands a step as a new history entry. A step that doesn't fit what the document holds is an error, and the document is left as it was. `ms` is the seam for showing it made at pace; text lands it at once for now. - **Text** implements both (format 1): - A step is a transaction's edits (LEB128 position, removed bytes, inserted bytes) and the selection after (`textcore.Transaction.encodeStep` / `StepView` / `applyStepTo`). - Removed bytes are kept so every step is checked against the text before anything lands, and so it can be reversed. - `History.end` and `pushComplete` now return what they did (`Committed`), and `lastApplied` / `lastUndone` name the step undo or redo moved. The typing path (`TextEditor.editNotifyEnd`), `replaceRange`, undo, redo, reload and restore all report. **Changed from the plan:** the owner *pushes* steps rather than the host pulling them through an `encodeStep` hook. Pulling needs the host to hear of each step first, which is `SHARED_HISTORY_PLAN`'s `historyStepped`, not built yet (plans#15 is still a proposal). Pushing needs nothing, and only the owner knows whether a keystroke joined its run. When authors land, a step's author is the host's to attach by `id`. I'll amend the plan. ## SDK impact - [ ] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [x] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag For plugin authors: all of it is additive. `step_format` defaults to 0 and `applyStep` to null, so an owner that records nothing changes nothing. Fingerprint `0x92a6bd46850d47c3` → `0x6fc6a45bf3a13b00`. ## Verified - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build check-web`, `zig build test-sdk-version` and `zig build check-tests-reached` pass. - `zig build test`: 561/562 (1 skipped). `zig build test-integration`: 53/53 steps, 529/529 tests. - A new integration test records a document while typing a key at a time (`added`, `joined`), a command's insert, undo, redo, undo, then an edit after going back. It checks the seven kinds in order, plays them into a fresh untitled document to the same text, checks an unrecorded document reports nothing, and checks a step that doesn't fit is refused with the document untouched. - textcore unit tests: forward and reverse round-trips, refusal, truncation at every length, 4,000 bit flips, and commits reporting joined versus added. - [ ] Windows: not run. - [ ] Linux: CI. - [ ] Web: `check-web` builds. Nothing records there yet. ## Follow-ups - Plan step 3: the host's tracks install the recorder (timing, checkpoints, the focus track, profiling). - Pixi's steps (plan step 4) after the SDK release that carries this. - Showing text's steps typed out at pace (`ms`), and text's `motion` (per-keystroke timing), are seams for later. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- docs/PLUGINS.md | 11 ++ plugins/text/plugin.zig | 6 + plugins/text/src/Document.zig | 61 ++++++- plugins/text/src/TextEditor.zig | 3 +- plugins/text/src/textcore/History.zig | 109 ++++++++++-- plugins/text/src/textcore/Transaction.zig | 198 ++++++++++++++++++++++ sdk/src/Host.zig | 33 ++++ sdk/src/Plugin.zig | 24 +++ sdk/src/Step.zig | 36 ++++ sdk/src/sdk.zig | 2 + sdk/src/version.zig | 2 +- tests/integration.zig | 90 ++++++++++ 12 files changed, 560 insertions(+), 15 deletions(-) create mode 100644 sdk/src/Step.zig diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index bbe1ca89a..a622b6f2d 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -666,6 +666,17 @@ gets the same, and `DropZones.wheel`/`at`/`draw` give a plugin the same drop for reaches a moment it snapshotted. An owner without them still plays in demos; a moment with its document open just is not snapshotted, and seeks through it replay from the keyframe. `text` implements all three. +- **Steps** (optional) — what a document's history records and replays (`sdk.Step`, + `plans/RECORDING_PLAN.md`). While `host.recording(doc.id)` says so, the owner reports each + change as it happens with `host.recordStep(doc.id, step)`: a new history entry (`added`), more of + the entry on top (`joined`, a run of typing), an entry taken back (`undone`, its effect the + reverse) or put back (`redone`), and the history starting over (`restarted`, a reload). Every + step's `effect` applies **forward**, in the owner's own encoding, numbered by the plugin's + `step_format` (bump it when an older build would misread a step); `motion` is how it was made, + when the owner knows. `applyStep(doc, effect, motion, ms)` lands one as a new history entry, + shown being made over `ms` (0: at once), and refuses one that does not fit what the document + holds, leaving it as it was. Nobody recording, nothing is encoded. `text` implements both: + each step is a transaction's edits, removed and inserted bytes, and the selection after. - **Rendering** — `drawDocument(doc)` (the document's content, called through the document's surface wherever a region accepting `document` shows it), `infobarEntries(active_doc)` (icon + text chips Fizzy draws in the infobar; plugins do not draw into the bar). diff --git a/plugins/text/plugin.zig b/plugins/text/plugin.zig index 3cce7107c..5b1ca1eee 100644 --- a/plugins/text/plugin.zig +++ b/plugins/text/plugin.zig @@ -30,6 +30,7 @@ var plugin: sdk.Plugin = .{ .vtable = &vtable, .id = plugin_id, .display_name = plugin_options.name, + .step_format = Document.step_format, }; const vtable: sdk.Plugin.VTable = .{ @@ -68,6 +69,7 @@ const vtable: sdk.Plugin.VTable = .{ .restoreDocumentState = restoreDocumentState, .historyLine = historyLine, .historyGoTo = historyGoTo, + .applyStep = applyStep, .documentFingerprint = documentFingerprint, .isDirty = isDirty, .saveDocument = saveDocument, @@ -412,6 +414,10 @@ fn historyGoTo(_: *anyopaque, handle: DocHandle, id: u64) anyerror!void { const doc = docFrom(handle) orelse return error.DocumentNotFound; try doc.goToStep(id); } +fn applyStep(_: *anyopaque, handle: DocHandle, effect: []const u8, _: []const u8, _: u32) anyerror!void { + const doc = docFrom(handle) orelse return error.DocumentNotFound; + try doc.applyStep(effect); +} fn documentFingerprint(_: *anyopaque, handle: DocHandle) u64 { const doc = docFrom(handle) orelse return 0; return doc.fingerprint(); diff --git a/plugins/text/src/Document.zig b/plugins/text/src/Document.zig index b30fda897..371e62f2b 100644 --- a/plugins/text/src/Document.zig +++ b/plugins/text/src/Document.zig @@ -429,6 +429,7 @@ pub fn reloadFromDisk(self: *Document) !void { try tc.encoding.appendLossy(&self.text, gpa, bytes); self.history.deinit(gpa); self.history = .{}; + self.recordStep(.{ .kind = .restarted, .id = 0 }); self.clean_op_id = 0; self.refreshLineCount(); self.sel_start = 0; @@ -480,6 +481,7 @@ pub fn restoreState(self: *Document, bytes: []const u8) !void { try self.text.appendSlice(gpa, st.text); self.history.deinit(gpa); self.history = .{}; + self.recordStep(.{ .kind = .restarted, .id = 0 }); // Clean is a history at its saved id; dirty is anything else. self.clean_op_id = if (st.dirty) std.math.maxInt(u64) else self.history.topOpId(); self.refreshLineCount(); @@ -522,7 +524,8 @@ pub fn saveAs(self: *Document, new_path: []const u8) !void { pub fn replaceRange(self: *Document, start: usize, end: usize, new: []const u8) !void { const gpa = sdk.allocator(); const after: tc.Range = .collapsed(start + new.len); - try self.history.pushComplete(gpa, start, self.text.items[start..end], new, .init(start, end), after); + const committed = try self.history.pushComplete(gpa, start, self.text.items[start..end], new, .init(start, end), after); + if (committed) |c| self.recordCommitted(c); try self.text.replaceRange(gpa, start, end - start, new); self.refreshLineCount(); self.sel_start = after.head; @@ -536,6 +539,7 @@ pub fn replaceRange(self: *Document, start: usize, end: usize, new: []const u8) pub fn undo(self: *Document) void { const gpa = sdk.allocator(); const sel = self.history.applyUndo(gpa, &self.text) orelse return; + if (self.history.lastUndone()) |s| self.recordEdits(.undone, s.step, s.edits, s.sel_before, true); self.refreshLineCount(); self.sel_start = @min(sel.start(), self.text.items.len); self.sel_end = @min(sel.end(), self.text.items.len); @@ -547,6 +551,7 @@ pub fn undo(self: *Document) void { pub fn redo(self: *Document) void { const gpa = sdk.allocator(); const sel = self.history.applyRedo(gpa, &self.text) orelse return; + if (self.history.lastApplied()) |s| self.recordEdits(.redone, s.step, s.edits, s.sel_after, false); self.refreshLineCount(); self.sel_start = @min(sel.start(), self.text.items.len); self.sel_end = @min(sel.end(), self.text.items.len); @@ -554,6 +559,60 @@ pub fn redo(self: *Document) void { sdk.host().documentEdited(self.id); } +/// The text plugin's step encoding (`sdk.Plugin.step_format`): `tc.Transaction.encodeStep`'s. +pub const step_format = 1; + +/// Report a step of this document's history to whoever records it (`sdk.Host.recordStep`), when +/// anyone does: nothing is encoded otherwise. +fn recordStep(self: *const Document, step: sdk.Step) void { + const host = sdk.host(); + if (!host.recording(self.id)) return; + host.recordStep(self.id, step); +} + +/// What `history.end` or `pushComplete` did, reported as a step. +pub fn recordCommitted(self: *const Document, c: tc.History.Committed) void { + self.recordEdits(if (c.joined) .joined else .added, c.step, c.edits, c.sel_after, false); +} + +/// `edits` of history step `id` reported as a step, forward or (`reverse`) as their undo, `sel` +/// the selection they leave. +fn recordEdits(self: *const Document, kind: sdk.Step.Kind, id: u64, edits: []const tc.Transaction.Edit, sel: tc.Range, reverse: bool) void { + if (!sdk.host().recording(self.id)) return; + const gpa = sdk.allocator(); + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + tc.Transaction.encodeStep(&out.writer, edits, sel, reverse) catch { + std.log.err("text: a step of {s} was not recorded: out of memory", .{self.path}); + return; + }; + self.recordStep(.{ .kind = kind, .id = id, .effect = out.written() }); +} + +/// Apply a recorded step (`step_format`) as a new step of this document's history: the edits +/// checked against the text and landed together, the selection where they left it. `error.BadStep` +/// or `error.StepDoesNotApply` leave the document as it was. Lands at once: showing it typed out +/// over a replay's `ms` is the seam for later (`sdk.Plugin.VTable.applyStep`). +pub fn applyStep(self: *Document, effect: []const u8) !void { + const gpa = sdk.allocator(); + const view = try tc.Transaction.StepView.init(effect); + var txn = try tc.Transaction.applyStepTo(gpa, &self.text, view); + const committed = self.history.push(gpa, txn) catch |err| { + // Kept out of the history, the text would not undo: take it back. + txn.applyInverse(gpa, &self.text) catch {}; + txn.deinit(gpa); + return err; + }; + self.recordCommitted(committed); + self.refreshLineCount(); + const sel = view.sel; + self.sel_start = @min(sel.start(), self.text.items.len); + self.sel_end = @min(sel.end(), self.text.items.len); + self.sel_cursor = @min(sel.head, self.text.items.len); + self.pending_sel = sel; + sdk.host().documentEdited(self.id); +} + /// Undo or redo until history step `step` is the last applied; 0 goes back before the first. pub fn goToStep(self: *Document, step: u64) !void { const n = self.history.stepsTo(step) orelse return error.NoSuchStep; diff --git a/plugins/text/src/TextEditor.zig b/plugins/text/src/TextEditor.zig index 7f4ba06b6..b26450361 100644 --- a/plugins/text/src/TextEditor.zig +++ b/plugins/text/src/TextEditor.zig @@ -1830,7 +1830,8 @@ fn editNotifyInserted(ctx: *anyopaque, pos: usize, bytes: []const u8) void { docFromCtx(ctx).history.note(sdk.allocator(), pos, "", bytes); } fn editNotifyEnd(ctx: *anyopaque, sel_after: tc.Range) void { - docFromCtx(ctx).history.end(sdk.allocator(), sel_after); + const doc = docFromCtx(ctx); + if (doc.history.end(sdk.allocator(), sel_after)) |c| doc.recordCommitted(c); } const max_text_bytes: usize = 64 * 1024 * 1024; diff --git a/plugins/text/src/textcore/History.zig b/plugins/text/src/textcore/History.zig index 77898e135..db242a585 100644 --- a/plugins/text/src/textcore/History.zig +++ b/plugins/text/src/textcore/History.zig @@ -187,14 +187,51 @@ pub fn note( }; } -/// 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; +/// What a commit did to the history, for whoever records it (`sdk.Step`). +pub const Committed = struct { + step: u64, + /// It joined the step on top (a run of typing) rather than starting one. + joined: bool, + /// What this commit did: the whole step when it starts one, only the new part when it joins. + /// Valid until the history next changes. + edits: []const Transaction.Edit, + sel_after: Range, +}; + +/// A step as the history holds it: its id, its edits, and the selection either side. Valid until +/// the history next changes. +pub const StepRef = struct { + step: u64, + edits: []const Transaction.Edit, + sel_before: Range, + sel_after: Range, + + fn of(e: *const Entry) StepRef { + return .{ .step = e.step, .edits = e.txn.edits.items, .sel_before = e.txn.sel_before, .sel_after = e.txn.sel_after }; + } +}; + +/// The step `applyRedo` just put back: the last applied. Null when none is. +pub fn lastApplied(self: *const History) ?StepRef { + const items = self.undo_stack.items; + return if (items.len == 0) null else .of(&items[items.len - 1]); +} + +/// The step `applyUndo` just took back: the next to redo. Null when there is none. +pub fn lastUndone(self: *const History) ?StepRef { + const items = self.redo_stack.items; + return if (items.len == 0) null else .of(&items[items.len - 1]); +} + +/// Commit the pending edit, merging it into the current group when VSCode's rule says to. Returns +/// what it did, or null when there was nothing to commit. +pub fn end(self: *History, gpa: Allocator, sel_after: Range) ?Committed { + var p = self.pending orelse return null; self.pending = null; if (p.txn.isEmpty()) { p.txn.deinit(gpa); - return; + return null; } p.txn.sel_after = sel_after; @@ -228,8 +265,10 @@ pub fn end(self: *History, gpa: Allocator, sel_after: Range) void { self.undo_stack.items.len > 0 and !shouldBreakGroup(prev_kind, kind); + var committed: Committed = undefined; if (can_merge) { var top = &self.undo_stack.items[self.undo_stack.items.len - 1]; + const first_new = top.txn.edits.items.len; // 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| { @@ -246,6 +285,7 @@ pub fn end(self: *History, gpa: Allocator, sel_after: Range) void { top.kind = kind; top.id = self.next_id; self.next_id += 1; + committed = .{ .step = top.step, .joined = true, .edits = top.txn.edits.items[first_new..], .sel_after = sel_after }; } else { self.undo_stack.append(gpa, .{ .txn = p.txn, @@ -255,16 +295,19 @@ pub fn end(self: *History, gpa: Allocator, sel_after: Range) void { }) catch |err| { std.log.err("History.end: push failed: {s}", .{@errorName(err)}); p.txn.deinit(gpa); - return; + return null; }; self.next_id += 1; + const top = &self.undo_stack.items[self.undo_stack.items.len - 1]; + committed = .{ .step = top.step, .joined = false, .edits = top.txn.edits.items, .sel_after = sel_after }; } self.group_open = true; self.version += 1; + return committed; } /// Record one complete edit built outside the begin/note/end flow (the paste command). Always -/// its own undo step. +/// its own undo step. Null when it changed nothing. pub fn pushComplete( self: *History, gpa: Allocator, @@ -273,19 +316,27 @@ pub fn pushComplete( inserted: []const u8, sel_before: Range, sel_after: Range, -) !void { +) !?Committed { 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; + return null; } + return try self.push(gpa, txn); +} + +/// Take `txn`, already applied, as a step of its own: a recorded step played into the document +/// (`Transaction.applyStepTo`). +pub fn push(self: *History, gpa: Allocator, txn: Transaction) !Committed { self.clearRedo(gpa); try self.undo_stack.append(gpa, .{ .txn = txn, .kind = .other, .id = self.next_id, .step = self.next_id }); self.next_id += 1; self.group_open = false; self.version += 1; + const top = &self.undo_stack.items[self.undo_stack.items.len - 1]; + return .{ .step = top.step, .joined = false, .edits = top.txn.edits.items, .sel_after = top.txn.sel_after }; } fn clearRedo(self: *History, gpa: Allocator) void { @@ -422,7 +473,7 @@ const Harness = struct { 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)); + _ = self.hist.end(self.gpa, .collapsed(self.cursor)); } } @@ -434,7 +485,7 @@ const Harness = struct { 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)); + _ = self.hist.end(self.gpa, .collapsed(self.cursor)); } } @@ -533,7 +584,7 @@ test "undo restores the selection, not just an offset" { hist.begin(.init(6, 11)); hist.note(a, 6, "world", "X"); try text.replaceRange(a, 6, 5, "X"); - hist.end(a, .collapsed(7)); + _ = hist.end(a, .collapsed(7)); const sel = hist.applyUndo(a, &text).?; try t.expectEqualStrings("hello world", text.items); @@ -622,7 +673,7 @@ test "cut-shaped deletion is one undoable step" { 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)); + _ = h.hist.end(h.gpa, .collapsed(6)); try t.expectEqualStrings("hello ", h.text.items); _ = h.undo(); @@ -657,3 +708,37 @@ test "the line: steps keep their ids through merges, undo and redo" { try t.expectEqual(@as(?isize, -1), h.hist.stepsTo(0)); try t.expectEqual(@as(?isize, null), h.hist.stepsTo(9999)); } + +test "a commit says whether it started a step or joined one, and what it added" { + const a = t.allocator; + var hist: History = .{}; + defer hist.deinit(a); + + hist.begin(.collapsed(0)); + hist.note(a, 0, "", "a"); + const first = hist.end(a, .collapsed(1)).?; + try t.expect(!first.joined); + try t.expectEqual(@as(usize, 1), first.edits.len); + + hist.begin(.collapsed(1)); + hist.note(a, 1, "", "b"); + const second = hist.end(a, .collapsed(2)).?; + // Typing runs on: the same step, and only the new keystroke. + try t.expect(second.joined); + try t.expectEqual(first.step, second.step); + try t.expectEqual(@as(usize, 1), second.edits.len); + try t.expectEqualStrings("b", second.edits[0].inserted); + + // An empty commit commits nothing. + hist.begin(.collapsed(2)); + try t.expect(hist.end(a, .collapsed(2)) == null); + + var text: std.ArrayList(u8) = .empty; + defer text.deinit(a); + try text.appendSlice(a, "ab"); + _ = hist.applyUndo(a, &text).?; + try t.expectEqual(first.step, hist.lastUndone().?.step); + try t.expectEqual(@as(usize, 2), hist.lastUndone().?.edits.len); + _ = hist.applyRedo(a, &text).?; + try t.expectEqual(first.step, hist.lastApplied().?.step); +} diff --git a/plugins/text/src/textcore/Transaction.zig b/plugins/text/src/textcore/Transaction.zig index 29f034ba1..f1e66870f 100644 --- a/plugins/text/src/textcore/Transaction.zig +++ b/plugins/text/src/textcore/Transaction.zig @@ -114,6 +114,121 @@ pub fn applyInverse(self: Transaction, gpa: Allocator, text: *std.ArrayList(u8)) } } +// -- as a step -------------------------------------------------------------------------------- + +/// Edits as a recorded step's effect (`sdk.Step`, the text plugin's step format 1): what fizzy +/// keeps of a change to replay it. Every count and offset LEB128, then the bytes: +/// +/// edits · per edit: pos, removed length, removed, inserted length, inserted · selection after: +/// anchor, head +/// +/// `removed` is kept although a forward replay only needs its length: it is what makes a step +/// checkable (`applyStepTo` refuses one whose removed bytes are not there), and what lets a step +/// be reversed. `reverse` writes the edits' undo as a forward step: inverses, newest first, the +/// selection the undo restores in `sel`. +pub fn encodeStep(w: *std.Io.Writer, edits: []const Edit, sel: Range, reverse: bool) std.Io.Writer.Error!void { + try w.writeUleb128(edits.len); + for (0..edits.len) |i| { + const e = edits[if (reverse) edits.len - 1 - i else i]; + const removed = if (reverse) e.inserted else e.removed; + const inserted = if (reverse) e.removed else e.inserted; + try w.writeUleb128(e.pos); + try w.writeUleb128(removed.len); + try w.writeAll(removed); + try w.writeUleb128(inserted.len); + try w.writeAll(inserted); + } + try w.writeUleb128(sel.anchor); + try w.writeUleb128(sel.head); +} + +/// A step `encodeStep` wrote, read back without copying: slices of `bytes`. +pub const StepView = struct { + bytes: []const u8, + edits: usize, + sel: Range, + + pub const Error = error{BadStep}; + + /// Read the step in `bytes`, checking it holds together, or `error.BadStep`. + pub fn init(bytes: []const u8) Error!StepView { + var r: std.Io.Reader = .fixed(bytes); + const n = take(&r) catch return error.BadStep; + for (0..n) |_| { + _ = take(&r) catch return error.BadStep; + for (0..2) |_| { + const len = take(&r) catch return error.BadStep; + r.discardAll(len) catch return error.BadStep; + } + } + const anchor = take(&r) catch return error.BadStep; + const head = take(&r) catch return error.BadStep; + if (r.seek != bytes.len) return error.BadStep; + return .{ .bytes = bytes, .edits = n, .sel = .init(anchor, head) }; + } + + /// Each edit in order: `{ pos, removed, inserted }`, slices of the step's bytes. + pub fn iterator(self: StepView) Iterator { + var r: std.Io.Reader = .fixed(self.bytes); + _ = take(&r) catch unreachable; + return .{ .r = r, .left = self.edits }; + } + + pub const Iterator = struct { + r: std.Io.Reader, + left: usize, + + pub const Part = struct { pos: usize, removed: []const u8, inserted: []const u8 }; + + pub fn next(it: *Iterator) ?Part { + if (it.left == 0) return null; + it.left -= 1; + // `init` checked every length. + const pos = take(&it.r) catch unreachable; + const removed = it.r.take(take(&it.r) catch unreachable) catch unreachable; + const inserted = it.r.take(take(&it.r) catch unreachable) catch unreachable; + return .{ .pos = pos, .removed = removed, .inserted = inserted }; + } + }; + + fn take(r: *std.Io.Reader) !usize { + return r.takeLeb128(usize); + } +}; + +/// Apply a step to `text` forward as one transaction, which it returns (owned) for the history. +/// Every edit must find at its `pos` the bytes it says it removes, or `error.StepDoesNotApply`, +/// and `text` is left as it was. +pub fn applyStepTo(gpa: Allocator, text: *std.ArrayList(u8), step: StepView) !Transaction { + var txn: Transaction = .{ .sel_after = step.sel }; + errdefer txn.deinit(gpa); + var it = step.iterator(); + var first = true; + while (it.next()) |e| { + if (first) txn.sel_before = .collapsed(e.pos); + first = false; + try txn.append(gpa, e.pos, e.removed, e.inserted); + } + // Checked against the text as each edit reaches it, then applied atomically. + var check: usize = 0; + errdefer { + // Undo the edits already applied, newest first. + var j = check; + while (j > 0) { + j -= 1; + const e = txn.edits.items[j]; + text.replaceRange(gpa, e.pos, e.inserted.len, e.removed) catch {}; + } + } + for (txn.edits.items) |e| { + if (e.pos > text.items.len or e.removed.len > text.items.len - e.pos) return error.StepDoesNotApply; + if (!std.mem.eql(u8, text.items[e.pos..][0..e.removed.len], e.removed)) return error.StepDoesNotApply; + try text.replaceRange(gpa, e.pos, e.removed.len, e.inserted); + check += 1; + } + return txn; +} + // -- tests ------------------------------------------------------------------------------------ const t = std.testing; @@ -255,3 +370,86 @@ test "random edit sequences round-trip" { try t.expectEqualStrings(after, text.items); } } + +fn stepBytes(gpa: Allocator, edits: []const Edit, sel: Range, reverse: bool) ![]u8 { + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + try encodeStep(&out.writer, edits, sel, reverse); + return out.toOwnedSlice(); +} + +test "a step replays its edits forward, and its reverse takes them back" { + const a = t.allocator; + var txn: Transaction = .{ .sel_before = .collapsed(5), .sel_after = .collapsed(12) }; + defer txn.deinit(a); + try txn.append(a, 5, "", ", wide"); + try txn.append(a, 0, "hello", "HELLO"); + try txn.append(a, 12, "world", "wörld"); + + var text = try listOf(a, "hello world"); + defer text.deinit(a); + var original = try listOf(a, "hello world"); + defer original.deinit(a); + + const fwd = try stepBytes(a, txn.edits.items, txn.sel_after, false); + defer a.free(fwd); + var applied = try applyStepTo(a, &text, try .init(fwd)); + defer applied.deinit(a); + try t.expectEqualStrings("HELLO, wide wörld", text.items); + try t.expectEqual(txn.sel_after, applied.sel_after); + // What the history keeps is the same edits: it undoes as the original would. + try applied.applyInverse(a, &text); + try t.expectEqualStrings("hello world", text.items); + + try txn.applyForward(a, &original); + const back = try stepBytes(a, txn.edits.items, txn.sel_before, true); + defer a.free(back); + var undone = try applyStepTo(a, &original, try .init(back)); + defer undone.deinit(a); + try t.expectEqualStrings("hello world", original.items); + try t.expectEqual(txn.sel_before, undone.sel_after); +} + +test "a step that does not fit the text is refused, and the text is left as it was" { + const a = t.allocator; + var txn: Transaction = .{}; + defer txn.deinit(a); + try txn.append(a, 0, "ab", "x"); + try txn.append(a, 1, "cd", "y"); + const bytes = try stepBytes(a, txn.edits.items, .collapsed(2), false); + defer a.free(bytes); + + // The first edit fits; the second finds other bytes. Nothing of either stays. + var text = try listOf(a, "abXX"); + defer text.deinit(a); + try t.expectError(error.StepDoesNotApply, applyStepTo(a, &text, try .init(bytes))); + try t.expectEqualStrings("abXX", text.items); + var short = try listOf(a, "a"); + defer short.deinit(a); + try t.expectError(error.StepDoesNotApply, applyStepTo(a, &short, try .init(bytes))); + try t.expectEqualStrings("a", short.items); +} + +test "a damaged step is an error, never a crash" { + const a = t.allocator; + var txn: Transaction = .{}; + defer txn.deinit(a); + try txn.append(a, 3, "abc", "défg"); + const bytes = try stepBytes(a, txn.edits.items, .init(1, 4), false); + defer a.free(bytes); + for (0..bytes.len) |n| try t.expectError(error.BadStep, StepView.init(bytes[0..n])); + + var prng: std.Random.DefaultPrng = .init(0x7e47); + const r = prng.random(); + const copy = try a.dupe(u8, bytes); + defer a.free(copy); + for (0..4000) |_| { + @memcpy(copy, bytes); + copy[r.uintLessThan(usize, copy.len)] ^= @as(u8, 1) << r.int(u3); + const view = StepView.init(copy) catch continue; + var text = try listOf(a, "xyzabcxyz"); + defer text.deinit(a); + var got = applyStepTo(a, &text, view) catch continue; + got.deinit(a); + } +} diff --git a/sdk/src/Host.zig b/sdk/src/Host.zig index beb299509..494ea31fc 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -35,6 +35,7 @@ pub const OpenAction = @import("menus.zig").OpenAction; pub const accounts = @import("accounts.zig"); pub const Command = @import("Command.zig"); pub const Claim = @import("Claim.zig"); +pub const Step = @import("Step.zig"); pub const events = @import("events.zig"); /// Per-plugin opaque settings blobs pending a write: plugin id -> serialized zon text, or `null` @@ -287,6 +288,9 @@ activity_run: u64 = 0, /// Documents held by a plugin while it works on them (`Claim`). claims: std.ArrayListUnmanaged(Claim) = .empty, +/// Who records documents' steps (`recordStep`): the app's tracks. Null, nobody does, and owners +/// encode nothing. +recorder: ?Recorder = null, next_claim_id: u32 = 1, /// Pluggable language/format support (syntax highlighting, preview panes). language_support: std.ArrayListUnmanaged(LanguageSupport) = .empty, @@ -1559,6 +1563,35 @@ fn claimRefusing(self: *Host, c: *const Command) ?Claim { return self.documentClaim(doc.id); } +// ---- recording ------------------------------------------------------------------------------- + +/// What keeps the steps owners report: set by the app, never by a plugin. +pub const Recorder = struct { + ctx: *anyopaque, + vtable: *const VTable, + + pub const VTable = struct { + /// Whether `doc_id`'s steps are wanted. Asked before every step, so it is cheap. + wants: *const fn (ctx: *anyopaque, doc_id: u64) bool, + /// Keep `step` of `doc_id`. Its bytes are only valid for the call. + step: *const fn (ctx: *anyopaque, doc_id: u64, step: Step) void, + }; +}; + +/// Whether `doc_id`'s steps are recorded. An owner asks before it encodes one, so a document +/// nobody records costs nothing. +pub fn recording(self: *const Host, doc_id: u64) bool { + const r = self.recorder orelse return false; + return r.vtable.wants(r.ctx, doc_id); +} + +/// A step of `doc_id`'s history, from its **owner**, as it happens (`Step`): each entry added or +/// joined, undone or redone, and the history starting over. Only while `recording(doc_id)`. +pub fn recordStep(self: *Host, doc_id: u64, step: Step) void { + const r = self.recorder orelse return; + r.vtable.step(r.ctx, doc_id, step); +} + // ---- activity -------------------------------------------------------------------------------- /// A command that edited a document (`activity`). diff --git a/sdk/src/Plugin.zig b/sdk/src/Plugin.zig index 4dfced716..e96f6b007 100644 --- a/sdk/src/Plugin.zig +++ b/sdk/src/Plugin.zig @@ -36,6 +36,12 @@ display_name: []const u8, /// not share. internal: bool = false, +/// The encoding of the steps this plugin records and applies (`Step`, `VTable.applyStep`), which +/// it numbers: bumped when a step it writes would be misread by the build before. A tape names it +/// beside each file (`Tape.File.format`), and an app plays only the steps of formats the owner +/// reads. 0: records no steps. +step_format: u32 = 0, + /// Names an error this plugin returned. Zig numbers errors per compilation, so `@errorName` in /// the host on a dylib's error reads the host's table and prints an unrelated name. The default /// is evaluated in the binary that builds this struct — the plugin's own — so it reads the @@ -231,6 +237,12 @@ pub const VTable = struct { /// Undo or redo `doc` until step `id` (`HistoryLine.Entry.id`) is the last one applied; 0 /// goes back before the first. An id not on the line is an error, and nothing moves. historyGoTo: ?*const fn (state: *anyopaque, doc: DocHandle, id: u64) anyerror!void = null, + /// Apply a step this owner recorded (`Step`, `Host.recordStep`) to `doc`, forward, as a new + /// entry on its history: `effect` and `motion` in the owner's `step_format`. `ms` asks for it + /// to be shown being made over that long (a replay at pace); 0 lands it at once (a seek). A + /// step that does not fit what `doc` holds is an error, and `doc` is left as it was. Absent = + /// tapes of this owner's documents cannot be played. + applyStep: ?*const fn (state: *anyopaque, doc: DocHandle, effect: []const u8, motion: []const u8, ms: u32) anyerror!void = null, isDirty: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, /// Register a loaded/created document in the plugin's open-doc map. `file` points at @@ -607,6 +619,18 @@ pub fn historyLine(self: Plugin, doc: DocHandle, arena: std.mem.Allocator) ?Hist }; } +/// Apply a recorded step to `doc` (`applyStep`). Returns whether it landed. +pub fn applyStep(self: Plugin, doc: DocHandle, effect: []const u8, motion: []const u8, ms: u32) bool { + const prof = core.profile.begin(self.id, "applyStep"); + defer prof.end(); + const f = self.vtable.applyStep orelse return false; + f(self.state, doc, effect, motion, ms) catch |err| { + std.log.err("{s}: applyStep failed: {s}", .{ self.id, self.errorName(err) }); + return false; + }; + return true; +} + /// Go to step `id` of `doc`'s history (`historyGoTo`). Returns whether it went. pub fn historyGoTo(self: Plugin, doc: DocHandle, id: u64) bool { const f = self.vtable.historyGoTo orelse return false; diff --git a/sdk/src/Step.zig b/sdk/src/Step.zig new file mode 100644 index 000000000..76a23540e --- /dev/null +++ b/sdk/src/Step.zig @@ -0,0 +1,36 @@ +//! One step of a document's history, as its owner reports it to whoever records (`Host.recordStep`): +//! what the document became, not the input that made it, so it replays exactly at any window size +//! and in any layout (`plans/RECORDING_PLAN.md`). +//! +//! Every step is applied **forward**: a step taken back is reported as its reverse (`undone`), so a +//! replay is the effects in the order they came, and never asks an owner to run one backwards. +//! The bytes are the owner's (`Plugin.step_format` numbers its encoding), applied by its +//! `Plugin.VTable.applyStep`, and only valid for the call: whoever records copies them. +const Self = @This(); + +kind: Kind, +/// The history entry this step is, or joins, or takes back (`Plugin.HistoryLine.Entry.id`). 0 for +/// `restarted`. +id: u64, +/// What the step did, in the owner's encoding, ready to apply forward. Empty for `restarted`. +effect: []const u8 = "", +/// How it was made, when the owner knows (a pen's path and timing, in the document's own +/// coordinates). Empty: a replay shows the effect made the owner's own way. +motion: []const u8 = "", +/// How long it took to make, as the owner saw it (a stroke from press to release). 0: at once. +ms: u32 = 0, + +pub const Kind = enum { + /// A new entry on the history. + added, + /// More of the entry on top: a keystroke joining a run of typing. `effect` is only what this + /// part did. + joined, + /// Entry `id` was taken back; `effect` is its reverse. + undone, + /// Entry `id` was put back. + redone, + /// The history started over (a reload from disk, a restore): nothing before this applies to + /// what the document is now, and whoever records takes it as it is from here. + restarted, +}; diff --git a/sdk/src/sdk.zig b/sdk/src/sdk.zig index 2e006a3fb..b4afc7dc1 100644 --- a/sdk/src/sdk.zig +++ b/sdk/src/sdk.zig @@ -71,6 +71,8 @@ pub const NativeMenuItem = menus.NativeMenuItem; /// A named action fizzy invokes by id, without knowing what it does. pub const Command = @import("Command.zig"); pub const Claim = @import("Claim.zig"); +/// One step of a document's history, recorded (`Host.recordStep`) and applied (`Plugin.applyStep`). +pub const Step = @import("Step.zig"); /// Events: a plugin announces, any plugin listens (`Host.emit`, `Host.subscribe`). pub const events = @import("events.zig"); pub const menu = @import("menu.zig"); diff --git a/sdk/src/version.zig b/sdk/src/version.zig index d2df627b7..8bc456baa 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -75,7 +75,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the /// next SDK release. -pub const recorded_sdk_shape_fingerprint: u64 = 0x92a6bd46850d47c3; +pub const recorded_sdk_shape_fingerprint: u64 = 0x6fc6a45bf3a13b00; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/tests/integration.zig b/tests/integration.zig index cf6757d18..35ed52292 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7202,6 +7202,96 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try dvui.testing.settle(headlessFrame); } + // A document's steps (`Host.recordStep`): while recorded, each change is reported as it + // happens, forward, and played into a fresh document (`Plugin.applyStep`) they make the same + // text. Typing runs join a step; an undo is its step reversed; nothing is encoded unrecorded. + { + const host = &editor.app.host; + const was = editor.activeDoc() orelse return error.TestUnexpectedResult; + const newDoc = struct { + fn newDoc(ed: *fizzy.Editor, h: *sdk.Host, not: u64) !sdk.DocHandle { + try h.runCommand("fizzy.newFile"); + for (0..200) |_| { + _ = try dvui.testing.step(headlessFrame); + const d = ed.activeDoc() orelse continue; + if (d.id != not and std.mem.startsWith(u8, std.fs.path.basename(d.owner.documentPath(d)), "untitled")) return d; + } + return error.TestUnexpectedResult; + } + }.newDoc; + const textOf = struct { + fn textOf(h: *sdk.Host, d: sdk.DocHandle) ![]const u8 { + const args = try std.fmt.allocPrint(h.arena(), ".{{ .path = \"{s}\" }}", .{d.owner.documentPath(d)}); + const zon = switch (h.callCommand("text.read", args, h.arena())) { + .ok => |r| r orelse return error.TestUnexpectedResult, + else => return error.TestUnexpectedResult, + }; + const at = std.mem.indexOf(u8, zon, ".text = \"") orelse return error.TestUnexpectedResult; + const rest = zon[at + ".text = \"".len ..]; + return rest[0 .. std.mem.indexOfScalar(u8, rest, '"') orelse return error.TestUnexpectedResult]; + } + }.textOf; + const Recorded = struct { + doc: u64 = 0, + kinds: std.ArrayList(sdk.Step.Kind) = .empty, + effects: std.ArrayList([]u8) = .empty, + + fn wants(ctx: *anyopaque, id: u64) bool { + return @as(*@This(), @ptrCast(@alignCast(ctx))).doc == id; + } + fn step(ctx: *anyopaque, id: u64, st: sdk.Step) void { + const self: *@This() = @ptrCast(@alignCast(ctx)); + std.debug.assert(id == self.doc); + self.kinds.append(std.testing.allocator, st.kind) catch unreachable; + self.effects.append(std.testing.allocator, std.testing.allocator.dupe(u8, st.effect) catch unreachable) catch unreachable; + } + fn deinit(self: *@This()) void { + for (self.effects.items) |e| std.testing.allocator.free(e); + self.effects.deinit(std.testing.allocator); + self.kinds.deinit(std.testing.allocator); + } + }; + var rec: Recorded = .{}; + defer rec.deinit(); + host.recorder = .{ .ctx = &rec, .vtable = &.{ .wants = Recorded.wants, .step = Recorded.step } }; + defer host.recorder = null; + + const a = try newDoc(editor, host, was.id); + rec.doc = a.id; + // Typed a keystroke at a time: one step, joined. + for ([_][]const u8{ "a", "b" }) |key| { + try dvui.testing.writeText(key); + try dvui.testing.settle(headlessFrame); + } + try std.testing.expect(host.callCommand("text.insert", ".{ .text = \"X\", .line = 1 }", host.arena()) == .ok); + try editor.undo(); + try editor.redo(); + try editor.undo(); + // An edit after going back: the step gone back over stays undone. + try std.testing.expect(host.callCommand("text.insert", ".{ .text = \"Y\", .line = 1 }", host.arena()) == .ok); + try dvui.testing.settle(headlessFrame); + try std.testing.expectEqualStrings("Yab", try textOf(host, a)); + try std.testing.expectEqualSlices(sdk.Step.Kind, &.{ .added, .joined, .added, .undone, .redone, .undone, .added }, rec.kinds.items); + + // Played into a document of its own, forward, in order: the same text. + const b = try newDoc(editor, host, a.id); + try std.testing.expect(b.owner.step_format == a.owner.step_format and a.owner.step_format != 0); + for (rec.effects.items) |effect| try std.testing.expect(b.owner.applyStep(b, effect, "", 0)); + try dvui.testing.settle(headlessFrame); + try std.testing.expectEqualStrings("Yab", try textOf(host, b)); + // Unrecorded, B reported nothing; and a step that does not fit is refused, B untouched: + // the undo takes back an `X` that is not there. + try std.testing.expectEqual(@as(usize, 7), rec.kinds.items.len); + // (Through the hook itself: the wrapper logs the refusal as an error, which fails a run.) + try std.testing.expectError(error.StepDoesNotApply, b.owner.vtable.applyStep.?(b.owner.state, b, rec.effects.items[3], "", 0)); + try std.testing.expectEqualStrings("Yab", try textOf(host, b)); + + try editor.rawCloseFileID(b.id); + try editor.rawCloseFileID(a.id); + editor.workbench.setActiveDocIndex(editor.app.open_files.getIndex(was.id) orelse return error.TestUnexpectedResult); + try dvui.testing.settle(headlessFrame); + } + // Revealing a view (`Host.revealSurface`): with the bottom panel shut, revealing Output opens // the panel with Output selected, and a view no region shows is not revealed. { From ae3d39f2a80b0f695a420d8097ebaba83e445d06 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:33:44 -0500 Subject: [PATCH 20/46] tape: a live tape with no ops finishes instead of crashing (#457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Debug fizzy at `main` crashed a few seconds after a fizzy-mcp run: ``` panic: index out of bounds: index 0, len 0 sdk/tape/Sequencer.zig:185:23 in advance: `const op = ops[self.cursor];` sdk/replay/LiveDriver.zig:162:25 in frame ``` ## Why `LiveDriver.frame` asks the sequencer for `seq.nextAt()`, which is `inf` when nothing is left. `Sequencer.advance` decided it had reached the target with `next_at > until`, but with both infinite that is false, so it read `ops[cursor]` past the end. In `LiveDriver.frame` only an **empty** tape can be there: a tape that runs out is finished in the same frame (`seq.done()`). An empty live tape is valid. `Tape.validate` has a test for it (`Tape.zig`, "empty"), and builders make them: the agent plugin's `Script.stroke` with no points adds nothing, so a run whose tape steps add no ops plays an empty tape. So I didn't add a guard in the driver or reject empty tapes. The sequencer's end-of-ops test is now explicit (`cursor >= ops.len`), and `now` never takes an infinite target. An empty tape now finishes on its first idle frame (`.finished`), like any other. ## Testing - New `sdk/tape` test, "asked for what is next with nothing left, it has reached the end": it advances an empty tape, and a spent one-op tape, to `nextAt()`. Without the fix it panics with the same `index 0, len 0`. - `scripts/check-fmt.sh`, `zig build test` (91/91 steps), `zig build test-sdk-version` pass locally. `test-integration` and `check-web` are left to CI; the change is std-only sequencer code. No SDK boundary change (the fingerprint is unchanged). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- sdk/tape/Sequencer.zig | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/sdk/tape/Sequencer.zig b/sdk/tape/Sequencer.zig index 1a2b9c7fb..48ef0dc54 100644 --- a/sdk/tape/Sequencer.zig +++ b/sdk/tape/Sequencer.zig @@ -180,8 +180,10 @@ pub fn advance(self: *Sequencer, until: f64, wall_ms: f64, sink: Sink) Progress const next_at: f64 = if (self.cursor < ops.len) @floatFromInt(ops[self.cursor].at) else std.math.inf(f64); // Spans run up to the next op's start: anything still in flight then lands before it. if (self.advanceSpans(@min(until, next_at), sink)) |p| return p; - if (next_at > until) { - self.now = @max(self.now, until); + // Past the last op, `until` may itself be infinite: `nextAt`'s "nothing next", which a + // live driver asks for as it is. Infinity is not past infinity, so the end is its own test. + if (self.cursor >= ops.len or next_at > until) { + if (std.math.isFinite(until)) self.now = @max(self.now, until); return .reached; } self.finishSpans(sink); @@ -514,6 +516,27 @@ fn playTo(seq: *Sequencer, t: f64, frame_ms: f64, sink: Sink) void { } } +test "asked for what is next with nothing left, it has reached the end" { + // A live driver asks for `nextAt`, which is infinite once the ops are spent — at once, for an + // empty tape (valid live: `Tape.validate`). + var log: Log = .{}; + defer log.deinit(); + const empty: Tape = .{ .name = "empty", .ops = &.{} }; + var seq: Sequencer = .init(&empty); + try testing.expect(seq.done()); + try testing.expectEqual(Progress.reached, seq.advance(seq.nextAt(), 16, log.sink())); + try testing.expectEqual(@as(f64, 0), seq.now); + try testing.expectEqualStrings("", log.out.items); + + const ops = [_]Tape.Op{.{ .at = 40, .do = .{ .key = "mod+s" } }}; + const one: Tape = .{ .name = "one", .ops = &ops }; + seq = .init(&one); + try testing.expectEqual(Progress.yielded, seq.advance(seq.nextAt(), 16, log.sink())); + try testing.expect(seq.done()); + try testing.expectEqual(Progress.reached, seq.advance(seq.nextAt(), 16, log.sink())); + try testing.expectEqual(@as(f64, 40), seq.now); +} + test "a click lands a frame after the glide that brought the pointer to it" { const ops = [_]Tape.Op{ .{ .at = 0, .do = .{ .keyframe = 0 } }, From 22d397ce7439f64362ef016276a6ec0c6ee50668 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:35:07 -0500 Subject: [PATCH 21/46] app: a refused plugin says whether its mode or its SDK shape differs (#458) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin whose ABI fingerprint differed from the host's while its `sdk_version` matched was reported as **"SDK versions match, but optimize mode does not match"**. That was true only half the time. Under the SDK release train, feature PRs move `recorded_sdk_shape_fingerprint` and leave `sdk_version` alone. A Debug Fizzy built from `main` after an `sdk` PR (#442 after 0.2.27, for example) therefore refused Debug plugins built against `sdk-v0.2.27`, and showed the optimize-mode message even though both were Debug builds. Building the plugins with `zig build --fork=/sdk` fixed it. ## What changes The fingerprint is only `sdk_shape_fingerprint` folded with `optimize_safety_class`. Arch and OS are not part of it, even though the loader's old comment said they were. So the host can tell the two causes apart with no new export: it recomputes the fold for the other class. - `sdk.dylib.optimizeClassOf(plugin_fp)` returns the class the plugin's fingerprint was folded with when its shape matches the host's, and null when the shape differs. It has a unit test. - `AbiBuildEnvMismatch` is split into two errors, in both the desktop and the web loader: - **`AbiOptimizeMismatch`** (same shape, other class). It names both modes: *"built ReleaseFast or ReleaseSmall, but this Fizzy is a Debug build — rebuild the plugin in Debug, or run a release build of Fizzy"*, or the reverse. - **`AbiShapeMismatch`** (different shape, same version): *"same SDK version but a different SDK shape — a Fizzy built from main past a release needs plugins built with zig build --fork=/sdk (or this Fizzy is older than the plugin's SDK)"*. - `AbiMismatch` (different shape and version) is unchanged. - The optimize class is checked first, so a plugin from another release in another mode is reported as the mode problem it actually is. - The store's "Needs release" wording was already correct: it compares the host's class with the store's ReleaseFast-only builds at comptime. Only its doc comment changes, to the new error name. - `plugins/AGENTS.md` gets the new pitfall: a Fizzy built from main past a release needs `--fork`. ## SDK This adds a public function to `sdk/src/dylib.zig` but doesn't move the fingerprint (`test-sdk-version` passes), and `sdk_version` is unchanged. Plugins see no change. ## Verified macOS: `check-fmt`, `zig build`, `zig build test`, `test-sdk-version`, `check-web`, `check-tests-reached`, and `test-integration` (53/53 steps). The two cases are covered by the `optimizeClassOf` unit test. I didn't load a mismatched dylib by hand. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- app/App.zig | 9 ++++++++- app/store/PluginLoader.zig | 15 ++++++++------- app/store/PluginLoader_web.zig | 7 +++++-- app/store/PluginStore.zig | 3 +-- plugins/AGENTS.md | 5 ++++- sdk/src/dylib.zig | 23 +++++++++++++++++++++++ 6 files changed, 49 insertions(+), 13 deletions(-) diff --git a/app/App.zig b/app/App.zig index b9b815f54..0c678983a 100644 --- a/app/App.zig +++ b/app/App.zig @@ -885,7 +885,14 @@ const missing_symbols_reason = "the plugin is missing required entry symbols — const load_failure_reasons = .{ .AbiMismatch = "built against an incompatible Fizzy SDK — rebuild the plugin against this Fizzy build", - .AbiBuildEnvMismatch = "SDK versions match, but optimize mode does not match", + .AbiOptimizeMismatch = if (std.mem.eql(u8, sdk.dylib.optimize_safety_class, "safe")) + "built ReleaseFast or ReleaseSmall, but this Fizzy is a " ++ @tagName(builtin.mode) ++ + " build — rebuild the plugin in Debug, or run a release build of Fizzy" + else + "built Debug or ReleaseSafe, but this Fizzy is a " ++ @tagName(builtin.mode) ++ + " build — rebuild the plugin with -Doptimize=ReleaseFast", + .AbiShapeMismatch = "same SDK version but a different SDK shape — a Fizzy built from main past " ++ + "a release needs plugins built with zig build --fork=/sdk (or this Fizzy is older than the plugin's SDK)", .SdkVersionMismatch = "requires a newer Fizzy SDK — update Fizzy or install a matching plugin build", .PluginIdMismatch = "plugin id in the dylib does not match its filename — rename the file or fix manifest.id", .DylibOpenFailed = "the plugin library could not be opened (missing file, wrong architecture, or unresolved symbols)", diff --git a/app/store/PluginLoader.zig b/app/store/PluginLoader.zig index f0c53e85f..333f793ba 100644 --- a/app/store/PluginLoader.zig +++ b/app/store/PluginLoader.zig @@ -62,7 +62,8 @@ pub const LoadError = error{ SetRenderBridgeSymbolMissing, SdkVersionSymbolMissing, AbiMismatch, - AbiBuildEnvMismatch, + AbiOptimizeMismatch, + AbiShapeMismatch, SdkVersionMismatch, PluginIdMismatch, RegisterRejected, @@ -403,14 +404,14 @@ pub fn open(allocator: std.mem.Allocator, path: []const u8, expected_id: []const plugin_fp, }); } else { - // `sdk_version` is bumped exactly when the SDK boundary shape changes - // (`test-sdk-version` CI enforces this). So if the plugin was built against the - // same `sdk_version` we run, the fingerprint can only differ on things - // `sdk_version` does not cover — arch, os, or optimize mode (Debug vs ReleaseFast). - // Report that as a distinct, less alarming error than a genuine SDK drift. + // The fingerprint is the boundary shape folded with the optimize class, so a refusal + // is one or the other. The shape can move without the version: feature PRs record + // a new shape and only a release bumps `sdk_version`, so a Fizzy built from `main` + // past a release refuses plugins built against that release. + if (dylib_api.optimizeClassOf(plugin_fp) != null) return error.AbiOptimizeMismatch; const built_with = readVersionTriplet(lookupVersionFn(&lib, dylib_api.symbol_sdk_version)); if (std.SemanticVersion.order(built_with, version.sdk_version) == .eq) { - return error.AbiBuildEnvMismatch; + return error.AbiShapeMismatch; } return error.AbiMismatch; } diff --git a/app/store/PluginLoader_web.zig b/app/store/PluginLoader_web.zig index 954afb280..45c8ef0e4 100644 --- a/app/store/PluginLoader_web.zig +++ b/app/store/PluginLoader_web.zig @@ -26,7 +26,8 @@ pub const LoadError = error{ Unsupported, LoadFailed, AbiMismatch, - AbiBuildEnvMismatch, + AbiOptimizeMismatch, + AbiShapeMismatch, SdkVersionMismatch, PluginIdMismatch, RegisterRejected, @@ -288,8 +289,10 @@ pub fn prepare(url: []const u8, expected_id: []const u8, lib_in: WebDynLib) Load const abi_fp_fn = lib.lookup(dylib_api.GetAbiFingerprintFn, dylib_api.symbol_abi_fingerprint) orelse return error.LoadFailed; const plugin_fp = abi_fp_fn(); if (!dylib_api.fingerprintMatches(plugin_fp)) { + // Same split as the desktop loader's: optimize class, then shape under one version. + if (dylib_api.optimizeClassOf(plugin_fp) != null) return error.AbiOptimizeMismatch; const built_with = readVersionTriplet(lib.lookup(dylib_api.GetSdkVersionFn, dylib_api.symbol_sdk_version)); - if (std.SemanticVersion.order(built_with, version.sdk_version) == .eq) return error.AbiBuildEnvMismatch; + if (std.SemanticVersion.order(built_with, version.sdk_version) == .eq) return error.AbiShapeMismatch; return error.AbiMismatch; } diff --git a/app/store/PluginStore.zig b/app/store/PluginStore.zig index f19bf176f..6a23ef2f4 100644 --- a/app/store/PluginStore.zig +++ b/app/store/PluginStore.zig @@ -2875,8 +2875,7 @@ const store_optimize_class = "fast"; /// shard URL the store never publishes under, and *every* plugin — including ones whose SDK /// version matches this host exactly — reads "No compatible build in store". That message points /// at the store, but the cause is entirely local and comptime-known, so say so instead. Same -/// condition the local load path reports as `error.AbiBuildEnvMismatch` ("SDK versions match, but -/// optimize mode does not match"). +/// condition the local load path reports as `error.AbiOptimizeMismatch`. const host_optimize_matches_store = std.mem.eql(u8, dylib.optimize_safety_class, store_optimize_class); /// Cap on the reported min width of the no-build message (same `max_size_content` trick as diff --git a/plugins/AGENTS.md b/plugins/AGENTS.md index 5177de2c5..334c522a5 100644 --- a/plugins/AGENTS.md +++ b/plugins/AGENTS.md @@ -32,8 +32,11 @@ Each directory is file for file the shape a third-party plugin has, and the temp - **A test build that overwrote a user's installed plugin is theirs to restore.** Say so. Don't copy anything back from the loader's `.load-copy/`. - **An installed dylib refuses to load if its optimize mode differs from the app's** - (`AbiBuildEnvMismatch`). `zig build` defaults to Debug, and store plugins are built + (`AbiOptimizeMismatch`). `zig build` defaults to Debug, and store plugins are built ReleaseFast. +- **A Fizzy built from `main` past an SDK release refuses plugins built against that release** + (`AbiShapeMismatch`): `sdk` feature PRs move the shape and leave `sdk_version` alone. Build + the plugin against the checkout's SDK with `zig build --fork=/sdk`. - **A plugin built from the released SDK tarball ignores local `sdk/` and `core/` edits.** To test an SDK change end to end, point its `build.zig.zon` `.fizzy` dependency at a *relative* path (`../../fizzy/sdk`; absolute paths are rejected). diff --git a/sdk/src/dylib.zig b/sdk/src/dylib.zig index 926e02b3c..769f4251f 100644 --- a/sdk/src/dylib.zig +++ b/sdk/src/dylib.zig @@ -307,6 +307,22 @@ pub fn fingerprintMatches(plugin_fp: u64) bool { return plugin_fp == abi_fingerprint; } +/// The optimize class a plugin's `abi_fingerprint` was folded with, when its boundary shape is +/// this build's; null when the shape itself differs. A fingerprint is only the shape and the +/// class, so a refused plugin is one or the other, and the host can say which: a plugin built in +/// another mode, or one built against another SDK shape that kept the version number (a host +/// built from `main` after an `sdk` feature PR, before the release). +pub fn optimizeClassOf(plugin_fp: u64) ?[]const u8 { + inline for (.{ "safe", "fast" }) |class| { + const fp = comptime blk: { + @setEvalBranchQuota(1_000_000); + break :blk fingerprint.foldString(sdk_shape_fingerprint, class); + }; + if (plugin_fp == fp) return class; + } + return null; +} + pub fn tripletFromSemver(v: std.SemanticVersion) VersionTriplet { return .{ .major = @intCast(v.major), @@ -458,3 +474,10 @@ test "abi fingerprint is non-zero and self-consistent" { try std.testing.expect(fingerprintMatches(abi_fingerprint)); try std.testing.expect(!fingerprintMatches(abi_fingerprint +% 1)); } + +test "optimizeClassOf tells a mode mismatch from a shape mismatch" { + try std.testing.expectEqualStrings(optimize_safety_class, optimizeClassOf(abi_fingerprint).?); + const other = if (std.mem.eql(u8, optimize_safety_class, "safe")) "fast" else "safe"; + try std.testing.expectEqualStrings(other, optimizeClassOf(fingerprint.foldString(sdk_shape_fingerprint, other)).?); + try std.testing.expectEqual(null, optimizeClassOf(fingerprint.foldString(sdk_shape_fingerprint +% 1, other))); +} From d1f35866079c11c9ef28e182ab3f8b28d7983a05 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:51:43 -0500 Subject: [PATCH 22/46] sdk: release 0.2.28 (#455) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changes `sdk_version` goes from 0.2.27 to 0.2.28. Merging this tags `sdk-v0.2.28` and publishes its tarball. **Why now:** - **Agents:** fizzyedit/agent#16 (an agent's edits don't lock the document, and a document an agent makes opens beside the person) needs #442's `OpenOptions.beside`. - **Pixi's CI:** fizzy `main` dropped `setExplorerNewFilePath` in #449, so every pixi PR fails the `fizzy-main` check until pixi repins to a release that has `files.reveal`. - **Untitled sprites:** #447's `loadUntitledDocument` lets a plugin's untitled documents come back from a crash. Pixi needs a release carrying it to implement that. ## SDK impact - [ ] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [x] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag **`sdk` PRs since `sdk-v0.2.27`:** - **#442:** a plugin can open a document beside the person. `EditorAPI.OpenOptions.beside` (one new defaulted field), and `Host.openBeside` / `endOpenBeside`, so a command run on someone's behalf opens what it makes beside them. - **#447:** an untitled document survives a crash or a restart. A new optional vtable member, `loadUntitledDocument`, for an owner whose untitled documents should come back. `Plugin.bytesPath` is the path to hand `documentBytes` for a document's bytes outside a save. - **#456:** owners report a document's steps, and apply recorded ones (`Step`, a vtable `step_format` and `applyStep`, Host events). All additive: `step_format` defaults to 0 and `applyStep` to null, so an owner that records nothing changes nothing. - **#449:** the file tree keeps its own folders, and `files.reveal` shows new files. `Host.setExplorerNewFilePath(path)` becomes `host.getServiceTyped(sdk.services.files.Api)` then `.reveal(path)`. The `files` service is now version 2. `explorerBranchIsOpen` and `setExplorerBranchOpen` are gone, with no replacement. **Reaching plugins, no fingerprint move:** - **#444:** `core`: a canvas the person hasn't moved stays fitted to its pane, refitting live as the pane changes size. `fitToView` animates there. - **#446:** `core`: `core.widgets.glass_button`, round glass buttons and viewport bubbles. - **#450:** `core`: a kept frost reads again under a region's swap. - **#451:** `core`: a row carried out of a tree is the OS's glass, and stays still. - **#453:** `tape`: steps of a document's history, and a compressed binary form. **`tape.binary.encode` takes a third argument, `Options`** (`.{}` keeps the old output). - **#457:** `tape`: a live tape with no ops finishes instead of crashing. - **#424:** `core`: the glass shader written once in HLSL, its Metal and GLSL generated. - **#458:** `sdk/src/dylib.zig`: a refused plugin says whether its build mode or its SDK shape differs (it blamed the mode for both). - **#435:** `tape`, `replay`: `builtin.target.os`, `.cpu` and `.abi` in place of `builtin.os`. Not in this release: #437 (the Zig 0.17 switch, labelled `sdk`) merged into the `zig/0.17` integration branch, not `main`. `main` still builds with Zig 0.16. ## Verified - [x] macOS: - On this head, `zig build test-sdk-version` passes, and `scripts/pack-sdk.sh` packs `fizzy-sdk-v0.2.28.tar.gz`. - Rebased on `main` after #456, #453, #457, #424 and #458. Re-checked on the new head: `zig build test-sdk-version` passes and `scripts/pack-sdk.sh` packs. - fizzyedit/agent `main` doesn't build against it: `src/Recording.zig:127` calls `tape.binary.encode(gpa, t)`, and #453 added an `Options` argument (its notes say no plugin calls it; the agent's recording does). The fix is `encode(self.gpa, t, .{})`, in the agent's repin. - fizzyedit/pixi `main` doesn't build against it: `src/dialogs/NewFile.zig:230` calls the removed `setExplorerNewFilePath`. Expected; see Follow-ups. - [ ] Windows: - [ ] Linux: CI. - [ ] Web: ## Follow-ups - The repin automation opens `sdk: repin to fizzy SDK 0.2.28` PRs in the store plugins whose build needs it. - **pixi's** opens as a draft, since its build breaks. It needs #449's change: `NewFile.zig` calls `files.reveal`. #449's notes say that change is ready locally, to push with this repin. **atlas** needs the same, for New Note. - **agent's** repin opens as a draft too: it needs `tape.binary.encode(self.gpa, t, .{})` in `src/Recording.zig` (#453). - **Then:** once agent repins, fizzyedit/agent#16 can go ready, and pixi can implement `loadUntitledDocument`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- sdk/sdk_version.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/sdk_version.zig b/sdk/sdk_version.zig index 7700a3180..cc8205b44 100644 --- a/sdk/sdk_version.zig +++ b/sdk/sdk_version.zig @@ -23,5 +23,5 @@ const std = @import("std"); pub const sdk_version = std.SemanticVersion{ .major = 0, .minor = 2, - .patch = 27, + .patch = 28, }; From 66040f9962043e32538b9cc3f764c95ffb7316dc Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:18:03 -0500 Subject: [PATCH 23/46] app: a track per document, recorded behind FIZZY_TRACKS (#459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 3, first half: the tracks, behind a switch. ## What changes With `FIZZY_TRACKS=1`, the app keeps a **track** for each open document whose owner records steps (`app.Tracks`, installed as `Host.recorder`; only text does today): - **Steps with their moments**: each `sdk.Step` (#456) with the time it came on the frame clock, its effect and motion appended to the track's bytes. - **A base**, the document's state (`captureDocumentState`) when the track began. It's taken when the document opens, so the first edit is a step and isn't folded into the base. - **Checkpoints** after 256 steps, 1 MB of steps, or a 30 s pause, each with the document's fingerprint. - **`replayInto(doc_id, target, count)`** puts another document of the same owner where the track was after `count` steps: the nearest state, then the steps since, at once. Where a checkpoint stands at `count`, the replay must match its fingerprint. A replay target records none of it. - A closed document's track is kept for the session, and a reload (`restarted`) starts a new one. - **Which document the person worked in** is noted whenever a step lands in a different document than the last. Looking at a document without editing it isn't seen yet; a `fizzy.document.activated` event from the workbench is the named seam. **Nothing runs on a clock.** A step costs an append when it comes. A document's state is never taken from inside an edit, because owners report steps from inside theirs and the selection may not be settled yet. Instead a track asks, and the next frame takes it at its top (`Tracks.tick`, beside `doc_io.tick`), before anything draws. A frame nothing asked of returns on one branch, and a sleeping app runs nothing. Profiler scopes: `tracks.step`, `tracks.tick`, `tracks.replay`. **A text fix this found:** - Undo, redo and paste set a selection the next draw installs (`pending_sel`). Text's `captureState` and `fingerprint` read the widget's caret instead, from before the edit. - So a checkpoint taken before that draw didn't match its own document, and a demo snapshot taken then had the same problem. - They now read `Document.selection()`: the pending selection when there is one, clamped as the draw clamps it. **Left for the second half (3b), per the plan's "invisible, or it isn't done":** - Sealing steps into chunks deflated off the UI thread, and thinning checkpoints with age. - A cap. - The devtools track view. - The recording-on vs off frame-time check in `test-integration`, and a `bench-replay` track case for the budgets (under 20 µs a pixi stroke, under 2 ms a checkpoint on the UI thread). - Turning tracks on by default once those numbers hold. Until then a base per open text document and every step stay in memory, which is why this is behind a switch. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag (The text fix is inside the bundled plugin; `test-sdk-version` passes.) ## Verified - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build check-web`, `zig build test-sdk-version` and `zig build check-tests-reached` pass. - `zig build test`: 562/563 (1 skipped). `zig build test-integration`: 53/53 steps, 530/530 tests. - The new integration test turns tracking on and opens a document, then types two keystrokes, runs 300 inserts and undoes once. It checks: - 303 steps with non-decreasing times, a base, and one checkpoint at step 303; - an undone last step, and the focus noted on that document; - replaying all of it into a fresh document matches its text and the original's fingerprint, through the checkpoint check; - replaying 2 steps from the base gives `ab`; - the target recorded nothing; - a closed document's track is kept. - Not measured yet: that's 3b, before the default flips. - [ ] Windows: not run. - [ ] Linux: CI. - [ ] Web: `check-web` builds. The switch is off there (`envSwitch` is null on wasm). ## Follow-ups - 3b as above. - A `fizzy.document.activated` event for the focus track. - A base kept as the file on disk (path and hash) rather than a copy, for documents opened clean: the plan names it, and it halves a big sprite's memory. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- app/App.zig | 5 + app/Tracks.zig | 292 ++++++++++++++++++++++++++++++++++ app/root.zig | 2 + plugins/text/src/Document.zig | 19 ++- src/editor/Editor.zig | 16 ++ tests/integration.zig | 84 ++++++++++ 6 files changed, 414 insertions(+), 4 deletions(-) create mode 100644 app/Tracks.zig diff --git a/app/App.zig b/app/App.zig index 0c678983a..c8f19265d 100644 --- a/app/App.zig +++ b/app/App.zig @@ -10,6 +10,7 @@ const std = @import("std"); const dvui = @import("dvui"); const core = @import("core"); pub const Secrets = @import("Secrets.zig"); +const Tracks = @import("Tracks.zig"); const sdk = @import("fizzy_sdk"); const builtin = @import("builtin"); const build_opts = @import("build_opts"); @@ -202,6 +203,9 @@ folder_retired: std.ArrayListUnmanaged([]const u8) = .empty, pending_folder_close: bool = false, open_files: std.AutoArrayHashMapUnmanaged(u64, sdk.DocHandle) = .empty, +/// Each open document's history, timed (`Tracks`): set up once the app is at its final address, +/// and only then the host's recorder. +tracks: ?Tracks = null, file_id_counter: u64 = 0, @@ -925,6 +929,7 @@ pub fn formatPluginProbeDetail(allocator: std.mem.Allocator, info: PluginLoader. /// Removes the entry from the plugin's document registry; fizzy still removes /// the matching `DocHandle` from `open_files`. pub fn closeDocumentResources(app: *App, doc: sdk.DocHandle) void { + if (app.tracks) |*t| t.closed(doc.id); // Whoever held it is holding nothing now (`sdk.Claim`). app.host.releaseDocumentClaims(doc.id); _ = doc.owner.closeDocument(doc); diff --git a/app/Tracks.zig b/app/Tracks.zig new file mode 100644 index 000000000..bc84bcb4e --- /dev/null +++ b/app/Tracks.zig @@ -0,0 +1,292 @@ +//! A track per open document: its history with the time put back in (`plans/RECORDING_PLAN.md`). +//! +//! The host's recorder (`sdk.Host.Recorder`): owners report each step as it happens +//! (`sdk.Step`), and a track keeps it with the moment it came, beside the document's state when +//! the track began (`base`) and every so often since (`checkpoints`), so any moment of it can be +//! put back into a document: the nearest state, then the steps after it (`replayInto`). +//! +//! **Nothing runs on a clock.** A step costs an append, when it comes. Taking a document's state +//! is never done while an edit is half applied (the owner reports a step from inside its edit): +//! a track asks for one, and the next frame takes it at its top (`tick`), before anything draws. +//! A frame with nothing asked of it returns at once, and a sleeping app does nothing at all. +//! +//! **What it costs.** A base per open document that records steps, as `captureDocumentState` +//! makes it, and the steps' bytes, in memory for the session. Chunking, deflating off the UI thread, +//! thinning checkpoints and a cap are the next step of the plan; until then tracks are behind +//! `on` (fizzy: `FIZZY_TRACKS=1`). +const Tracks = @This(); + +const std = @import("std"); +const dvui = @import("dvui"); +const core = @import("core"); +const sdk = @import("fizzy_sdk"); + +gpa: std.mem.Allocator, +host: *sdk.Host, +/// Whether documents are recorded. Set before documents open: one opened while off has no track. +on: bool = false, +/// The tracks of open documents, by document id. +open: std.AutoArrayHashMapUnmanaged(u64, *Track) = .empty, +/// Tracks that ended this session: their document closed, or its history started over. +ended: std.ArrayListUnmanaged(*Track) = .empty, +/// Which document the person was working in, each time it changed, as steps landed. +focus: std.ArrayListUnmanaged(Focus) = .empty, +/// Some track wants its document's state taken at the next frame's top. +due: bool = false, +/// A document being replayed into: its own steps are not recorded meanwhile. +muted: u64 = 0, +/// The session's start, on the frame clock (`dvui.frameTimeNS`): what `Track.Rec.at` counts from. +start_ns: ?i128 = null, + +/// A step takes a checkpoint after this many since the last one, or this many bytes of them, or +/// this long a pause before it: so a seek never replays more than this from the last state. +pub const checkpoint_steps = 256; +pub const checkpoint_bytes = 1 << 20; +pub const checkpoint_gap_ms = 30_000; + +pub const Focus = struct { + /// ms since the session's start. + at: u32, + doc_id: u64, +}; + +pub const Track = struct { + doc_id: u64, + owner: *sdk.Plugin, + /// The owner's `step_format` when the track began. + format: u32, + /// The document when the track began; null until the next frame takes it. + base: ?State = null, + steps: std.ArrayListUnmanaged(Rec) = .empty, + /// Every step's effect and motion, one after another. + bytes: std.ArrayListUnmanaged(u8) = .empty, + checkpoints: std.ArrayListUnmanaged(State) = .empty, + checkpoint_due: bool = false, + /// Steps, and their bytes, since the last state taken. + since_steps: u32 = 0, + since_bytes: usize = 0, + + pub const Rec = struct { + kind: sdk.Step.Kind, + /// The history entry (`sdk.Step.id`). + id: u64, + /// ms since the session's start. + at: u32, + ms: u32, + effect: Span, + motion: Span, + }; + + pub const Span = struct { start: usize, len: usize }; + + /// The document as `captureDocumentState` gave it, after the first `step` steps. + pub const State = struct { + at: u32, + step: usize, + bytes: []u8, + /// `documentFingerprint` then, when the owner has one: a replay that reaches this moment + /// must come out the same. + fingerprint: ?u64, + }; + + pub fn effect(self: *const Track, r: Rec) []const u8 { + return self.bytes.items[r.effect.start..][0..r.effect.len]; + } + + pub fn motion(self: *const Track, r: Rec) []const u8 { + return self.bytes.items[r.motion.start..][0..r.motion.len]; + } + + /// The state a replay to `count` steps starts from: the latest at or before it. + pub fn stateBefore(self: *const Track, count: usize) ?*const State { + var i = self.checkpoints.items.len; + while (i > 0) { + i -= 1; + if (self.checkpoints.items[i].step <= count) return &self.checkpoints.items[i]; + } + return if (self.base) |*b| b else null; + } + + fn deinit(self: *Track, gpa: std.mem.Allocator) void { + if (self.base) |b| gpa.free(b.bytes); + for (self.checkpoints.items) |c| gpa.free(c.bytes); + self.checkpoints.deinit(gpa); + self.steps.deinit(gpa); + self.bytes.deinit(gpa); + gpa.destroy(self); + } +}; + +pub fn init(gpa: std.mem.Allocator, host: *sdk.Host) Tracks { + return .{ .gpa = gpa, .host = host }; +} + +pub fn deinit(self: *Tracks) void { + for (self.open.values()) |t| t.deinit(self.gpa); + self.open.deinit(self.gpa); + for (self.ended.items) |t| t.deinit(self.gpa); + self.ended.deinit(self.gpa); + self.focus.deinit(self.gpa); + self.* = .{ .gpa = self.gpa, .host = self.host, .on = self.on }; +} + +/// What the host calls with each step (`sdk.Host.recorder`). +pub fn recorder(self: *Tracks) sdk.Host.Recorder { + return .{ .ctx = self, .vtable = &.{ .wants = wants, .step = step } }; +} + +/// `doc` has opened: give it a track, when tracks are on and its owner records steps. Its base is +/// taken at the next frame's top. +pub fn opened(self: *Tracks, doc: sdk.DocHandle) void { + if (!self.on or doc.owner.step_format == 0 or doc.owner.vtable.captureDocumentState == null) return; + if (self.open.contains(doc.id)) return; + const t = self.gpa.create(Track) catch return; + t.* = .{ .doc_id = doc.id, .owner = doc.owner, .format = doc.owner.step_format }; + self.open.put(self.gpa, doc.id, t) catch { + self.gpa.destroy(t); + return; + }; + self.due = true; +} + +/// `doc_id` has closed: its track ends, kept for the session. One whose base was never taken +/// holds nothing and goes. +pub fn closed(self: *Tracks, doc_id: u64) void { + const kv = self.open.fetchSwapRemove(doc_id) orelse return; + self.end(kv.value); +} + +fn end(self: *Tracks, t: *Track) void { + if (t.base == null) return t.deinit(self.gpa); + self.ended.append(self.gpa, t) catch t.deinit(self.gpa); +} + +/// At the top of a frame, before anything draws: take each state a track asked for. Returns at +/// once when none did. +pub fn tick(self: *Tracks) void { + if (!self.due) return; + self.due = false; + const prof = core.profile.begin("fizzy", "tracks.tick"); + defer prof.end(); + for (self.open.values()) |t| { + const doc = self.host.docById(t.doc_id) orelse continue; + if (t.base == null) { + // Steps reported before the base was taken (an open then an edit in one frame) are + // in it, so they go. + t.steps.clearRetainingCapacity(); + t.bytes.clearRetainingCapacity(); + t.base = self.take(doc, 0) orelse continue; + } else if (t.checkpoint_due) { + const s = self.take(doc, t.steps.items.len) orelse continue; + t.checkpoints.append(self.gpa, s) catch { + self.gpa.free(s.bytes); + continue; + }; + } else continue; + t.checkpoint_due = false; + t.since_steps = 0; + t.since_bytes = 0; + } +} + +fn take(self: *Tracks, doc: sdk.DocHandle, count: usize) ?Track.State { + const bytes = doc.owner.captureDocumentState(doc, self.gpa) orelse return null; + return .{ .at = self.now(), .step = count, .bytes = bytes, .fingerprint = doc.owner.documentFingerprint(doc) }; +} + +/// ms since the session's start, on the frame clock. +fn now(self: *Tracks) u32 { + const t = dvui.frameTimeNS(); + const start = self.start_ns orelse blk: { + self.start_ns = t; + break :blk t; + }; + return std.math.lossyCast(u32, @divTrunc(t - start, std.time.ns_per_ms)); +} + +fn from(ctx: *anyopaque) *Tracks { + return @ptrCast(@alignCast(ctx)); +} + +fn wants(ctx: *anyopaque, doc_id: u64) bool { + const self = from(ctx); + return self.on and doc_id != self.muted and self.open.contains(doc_id); +} + +fn step(ctx: *anyopaque, doc_id: u64, st: sdk.Step) void { + const self = from(ctx); + const prof = core.profile.begin("fizzy", "tracks.step"); + defer prof.end(); + const t = self.open.get(doc_id) orelse return; + if (st.kind == .restarted) { + // What came before applies to nothing the document is now: a new track, from what it is. + const fresh = self.gpa.create(Track) catch return; + fresh.* = .{ .doc_id = doc_id, .owner = t.owner, .format = t.owner.step_format }; + self.open.putAssumeCapacity(doc_id, fresh); + self.end(t); + self.due = true; + return; + } + const at = self.now(); + const last_at = if (t.steps.getLastOrNull()) |r| r.at else if (t.base) |b| b.at else at; + const effect_at = t.bytes.items.len; + t.bytes.ensureUnusedCapacity(self.gpa, st.effect.len + st.motion.len) catch return; + t.steps.ensureUnusedCapacity(self.gpa, 1) catch return; + t.bytes.appendSliceAssumeCapacity(st.effect); + t.bytes.appendSliceAssumeCapacity(st.motion); + t.steps.appendAssumeCapacity(.{ + .kind = st.kind, + .id = st.id, + .at = at, + .ms = st.ms, + .effect = .{ .start = effect_at, .len = st.effect.len }, + .motion = .{ .start = effect_at + st.effect.len, .len = st.motion.len }, + }); + t.since_steps += 1; + t.since_bytes += st.effect.len + st.motion.len; + // After a pause the state is taken just after the step that ended it: near enough the + // pause's start for a seek, and taken where nothing is half applied. + if (t.since_steps >= checkpoint_steps or t.since_bytes >= checkpoint_bytes or at -| last_at >= checkpoint_gap_ms) { + t.checkpoint_due = true; + self.due = true; + } + + // A step in another document than the last is a cut. (Looking at a document without editing + // it is not seen here; a `fizzy.document.activated` event from the workbench is the seam.) + if (self.focus.getLastOrNull()) |f| if (f.doc_id == doc_id) return; + self.focus.append(self.gpa, .{ .at = at, .doc_id = doc_id }) catch {}; +} + +pub const ReplayError = error{ NoTrack, NoState, RestoreFailed, StepFailed, Mismatch }; + +/// Put `target` (a document of the track's owner) where `doc_id`'s track was after its first +/// `count` steps: the latest state at or before them, restored, then the steps since, applied at +/// once. `target` records none of it. Where a checkpoint stands at `count` with a fingerprint, +/// the replay must reach it (`error.Mismatch`). +pub fn replayInto(self: *Tracks, doc_id: u64, target: sdk.DocHandle, count: usize) ReplayError!void { + const prof = core.profile.begin("fizzy", "tracks.replay"); + defer prof.end(); + const t = self.trackOf(doc_id) orelse return error.NoTrack; + const n = @min(count, t.steps.items.len); + const s = t.stateBefore(n) orelse return error.NoState; + self.muted = target.id; + defer self.muted = 0; + if (!target.owner.restoreDocumentState(target, s.bytes)) return error.RestoreFailed; + for (t.steps.items[s.step..n]) |r| { + if (!target.owner.applyStep(target, t.effect(r), t.motion(r), 0)) return error.StepFailed; + } + for (t.checkpoints.items) |c| if (c.step == n) if (c.fingerprint) |want| { + if (target.owner.documentFingerprint(target) != want) return error.Mismatch; + }; +} + +/// The open document's track, or else the latest that ended for it. +pub fn trackOf(self: *Tracks, doc_id: u64) ?*Track { + if (self.open.get(doc_id)) |t| return t; + var i = self.ended.items.len; + while (i > 0) { + i -= 1; + if (self.ended.items[i].doc_id == doc_id) return self.ended.items[i]; + } + return null; +} diff --git a/app/root.zig b/app/root.zig index ee4c9939d..d93135773 100644 --- a/app/root.zig +++ b/app/root.zig @@ -40,6 +40,8 @@ pub const crash = @import("crash"); pub const Recents = @import("Recents.zig"); /// The commands just run on a document, shown on its pane (`Host.activity`). pub const ActivityFeed = @import("ActivityFeed.zig"); +/// A track per open document: its history with the time put back in. +pub const Tracks = @import("Tracks.zig"); /// Key chords → command ids: the table, its ZON form, chord matching, and the dvui adapter. pub const keymap = struct { diff --git a/plugins/text/src/Document.zig b/plugins/text/src/Document.zig index 371e62f2b..f0632c505 100644 --- a/plugins/text/src/Document.zig +++ b/plugins/text/src/Document.zig @@ -447,10 +447,11 @@ pub fn reloadFromDisk(self: *Document) !void { /// contents, selection with the caret's end, scroll, preview, and whether it is dirty /// (`doc_state`). pub fn captureState(self: *const Document, allocator: std.mem.Allocator) ![]u8 { + const sel = self.selection(); return doc_state.encode(allocator, .{ .text = self.text.items, - .anchor = if (self.sel_cursor == self.sel_start) self.sel_end else self.sel_start, - .head = self.sel_cursor, + .anchor = sel.anchor, + .head = sel.head, .dirty = self.isDirty(), .focused = self.editor_focused, .untitled = self.unsaved, @@ -465,11 +466,21 @@ pub fn captureState(self: *const Document, allocator: std.mem.Allocator) ![]u8 { pub fn fingerprint(self: *const Document) u64 { var h = std.hash.Wyhash.init(0x7e47_f1); h.update(self.text.items); - const anchor = if (self.sel_cursor == self.sel_start) self.sel_end else self.sel_start; - h.update(std.mem.asBytes(&[_]u64{ anchor, self.sel_cursor, @intFromBool(self.isDirty()), @intFromEnum(self.preview_mode) })); + const sel = self.selection(); + h.update(std.mem.asBytes(&[_]u64{ sel.anchor, sel.head, @intFromBool(self.isDirty()), @intFromEnum(self.preview_mode) })); return h.final(); } +/// The selection as the document has it: one an edit from outside the widget set +/// (`pending_sel`, which the next draw installs, clamped as it does), else the widget's. +pub fn selection(self: *const Document) tc.Range { + if (self.pending_sel) |r| { + const len = self.text.items.len; + return .init(@min(r.anchor, len), @min(r.head, len)); + } + return .init(if (self.sel_cursor == self.sel_start) self.sel_end else self.sel_start, self.sel_cursor); +} + /// Put the document back as `captureState` found it, in place — the buffer replaced as a reload /// replaces it (`reloadFromDisk`), so the next draw lays it out afresh. Undo history starts over. pub fn restoreState(self: *Document, bytes: []const u8) !void { diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 30c93b7e9..849ff0634 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -2027,6 +2027,9 @@ fn storeUninstalled(arena: std.mem.Allocator) []const @import("app").layout.Stat } pub fn postInit(editor: *Editor) !void { + // Documents' tracks, while they are behind a switch (plans/RECORDING_PLAN.md, step 3). + editor.app.tracks = .init(editor.app.gpa, &editor.app.host); + if (Popout.envSwitch("FIZZY_TRACKS") orelse false) editor.setTracking(true); if (comptime build_opts.has_app_layout) { dvui.log.info("layout: app-supplied", .{}); } else { @@ -2736,6 +2739,7 @@ pub fn insertOpenDoc(editor: *Editor, doc_buf: *anyopaque, owner: *sdk.Plugin, i if (editor.document_watcher) |*w| { if (editor.app.docById(id)) |doc| w.track(doc); } + if (editor.app.tracks) |*t| if (editor.app.docById(id)) |doc| t.opened(doc); if (editor.app.docById(id)) |doc| { editor.registerDocSurface(doc) catch |err| { dvui.log.err("document surface for {s}: {t}", .{ owner.documentPath(doc), err }); @@ -2964,6 +2968,14 @@ fn drawDocSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result { return .ok; } +/// Record documents' tracks, or stop. Documents open now get one; those opened after too. +pub fn setTracking(editor: *Editor, on: bool) void { + const t = &(editor.app.tracks orelse return); + t.on = on; + editor.app.host.recorder = if (on) t.recorder() else null; + if (on) for (editor.app.open_files.values()) |doc| t.opened(doc); +} + pub fn activeDoc(editor: *Editor) ?sdk.DocHandle { return editor.workbench.activeDoc(); } @@ -3948,6 +3960,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result { editor.app.file_table.pump(); // Saves whose encode finished on their worker: on to their write, or done. editor.doc_io.tick(); + // The document states tracks asked for since last frame, before anything draws. + if (editor.app.tracks) |*t| t.tick(); editor.promoteEditedPreviews(); editor.window_opacity = if (dvui.themeGet().dark) editor.app.settings.window_opacity_dark else editor.app.settings.window_opacity_light; @@ -6183,6 +6197,8 @@ pub fn deinit(editor: *Editor) !void { const demo_was_active = editor.demo.active(); editor.demo.deinit(); editor.black_box.deinit(editor.app.gpa); + editor.app.host.recorder = null; + if (editor.app.tracks) |*t| t.deinit(); // Owned outright rather than cached by dvui, so it has to be released explicitly. editor.app.layout.center_transition.discard(); editor.app.layout.center_prev_id = null; diff --git a/tests/integration.zig b/tests/integration.zig index 35ed52292..1280b0f9e 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -14,6 +14,7 @@ const std = @import("std"); const dvui = @import("dvui"); const fizzy = @import("fizzy"); +const Tracks = @import("app").Tracks; const shim = @import("fizzy_shim.zig"); const TextEntryWidget = @import("text").TextEntryWidget; const sdk = @import("fizzy_sdk"); @@ -7292,6 +7293,89 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try dvui.testing.settle(headlessFrame); } + // Tracks (`app.Tracks`): with tracking on, an open document's steps are kept with their times, + // beside its state when the track began and every so often since; any moment of the track + // replays into another document. A replay target records nothing of it. + { + const host = &editor.app.host; + const was = editor.activeDoc() orelse return error.TestUnexpectedResult; + const newDoc = struct { + fn newDoc(ed: *fizzy.Editor, h: *sdk.Host, not: u64) !sdk.DocHandle { + try h.runCommand("fizzy.newFile"); + for (0..200) |_| { + _ = try dvui.testing.step(headlessFrame); + const d = ed.activeDoc() orelse continue; + if (d.id != not and std.mem.startsWith(u8, std.fs.path.basename(d.owner.documentPath(d)), "untitled")) return d; + } + return error.TestUnexpectedResult; + } + }.newDoc; + const textOf = struct { + fn textOf(h: *sdk.Host, d: sdk.DocHandle) ![]const u8 { + const args = try std.fmt.allocPrint(h.arena(), ".{{ .path = \"{s}\" }}", .{d.owner.documentPath(d)}); + const zon = switch (h.callCommand("text.read", args, h.arena())) { + .ok => |r| r orelse return error.TestUnexpectedResult, + else => return error.TestUnexpectedResult, + }; + const at = std.mem.indexOf(u8, zon, ".text = \"") orelse return error.TestUnexpectedResult; + const rest = zon[at + ".text = \"".len ..]; + return rest[0 .. std.mem.indexOfScalar(u8, rest, '"') orelse return error.TestUnexpectedResult]; + } + }.textOf; + editor.setTracking(true); + defer editor.setTracking(false); + const tracks = &editor.app.tracks.?; + + const a = try newDoc(editor, host, was.id); + try dvui.testing.settle(headlessFrame); + for ([_][]const u8{ "a", "b" }) |key| { + try dvui.testing.writeText(key); + try dvui.testing.settle(headlessFrame); + } + // Enough steps for a checkpoint, then an undo. + for (0..Tracks.checkpoint_steps + 44) |_| { + try std.testing.expect(host.callCommand("text.insert", ".{ .text = \"x\", .line = 1 }", host.arena()) == .ok); + } + try editor.undo(); + try dvui.testing.settle(headlessFrame); + const want = try std.testing.allocator.dupe(u8, try textOf(host, a)); + defer std.testing.allocator.free(want); + try std.testing.expectEqual(Tracks.checkpoint_steps + 43 + 2, want.len); + + const track = tracks.trackOf(a.id) orelse return error.TestUnexpectedResult; + const n = track.steps.items.len; + try std.testing.expectEqual(@as(usize, 2 + Tracks.checkpoint_steps + 44 + 1), n); + try std.testing.expect(track.base != null); + try std.testing.expectEqual(@as(usize, 1), track.checkpoints.items.len); + try std.testing.expectEqual(n, track.checkpoints.items[0].step); + try std.testing.expectEqual(sdk.Step.Kind.undone, track.steps.items[n - 1].kind); + for (track.steps.items[1..], track.steps.items[0 .. n - 1]) |later, earlier| try std.testing.expect(later.at >= earlier.at); + try std.testing.expectEqual(a.id, tracks.focus.items[tracks.focus.items.len - 1].doc_id); + + // All of it, from the checkpoint, to the fingerprint it recorded; and the start of it, + // from the base. + const b = try newDoc(editor, host, a.id); + try dvui.testing.settle(headlessFrame); + try tracks.replayInto(a.id, b, n); + try dvui.testing.settle(headlessFrame); + try std.testing.expectEqualStrings(want, try textOf(host, b)); + try std.testing.expectEqual(a.owner.documentFingerprint(a), b.owner.documentFingerprint(b)); + try tracks.replayInto(a.id, b, 2); + try dvui.testing.settle(headlessFrame); + try std.testing.expectEqualStrings("ab", try textOf(host, b)); + // The target kept its own track, and recorded none of the replay. + const tb = tracks.trackOf(b.id) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(usize, 0), tb.steps.items.len); + + // Closed, a document's track is kept for the session. + try editor.rawCloseFileID(b.id); + try editor.rawCloseFileID(a.id); + try std.testing.expect(tracks.open.get(a.id) == null); + try std.testing.expectEqual(track, tracks.trackOf(a.id).?); + editor.workbench.setActiveDocIndex(editor.app.open_files.getIndex(was.id) orelse return error.TestUnexpectedResult); + try dvui.testing.settle(headlessFrame); + } + // Revealing a view (`Host.revealSurface`): with the bottom panel shut, revealing Output opens // the panel with Output selected, and a view no region shows is not revealed. { From f6d248b2636b7b80c96cd15d0d900888a7544157 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:31:32 -0500 Subject: [PATCH 24/46] app: workspaces are views of the app's, behind FIZZY_WORKSPACES (#461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #260 (step 2 of `plans/WORKSPACES_PLAN.md`) ## What changes Behind `FIZZY_WORKSPACES=1`, the workbench's document panes stop being leaves of its own split tree and become views the app places, `fizzy.workspace.`, each titled by the document it shows. This is the ground tearing a tab off into its own window (step 5) is built on: once a pane is an ordinary view, a float can hold one. - **Main shows the first workspace** by its keywords, in place of `workbench.workspaces` (which the workbench doesn't register while the flag is on). - **A pane beside another is an app split.** A tab dropped on a pane's edge, Open to the Side and `showBeside` now split the place holding the anchor pane (`Region.splitOn`) and assign the new workspace there. Before, they split the workbench's `DockLayout`. - **An emptied workspace closes its place** (`ViewDrag.withdraw`, new). A minted half slides shut. A float closes. When Main itself empties, it takes the views of the half beside it, as an emptied split does today. The only workspace never goes, and shows the home page. - **Saved in `layout.zon`:** the split, which place holds which workspace, and each pane's documents (`Pane N`, as before). `panes.zon` is neither read nor written while the flag is on; migrating it is step 4. How it fits together: the workbench gets one seam, `Workbench.places` (`beside` / `placed` / `close`), which fizzy's new `src/editor/Workspaces.zig` fills in before the plugin registers. Fizzy also keeps one surface per pane in step with `wb.workspaces`, once a frame after the rebuild. Documents still go into panes through the workbench exactly as now (tabs, drops, previews, placeholders, close flow); that moves to the app in step 3. With the flag off, nothing changes. The new branches are all `places == null`. ## SDK impact - [x] None. `Workbench` is fizzy-internal state shared with its own bundled dylib; the SDK boundary is untouched (`test-sdk-version` passes). ## Verified - [x] macOS: a sandbox profile run with `FIZZY_WORKSPACES=1`, driven through the agent plugin's `fizzy_act` with screenshots at each step: - opened three files; - dragged a tab onto the pane's right drop → `Main` + `Main/r1`, workspace 1 holding the tab; - closed it → `Main/r1` slid shut; - split again, then closed Main's last document → Main took workspace 2 and the split closed; - quit and relaunched → both workspaces and their tabs came back; - dragged a tab from the right workspace onto Main's strip → it went in among Main's tabs and the right half closed. - `zig build`, `zig build test`, `zig build test-integration` (53/53 steps), `zig build check-web`, `zig build test-sdk-version`, `scripts/check-fmt.sh`. - [ ] Windows: - [ ] Linux: - [ ] Web: builds (`check-web`); the flag is never on there (no environment). ## Follow-ups - Step 3: documents through the app (`openFile` into the active workspace; the drop rules for documents). - Step 4: `panes.zon` → `layout.zon`. Until then, a place whose workspace had no documents at quit comes back empty. - Step 5: tear a tab off over the desktop, or drop it on its own workspace's middle, into a float holding a new workspace, and back again. - fizzy's restart-into-a-rebuild (`--handover`) doesn't carry `FIZZY_*` env flags, so it comes back with this flag off. That affects every env flag, not just this one. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- app/layout/ViewDrag.zig | 28 ++- plugins/workbench/plugin.zig | 4 +- plugins/workbench/src/Workbench.zig | 52 +++++- plugins/workbench/src/Workspace.zig | 10 +- plugins/workbench/src/workbench_layout.zig | 10 +- src/editor/Editor.zig | 14 +- src/editor/Workspaces.zig | 206 +++++++++++++++++++++ 7 files changed, 309 insertions(+), 15 deletions(-) create mode 100644 src/editor/Workspaces.zig diff --git a/app/layout/ViewDrag.zig b/app/layout/ViewDrag.zig index 2d9cec407..517a3f00b 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -2485,7 +2485,7 @@ fn floatOut(l: *Layout, source: []const u8, moved: []const u8, at: ?dvui.Point.P /// The declared place showing `id` now, if one is: where a view carried out of the picker comes /// from (`floatOut`). -fn holderOf(l: *Layout, id: []const u8) ?[]const u8 { +pub fn holderOf(l: *Layout, id: []const u8) ?[]const u8 { for (l.state.regions.items) |r| { for (holding(l, r.name)) |held| { if (std.mem.eql(u8, held, id)) return r.name; @@ -2794,6 +2794,32 @@ fn shutIfEmptied(l: *Layout, name: []const u8) void { _ = closeEmptied(l, name); } +/// Take view `id` out of the place holding it, and close that place if it is left with nothing: +/// a half of a split the user made closes into the other half, and a float closes. What an +/// application does with a view of its own that has nothing left to show (fizzy's emptied +/// workspaces). A place that cannot close keeps the view, and this returns false: an emptied +/// view is still the place's to show, rather than a hole in it. +pub fn withdraw(l: *Layout, id: []const u8) bool { + const name = ownId(l.arena, holderOf(l, id) orelse return false) orelse return false; + const kept = idsWithout(l.arena, holding(l, name), id); + if (kept.len == 0 and !closable(l, name)) return false; + // A place its keywords fill, left something to show, is left alone as in `takeOut`. + if (kept.len == 0 or l.state.assignment(name) != null) l.state.assign(l.gpa, name, kept) catch {}; + reselect(l, name, id, kept); + shutIfEmptied(l, name); + l.state.markDirty(); + dvui.refresh(null, @src(), null); + return true; +} + +/// Whether the emptied place `name` would close (`closeEmptied`), rather than stay empty. +fn closable(l: *Layout, name: []const u8) bool { + if (l.state.floats.find(name) != null and l.state.splits.root(name) == null) return true; + if (l.state.isMinted(name)) return true; + const sibling = l.state.siblingLeaf(name) orelse return false; + return l.state.isMinted(sibling); +} + /// A merge of two halves of a split: `gone` closed, its views now in `into`. const Merged = struct { gone: []const u8, into: []const u8 }; diff --git a/plugins/workbench/plugin.zig b/plugins/workbench/plugin.zig index 117c99eea..8a7c4b7fd 100644 --- a/plugins/workbench/plugin.zig +++ b/plugins/workbench/plugin.zig @@ -62,7 +62,9 @@ pub fn register(host: *sdk.Host) !void { .draw = drawFiles, }); } - try host.registerSurface(.{ + // Where the app arranges the panes, each is a view of its own (`Workbench.places`), and + // there is no one surface drawing them all. + if (runtime.workbench().places == null) try host.registerSurface(.{ .id = center_workspaces, .owner = &plugin, .title = "Workspace", diff --git a/plugins/workbench/src/Workbench.zig b/plugins/workbench/src/Workbench.zig index e0e195b16..a1ae8ba29 100644 --- a/plugins/workbench/src/Workbench.zig +++ b/plugins/workbench/src/Workbench.zig @@ -38,6 +38,12 @@ workspaces: std.AutoArrayHashMapUnmanaged(u64, Workspace) = .empty, /// plugin's own directory whenever it changes (`workbench_layout.savePanes`). panes: Panes, panes_dirty: bool = false, +/// Where the panes are arranged when the app arranges them instead of `panes`: each pane a view +/// of the app's, in a place of the app's, beside another by a split of the app's (fizzy's +/// `Workspaces`, behind `FIZZY_WORKSPACES=1`). Null: the panes are this tree's leaves, as before. +/// Filled in by the app before this plugin registers, and instance state for the same +/// two-copies reason as `pending_new_file_path` below. +places: ?Places = null, /// How tall a pane's tab strip was, the last time a pane had one. An empty pane beside others /// holds the same room open, so its card lines up with its neighbours' instead of starting /// higher. @@ -80,6 +86,20 @@ pending_new_file_path: ?[]u8 = null, /// document yet. Strings are the app's; this only borrows them. loading: std.StringArrayHashMapUnmanaged(Loading) = .empty, +/// The app's half of arranging the panes (`places`). Every pane is named by its grouping; what +/// it is called in the app's layout is the app's business. +pub const Places = struct { + ctx: *anyopaque, + /// Give `grouping`'s pane a place on `side` of the place holding `anchor`'s. False when there + /// is no room, or `anchor`'s pane is in no place yet. + beside: *const fn (ctx: *anyopaque, grouping: u64, anchor: u64, side: Panes.Side) bool, + /// Whether `grouping`'s pane has a place, or will have one without being given it: the one + /// pane the layout's keywords show where no other is. + placed: *const fn (ctx: *anyopaque, grouping: u64) bool, + /// `grouping`'s pane has emptied and is going: its place closes, into the one beside it. + close: *const fn (ctx: *anyopaque, grouping: u64) void, +}; + pub const Loading = struct { /// The file being opened: the tab's icon comes from its extension. path: []const u8, @@ -148,8 +168,20 @@ pub fn paneLeaf(self: *Workbench, grouping: u64) ?Panes.NodeIndex { } /// Give `grouping` a leaf if it has none: the root when the tree is empty, else a split off the -/// current pane's right edge (or off the last pane, when the current one is gone). +/// current pane's right edge (or off the last pane, when the current one is gone). Where the app +/// arranges the panes, a place beside the current one's. fn seatPane(self: *Workbench, grouping: u64) !void { + if (self.places) |p| { + if (p.placed(p.ctx, grouping)) return; + const anchor = if (self.open_workspace_grouping != grouping and self.workspaces.contains(self.open_workspace_grouping)) + self.open_workspace_grouping + else for (self.workspaces.keys()) |g| { + if (g != grouping) break g; + } else return; + // No room yet (the layout has not drawn): the app places it once there is. + _ = p.beside(p.ctx, grouping, anchor, .right); + return; + } if (self.paneLeaf(grouping) != null) return; const root = &self.panes.nodes.items[self.panes.root]; if (root.* == .leaf and root.leaf.tabs.items.len == 0) { @@ -170,6 +202,14 @@ fn seatPane(self: *Workbench, grouping: u64) !void { /// Open a new pane for `grouping` on `side` of `anchor`'s pane — a drop on a pane's edge. The /// workspace is created too, so `pane()` afterwards finds both. pub fn paneBeside(self: *Workbench, grouping: u64, anchor: u64, side: Panes.Side) !void { + if (self.places) |p| { + if (!p.beside(p.ctx, grouping, anchor, side)) return error.NoRoom; + const gop = try self.workspaces.getOrPut(self.allocator, grouping); + if (!gop.found_existing) gop.value_ptr.* = Workspace.init(grouping); + gop.value_ptr.expecting = true; + if (grouping > self.grouping_id_counter) self.grouping_id_counter = grouping; + return; + } if (self.paneLeaf(grouping) != null) return; const anchor_leaf = self.paneLeaf(anchor) orelse return error.NoSuchPane; var buf: [32]u8 = undefined; @@ -186,16 +226,26 @@ pub fn paneBeside(self: *Workbench, grouping: u64, anchor: u64, side: Panes.Side /// Send an emptied pane's leaf sliding shut. The workspace itself leaves once the leaf has /// gone (`workbench_layout.rebuildWorkspaces`). pub fn closePane(self: *Workbench, grouping: u64) void { + if (self.places) |p| return p.close(p.ctx, grouping); const leaf = self.paneLeaf(grouping) orelse return; self.panes.closeLeaf(leaf); } /// Whether `grouping`'s pane is sliding shut (`closePane`). pub fn paneClosing(self: *Workbench, grouping: u64) bool { + // The app's place slides shut on its own; the pane is gone from the moment it closes. + if (self.places != null) return false; const leaf = self.paneLeaf(grouping) orelse return false; return self.panes.leafClosing(leaf); } +/// Whether this is the only pane: the one that shows the home page when empty, and the one that +/// never goes. +pub fn solePane(self: *const Workbench) bool { + if (self.places != null) return self.workspaces.count() <= 1; + return self.panes.nodes.items[self.panes.root] == .leaf; +} + /// At shutdown, after the last frame: `backend` frees what a frame would have (`Workspace.deinitAtQuit`). pub fn deinitWorkspaces(self: *Workbench, backend: dvui.Backend) void { for (self.workspaces.values()) |*workspace| workspace.deinitAtQuit(backend); diff --git a/plugins/workbench/src/Workspace.zig b/plugins/workbench/src/Workspace.zig index 7373f1422..8a03198dc 100644 --- a/plugins/workbench/src/Workspace.zig +++ b/plugins/workbench/src/Workspace.zig @@ -176,7 +176,7 @@ pub fn draw(self: *Workspace) !dvui.App.Result { if (tabs.len > 0) { self.drawTabs(region, tabs, selected); self.offerStrip(region, self.tabCount()); - } else if (!solePane()) { + } else if (!runtime.workbench().solePane()) { // An empty pane in a split looks like its neighbours with nothing open: the tab strip's // room, then the same card, empty — not a bare pane that starts higher than they do. const h = runtime.workbench().tab_strip_h; @@ -815,7 +815,7 @@ fn drawCanvas(self: *Workspace, region: sdk.Host.Region, has_tabs: bool) !void { // it. An empty pane beside others — above all one sliding shut after its last tab // closed — is just an empty document pane: the logo and buttons flashing up in it as it // went read as a page opening, not a file closing. - if (!solePane()) return; + if (!runtime.workbench().solePane()) return; const alpha = dvui.alpha(1.0); dvui.alphaSet(1.0); @@ -831,12 +831,6 @@ fn dropSnapshot(self: *Workspace) void { self.snapshot_idle_frames = 0; } -/// Whether this is the workbench's only pane — the one that shows the home page when empty. -fn solePane() bool { - const wb = runtime.workbench(); - return wb.panes.nodes.items[wb.panes.root] == .leaf; -} - pub fn drawHomePage(_: *Workspace) !void { const logo_pixel_size = 32; const logo_width = 3; diff --git a/plugins/workbench/src/workbench_layout.zig b/plugins/workbench/src/workbench_layout.zig index f832d29bd..300d3ba1f 100644 --- a/plugins/workbench/src/workbench_layout.zig +++ b/plugins/workbench/src/workbench_layout.zig @@ -21,7 +21,8 @@ pub fn rebuildWorkspaces(wb: *Workbench) !void { if (!wb.restored) { wb.restored = true; - loadPanes(wb); + // Where the app arranges the panes, its own layout brought them back. + if (wb.places == null) loadPanes(wb); for (host.assignedRegionNames()) |region_name| { const grouping = Workspace.groupingOfName(region_name) orelse continue; // A copy: the assignment is borrowed until the next `assignSurfaces`, and each open @@ -92,11 +93,14 @@ pub fn rebuildWorkspaces(wb: *Workbench) !void { if (live > 0 or ws.expecting) { // Filled (or about to be) — including one that was on its way out when its // document landed, which is a pane to keep, not a hole to finish closing. - if (wb.paneLeaf(ws.grouping)) |leaf| wb.panes.reopenLeaf(leaf); + if (wb.places == null) if (wb.paneLeaf(ws.grouping)) |leaf| wb.panes.reopenLeaf(leaf); k += 1; continue; } - if (wb.paneLeaf(ws.grouping) != null) { + // The app's place slides shut by itself once it is closed, and the pane goes now. + if (wb.places) |p| { + p.close(p.ctx, ws.grouping); + } else if (wb.paneLeaf(ws.grouping) != null) { wb.closePane(ws.grouping); k += 1; continue; diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 849ff0634..4481ff14e 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -103,12 +103,15 @@ pub const Host = sdk.Host; /// Workbench: the file-management home — file tree, open/load flow, and the /// workspace/tabs/splits system, plus the per-branch explorer decoration registry. pub const Workbench = workbench_mod.Workbench; +pub const Workspaces = @import("Workspaces.zig"); /// The host runtime — everything an application on fizzy runs unchanged. app: App, /// File-management workbench (per-branch explorer decorations, …) workbench: Workbench, +/// The workbench's panes as the app's own views, when `FIZZY_WORKSPACES=1` asked (`Workspaces`). +workspaces: Workspaces = .{}, /// The first claim id not yet shown beside the person (`showClaimed`). claims_seen: u32 = 1, @@ -2049,6 +2052,12 @@ pub fn postInit(editor: *Editor) !void { } else |_| {} } sdk.installRuntime(&editor.app.gpa, &editor.app.host, null); + // Before the plugins register: the workbench offers its one surface of every pane only where + // the app does not place each pane itself. + if (Workspaces.wanted(editor.app.gpa)) { + editor.workspaces.install(&editor.workbench); + dvui.log.info("layout: workspaces are views (FIZZY_WORKSPACES)", .{}); + } editor.demo.attach(editor); // `FIZZY_DEMO=tour` plays a bundled demo from launch — how a screen recording is made — and @@ -4537,6 +4546,7 @@ pub fn setWindowStyle(_: *Editor) void { pub fn rebuildWorkspaces(editor: *Editor) !void { try editor.workbench.rebuildWorkspaces(); + editor.workspaces.sync(); } /// Write every region's extent and assignment to `layout.zon`, by name. One record per region @@ -6134,7 +6144,8 @@ pub fn closeFile(editor: *Editor, index: usize) !void { /// only its image stays for the slide. fn snapshotClosingPane(editor: *Editor, doc: sdk.DocHandle) void { const wb = editor.workbench; - if (wb.panes.nodes.items[wb.panes.root] == .leaf) return; + // Where the app arranges the panes, the place slides shut on its own and draws no picture. + if (wb.places != null or wb.solePane()) return; const id = sdk.document.surfaceId(editor.app.host.arena(), doc.owner.id, doc.owner.documentPath(doc)) catch return; for (wb.workspaces.values()) |*ws| { if (!ws.hasTab(id) or ws.tabCount() != 1) continue; @@ -6238,6 +6249,7 @@ pub fn deinit(editor: *Editor) !void { // Tear workspaces down first, while the plugins whose documents they hold are still alive. editor.workbench.deinitWorkspaces(fizzy.entry().window.backend); + editor.workspaces.deinit(editor.app.gpa); // Drain & join the save-queue worker before tearing anything else down. Any // queued jobs need to finish writing or be dropped before File data is freed. diff --git a/src/editor/Workspaces.zig b/src/editor/Workspaces.zig new file mode 100644 index 000000000..0d8ae36ab --- /dev/null +++ b/src/editor/Workspaces.zig @@ -0,0 +1,206 @@ +//! The workbench's document panes as views of the app's own, behind `FIZZY_WORKSPACES=1`: each +//! pane is a surface, `fizzy.workspace.`, that sits in a place like any view — Main +//! shows the first by its keywords, a split of a place holds the next, a float can hold one, and +//! the picker and the view drag move them. What a pane draws is the workbench's own pane +//! (`Workspace.draw`): its tabs, its document slot, its home page. What is gone is the +//! workbench's arrangement of them (`Workbench.panes`): a pane opened beside another is a split +//! of the app's (`Region.splitOn`), and one emptied closes its place as any emptied view's does. +//! +//! The first step of `plans/WORKSPACES_PLAN.md`'s workspaces (step 2): documents still go into +//! panes through the workbench, and its `panes.zon` is not read while this is on. +const std = @import("std"); +const builtin = @import("builtin"); +const dvui = @import("dvui"); +const fizzy = @import("../fizzy.zig"); +const sdk = fizzy.sdk; +const app = @import("app"); +const Editor = @import("Editor.zig"); +const Workbench = Editor.Workbench; +const Layout = app.layout.Layout; + +const Workspaces = @This(); + +/// Each pane's view, by its grouping. +views: std.AutoArrayHashMapUnmanaged(u64, *View) = .empty, + +const View = struct { + editor: *Editor, + grouping: u64, + /// The surface id, `fizzy.workspace.`. + id: []u8, +}; + +pub const id_prefix = "fizzy.workspace."; + +/// Whether `FIZZY_WORKSPACES=1` asked for this. Never on the web, which has no environment. +pub fn wanted(gpa: std.mem.Allocator) bool { + if (comptime builtin.target.cpu.arch == .wasm32) return false; + const v = std.process.Environ.getAlloc(fizzy.core.platform.processEnviron(), gpa, "FIZZY_WORKSPACES") catch return false; + defer gpa.free(v); + return std.mem.eql(u8, v, "1"); +} + +/// Hand the workbench the app's places to arrange its panes in. Before the workbench registers, +/// so it knows not to offer its one surface of every pane, and with `self` at its final address. +pub fn install(self: *Workspaces, wb: *Workbench) void { + wb.places = .{ .ctx = self, .beside = beside, .placed = placed, .close = close }; +} + +pub fn deinit(self: *Workspaces, gpa: std.mem.Allocator) void { + for (self.views.values()) |v| { + gpa.free(v.id); + gpa.destroy(v); + } + self.views.deinit(gpa); +} + +fn editorOf(self: *Workspaces) *Editor { + return @alignCast(@fieldParentPtr("workspaces", self)); +} + +/// The view id of `grouping`'s pane, in `buf`. +fn viewId(buf: []u8, grouping: u64) []const u8 { + return std.fmt.bufPrint(buf, id_prefix ++ "{d}", .{grouping}) catch id_prefix; +} + +/// Bring the views in line with the workbench's panes, once a frame after it has rebuilt them +/// and before anything draws: a view for every pane, none for a pane that has gone, each titled +/// by the document its pane shows. A pane that has no place yet — opened before the layout had +/// drawn, so there was no room to split — is given one beside the first. +pub fn sync(self: *Workspaces) void { + const editor = self.editorOf(); + const wb = &editor.workbench; + if (wb.places == null) return; + const gpa = editor.app.gpa; + const host = &editor.app.host; + + var i: usize = 0; + while (i < self.views.count()) { + const g = self.views.keys()[i]; + if (wb.workspaces.contains(g)) { + i += 1; + continue; + } + const v = self.views.values()[i]; + host.unregisterSurface(v.id); + gpa.free(v.id); + gpa.destroy(v); + self.views.orderedRemoveAt(i); + } + + for (wb.workspaces.keys()) |g| { + if (self.views.contains(g)) continue; + self.register(g) catch |err| dvui.log.err("workspaces: no view for pane {d}: {t}", .{ g, err }); + } + + for (wb.workspaces.values()) |*ws| { + const v = self.views.get(ws.grouping) orelse continue; + const title = blk: { + const doc = ws.active orelse break :blk "Workspace"; + const id = sdk.document.surfaceId(host.arena(), doc.owner.id, doc.owner.documentPath(doc)) catch break :blk "Workspace"; + break :blk if (host.surfaceById(id)) |s| s.title else "Workspace"; + }; + const s = host.surfaceById(v.id) orelse continue; + if (!std.mem.eql(u8, s.title, title)) host.setSurfaceTitle(v.id, title) catch {}; + } + + // Every pane but one is somewhere it was put; the one is where the keywords show it. More + // than one with nowhere to be would all be Main's, and Main shows one. + if (editor.app.layout.regions.items.len == 0) return; + var first: ?u64 = null; + for (wb.workspaces.keys()) |g| { + if (self.assigned(g)) continue; + if (first == null) { + first = g; + continue; + } + _ = beside(self, g, first.?, .right); + } +} + +fn register(self: *Workspaces, grouping: u64) !void { + const editor = self.editorOf(); + const gpa = editor.app.gpa; + const v = try gpa.create(View); + errdefer gpa.destroy(v); + var buf: [64]u8 = undefined; + v.* = .{ .editor = editor, .grouping = grouping, .id = try gpa.dupe(u8, viewId(&buf, grouping)) }; + errdefer gpa.free(v.id); + try self.views.put(gpa, grouping, v); + errdefer _ = self.views.orderedRemove(grouping); + try editor.app.host.registerSurface(.{ + .id = v.id, + .title = "Workspace", + .keywords = sdk.keywords.ide.main, + .ctx = v, + .draw = drawView, + }); +} + +fn drawView(ctx: ?*anyopaque) anyerror!dvui.App.Result { + const v: *View = @ptrCast(@alignCast(ctx.?)); + const ws = v.editor.workbench.workspaces.getPtr(v.grouping) orelse return .ok; + return ws.draw(); +} + +/// Whether some place's list names `grouping`'s view: put there by a split, a drop or a session. +fn assigned(self: *Workspaces, grouping: u64) bool { + const editor = self.editorOf(); + var buf: [64]u8 = undefined; + const id = viewId(&buf, grouping); + var it = editor.app.layout.assignments.valueIterator(); + while (it.next()) |ids| for (ids.*) |x| if (std.mem.eql(u8, x, id)) return true; + return false; +} + +fn layoutOf(editor: *Editor) Layout { + return Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, editor.app.host.arena()); +} + +fn beside(ctx: *anyopaque, grouping: u64, anchor: u64, side: Workbench.Panes.Side) bool { + const self: *Workspaces = @ptrCast(@alignCast(ctx)); + const editor = self.editorOf(); + var l = layoutOf(editor); + var abuf: [64]u8 = undefined; + const place = Layout.ViewDrag.holderOf(&l, viewId(&abuf, anchor)) orelse return false; + const new = Layout.splitOn(&l, place, switch (side) { + .left => .left, + .right => .right, + .top => .top, + .bottom => .bottom, + }) orelse return false; + var buf: [64]u8 = undefined; + const id = l.arena.dupe(u8, viewId(&buf, grouping)) catch return false; + l.state.assign(l.gpa, new, &.{id}) catch return false; + l.state.markDirty(); + return true; +} + +fn placed(ctx: *anyopaque, grouping: u64) bool { + const self: *Workspaces = @ptrCast(@alignCast(ctx)); + if (self.assigned(grouping)) return true; + // Unput, it is shown by the keywords only while no other pane is. + for (self.editorOf().workbench.workspaces.keys()) |g| { + if (g != grouping and !self.assigned(g)) return false; + } + return true; +} + +fn close(ctx: *anyopaque, grouping: u64) void { + const self: *Workspaces = @ptrCast(@alignCast(ctx)); + const editor = self.editorOf(); + var l = layoutOf(editor); + var buf: [64]u8 = undefined; + const id = l.arena.dupe(u8, viewId(&buf, grouping)) catch return; + if (Layout.ViewDrag.withdraw(&l, id)) return; + // A place that cannot close keeps the room, but not a list naming a view that is going. + for (editor.app.host.assignedRegionNames()) |name| { + const ids = editor.app.host.assignedSurfaces(name) orelse continue; + var kept: std.ArrayListUnmanaged([]const u8) = .empty; + var found = false; + for (ids) |x| { + if (std.mem.eql(u8, x, id)) found = true else kept.append(l.arena, x) catch return; + } + if (found) l.state.assign(l.gpa, l.arena.dupe(u8, name) catch return, kept.items) catch {}; + } +} From fc1246f49b239a7a6ae39d8f54d2f24c5741c0c8 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:18:22 -0500 Subject: [PATCH 25/46] app: tracks measured, with delta checkpoints and a cap (#460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 3, second half. Builds on #459, now merged; `main` is merged in. ## What changes Tracks (#459) now hold a bounded amount and are measured. They stay behind `FIZZY_TRACKS` (or `fizzy.tracks.record`) until the default-on PR. - **Checkpoints are deltas** (`app/state_delta.zig`). A checkpoint is kept as the bytes that differ from the latest whole state: the base, or a checkpoint kept whole because its delta had come to half the document. It's generic over any owner's `captureDocumentState` bytes, so no plugin writes anything for it. - The common prefix and suffix are trimmed, a 64-byte block at a time. - A middle the same length in both (nothing shifted, like a canvas) is kept as the runs of blocks that differ, so strokes far apart are a few runs. - Any other middle (text inserted, which shifts what follows) is kept as it is. - Restoring one rebuilds the whole state from its reference first. - **Checkpoints are weighed.** One is taken only once the steps since the last weigh as much as the last delta did (`state_share`), and then after 256 steps or a 30 s pause. At 8,192 steps one is taken whatever it costs, so a seek never replays more than that. - Before this, a checkpoint was a whole copy every 256 steps by count alone. A 256 KB document held 78 copies of itself after 20,000 steps, and an 8 MB one hit the cap. - **Old checkpoints thin.** Those older than an hour keep one per 4,096 steps. Only deltas go; a whole state is what the deltas after it are made from. - **A cap**, 256 MB in memory. Past it, the oldest ended tracks go first, then all but each open track's newest checkpoint and the whole state it's made from. Steps and bases stay, because undo will walk them; making those smaller is compaction (plan step 12). - **`fizzy.tracks`** reports whether tracks record, what they hold against the cap, how many frames did any track work, and per track its document, owner, steps, checkpoints and bytes. It's devtools' and an agent's view of tracks. - **`fizzy.tracks.record { on }`** turns tracks on or off. - A track keeps its document's handle (stable for its life) instead of asking the host for it, so `Tracks` needs no host, and the frame clock is injectable for tests and the bench. **Measured**, `zig build bench-tracks -Doptimize=ReleaseFast`. The owner is a stand-in whose state is a buffer and whose steps write at scattered offsets, so this is the tracks' own work around an owner's. Text's state is a copy of its text plus a small header, so these stand in fairly for it. Pixi's real costs come with plan step 4. The machine was at load 7–8 from other sessions, so treat the timings as upper bounds; the sizes and counts don't depend on load. | Steps | Step | Document | ns/step* | Checkpoints | Held | Worst replay | |---:|---:|---:|---:|---:|---:|---:| | 2,000 | 24 B | 64 KB | 134 | 7 | 220 KB | 0.011 ms | | 2,000 | 200 B | 256 KB | 262 | 7 | 756 KB | 0.036 ms | | 20,000 | 200 B | 256 KB | 225 | 78 | 5.1 MB | 0.029 ms | | 20,000 | 200 B | 8 MB | 4,650 | 78 | 12.9 MB | 0.6 ms | \* each step with its share of the checkpoints it asks for. **Whole copies against deltas,** for 20,000 strokes on the 8 MB sprite: - **Whole copies:** 29.6 MB held, 2 checkpoints. - **Deltas:** 12.9 MB held, 78 checkpoints. That's the 8 MB base, 4 MB of steps, and about 1 MB of checkpoints. - On the 256 KB sprite: 9.1 MB and 15 checkpoints, now 5.1 MB and 78. **At a frame's top:** - **One checkpoint:** 0.021 ms for a 64 KB document, 0.12 ms for 1 MB, 1.2 ms for 8 MB. The budget is 2 ms; the whole copy cost about the same. - A first version of the codec compared byte by byte and twice over, and took 3 ms for 8 MB. It now compares a block at a time, in one pass. - **Nothing asked:** about 1 ns. The "nothing per frame" claim is also a test rather than a timing. With tracks on in the real app (headless), 120 idle frames do no tracks work (`Tracks.worked` doesn't move). Wall-clock comparisons in Debug CI would be flaky. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build check-web`, `zig build test-sdk-version`, `zig build check-tests-reached` and `zig build check-bench-replay` pass. - `zig build test`: 568/569 (1 skipped). `zig build test-integration`: 53/53 steps, 540/540 tests. - `fizzy-state-delta-tests` (unit, std-only): one edit is a delta about its size; inserted text; changes far apart on a canvas are a few runs; growing, shrinking and emptying; 500 random edits round-trip; truncation at every length and 4,000 bit flips are errors, never crashes. - New `Tracks` unit tests: - a frame's top works only when asked; - a checkpoint as a delta, kept whole past half the document, then deltas against that; - none until the steps outweigh the last delta, and one at the most a seek replays; - a replay through a delta to its bytes and fingerprint; - thinning after an hour, with an injected clock; - the cap's order: ended tracks first, then older checkpoints. - The integration test now also checks idle frames and `fizzy.tracks`. - Not measured in a running fizzy yet. The shared sandbox fizzy belongs to another session, so that measurement comes with the default-on PR, with pixi's real strokes. - [ ] Windows: not run. - [ ] Linux: CI. - [ ] Web: `check-web` builds. ## Follow-ups - **Default on:** - a measurement in a running fizzy, for text now and pixi after step 4; - the flip, with a setting rather than the switch. - **A memory decision for that PR:** checkpoints are now small, so a track is mostly its base and its steps, held beside the owner's own undo history. That's about what undo holds plus a base. The plan's "undo history plus 10%" target needs plan step 11, where owners page their undo from the track. - **No worker thread for checkpoints.** A checkpoint is taken and diffed on the frame that takes it, on every target: the web has no threads, and a worker would save only the diff (about half of 1.2 ms for 8 MB, on a frame every few hundred strokes), since the capture's copy reads the live document anyway. - If pixi's real numbers ever show that frame running over, the fallback works on the web too: spread the diff over the frames after, a slice each within a small budget. The copy is immutable once taken. - With checkpoints as deltas, there's little left for the plan's "deflate sealed chunks on a worker" to save. That's dropped for the same reason. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- app/Tracks.zig | 353 ++++++++++++++++++++++++++++++++--- app/root.zig | 1 + app/state_delta.zig | 234 +++++++++++++++++++++++ build/app.zig | 29 +++ src/editor/Editor.zig | 2 +- src/editor/Keybinds.zig | 68 +++++++ tests/bench/bench_tracks.zig | 153 +++++++++++++++ tests/integration.zig | 16 ++ 8 files changed, 834 insertions(+), 22 deletions(-) create mode 100644 app/state_delta.zig create mode 100644 tests/bench/bench_tracks.zig diff --git a/app/Tracks.zig b/app/Tracks.zig index bc84bcb4e..836462eb2 100644 --- a/app/Tracks.zig +++ b/app/Tracks.zig @@ -11,18 +11,27 @@ //! A frame with nothing asked of it returns at once, and a sleeping app does nothing at all. //! //! **What it costs.** A base per open document that records steps, as `captureDocumentState` -//! makes it, and the steps' bytes, in memory for the session. Chunking, deflating off the UI thread, -//! thinning checkpoints and a cap are the next step of the plan; until then tracks are behind -//! `on` (fizzy: `FIZZY_TRACKS=1`). +//! makes it, the steps' bytes, and the checkpoints, in memory for the session (`held`). A +//! checkpoint is kept as what differs from the latest whole state (`state_delta`): the base, or a +//! checkpoint kept whole because its delta had come to half the document. Older +//! checkpoints are thinned (`thin`), and past `cap` the oldest ended tracks go, then all but each +//! open track's newest checkpoint (`shed`); steps and bases stay, since undo will walk them. +//! Taking and diffing a checkpoint happen on the frame that takes it, on every target (the web has +//! no threads); were that ever too long, the diff would be spread over the frames after, a slice +//! each, the copy being immutable once taken. Until tracks are on by default they are behind `on` +//! (fizzy: `FIZZY_TRACKS=1`). `zig build bench-tracks` measures what a step, a checkpoint and a +//! replay cost. const Tracks = @This(); const std = @import("std"); const dvui = @import("dvui"); const core = @import("core"); const sdk = @import("fizzy_sdk"); +const state_delta = @import("state_delta.zig"); gpa: std.mem.Allocator, -host: *sdk.Host, +/// The frame clock: what a step's moment is read from. A bench gives its own. +clock: *const fn () i128 = dvui.frameTimeNS, /// Whether documents are recorded. Set before documents open: one opened while off has no track. on: bool = false, /// The tracks of open documents, by document id. @@ -37,12 +46,27 @@ due: bool = false, muted: u64 = 0, /// The session's start, on the frame clock (`dvui.frameTimeNS`): what `Track.Rec.at` counts from. start_ns: ?i128 = null, +/// Bytes every track holds: bases, steps and checkpoints. +held: usize = 0, +/// What `held` may come to before tracks shed (`shed`). +cap: usize = default_cap, +/// Frames whose top did any work (`tick`): one per state asked for, never one a frame. +worked: u64 = 0, -/// A step takes a checkpoint after this many since the last one, or this many bytes of them, or -/// this long a pause before it: so a seek never replays more than this from the last state. +pub const default_cap = 256 << 20; +/// Checkpoints older than this are thinned to one per `sparse_steps` steps. +pub const dense_ms = 60 * std.time.ms_per_min; +pub const sparse_steps = 4096; + +/// When a step asks for a checkpoint. A checkpoint costs what differs from the latest whole state, +/// so it is taken only once the steps since the last are worth a share of one (`state_share`: +/// their bytes times it reach the last checkpoint's size), and then after `checkpoint_steps` of +/// them, or a pause of `checkpoint_gap_ms` before one. Past `checkpoint_max_steps` one is taken +/// whatever it costs, so a seek never replays more than that. pub const checkpoint_steps = 256; -pub const checkpoint_bytes = 1 << 20; +pub const checkpoint_max_steps = 8192; pub const checkpoint_gap_ms = 30_000; +pub const state_share = 1; pub const Focus = struct { /// ms since the session's start. @@ -52,6 +76,8 @@ pub const Focus = struct { pub const Track = struct { doc_id: u64, + /// The document's own handle, stable for its life: what its states are taken from. + doc: sdk.DocHandle, owner: *sdk.Plugin, /// The owner's `step_format` when the track began. format: u32, @@ -62,6 +88,9 @@ pub const Track = struct { bytes: std.ArrayListUnmanaged(u8) = .empty, checkpoints: std.ArrayListUnmanaged(State) = .empty, checkpoint_due: bool = false, + /// The size of the last checkpoint kept as a delta: about what the next will cost. 0 after a + /// whole state, whose next delta starts small. + state_len: usize = 0, /// Steps, and their bytes, since the last state taken. since_steps: u32 = 0, since_bytes: usize = 0, @@ -79,11 +108,13 @@ pub const Track = struct { pub const Span = struct { start: usize, len: usize }; - /// The document as `captureDocumentState` gave it, after the first `step` steps. + /// The document as `captureDocumentState` gave it, after the first `step` steps: whole, or as + /// what differs from the latest whole state before it (`state_delta`). pub const State = struct { at: u32, step: usize, bytes: []u8, + whole: bool = true, /// `documentFingerprint` then, when the owner has one: a replay that reaches this moment /// must come out the same. fingerprint: ?u64, @@ -107,6 +138,33 @@ pub const Track = struct { return if (self.base) |*b| b else null; } + /// The latest whole state before checkpoint `i` (`checkpoints.len`: after them all): what a + /// delta there is against. The base when no checkpoint before it is whole. + fn wholeBefore(self: *const Track, i: usize) []const u8 { + var j = i; + while (j > 0) { + j -= 1; + if (self.checkpoints.items[j].whole) return self.checkpoints.items[j].bytes; + } + return self.base.?.bytes; + } + + /// `s` (the base, or one of the checkpoints) as the whole state: borrowed when it is kept + /// whole, else made from its delta, `owned` by `gpa`. + pub fn wholeState(self: *const Track, gpa: std.mem.Allocator, s: *const State) state_delta.Error!struct { bytes: []const u8, owned: bool } { + if (s.whole) return .{ .bytes = s.bytes, .owned = false }; + const i = (@intFromPtr(s) - @intFromPtr(self.checkpoints.items.ptr)) / @sizeOf(State); + return .{ .bytes = try state_delta.apply(gpa, self.wholeBefore(i), s.bytes), .owned = true }; + } + + /// Bytes this track holds. + pub fn size(self: *const Track) usize { + var n = self.bytes.items.len + self.steps.items.len * @sizeOf(Rec); + if (self.base) |b| n += b.bytes.len; + for (self.checkpoints.items) |c| n += c.bytes.len; + return n; + } + fn deinit(self: *Track, gpa: std.mem.Allocator) void { if (self.base) |b| gpa.free(b.bytes); for (self.checkpoints.items) |c| gpa.free(c.bytes); @@ -117,8 +175,8 @@ pub const Track = struct { } }; -pub fn init(gpa: std.mem.Allocator, host: *sdk.Host) Tracks { - return .{ .gpa = gpa, .host = host }; +pub fn init(gpa: std.mem.Allocator) Tracks { + return .{ .gpa = gpa }; } pub fn deinit(self: *Tracks) void { @@ -127,7 +185,7 @@ pub fn deinit(self: *Tracks) void { for (self.ended.items) |t| t.deinit(self.gpa); self.ended.deinit(self.gpa); self.focus.deinit(self.gpa); - self.* = .{ .gpa = self.gpa, .host = self.host, .on = self.on }; + self.* = .{ .gpa = self.gpa, .clock = self.clock, .on = self.on, .cap = self.cap }; } /// What the host calls with each step (`sdk.Host.recorder`). @@ -141,7 +199,7 @@ pub fn opened(self: *Tracks, doc: sdk.DocHandle) void { if (!self.on or doc.owner.step_format == 0 or doc.owner.vtable.captureDocumentState == null) return; if (self.open.contains(doc.id)) return; const t = self.gpa.create(Track) catch return; - t.* = .{ .doc_id = doc.id, .owner = doc.owner, .format = doc.owner.step_format }; + t.* = .{ .doc_id = doc.id, .doc = doc, .owner = doc.owner, .format = doc.owner.step_format }; self.open.put(self.gpa, doc.id, t) catch { self.gpa.destroy(t); return; @@ -157,8 +215,13 @@ pub fn closed(self: *Tracks, doc_id: u64) void { } fn end(self: *Tracks, t: *Track) void { - if (t.base == null) return t.deinit(self.gpa); - self.ended.append(self.gpa, t) catch t.deinit(self.gpa); + if (t.base == null) return self.drop(t); + self.ended.append(self.gpa, t) catch self.drop(t); +} + +fn drop(self: *Tracks, t: *Track) void { + self.held -|= t.size(); + t.deinit(self.gpa); } /// At the top of a frame, before anything draws: take each state a track asked for. Returns at @@ -166,26 +229,103 @@ fn end(self: *Tracks, t: *Track) void { pub fn tick(self: *Tracks) void { if (!self.due) return; self.due = false; + self.worked += 1; const prof = core.profile.begin("fizzy", "tracks.tick"); defer prof.end(); for (self.open.values()) |t| { - const doc = self.host.docById(t.doc_id) orelse continue; + const before = t.size(); if (t.base == null) { // Steps reported before the base was taken (an open then an edit in one frame) are // in it, so they go. t.steps.clearRetainingCapacity(); t.bytes.clearRetainingCapacity(); - t.base = self.take(doc, 0) orelse continue; + t.base = self.take(t.doc, 0) orelse continue; } else if (t.checkpoint_due) { - const s = self.take(doc, t.steps.items.len) orelse continue; + var s = self.take(t.doc, t.steps.items.len) orelse continue; + // As what differs from the latest whole state, unless that is half the document or + // more: then whole, and the next ones differ from it. + if (state_delta.encode(self.gpa, t.wholeBefore(t.checkpoints.items.len), s.bytes)) |delta| { + if (delta.len * 2 < s.bytes.len) { + self.gpa.free(s.bytes); + s.bytes = delta; + s.whole = false; + } else self.gpa.free(delta); + } else |_| {} t.checkpoints.append(self.gpa, s) catch { self.gpa.free(s.bytes); continue; }; + self.thin(t); } else continue; t.checkpoint_due = false; t.since_steps = 0; t.since_bytes = 0; + t.state_len = if (t.checkpoints.getLastOrNull()) |c| (if (c.whole) 0 else c.bytes.len) else 0; + self.held = self.held - @min(self.held, before) + t.size(); + } + if (self.held > self.cap) self.shed(); +} + +/// Thin `t`'s checkpoints older than `dense_ms` to one per `sparse_steps` steps: a seek that far +/// back replays more, and the track holds less. Only deltas go: a whole state is what the deltas +/// after it are made from. +fn thin(self: *Tracks, t: *Track) void { + const now_ms = self.now(); + var kept: usize = 0; + var last_step: ?usize = null; + for (t.checkpoints.items) |c| { + const old = now_ms -| c.at >= dense_ms; + if (old and !c.whole) if (last_step) |l| if (c.step - l < sparse_steps) { + self.gpa.free(c.bytes); + continue; + }; + t.checkpoints.items[kept] = c; + kept += 1; + last_step = c.step; + } + t.checkpoints.shrinkRetainingCapacity(kept); +} + +/// Over the cap: the oldest ended tracks go first; then every open track keeps only its newest +/// checkpoint, and the whole state it is a delta of. Steps and bases stay: undo will walk them, +/// and compacting (plan step 12) is what makes them smaller. +pub fn shed(self: *Tracks) void { + const prof = core.profile.begin("fizzy", "tracks.shed"); + defer prof.end(); + var gone: usize = 0; + while (self.held > self.cap and gone < self.ended.items.len) : (gone += 1) self.drop(self.ended.items[gone]); + if (gone > 0) { + std.mem.copyForwards(*Track, self.ended.items, self.ended.items[gone..]); + self.ended.shrinkRetainingCapacity(self.ended.items.len - gone); + } + if (self.held <= self.cap) return; + for (self.open.values()) |t| { + const items = t.checkpoints.items; + if (items.len < 2) continue; + const last = items.len - 1; + // The whole state the newest is a delta of, when that is a checkpoint and not the base. + var whole: ?usize = null; + if (!items[last].whole) { + var j = last; + while (j > 0) { + j -= 1; + if (items[j].whole) { + whole = j; + break; + } + } + } + var kept: usize = 0; + for (items, 0..) |c, i| { + if (i == last or i == whole) { + items[kept] = c; + kept += 1; + continue; + } + self.held -|= c.bytes.len; + self.gpa.free(c.bytes); + } + t.checkpoints.shrinkRetainingCapacity(kept); } } @@ -196,7 +336,7 @@ fn take(self: *Tracks, doc: sdk.DocHandle, count: usize) ?Track.State { /// ms since the session's start, on the frame clock. fn now(self: *Tracks) u32 { - const t = dvui.frameTimeNS(); + const t = self.clock(); const start = self.start_ns orelse blk: { self.start_ns = t; break :blk t; @@ -221,7 +361,7 @@ fn step(ctx: *anyopaque, doc_id: u64, st: sdk.Step) void { if (st.kind == .restarted) { // What came before applies to nothing the document is now: a new track, from what it is. const fresh = self.gpa.create(Track) catch return; - fresh.* = .{ .doc_id = doc_id, .owner = t.owner, .format = t.owner.step_format }; + fresh.* = .{ .doc_id = doc_id, .doc = t.doc, .owner = t.owner, .format = t.owner.step_format }; self.open.putAssumeCapacity(doc_id, fresh); self.end(t); self.due = true; @@ -244,9 +384,12 @@ fn step(ctx: *anyopaque, doc_id: u64, st: sdk.Step) void { }); t.since_steps += 1; t.since_bytes += st.effect.len + st.motion.len; + self.held += st.effect.len + st.motion.len + @sizeOf(Track.Rec); + if (self.held > self.cap) self.shed(); // After a pause the state is taken just after the step that ended it: near enough the // pause's start for a seek, and taken where nothing is half applied. - if (t.since_steps >= checkpoint_steps or t.since_bytes >= checkpoint_bytes or at -| last_at >= checkpoint_gap_ms) { + const worth = t.since_bytes * state_share >= t.state_len; + if (t.since_steps >= checkpoint_max_steps or (worth and (t.since_steps >= checkpoint_steps or at -| last_at >= checkpoint_gap_ms))) { t.checkpoint_due = true; self.due = true; } @@ -269,9 +412,11 @@ pub fn replayInto(self: *Tracks, doc_id: u64, target: sdk.DocHandle, count: usiz const t = self.trackOf(doc_id) orelse return error.NoTrack; const n = @min(count, t.steps.items.len); const s = t.stateBefore(n) orelse return error.NoState; + const state = t.wholeState(self.gpa, s) catch return error.RestoreFailed; + defer if (state.owned) self.gpa.free(state.bytes); self.muted = target.id; defer self.muted = 0; - if (!target.owner.restoreDocumentState(target, s.bytes)) return error.RestoreFailed; + if (!target.owner.restoreDocumentState(target, state.bytes)) return error.RestoreFailed; for (t.steps.items[s.step..n]) |r| { if (!target.owner.applyStep(target, t.effect(r), t.motion(r), 0)) return error.StepFailed; } @@ -290,3 +435,169 @@ pub fn trackOf(self: *Tracks, doc_id: u64) ?*Track { } return null; } + +// ---- tests --------------------------------------------------------------------------------- + +const testing = std.testing; + +/// A stand-in owner whose document is a buffer: its state a copy, a step written in at its start. +const Stand = struct { + var bytes: [4096]u8 = @splat(0); + var clock_ns: i128 = 0; + + fn clock() i128 { + return clock_ns; + } + fn capture(_: *anyopaque, _: sdk.DocHandle, allocator: std.mem.Allocator) anyerror![]u8 { + return allocator.dupe(u8, &bytes); + } + fn restore(_: *anyopaque, _: sdk.DocHandle, saved: []const u8) anyerror!void { + @memcpy(&bytes, saved); + } + fn fingerprint(_: *anyopaque, _: sdk.DocHandle) u64 { + return std.hash.Wyhash.hash(0, &bytes); + } + fn apply(_: *anyopaque, _: sdk.DocHandle, effect: []const u8, _: []const u8, _: u32) anyerror!void { + @memcpy(bytes[0..effect.len], effect); + } + + var state: u8 = 0; + const vtable: sdk.Plugin.VTable = .{ .captureDocumentState = capture, .restoreDocumentState = restore, .documentFingerprint = fingerprint, .applyStep = apply }; + var plugin: sdk.Plugin = .{ .state = &state, .vtable = &vtable, .id = "stand", .display_name = "Stand", .step_format = 1 }; + + fn doc(id: u64) sdk.DocHandle { + return .{ .ptr = &state, .owner = &plugin, .id = id }; + } + + fn tracks() Tracks { + bytes = @splat(0); + clock_ns = 0; + var t: Tracks = .init(testing.allocator); + t.clock = clock; + t.on = true; + return t; + } + + /// A step of `effect` (written into the document as the owner would have) at `ms` past the last. + fn step(t: *Tracks, id: u64, effect: []const u8, ms: i128) void { + clock_ns += ms * std.time.ns_per_ms; + @memcpy(bytes[0..effect.len], effect); + const rec = t.recorder(); + rec.vtable.step(rec.ctx, id, .{ .kind = .added, .id = 1, .effect = effect }); + } +}; + +test "a frame's top does work only when a track asked for some" { + var t = Stand.tracks(); + defer t.deinit(); + t.opened(Stand.doc(1)); + t.tick(); + try testing.expectEqual(@as(u64, 1), t.worked); + for (0..1000) |_| t.tick(); + try testing.expectEqual(@as(u64, 1), t.worked); + // A step that asks for nothing asks nothing of a frame. + Stand.step(&t, 1, "ab", 10); + t.tick(); + try testing.expectEqual(@as(u64, 1), t.worked); + try testing.expectEqual(@as(usize, 1), t.trackOf(1).?.steps.items.len); +} + +test "a checkpoint is what differs from the whole state, taken once the steps since are worth one" { + var t = Stand.tracks(); + defer t.deinit(); + t.opened(Stand.doc(1)); + t.tick(); + const track = t.trackOf(1).?; + const sixteen: [16]u8 = @splat(5); + // The first after the base is a small delta: 16 bytes changed of 4 KB. + for (0..checkpoint_steps) |_| Stand.step(&t, 1, &sixteen, 1); + t.tick(); + try testing.expectEqual(@as(usize, 1), track.checkpoints.items.len); + try testing.expect(!track.checkpoints.items[0].whole); + try testing.expect(track.checkpoints.items[0].bytes.len < 64); + // A bigger change, a third of the document: still a delta, and what the next one weighs. + const third: [1500]u8 = @splat(6); + Stand.step(&t, 1, &third, 1); + for (0..checkpoint_steps - 1) |_| Stand.step(&t, 1, "", 1); + t.tick(); + try testing.expectEqual(@as(usize, 2), track.checkpoints.items.len); + try testing.expect(!track.checkpoints.items[1].whole); + // Steps worth less than that delta ask for none. + for (0..checkpoint_steps) |_| Stand.step(&t, 1, "x", 1); + t.tick(); + try testing.expectEqual(@as(usize, 2), track.checkpoints.items.len); + // However little they are worth, a seek replays no more than the most. + for (0..checkpoint_max_steps) |_| Stand.step(&t, 1, "", 1); + t.tick(); + try testing.expectEqual(@as(usize, 3), track.checkpoints.items.len); + // Changed by more than half: kept whole, and what the next deltas are made from. + const most: [3000]u8 = @splat(7); + Stand.step(&t, 1, &most, 1); + for (0..checkpoint_max_steps) |_| Stand.step(&t, 1, "", 1); + t.tick(); + try testing.expect(track.checkpoints.getLast().whole); + for (0..checkpoint_steps) |_| Stand.step(&t, 1, &sixteen, 1); + t.tick(); + try testing.expect(!track.checkpoints.getLast().whole); + try testing.expectEqual(t.held, track.size()); + + // A replay lands where the document was through a delta, and checks the fingerprint kept. + const want = Stand.bytes; + @memset(&Stand.bytes, 0); + try t.replayInto(1, Stand.doc(2), track.steps.items.len); + try testing.expectEqualSlices(u8, &want, &Stand.bytes); + // And through the one before the whole state. + try t.replayInto(1, Stand.doc(2), track.checkpoints.items[1].step); +} + +test "checkpoints older than an hour thin to one per sparse_steps" { + var t = Stand.tracks(); + defer t.deinit(); + t.opened(Stand.doc(1)); + t.tick(); + const track = t.trackOf(1).?; + // A checkpoint every `checkpoint_steps` steps of 16 bytes (a state's worth), a ms apart: + // dense while young. + var buf: [16]u8 = @splat(7); + for (0..sparse_steps * 2 / checkpoint_steps) |i| { + for (0..checkpoint_steps) |_| Stand.step(&t, 1, &buf, 1); + buf[0] = @truncate(i); + t.tick(); + } + const young = track.checkpoints.items.len; + try testing.expectEqual(@as(usize, sparse_steps * 2 / checkpoint_steps), young); + // An hour on, the next checkpoint thins the old ones. + Stand.clock_ns += dense_ms * std.time.ns_per_ms; + for (0..checkpoint_steps) |_| Stand.step(&t, 1, &buf, 1); + t.tick(); + const kept = track.checkpoints.items; + try testing.expect(kept.len < young); + for (kept[1 .. kept.len - 1], kept[0 .. kept.len - 2]) |c, prev| try testing.expect(c.step - prev.step >= sparse_steps); + try testing.expectEqual(t.held, track.size()); +} + +test "over the cap, ended tracks go first, then all but each open track's newest checkpoint" { + var t = Stand.tracks(); + defer t.deinit(); + for ([_]u64{ 1, 2 }) |id| t.opened(Stand.doc(id)); + t.tick(); + var buf: [16]u8 = @splat(3); + for (0..3) |_| { + for (0..checkpoint_steps) |_| Stand.step(&t, 1, &buf, 1); + t.tick(); + } + try testing.expectEqual(@as(usize, 3), t.trackOf(1).?.checkpoints.items.len); + t.closed(2); + try testing.expectEqual(@as(usize, 1), t.ended.items.len); + + t.cap = t.held - 1; + t.shed(); + try testing.expectEqual(@as(usize, 0), t.ended.items.len); + try testing.expect(t.trackOf(2) == null); + t.cap = 1; + t.shed(); + const track = t.trackOf(1).?; + try testing.expectEqual(@as(usize, 1), track.checkpoints.items.len); + try testing.expectEqual(track.steps.items.len, track.checkpoints.items[0].step); + try testing.expectEqual(t.held, track.size()); +} diff --git a/app/root.zig b/app/root.zig index d93135773..d0fb148b5 100644 --- a/app/root.zig +++ b/app/root.zig @@ -167,4 +167,5 @@ test { _ = watch.SettingsWatcher; _ = watch.ExecutableWatcher; _ = store.Loader; + _ = Tracks; } diff --git a/app/state_delta.zig b/app/state_delta.zig new file mode 100644 index 000000000..5caa4deb5 --- /dev/null +++ b/app/state_delta.zig @@ -0,0 +1,234 @@ +//! What one document state is, as the bytes that differ from another: how a track keeps its +//! checkpoints (`Tracks`) without a whole copy of the document each time. +//! +//! Generic over any owner's state bytes (`captureDocumentState`'s), so no owner writes anything for +//! it. Two steps, each a pass over the bytes: +//! +//! * **The common prefix and suffix are trimmed.** Typing in one place, strokes in one area, +//! leave a small middle between them that differs. +//! * **The middle,** when it is as long in both (nothing shifted: a canvas's pixels, a fixed +//! layout), is kept as the runs of `block`-byte blocks that differ, so strokes far apart are +//! a few runs rather than one span from the first to the last. When the lengths differ (text +//! inserted, which shifts what follows), it is kept as it is. +//! +//! Layout, every count LEB128: the new length, the prefix, the suffix, a mode byte, then the +//! mode's own: `raw`, the middle's bytes; `runs`, a count, then per run its gap from the last +//! run's end, its length, its bytes. +//! +//! std-only, so it tests without a window (`fizzy-state-delta-tests`). +const std = @import("std"); + +/// The unit runs are found in: smaller finds tighter runs over more of them. +pub const block = 64; + +const Mode = enum(u8) { raw, runs }; + +pub const Error = error{BadDelta} || std.mem.Allocator.Error; + +/// `new` as a delta against `ref`, allocated with `gpa`. +pub fn encode(gpa: std.mem.Allocator, ref: []const u8, new: []const u8) std.mem.Allocator.Error![]u8 { + const shortest = @min(ref.len, new.len); + // A block at a time while they agree (a memcmp each), then a byte at a time. + var prefix: usize = 0; + while (prefix + block <= shortest and std.mem.eql(u8, ref[prefix..][0..block], new[prefix..][0..block])) prefix += block; + while (prefix < shortest and ref[prefix] == new[prefix]) prefix += 1; + var suffix: usize = 0; + while (suffix + block <= shortest - prefix and std.mem.eql(u8, ref[ref.len - suffix - block ..][0..block], new[new.len - suffix - block ..][0..block])) suffix += block; + while (suffix < shortest - prefix and ref[ref.len - 1 - suffix] == new[new.len - 1 - suffix]) suffix += 1; + const a = ref[prefix .. ref.len - suffix]; + const b = new[prefix .. new.len - suffix]; + + var out: std.Io.Writer.Allocating = .init(gpa); + errdefer out.deinit(); + const w = &out.writer; + write(w, new.len, prefix, suffix) catch return error.OutOfMemory; + if (a.len != b.len) { + w.writeByte(@intFromEnum(Mode.raw)) catch return error.OutOfMemory; + w.writeAll(b) catch return error.OutOfMemory; + return out.toOwnedSlice(); + } + w.writeByte(@intFromEnum(Mode.runs)) catch return error.OutOfMemory; + // Runs of differing blocks, found in one pass and kept so their count can lead them. + var runs: std.ArrayList(Run) = .empty; + defer runs.deinit(gpa); + var it: Runs = .{ .a = a, .b = b }; + while (it.next()) |r| try runs.append(gpa, r); + w.writeUleb128(runs.items.len) catch return error.OutOfMemory; + var end: usize = 0; + for (runs.items) |r| { + w.writeUleb128(r.start - end) catch return error.OutOfMemory; + w.writeUleb128(r.len) catch return error.OutOfMemory; + w.writeAll(b[r.start..][0..r.len]) catch return error.OutOfMemory; + end = r.start + r.len; + } + return out.toOwnedSlice(); +} + +fn write(w: *std.Io.Writer, len: usize, prefix: usize, suffix: usize) !void { + try w.writeUleb128(len); + try w.writeUleb128(prefix); + try w.writeUleb128(suffix); +} + +const Run = struct { start: usize, len: usize }; + +/// The runs of `block`-byte blocks where `a` and `b`, of one length, differ. +const Runs = struct { + a: []const u8, + b: []const u8, + at: usize = 0, + + fn next(self: *Runs) ?Run { + while (self.at < self.a.len and self.same(self.at)) self.at += block; + if (self.at >= self.a.len) return null; + const start = self.at; + while (self.at < self.a.len and !self.same(self.at)) self.at += block; + return .{ .start = start, .len = @min(self.at, self.a.len) - start }; + } + + fn same(self: *const Runs, at: usize) bool { + const n = @min(block, self.a.len - at); + return std.mem.eql(u8, self.a[at..][0..n], self.b[at..][0..n]); + } +}; + +/// The state `delta` stands for against `ref`, allocated with `gpa`. A delta that does not fit +/// `ref` is `error.BadDelta`. +pub fn apply(gpa: std.mem.Allocator, ref: []const u8, delta: []const u8) Error![]u8 { + var r: std.Io.Reader = .fixed(delta); + const len = take(&r) catch return error.BadDelta; + const prefix = take(&r) catch return error.BadDelta; + const suffix = take(&r) catch return error.BadDelta; + if (prefix > ref.len or suffix > ref.len - prefix or prefix > len or suffix > len - prefix) return error.BadDelta; + const mode = std.enums.fromInt(Mode, r.takeByte() catch return error.BadDelta) orelse return error.BadDelta; + const middle = len - prefix - suffix; + const out = try gpa.alloc(u8, len); + errdefer gpa.free(out); + @memcpy(out[0..prefix], ref[0..prefix]); + @memcpy(out[len - suffix ..], ref[ref.len - suffix ..]); + switch (mode) { + .raw => { + const bytes = r.take(middle) catch return error.BadDelta; + @memcpy(out[prefix..][0..middle], bytes); + }, + .runs => { + if (ref.len != len) return error.BadDelta; + @memcpy(out[prefix..][0..middle], ref[prefix..][0..middle]); + const n = take(&r) catch return error.BadDelta; + var end: usize = 0; + for (0..n) |_| { + const gap = take(&r) catch return error.BadDelta; + const run = take(&r) catch return error.BadDelta; + if (gap > middle - end or run > middle - end - gap) return error.BadDelta; + const bytes = r.take(run) catch return error.BadDelta; + @memcpy(out[prefix + end + gap ..][0..run], bytes); + end += gap + run; + } + }, + } + if (r.seek != delta.len) return error.BadDelta; + return out; +} + +fn take(r: *std.Io.Reader) !usize { + return r.takeLeb128(usize); +} + +// ---- tests --------------------------------------------------------------------------------- + +const testing = std.testing; + +fn roundTrip(ref: []const u8, new: []const u8) !usize { + const d = try encode(testing.allocator, ref, new); + defer testing.allocator.free(d); + const back = try apply(testing.allocator, ref, d); + defer testing.allocator.free(back); + try testing.expectEqualSlices(u8, new, back); + return d.len; +} + +test "an edit in one place is a delta of about its size" { + var ref: [64 << 10]u8 = undefined; + var prng: std.Random.DefaultPrng = .init(0xde17a); + prng.random().bytes(&ref); + var new = ref; + @memcpy(new[30_000..][0..20], "twenty bytes changed"); + try testing.expect(try roundTrip(&ref, &new) < 64); + // Nothing changed: next to nothing. + try testing.expect(try roundTrip(&ref, &ref) < 16); +} + +test "text inserted shifts what follows, and the delta is still the part between the edits" { + const ref = "fn main() void {\n std.debug.print(\"hello\", .{});\n}\n" ** 100; + const new = ref[0..2000] ++ "// inserted\n" ++ ref[2000..]; + const size = try roundTrip(ref, new); + try testing.expect(size < 32); + // Two edits far apart, lengths differing: the middle between them, kept as it is. + const two = ref[0..100] ++ "x" ++ ref[100..5000] ++ "y" ++ ref[5000..]; + try testing.expect(try roundTrip(ref, two) < 5000); +} + +test "changes far apart on a canvas are a few runs, not the span between them" { + var ref: [1 << 20]u8 = @splat(0); + var new = ref; + for ([_]usize{ 1000, 400_000, 900_000 }) |at| @memset(new[at..][0..30], 0xff); + try testing.expect(try roundTrip(&ref, &new) < 4 * block * 2); +} + +test "growing, shrinking, emptying and starting from nothing" { + _ = try roundTrip("", ""); + _ = try roundTrip("", "abc"); + _ = try roundTrip("abc", ""); + _ = try roundTrip("abc", "abcdef"); + _ = try roundTrip("abcdef", "abc"); + _ = try roundTrip("aaaa", "aa"); + _ = try roundTrip("xabcx", "xx"); +} + +test "random edits round-trip" { + var prng: std.Random.DefaultPrng = .init(0x5ea1); + const r = prng.random(); + var ref: [4096]u8 = undefined; + r.bytes(&ref); + for (0..500) |_| { + var new: std.ArrayList(u8) = .empty; + defer new.deinit(testing.allocator); + try new.appendSlice(testing.allocator, &ref); + for (0..1 + r.uintLessThan(usize, 4)) |_| { + const at = r.uintLessThan(usize, new.items.len + 1); + switch (r.uintLessThan(u8, 3)) { + 0 => try new.insertSlice(testing.allocator, at, "inserted"), + 1 => new.replaceRangeAssumeCapacity(at, @min(16, new.items.len - at), ""), + else => if (at < new.items.len) { + new.items[at] +%= 1; + }, + } + } + _ = try roundTrip(&ref, new.items); + } +} + +test "a damaged delta is an error, never a crash" { + var ref: [2048]u8 = undefined; + var prng: std.Random.DefaultPrng = .init(0xbad); + const r = prng.random(); + r.bytes(&ref); + var new = ref; + @memset(new[100..][0..40], 1); + @memset(new[1500..][0..40], 2); + const d = try encode(testing.allocator, &ref, &new); + defer testing.allocator.free(d); + for (0..d.len) |n| if (apply(testing.allocator, &ref, d[0..n])) |got| { + testing.allocator.free(got); + return error.TestUnexpectedResult; + } else |err| try testing.expectEqual(error.BadDelta, err); + const copy = try testing.allocator.dupe(u8, d); + defer testing.allocator.free(copy); + for (0..4000) |_| { + @memcpy(copy, d); + copy[r.uintLessThan(usize, copy.len)] ^= @as(u8, 1) << r.int(u3); + if (apply(testing.allocator, &ref, copy)) |got| testing.allocator.free(got) else |_| {} + } + // Against the wrong reference: refused rather than misread where it can tell. + try testing.expectError(error.BadDelta, apply(testing.allocator, ref[0..100], d)); +} diff --git a/build/app.zig b/build/app.zig index 3d2b47aed..81d0e95e5 100644 --- a/build/app.zig +++ b/build/app.zig @@ -644,6 +644,9 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. // stacking and where a closed float's views go. std-only (see float_rules.zig); // `Floats.zig` and `ViewDrag.zig` apply it, covered by `tests/integration.zig`. .{ "fizzy-float-rules-tests", "app/layout/float_rules.zig" }, + // A document state as the bytes that differ from another: how a track's checkpoints + // stay small. std-only (see state_delta.zig); `Tracks` keeps its checkpoints with it. + .{ "fizzy-state-delta-tests", "app/state_delta.zig" }, // One material on two sliders: what every glass is at the window's opacity and roughness, // the OS's form and the app's. std-only (see glass_look.zig); `LiquidField` and `Popout` // apply it. @@ -1233,6 +1236,32 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. bench_step.dependOn(&run_bench.step); } + // `zig build bench-tracks` — what a document's track costs (`app.Tracks`): a step, a frame's + // top idle and with a checkpoint, a replay, what a session holds. Same rules as `bench-text`. + { + const bench_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("tests/bench/bench_tracks.zig"), + }); + bench_module.addImport("app", app_module_test); + bench_module.addImport("fizzy_sdk", sdk_module_test); + + const bench_tracks = b.addTest(.{ .name = "fizzy-bench-tracks", .root_module = bench_module }); + bench_tracks.root_module.link_libcpp = !target_is_windows_msvc; + if (target.result.os.tag == .windows) { + bench_tracks.root_module.linkSystemLibrary("comctl32", .{}); + } + if (velopack_enabled) if (vz) |v| { + try velopack.linkVelopack(b, v, bench_tracks, .{ .target = target, .optimize = optimize }); + }; + + const bench_step = b.step("bench-tracks", "Benchmark a document's track: steps, checkpoints, replays (prints timings)"); + const run_bench = b.addRunArtifact(bench_tracks); + run_bench.has_side_effects = true; + bench_step.dependOn(&run_bench.step); + } + // `zig build bench-tape` — saving and loading a tape, ZON against the binary form, at the // sizes of a demo, a recording and a long session. Same rules as `bench-text`; std-only, so // it needs no window and builds in seconds. diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 4481ff14e..e5fba8cdd 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -2031,7 +2031,7 @@ fn storeUninstalled(arena: std.mem.Allocator) []const @import("app").layout.Stat pub fn postInit(editor: *Editor) !void { // Documents' tracks, while they are behind a switch (plans/RECORDING_PLAN.md, step 3). - editor.app.tracks = .init(editor.app.gpa, &editor.app.host); + editor.app.tracks = .init(editor.app.gpa); if (Popout.envSwitch("FIZZY_TRACKS") orelse false) editor.setTracking(true); if (comptime build_opts.has_app_layout) { dvui.log.info("layout: app-supplied", .{}); diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index 5bda67e1d..e742b485d 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -176,6 +176,9 @@ const fizzy_commands = [_]FizzyCommand{ .{ .id = "fizzy.restart", .title = "Restart Fizzy", .bind = null, .run = cmdRestart, .isEnabled = canRestart, .icon = icons.tvg.lucide.@"rotate-ccw" }, .{ .id = "fizzy.reportBug", .title = "Report a Bug", .bind = null, .run = cmdReportBug, .icon = icons.tvg.lucide.bug }, .{ .id = "fizzy.capture", .title = "Capture the Last 30 Seconds", .bind = null, .run = cmdCapture, .params = CaptureArgs.params, .runWith = CaptureArgs.bind(cmdCaptureWith), .icon = icons.tvg.lucide.gauge }, + .{ .id = "fizzy.tracks.record", .title = "Tracks: Record…", .bind = null, .run = cmdTracks, .params = TracksRecord.params, .runWith = TracksRecord.bind(cmdTracksRecord), .icon = icons.tvg.lucide.gauge }, + .{ .id = "fizzy.tracks.replay", .title = "Tracks: Replay…", .bind = null, .run = cmdTracks, .params = TracksReplay.params, .runWith = TracksReplay.bind(cmdTracksReplay), .icon = icons.tvg.lucide.gauge }, + .{ .id = "fizzy.tracks", .title = "Tracks: Report", .bind = null, .run = cmdTracks, .params = TracksArgs.params, .runWith = TracksArgs.bind(cmdTracksWith), .icon = icons.tvg.lucide.gauge }, .{ .id = "fizzy.writeTrace", .title = "Write Trace", .bind = null, .run = cmdWriteTrace, .params = TraceArgs.params, .runWith = TraceArgs.bind(cmdWriteTraceTo), .icon = icons.tvg.lucide.gauge }, } ++ open_folder_commands ++ demo_commands ++ automation_commands; @@ -584,6 +587,71 @@ fn cmdCaptureWith(state: *anyopaque, args: CaptureArgs.Args, call: *sdk.Command. try call.returns(written); } +const TracksArgs = sdk.Command.Params(struct {}); +const TracksRecord = sdk.Command.Params(struct { + on: sdk.Command.Arg(bool, .{ .description = "Record documents' tracks, or stop. Open documents start a track now." }), +}); + +const TracksReplay = sdk.Command.Params(struct { + from: sdk.Command.Arg([]const u8, .{ .description = "The open document whose track to replay, by its path." }), + into: sdk.Command.Arg([]const u8, .{ .description = "The open document to replay it into, by its path: one of the same owner, which records none of it." }), + steps: sdk.Command.Arg(u32, .{ .description = "How many of the track's steps; 0 for all of them." }) = .init(0), +}); + +/// Put one open document where another's track was after so many steps: its nearest state, then +/// the steps since (`Tracks.replayInto`). How an agent checks a track replays, and how a person +/// sees one played into a copy. +fn cmdTracksReplay(state: *anyopaque, args: TracksReplay.Args, call: *sdk.Command.Call) anyerror!void { + const editor = editorFromState(state); + const t = &(editor.app.tracks orelse return call.fail("no tracks in this app", .{})); + const from = editor.docFromPath(args.from) orelse return call.fail("no open document at {s}", .{args.from}); + const into = editor.docFromPath(args.into) orelse return call.fail("no open document at {s}", .{args.into}); + const track = t.trackOf(from.id) orelse return call.fail("{s} has no track", .{args.from}); + const n = if (args.steps == 0) track.steps.items.len else args.steps; + t.replayInto(from.id, into, n) catch |err| return call.fail("the replay stopped: {t}", .{err}); + try call.returns(.{ .steps = @min(n, track.steps.items.len), .checkpoints = track.checkpoints.items.len }); +} + +fn cmdTracksRecord(state: *anyopaque, args: TracksRecord.Args, call: *sdk.Command.Call) anyerror!void { + const editor = editorFromState(state); + editor.setTracking(args.on); + try call.returns(.{ .on = args.on }); +} + +/// What the documents' tracks hold (`app.Tracks`): whether they record, what every track holds +/// against the cap, how many frames did any of their work, and each track's steps, checkpoints +/// and bytes. Devtools' view of them, and an agent's. +fn cmdTracks(state: *anyopaque) anyerror!void { + const t = &(editorFromState(state).app.tracks orelse return); + dvui.log.info("tracks: {d} open, {d} ended, {d} KB of {d} KB", .{ t.open.count(), t.ended.items.len, t.held >> 10, t.cap >> 10 }); +} + +fn cmdTracksWith(state: *anyopaque, _: TracksArgs.Args, call: *sdk.Command.Call) anyerror!void { + const editor = editorFromState(state); + const t = &(editor.app.tracks orelse return call.fail("no tracks in this app", .{})); + const arena = editor.app.host.arena(); + const rows = try arena.alloc(TrackRow, t.open.count() + t.ended.items.len); + for (t.open.values(), 0..) |track, i| rows[i] = .of(track, true, editor); + for (t.ended.items, t.open.count()..) |track, i| rows[i] = .of(track, false, editor); + try call.returns(.{ .on = t.on, .held = t.held, .cap = t.cap, .worked = t.worked, .tracks = rows }); +} + +/// A track as `fizzy.tracks` reports it. An ended track's document is gone, so it has no path. +const TrackRow = struct { + doc_id: u64, + path: []const u8, + owner: []const u8, + open: bool, + steps: usize, + checkpoints: usize, + bytes: usize, + + fn of(track: *const @import("app").Tracks.Track, open: bool, editor: *Editor) TrackRow { + const path = if (!open) "" else if (editor.app.docById(track.doc_id)) |d| d.owner.documentPath(d) else ""; + return .{ .doc_id = track.doc_id, .path = path, .owner = track.owner.id, .open = open, .steps = track.steps.items.len, .checkpoints = track.checkpoints.items.len, .bytes = track.size() }; + } +}; + const TraceArgs = sdk.Command.Params(struct { path: sdk.Command.Arg([]const u8, .{ .description = "Where to write the trace (JSON). Empty writes it in the config folder's traces/, named by the time." }) = .init(""), ms: sdk.Command.Arg(u32, .{ .description = "Only the spans that ended in the last this many milliseconds; 0 for every one kept." }) = .init(0), diff --git a/tests/bench/bench_tracks.zig b/tests/bench/bench_tracks.zig new file mode 100644 index 000000000..b2170a9dd --- /dev/null +++ b/tests/bench/bench_tracks.zig @@ -0,0 +1,153 @@ +//! `zig build bench-tracks` — what a document's track costs (`app.Tracks`): a step recorded, a +//! frame's top with nothing asked of it and with a checkpoint, a replay, and what a session holds. +//! +//! The owner is a stand-in whose document is a plain buffer: taking its state is a copy, applying +//! a step writes the step's bytes in. So this measures the tracks' own work around an owner's, +//! not text's or pixi's encoding (`plans/RECORDING_PLAN.md`; those are measured where they are +//! built). Prints rather than asserts; compare runs at the same `-Doptimize`. +const std = @import("std"); +const sdk = @import("fizzy_sdk"); +const Tracks = @import("app").Tracks; + +fn nowNs() i96 { + return std.Io.Clock.boot.now(std.testing.io).nanoseconds; +} + +/// The bench's own frame clock: a step every 50 ms of it. +var clock_ns: i128 = 0; +fn clock() i128 { + return clock_ns; +} + +/// A document that is a buffer. +var doc_bytes: []u8 = &.{}; + +const Owner = struct { + fn capture(_: *anyopaque, _: sdk.DocHandle, allocator: std.mem.Allocator) anyerror![]u8 { + return allocator.dupe(u8, doc_bytes); + } + fn restore(_: *anyopaque, _: sdk.DocHandle, bytes: []const u8) anyerror!void { + @memcpy(doc_bytes[0..bytes.len], bytes); + } + fn fingerprint(_: *anyopaque, _: sdk.DocHandle) u64 { + return std.hash.Wyhash.hash(0, doc_bytes); + } + fn apply(_: *anyopaque, _: sdk.DocHandle, effect: []const u8, _: []const u8, _: u32) anyerror!void { + const at = std.mem.readInt(u32, effect[0..4], .little) % (doc_bytes.len - effect.len); + @memcpy(doc_bytes[at..][0..effect.len], effect); + } +}; + +var owner_state: u8 = 0; +const owner_vtable: sdk.Plugin.VTable = .{ + .captureDocumentState = Owner.capture, + .restoreDocumentState = Owner.restore, + .documentFingerprint = Owner.fingerprint, + .applyStep = Owner.apply, +}; +var owner: sdk.Plugin = .{ .state = &owner_state, .vtable = &owner_vtable, .id = "bench", .display_name = "Bench", .step_format = 1 }; + +fn doc(id: u64) sdk.DocHandle { + return .{ .ptr = &owner_state, .owner = &owner, .id = id }; +} + +/// Record `n` steps of `size` bytes each (a keystroke's ~24, a stroke's ~200), taking the +/// checkpoints they ask for at the next frame's top as the app does. +fn record(tracks: *Tracks, n: usize, size: usize) void { + var effect: [4096]u8 = undefined; + var prng: std.Random.DefaultPrng = .init(0x7ac5); + prng.random().bytes(&effect); + const rec = tracks.recorder(); + for (0..n) |i| { + std.mem.writeInt(u32, effect[0..4], @intCast(i * 7919), .little); + rec.vtable.step(rec.ctx, 1, .{ .kind = .added, .id = i + 1, .effect = effect[0..size] }); + clock_ns += 50 * std.time.ns_per_ms; + tracks.tick(); + } +} + +fn fresh(doc_size: usize) !Tracks { + std.testing.allocator.free(doc_bytes); + doc_bytes = try std.testing.allocator.alloc(u8, doc_size); + @memset(doc_bytes, 0); + var tracks: Tracks = .init(std.testing.allocator); + tracks.clock = clock; + tracks.on = true; + tracks.opened(doc(1)); + tracks.tick(); + return tracks; +} + +const runs = 5; + +test "bench tracks: a step, a frame's top, a replay, what a session holds" { + defer std.testing.allocator.free(doc_bytes); + + std.debug.print("\n{s:>8} {s:>9} {s:>9} | {s:>9} {s:>11} {s:>11} | {s:>9} {s:>12}\n", .{ + "steps", "step B", "doc KB", "ns/step*", "checkpoints", "held KB", "replay ms", "per step", + }); + for ([_]struct { n: usize, step: usize, doc: usize }{ + .{ .n = 2_000, .step = 24, .doc = 64 << 10 }, + .{ .n = 2_000, .step = 200, .doc = 256 << 10 }, + .{ .n = 20_000, .step = 200, .doc = 256 << 10 }, + .{ .n = 20_000, .step = 200, .doc = 8 << 20 }, + }) |c| { + var best_step: i96 = std.math.maxInt(i96); + var best_replay: i96 = std.math.maxInt(i96); + var checkpoints: usize = 0; + var held: usize = 0; + var replayed: usize = 0; + for (0..runs) |_| { + var tracks = try fresh(c.doc); + defer tracks.deinit(); + const t0 = nowNs(); + record(&tracks, c.n, c.step); + best_step = @min(best_step, nowNs() - t0); + const track = tracks.trackOf(1).?; + checkpoints = track.checkpoints.items.len; + held = tracks.held; + // A seek to just before the last checkpoint: the furthest a replay has to go, from the + // state before it. + const cps = track.checkpoints.items; + const to = if (cps.len > 0) cps[cps.len - 1].step - 1 else track.steps.items.len; + const from_step = if (track.stateBefore(to)) |st| st.step else 0; + replayed = to - from_step; + const t1 = nowNs(); + try tracks.replayInto(1, doc(2), to); + best_replay = @min(best_replay, nowNs() - t1); + } + const ns_step = @as(f64, @floatFromInt(best_step)) / @as(f64, @floatFromInt(c.n)); + const replay_ms = @as(f64, @floatFromInt(best_replay)) / std.time.ns_per_ms; + std.debug.print("{d:>8} {d:>9} {d:>9} | {d:>9.0} {d:>11} {d:>11} | {d:>9.3} {d:>9.0} ns\n", .{ + c.n, c.step, c.doc >> 10, ns_step, checkpoints, held >> 10, replay_ms, replay_ms * std.time.ns_per_ms / @as(f64, @floatFromInt(@max(replayed, 1))), + }); + } + + // One checkpoint at a frame's top, by the document's size: the copy a frame pays for it. + for ([_]usize{ 64 << 10, 1 << 20, 8 << 20 }) |size| { + var best: i96 = std.math.maxInt(i96); + for (0..runs) |_| { + var tracks = try fresh(size); + defer tracks.deinit(); + const rec = tracks.recorder(); + var effect: [8]u8 = @splat(1); + for (0..Tracks.checkpoint_max_steps) |i| rec.vtable.step(rec.ctx, 1, .{ .kind = .added, .id = i + 1, .effect = &effect }); + std.debug.assert(tracks.due); + const t0 = nowNs(); + tracks.tick(); + best = @min(best, nowNs() - t0); + } + std.debug.print("a checkpoint of a {d} KB document at a frame's top: {d:.3} ms\n", .{ size >> 10, @as(f64, @floatFromInt(best)) / std.time.ns_per_ms }); + } + + std.debug.print("* each step with its share of the checkpoints it asks for\n", .{}); + + // A frame's top with nothing asked of it: what every frame pays. + var tracks = try fresh(64 << 10); + defer tracks.deinit(); + const idle_frames = 1_000_000; + const t0 = nowNs(); + for (0..idle_frames) |_| tracks.tick(); + const idle_ns = @as(f64, @floatFromInt(nowNs() - t0)) / idle_frames; + std.debug.print("a frame's top with nothing asked: {d:.2} ns\n", .{idle_ns}); +} diff --git a/tests/integration.zig b/tests/integration.zig index 1280b0f9e..07eeb2ee0 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7367,6 +7367,22 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes const tb = tracks.trackOf(b.id) orelse return error.TestUnexpectedResult; try std.testing.expectEqual(@as(usize, 0), tb.steps.items.len); + // A frame nothing asked of does no tracks work: an idle app, recording, costs a branch. + const worked = tracks.worked; + for (0..120) |_| { + dvui.refresh(null, @src(), null); + _ = try dvui.testing.step(headlessFrame); + } + try std.testing.expectEqual(worked, tracks.worked); + // What they hold, as devtools and an agent read it. + const report = switch (host.callCommand("fizzy.tracks", "", host.arena())) { + .ok => |r| r orelse return error.TestUnexpectedResult, + else => return error.TestUnexpectedResult, + }; + try std.testing.expect(std.mem.indexOf(u8, report, ".on = true") != null); + const steps_row = try std.fmt.allocPrint(host.arena(), ".steps = {d}", .{n}); + try std.testing.expect(std.mem.indexOf(u8, report, steps_row) != null); + // Closed, a document's track is kept for the session. try editor.rawCloseFileID(b.id); try editor.rawCloseFileID(a.id); From fdb212ee782fea27c9b4eddff47716b6ada39b64 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:26:02 -0500 Subject: [PATCH 26/46] layout: a document lands in any place as a view would (#462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #260 (step 3 of `plans/WORKSPACES_PLAN.md`). ## What changes Behind `FIZZY_WORKSPACES=1`, a document is no longer limited to the workbench's panes: carried onto any place, it lands there inside a new workspace, the way a view would land. - **An edge splits the place.** A tab, or a file carried out of the tree, dropped on the edge of the Panel, the Sidebar or any other place splits it on that side; the new half is a workspace holding the document. A file that wasn't open opens straight into it, and what was carried with it (a selection out of the tree) goes too. - **The middle lands it as a view.** An empty place takes the new workspace. A place of tabs (the Panel) adds it as a tab and shows it. A place that shows one view gives the place to the workspace, and the view it replaces goes back to wherever its keywords show it. (A view would trade places with the dropped one, but a document's pane can't take a view.) The middle bubble shows add/replace as it does for views. - **A place's tab strip takes it where it's let go**, as another tab. - **A pane's only tab dropped on its own edge moves there.** Room opens on the other side, as for a view alone in its place: drop it on the right and it sits on the right with an empty place on its left. Before, it moved into a new pane, and the old one closed into it by a round trip that left a stray empty half (below). - **A lone tab carries its workspace.** A document that is its workspace's only tab takes the workspace along: dropped on a plain place, the workspace itself lands there (`State.Documents.alone`), as a drag of the workspace would. So a tab moved right and dropped back on the empty left half's middle joins the halves into one place again, and nothing is drawn anew. Before, a new workspace was made round the document and the old one closed behind it; that drew the document anew twice, and it blinked. A view joined with an empty half now stays the one view rather than becoming a place of tabs (`ViewDrag.join`). - **The app seats documents.** A document no pane holds goes into the workspace it was opened toward. This was the workbench's `rebuildWorkspaces`; it's now fizzy's `Workspaces.seat`. Grouping 0 means the active workspace once workspace 0 has gone, as the plan says. - **Fix: a split half made and closed in the same frame is removed.** `ViewDrag.closeMinted` returned early for a place it couldn't find drawn, so a half opened and emptied before its first draw stood empty for good. Now it's dropped outright, as its comment intended. How: the layout gets one seam, `State.documents` (`house(ids) → view id`). The app makes a view holding the documents, and the layout places it like any view it lands (`ViewDrag.house`, `insertView`). Fizzy's `Workspaces` fills it in and registers the view at once, so the place can select it in the same frame. The workbench gets `Places.open` for the lone-tab case. Step 5's tear-off will hand the same view to a float. With the flag off, `documents` and `places` are null, and the only change is the `closeMinted` fix. ## SDK impact - [x] None (`test-sdk-version` passes). ## Verified - [x] macOS: a sandbox profile run with `FIZZY_WORKSPACES=1`, driven through the agent plugin's `fizzy_act` with screenshots: - `b.txt` onto the Panel's right drop → Panel split, the new half a workspace showing `b.txt`; closing it slid the half shut; - `b.txt` onto the Panel's middle → a `B.TXT` tab beside Output, shown; - `c.txt` onto the Panel's strip between Output and Devtools → a `C.TXT` tab there; closing `c.txt` took that tab away and left the Panel's others; - a single tab onto its own pane's right drop → an empty place on the left, the tab on the right; - with two tabs, one dropped on its pane's right drop → its own workspace on the right, as before. - `zig build`, `zig build test`, `zig build test-integration` (53/53 steps), `zig build check-web`, `zig build test-sdk-version`, `scripts/check-fmt.sh`. - [ ] Windows: - [ ] Linux: - [ ] Web: builds; the flag is never on there. ## Follow-ups - The tab strip, close flow and active workspace are still the workbench's; they move with step 6, when its copy goes. `app/layout/SPLITS.md`'s "a document goes only to a document's slot" is updated then too, since it stays true with the flag off. - For the frame a tab is dropped, the pane it left can still draw that document before it catches up. Seen once in a screenshot; not fixed here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- app/layout/State.zig | 16 +++ app/layout/ViewDrag.zig | 117 +++++++++++++++++++-- plugins/workbench/src/Workbench.zig | 3 + plugins/workbench/src/Workspace.zig | 18 ++++ plugins/workbench/src/workbench_layout.zig | 5 +- src/editor/Workspaces.zig | 99 ++++++++++++++++- 6 files changed, 245 insertions(+), 13 deletions(-) diff --git a/app/layout/State.zig b/app/layout/State.zig index 8c2040bef..dcd4cc2a9 100644 --- a/app/layout/State.zig +++ b/app/layout/State.zig @@ -37,6 +37,19 @@ pub const StoreOffer = struct { title: []const u8, }; +/// How an application gives a document a home outside the slots its plugins made: a view of its +/// own that holds documents (fizzy's workspaces). With one, a document carried in the view drag +/// lands in any place as a view would, inside a new such view (`ViewDrag.place`). +pub const Documents = struct { + ctx: *anyopaque, + /// A new view holding the documents `ids`, the first shown — a document not open yet opened + /// in it — for the layout to place like any view it lands. Its id, or null when none went in. + house: *const fn (ctx: *anyopaque, ids: []const []const u8) ?[]const u8, + /// The view holding document `id` and nothing else, if one does: carried alone, the + /// document carries that view, which lands as itself rather than inside a new one. + alone: *const fn (ctx: *anyopaque, id: []const u8) ?[]const u8, +}; + pub const StoreCatalog = struct { uninstalled: *const fn (arena: std.mem.Allocator) []const StoreOffer, install: *const fn (id: []const u8) void, @@ -83,6 +96,9 @@ picker: Picker = .{}, /// Filled in by the application when it has a plugin store. Null means the picker only lists /// loaded surfaces — tests, and an app that never switched the store on. store_catalog: ?StoreCatalog = null, +/// Filled in by the application when documents can have homes outside their slots. Null means a +/// document goes only into a slot made for it. +documents: ?Documents = null, /// Region waiting for a store install to finish so its new surfaces can be assigned. Empty /// while nothing is pending. gpa-owned. pending_store_region: []const u8 = "", diff --git a/app/layout/ViewDrag.zig b/app/layout/ViewDrag.zig index 517a3f00b..612835c91 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -848,8 +848,9 @@ fn mapTargets(l: *Layout, d: *ViewDrag) void { // takes any view — so none shows its zones. if (std.mem.eql(u8, d.moved_id, held_id)) return; const surface_kw = draggedKeywords(l); - // A tab's content goes to a slot made for it, never a plain place (`Layout.slotted`). - const slotted = slottedId(l, d.moved_id); + // A tab's content goes to a slot made for it, never into a plain place (`Layout.slotted`) — + // only beside one, where the application gives it a home (`State.documents`). + const slotted = slottedId(l, d.moved_id) and l.state.documents == null; // Last frame's registry: complete, where this frame's is still being // filled in around the click that started the drag. const places = if (l.state.regions.items.len > 0) @@ -2242,10 +2243,16 @@ fn dropOnPluginChooser(l: *Layout, source: []const u8, dest: []const u8, mouse: /// that went in. pub fn insertInto(l: *Layout, source: []const u8, dest: []const u8, at: Insert) void { const moved = ownId(l.arena, movedFrom(l, source) orelse return) orelse return; - if (l.host.surfaceById(moved) == null) return; const r = regionNamed(l.state, dest) orelse return; if (!accepts(r.*, keywordsOf(l, moved))) return; - if (!r.kind_slot and slottedId(l, moved)) return; + // A document — open, or a file out of the tree — along a plain place's chooser goes in there + // inside a new view holding it (`house`). + if (!r.kind_slot and slottedId(l, moved)) { + if (l.state.documents == null) return; + const view = houseView(l, moved) orelse return; + return insertView(l, dest, view, at); + } + if (l.host.surfaceById(moved) == null) return; const assigned = l.state.assignment(dest); const held = holding(l, dest); const along = containsId(held, moved); @@ -2279,6 +2286,22 @@ pub fn insertInto(l: *Layout, source: []const u8, dest: []const u8, at: Insert) dvui.refresh(null, @src(), null); } +/// Put `view`, new and in no place yet, into `dest`'s list at `at`, and show it there. +fn insertView(l: *Layout, dest: []const u8, view: []const u8, at: Insert) void { + var list: std.ArrayListUnmanaged([]const u8) = .empty; + list.appendSlice(l.arena, holding(l, dest)) catch return; + const index = switch (at) { + .before => |id| indexOfId(list.items, id) orelse list.items.len, + .after => |id| if (indexOfId(list.items, id)) |i| i + 1 else list.items.len, + .end => list.items.len, + }; + list.insert(l.arena, index, view) catch return; + l.state.assign(l.gpa, dest, list.items) catch return; + selectNamed(l, dest, view); + l.state.markDirty(); + dvui.refresh(null, @src(), null); +} + /// What was carried with the view in hand that region `r` takes too (`with`), for its drop /// (`sdk.RegionSpec.Drop.others`): the rest of a selection lifted together, in its order. fn others(l: *Layout, r: Region) []const []const u8 { @@ -2329,7 +2352,7 @@ pub fn place(l: *Layout, source: []const u8, dest: []const u8, kind: Drop.Kind) if (!has_surface and !unopened(l, moved)) return; if (regionNamed(l.state, dest)) |r| { if (!accepts(r.*, keywordsOf(l, moved))) return; - if (!r.kind_slot and slottedId(l, moved)) return; + if (!r.kind_slot and slottedId(l, moved)) return house(l, dest, plan, moved); // A plugin's region is asked first: it makes its own places, so a split of it is // something only it can do (`RegionSpec.on_drop`). if (r.on_drop) |on_drop| { @@ -2376,6 +2399,79 @@ pub fn place(l: *Layout, source: []const u8, dest: []const u8, kind: Drop.Kind) dvui.refresh(null, @src(), null); } +/// A document carried onto a plain place, which only a slot made for it takes: it lands there +/// inside a new view of the application's that holds it, and what was carried with it +/// (`State.documents`), as a view carried there would land — an edge splits the place and the +/// new half holds it; the middle adds it to a place of tabs, and takes a place of one, the view +/// it replaces let go where its keywords show it (a document's slot is no place to send it back +/// to). Where the document came from is the slot's plugin's to tidy: its tab moves, as a drop +/// into another slot moves it. +fn house(l: *Layout, dest: []const u8, plan: Drop.Plan, moved: []const u8) void { + if (carriedView(l, moved)) |carried| return landView(l, carried.from, dest, plan, carried.view); + const place_name = switch (plan) { + .split => |sp| Region.splitOn(l, dest, sp.mint) orelse return, + .swap, .join => dest, + .remove, .float => return, + }; + const view = houseView(l, moved) orelse { + if (plan == .split) closeMinted(l, place_name); + return; + }; + const many = if (regionNamed(l.state, place_name)) |r| l.state.showsOf(place_name, r.shows) == .many else false; + const list = if (many and plan != .split) idsWith(l.arena, holding(l, place_name), view) else l.arena.dupe([]const u8, &.{view}) catch return; + l.state.assign(l.gpa, place_name, list) catch return; + selectNamed(l, place_name, view); + l.state.markDirty(); + dvui.refresh(null, @src(), null); +} + +/// The view a document carried alone carries with it (`State.Documents.alone`), and the place it +/// is in: the only tab of its workspace takes the workspace along, so the drop moves that, as +/// a drag of the workspace itself would, rather than making a new one round the document and +/// leaving the old to close — a round trip that drew the document anew twice, and blinked. +fn carriedView(l: *Layout, moved: []const u8) ?struct { view: []const u8, from: []const u8 } { + const docs = l.state.documents orelse return null; + if (l.state.view_drag.with_n > 1) return null; + const view = ownId(l.arena, docs.alone(docs.ctx, moved) orelse return null) orelse return null; + const from = ownId(l.arena, holderOf(l, view) orelse return null) orelse return null; + return .{ .view = view, .from = from }; +} + +/// Land view `view`, shown in place `from`, on `dest` as `plan` says — what `place` does with a +/// view carried out of its place, for one a document carried along (`carriedView`). +fn landView(l: *Layout, from: []const u8, dest: []const u8, plan: Drop.Plan, view: []const u8) void { + switch (plan) { + .split => |sp| { + const new = Region.splitOn(l, dest, sp.mint) orelse return; + l.state.assign(l.gpa, new, &.{view}) catch {}; + selectNamed(l, new, view); + takeOut(l, from, view, null); + }, + .swap, .join => if (std.mem.eql(u8, from, dest)) + return + else if (joins(l, from, dest)) + join(l, from, dest, view) + else + swap(l, from, dest, view), + .remove, .float => return, + } + shutIfEmptied(l, from); + l.state.markDirty(); + dvui.refresh(null, @src(), null); +} + +/// The application's new view holding `moved` and what was carried with it (`State.documents`). +fn houseView(l: *Layout, moved: []const u8) ?[]const u8 { + const docs = l.state.documents orelse return null; + var ids: std.ArrayListUnmanaged([]const u8) = .empty; + ids.append(l.arena, moved) catch return null; + const d = &l.state.view_drag; + if (d.active() and d.with_n > 1) for (d.with[1..d.with_n]) |it| { + if (it.id.len > 0 and slottedId(l, it.id)) ids.append(l.arena, ownId(l.arena, it.id) orelse continue) catch break; + }; + return ownId(l.arena, docs.house(docs.ctx, ids.items) orelse return null); +} + /// Whether the view carried out of `source` floats when it is dropped on the middle of `source` /// (`float_rules.canFloat`): not out of the picker, not a document — its place is the slot a /// plugin made for it — and not a view already alone in a float nobody split. @@ -2680,7 +2776,8 @@ fn join(l: *Layout, source: []const u8, dest: []const u8, moved: []const u8) voi } } if (!containsId(ids.items, moved)) ids.append(l.arena, moved) catch {}; - l.state.setShows(l.gpa, pair.keep, .many); + // As tabs only for more than one: a view joined with an empty half is the one view. + if (ids.items.len > 1) l.state.setShows(l.gpa, pair.keep, .many); l.state.assign(l.gpa, pair.keep, ids.items) catch {}; l.state.assign(l.gpa, pair.drop, &.{}) catch {}; selectNamed(l, pair.keep, moved); @@ -2862,13 +2959,13 @@ fn closeMinted(l: *Layout, name: []const u8) void { return; } } - const r = regionNamed(l.state, name) orelse return; - if (r.id != .zero and Split.sizeOf(r.id) > 0) { + if (regionNamed(l.state, name)) |r| if (r.id != .zero and Split.sizeOf(r.id) > 0) { Split.close(r.id); l.extents_changed = true; return; - } - // Never drawn at a size, so there is nothing to slide: drop it outright. + }; + // Never drawn at a size — or not drawn at all yet, made this frame — so there is nothing to + // slide: drop it outright. if (l.state.splits.collapse(l.gpa, name)) { if (l.state.clearExtent(l.gpa, name)) l.extents_changed = true; l.state.unassign(l.gpa, name); diff --git a/plugins/workbench/src/Workbench.zig b/plugins/workbench/src/Workbench.zig index a1ae8ba29..6029b8270 100644 --- a/plugins/workbench/src/Workbench.zig +++ b/plugins/workbench/src/Workbench.zig @@ -98,6 +98,9 @@ pub const Places = struct { placed: *const fn (ctx: *anyopaque, grouping: u64) bool, /// `grouping`'s pane has emptied and is going: its place closes, into the one beside it. close: *const fn (ctx: *anyopaque, grouping: u64) void, + /// Open an empty place on `side` of the place holding `grouping`'s pane, the pane staying + /// where it is: a pane's only tab dropped on its own edge, as a view alone in its place is. + open: *const fn (ctx: *anyopaque, grouping: u64, side: Panes.Side) bool, }; pub const Loading = struct { diff --git a/plugins/workbench/src/Workspace.zig b/plugins/workbench/src/Workspace.zig index 8a03198dc..2b7762dba 100644 --- a/plugins/workbench/src/Workspace.zig +++ b/plugins/workbench/src/Workspace.zig @@ -721,6 +721,15 @@ pub fn insertTab(self: *Workspace, id: []const u8, index: usize) void { pub fn paneDrop(ctx: ?*anyopaque, drop: sdk.RegionSpec.Drop) bool { const grouping: u64 = @as(u64, @intFromPtr(ctx orelse return false)) - 1; const wb = runtime.workbench(); + // A pane's only tab let go on the pane's own edge goes to that side as the pane, with room + // opening on the other: where the app places the panes, the place splits the way it does for + // a view alone in its place. Moved into a new pane there instead, the one it left closed + // behind it — the same tab and room, by a round trip. + if (wb.places) |p| if (drop.zone == .edge and drop.others.len == 0) { + if (wb.workspaces.getPtr(grouping)) |own| if (own.tabCount() == 1 and own.hasTab(drop.surface_id)) { + return p.open(p.ctx, grouping, paneSide(opposite(drop.zone.edge))); + }; + }; const target = switch (drop.zone) { .center => grouping, .edge => |side| blk: { @@ -776,6 +785,15 @@ fn openHere(self: *Workspace, id: []const u8, path: []const u8, at: ?usize) bool return started; } +fn opposite(side: sdk.RegionSpec.Drop.Side) sdk.RegionSpec.Drop.Side { + return switch (side) { + .left => .right, + .right => .left, + .top => .bottom, + .bottom => .top, + }; +} + fn paneSide(side: sdk.RegionSpec.Drop.Side) core.widgets.DockLayout.Side { return switch (side) { .left => .left, diff --git a/plugins/workbench/src/workbench_layout.zig b/plugins/workbench/src/workbench_layout.zig index 300d3ba1f..f1c4998a1 100644 --- a/plugins/workbench/src/workbench_layout.zig +++ b/plugins/workbench/src/workbench_layout.zig @@ -51,9 +51,10 @@ pub fn rebuildWorkspaces(wb: *Workbench) !void { } // A document nobody holds lands in the pane it was opened toward: the grouping the app - // stamped on it, which is the "open to the side" answer or the current pane. + // stamped on it, which is the "open to the side" answer or the current pane. Where the app + // arranges the panes, it seats them too (fizzy's `Workspaces.seat`). var i: usize = 0; - while (i < host.openDocCount()) : (i += 1) { + while (wb.places == null and i < host.openDocCount()) : (i += 1) { const doc = host.docByIndex(i) orelse continue; const id = try sdk.document.surfaceId(arena, doc.owner.id, doc.owner.documentPath(doc)); var held = false; diff --git a/src/editor/Workspaces.zig b/src/editor/Workspaces.zig index 0d8ae36ab..51f60b644 100644 --- a/src/editor/Workspaces.zig +++ b/src/editor/Workspaces.zig @@ -43,7 +43,9 @@ pub fn wanted(gpa: std.mem.Allocator) bool { /// Hand the workbench the app's places to arrange its panes in. Before the workbench registers, /// so it knows not to offer its one surface of every pane, and with `self` at its final address. pub fn install(self: *Workspaces, wb: *Workbench) void { - wb.places = .{ .ctx = self, .beside = beside, .placed = placed, .close = close }; + wb.places = .{ .ctx = self, .beside = beside, .placed = placed, .close = close, .open = open }; + // A document carried beside a view, rather than into a pane, opens a workspace there. + self.editorOf().app.layout.documents = .{ .ctx = self, .house = house, .alone = alone }; } pub fn deinit(self: *Workspaces, gpa: std.mem.Allocator) void { @@ -74,6 +76,8 @@ pub fn sync(self: *Workspaces) void { const gpa = editor.app.gpa; const host = &editor.app.host; + self.seat(); + var i: usize = 0; while (i < self.views.count()) { const g = self.views.keys()[i]; @@ -118,6 +122,35 @@ pub fn sync(self: *Workspaces) void { } } +/// Every open document in a workspace: one that no pane holds — its load has just landed, or +/// its plugin came back with it — goes into the workspace it was opened toward, the grouping the +/// app stamped on it ("Open to the Side"'s, or the active workspace's). A tab under another +/// owner's id for the same file (a plugin installed since last session) is replaced where it was. +fn seat(self: *Workspaces) void { + const editor = self.editorOf(); + const wb = &editor.workbench; + const host = &editor.app.host; + var i: usize = 0; + while (i < host.openDocCount()) : (i += 1) { + const doc = host.docByIndex(i) orelse continue; + const path = doc.owner.documentPath(doc); + const id = sdk.document.surfaceId(host.arena(), doc.owner.id, path) catch continue; + const held = for (wb.workspaces.values()) |*ws| { + if (ws.hasTab(id)) break true; + } else false; + if (held) continue; + var stale_in: ?u64 = null; + for (wb.workspaces.values()) |*ws| { + if (ws.removeTabsForPath(path)) stale_in = ws.grouping; + } + // Grouping 0 is the one the person is in, once the first workspace has gone. + const want = doc.owner.documentGrouping(doc); + const toward = if (want == 0 and !wb.workspaces.contains(0)) wb.open_workspace_grouping else want; + const ws = wb.pane(stale_in orelse toward) catch continue; + ws.addTab(id, false); + } +} + fn register(self: *Workspaces, grouping: u64) !void { const editor = self.editorOf(); const gpa = editor.app.gpa; @@ -176,6 +209,70 @@ fn beside(ctx: *anyopaque, grouping: u64, anchor: u64, side: Workbench.Panes.Sid return true; } +/// A new workspace holding the documents `ids` (`Layout.State.documents`), for the layout to +/// place as it lands any view: each moved out of the pane it was in, or opened in it when it is +/// not open yet, the first shown. Its view is registered at once, so the place it lands in can +/// select it this frame. Null, and no workspace, when none went in. +fn house(ctx: *anyopaque, ids: []const []const u8) ?[]const u8 { + const self: *Workspaces = @ptrCast(@alignCast(ctx)); + const editor = self.editorOf(); + const wb = &editor.workbench; + const host = &editor.app.host; + const g = wb.newGroupingID(); + const gop = wb.workspaces.getOrPut(wb.allocator, g) catch return null; + if (!gop.found_existing) gop.value_ptr.* = Editor.Workspace.init(g); + // Empty until a load lands, not emptied: the rebuild keeps it. + gop.value_ptr.expecting = true; + + var landed = false; + // The first last, so it is the one shown: a document shows as it goes in. + var i = ids.len; + while (i > 0) { + i -= 1; + const id = ids[i]; + if (host.surfaceById(id) == null) { + const path = sdk.document.pathOfSurfaceId(id) orelse continue; + landed = (host.openFile(.{ .path = path, .grouping = g }) catch false) or landed; + continue; + } + for (wb.workspaces.values()) |*ws| if (ws.grouping != g) ws.removeTab(id); + const ws = wb.workspaces.getPtr(g) orelse return null; + ws.addTab(id, true); + landed = true; + } + if (!landed) { + if (wb.workspaces.getPtr(g)) |ws| ws.deinit(); + _ = wb.workspaces.orderedRemove(g); + return null; + } + self.register(g) catch return null; + return self.views.get(g).?.id; +} + +fn open(ctx: *anyopaque, grouping: u64, side: Workbench.Panes.Side) bool { + const self: *Workspaces = @ptrCast(@alignCast(ctx)); + var l = layoutOf(self.editorOf()); + var buf: [64]u8 = undefined; + const place = Layout.ViewDrag.holderOf(&l, viewId(&buf, grouping)) orelse return false; + return Layout.splitOn(&l, place, switch (side) { + .left => .left, + .right => .right, + .top => .top, + .bottom => .bottom, + }) != null; +} + +/// The view of the workspace whose only tab is document `id`, if one is. +fn alone(ctx: *anyopaque, id: []const u8) ?[]const u8 { + const self: *Workspaces = @ptrCast(@alignCast(ctx)); + for (self.editorOf().workbench.workspaces.values()) |*ws| { + if (ws.tabCount() != 1 or !ws.hasTab(id)) continue; + const v = self.views.get(ws.grouping) orelse return null; + return v.id; + } + return null; +} + fn placed(ctx: *anyopaque, grouping: u64) bool { const self: *Workspaces = @ptrCast(@alignCast(ctx)); if (self.assigned(grouping)) return true; From 6b527e73ea0c212942befbbfcfb199e079d74ae7 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:15:30 -0500 Subject: [PATCH 27/46] layout: the region picker groups views by the place they are made for (#466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changes The region picker (the corner button's popup) listed every view a place could show in one flat grid, in registry order, so nothing said which views were made for the explorer and which for the panel. Each card now sits under the place its keywords make it for: 1. the place being edited, under its own name (opened from the Panel: "Panel" first); 2. each other place the shape declared, by its name ("Sidebar", "Main", …); 3. "Other": views whose keywords no place accepts; 4. the Store, as before. The fit is `sdk.keywords.strength`, the same rule the layout binds a surface by, so the header a card sits under is where its keywords would put it. Ties go to the place being edited. Places are one per keyword group (the rail and the explorer it chooses for are one place), and document panes are never a section. A filter ranks within each section and hides empty ones. Headers are the shape's region names, not fixed words, so a copied shape (studio's "Stack"/"Strip") gets its own without any change here. The Split submenu's Vertical and Horizontal rows, which left their icon column blank, now show the split they make: `columns-2` (side by side) and `rows-2` (stacked). ## SDK impact - [x] None ## Verified - [x] macOS: unit test for the section rule (`Picker.zig`); integration test opening the picker headless from the Panel and then the Sidebar, asserting card order by their `picker:` anchors. All gates pass locally (fmt, build, test, test-integration 53/53 steps, check-web, test-sdk-version). Not yet looked at on screen: the popup needs the window focused. - [ ] Windows: - [ ] Linux: - [ ] Web: ## Follow-ups None planned. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- app/layout/Layout.zig | 1 + app/layout/Picker.zig | 98 +++++++++++++++++++++++++++++++++++++++---- tests/integration.zig | 84 +++++++++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+), 9 deletions(-) diff --git a/app/layout/Layout.zig b/app/layout/Layout.zig index db18ba16a..f85619d14 100644 --- a/app/layout/Layout.zig +++ b/app/layout/Layout.zig @@ -926,6 +926,7 @@ test { _ = @import("Region.zig"); _ = @import("Drop.zig"); _ = @import("ViewDrag.zig"); + _ = @import("Picker.zig"); } /// Declare a region — the verb form of `Region.init`, so a shape writes `f.region(...)` beside /// `f.split(...)` and never names the type. Same arrangement as `dvui.box` over `BoxWidget.init`. diff --git a/app/layout/Picker.zig b/app/layout/Picker.zig index efa7b7040..6553951e5 100644 --- a/app/layout/Picker.zig +++ b/app/layout/Picker.zig @@ -11,6 +11,9 @@ //! Cards assign a surface here. That is how a sidebar view stays in the //! sidebar, and how a plugin readme lands in the center — keywords are the //! default guess, an assignment is the answer. "Back to defaults" undoes it. +//! The cards are listed under the place their keywords are for — this place +//! first, then each other place by its name, then "Other" — so a view made for +//! the panel reads as one, wherever the picker was opened. //! //! A card is also a handle. Dragged out of the popup it becomes the same //! floating view the corner button lifts out of a place (`ViewDrag`), and @@ -28,6 +31,7 @@ const std = @import("std"); const dvui = @import("dvui"); const core = @import("core"); const sdk = @import("fizzy_sdk"); +const icons = @import("icons"); const Split = core.widgets.Split; const Layout = @import("Layout.zig"); const State = @import("State.zig"); @@ -207,14 +211,14 @@ pub fn draw(self: *Picker, f: *Layout) void { .frost = core.widgets.menuFrost(), }, core.widgets.menuSurfaceOptions()); defer choices.deinit(); - if (core.widgets.menuRow(@src(), "Vertical", .{}) != null) { + if (core.widgets.menuRow(@src(), "Vertical", .{ .icon = icons.tvg.lucide.@"columns-2" }) != null) { choices.close(); f.splitNamed(region.name, .horizontal); self.close(gpa); state.discardSnapshots(gpa); return; } - if (core.widgets.menuRow(@src(), "Horizontal", .{ .id_extra = 1 }) != null) { + if (core.widgets.menuRow(@src(), "Horizontal", .{ .icon = icons.tvg.lucide.@"rows-2", .id_extra = 1 }) != null) { choices.close(); f.splitNamed(region.name, .vertical); self.close(gpa); @@ -269,11 +273,24 @@ pub fn draw(self: *Picker, f: *Layout) void { var row: ?*dvui.BoxWidget = null; defer if (row) |r| r.deinit(); var col: usize = 0; - for (offered(f, ®ion, &query)) |ranked| { + const places = placesFor(f, ®ion); + const ranked_all = offered(f, ®ion, &query); + // Each card under the place its keywords make it for, this one first, so what belongs in + // the explorer and what in the panel reads off the headers; a query ranks within each. + var current: ?usize = null; + for (0..places.len + 1) |section| for (ranked_all) |ranked| { // `i` is the surface's place in the registry, not in this list: a card keeps its widget // id while the filter reorders what is around it. const i = ranked.item; const s = &f.host.surfaces.items[i]; + if ((sectionOf(places, s.keywords) orelse places.len) != section) continue; + if (current != section) { + current = section; + if (row) |r| r.deinit(); + row = null; + col = 0; + sectionHeader(if (section < places.len) places[section].name else "Other", section); + } if (col == 0) { if (row) |r| r.deinit(); row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .id_extra = i, .expand = .horizontal }); @@ -330,7 +347,7 @@ pub fn draw(self: *Picker, f: *Layout) void { } state.markDirty(); dvui.refresh(null, @src(), null); - } + }; if (state.store_catalog) |store| drawStoreSection(f, ®ion, store, &row, &col, &query); } @@ -350,6 +367,73 @@ fn offered(f: *Layout, region: *const Layout.Region, query: *const core.fuzzy.Qu return out.items; } +/// A place a card can be listed under: a region's name and the keywords it accepts. +const Place = struct { + name: []const u8, + keywords: []const []const u8, +}; + +/// The places the picker sorts cards under: `region` itself first, then each other kind of place +/// the shape declared, in its order. One per keyword group — the icon rail and the explorer it +/// chooses for are one place — and never a document pane, which offers only documents. +fn placesFor(f: *Layout, region: *const Layout.Region) []const Place { + var out: std.ArrayListUnmanaged(Place) = .empty; + out.append(f.arena, .{ .name = region.name, .keywords = region.keywords }) catch return &.{}; + for (f.state.regions.items) |*r| { + if (r.name.len == 0 or r.kind_slot) continue; + const key = sdk.keywords.groupKey(r.keywords); + const seen = for (out.items) |p| { + if (sdk.keywords.groupKey(p.keywords) == key) break true; + } else false; + if (!seen) out.append(f.arena, .{ .name = r.name, .keywords = r.keywords }) catch break; + } + return out.items; +} + +/// The place a surface is made for: the one accepting its keywords most strongly, the first of +/// equals — `places[0]`, the place being edited, when it is among them. Null when none accepts it. +/// The same fit `Layout` binds by (`sdk.keywords.strength`), so the header a card sits under is +/// where its keywords would put it. +fn sectionOf(places: []const Place, keywords: []const []const u8) ?usize { + var best: ?usize = null; + var best_fit: sdk.keywords.Fit = .none; + for (places, 0..) |p, i| { + const fit = sdk.keywords.strength(p.keywords, keywords); + if (@intFromEnum(fit) > @intFromEnum(best_fit)) { + best = i; + best_fit = fit; + } + } + return best; +} + +test "a view is listed under the place its keywords are for, the edited place first" { + const kw = sdk.keywords.ide; + const places = [_]Place{ + .{ .name = "Panel", .keywords = kw.panel }, + .{ .name = "Sidebar", .keywords = kw.sidebar }, + .{ .name = "Main", .keywords = kw.main }, + }; + try std.testing.expectEqual(@as(?usize, 1), sectionOf(&places, &.{"explorer"})); + try std.testing.expectEqual(@as(?usize, 0), sectionOf(&places, &.{"output"})); + // Made for both: the place being edited wins the tie. + try std.testing.expectEqual(@as(?usize, 0), sectionOf(&places, &.{ "sidebar", "panel" })); + // A specific sub-place still lands under the area that holds it. + try std.testing.expectEqual(@as(?usize, 2), sectionOf(&places, &.{"main.document"})); + try std.testing.expectEqual(@as(?usize, null), sectionOf(&places, &.{"inspector"})); + try std.testing.expectEqual(@as(?usize, null), sectionOf(&places, &.{})); +} + +/// A heading over a run of cards — a place's name, or the store. +fn sectionHeader(title: []const u8, id_extra: usize) void { + dvui.labelNoFmt(@src(), title, .{}, .{ + .id_extra = id_extra, + .font = dvui.Font.theme(.heading), + .padding = .{ .y = 10, .x = 4 }, + .color_text = .{ .color = dvui.themeGet().color(.window, .text).opacity(0.6) }, + }); +} + /// Where a view comes from, as its card says it: the plugin's name, or "Built-in" for one the app /// itself registered (Output, Settings). Not the app's own name — this picker is the framework's, /// and an app built on it is not fizzy. @@ -746,11 +830,7 @@ fn drawStoreSection( } col.* = 0; - dvui.labelNoFmt(@src(), "Store", .{}, .{ - .font = dvui.Font.theme(.heading), - .padding = .{ .y = 10, .x = 4 }, - .color_text = .{ .color = dvui.themeGet().color(.window, .text).opacity(0.6) }, - }); + sectionHeader("Store", 10_000); for (offers.items, 0..) |offer, i| { if (col.* == 0) { diff --git a/tests/integration.zig b/tests/integration.zig index 07eeb2ee0..6389726de 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -2431,6 +2431,90 @@ test "a split before a region that hides itself is not drawn, and nothing draws try std.testing.expectEqual(@as(usize, 2), editor.app.layout.regions.items.len); } +/// Sidebar beside Main over Panel, with the picker drawn over them as fizzy's frame does. +const PickerFrame = struct { + var editor: ?*fizzy.Editor = null; + + fn draw(_: ?*anyopaque) anyerror!dvui.App.Result { + return .ok; + } + + fn frame() anyerror!dvui.App.Result { + const e = editor.?; + fizzy.core.anchor.want(); + var layout = fizzy.Editor.Layout.init(&e.app.host, &e.app.layout, e.app.gpa, dvui.currentWindow().arena()); + { + var content = try layout.region(@src(), .{ .dir = .horizontal }, .{ .expand = .both }); + defer content.deinit(); + { + var sidebar = try layout.region(@src(), .{ .name = "Sidebar", .keywords = sdk.keywords.ide.sidebar, .shows = .many }, .{ .min_size_content = .{ .w = 200 }, .expand = .vertical }); + defer sidebar.deinit(); + } + layout.split(@src(), .{}); + { + var right = try layout.region(@src(), .{ .dir = .vertical }, .{ .expand = .both }); + defer right.deinit(); + { + var main = try layout.region(@src(), .{ .name = "Main", .keywords = sdk.keywords.ide.main }, .{ .expand = .both }); + defer main.deinit(); + } + layout.split(@src(), .{}); + { + var panel = try layout.region(@src(), .{ .name = "Panel", .keywords = sdk.keywords.ide.panel, .shows = .many }, .{ .min_size_content = .{ .h = 200 }, .expand = .horizontal }); + defer panel.deinit(); + } + } + } + e.app.layout.publishRegions(); + layout.captureUnplaced(); + e.app.layout.picker.draw(&layout); + return .ok; + } +}; + +test "the picker lists each view under the place it is made for, the open place first" { + var ctx = try shim.init(std.testing.allocator); + defer ctx.deinit(std.testing.allocator); + + const editor = ctx.editor; + editor.app.gpa = std.testing.allocator; + defer editor.app.layout.regions.deinit(editor.app.gpa); + defer editor.app.layout.regions_building.deinit(editor.app.gpa); + defer editor.app.layout.deinitQualified(editor.app.gpa); + defer editor.app.layout.deinitAssignments(editor.app.gpa); + defer editor.app.layout.deinitExtents(editor.app.gpa); + defer editor.app.layout.picker.close(editor.app.gpa); + defer editor.app.layout.discardSnapshots(editor.app.gpa); + + try editor.app.host.registerSurface(.{ .id = "test.files", .title = "Files", .keywords = sdk.keywords.ide.sidebar, .draw = PickerFrame.draw }); + try editor.app.host.registerSurface(.{ .id = "test.output", .title = "Output", .keywords = sdk.keywords.ide.panel, .draw = PickerFrame.draw }); + try editor.app.host.registerSurface(.{ .id = "test.misc", .title = "Misc", .keywords = &.{"inspector"}, .draw = PickerFrame.draw }); + + PickerFrame.editor = editor; + defer PickerFrame.editor = null; + try dvui.testing.settle(PickerFrame.frame); + + const y = struct { + fn of(id: []const u8) !f32 { + var buf: [64]u8 = undefined; + return (try dvui.testing.tagGet(try std.fmt.bufPrint(&buf, "picker:{s}", .{id}))).rect.y; + } + }.of; + + // From the panel: its own view first, then the sidebar's, then the one no place asks for. + // Stepped, not settled: the open popup keeps asking for frames. + editor.app.layout.openPicker(editor.app.gpa, "Panel", null); + for (0..4) |_| _ = try dvui.testing.step(PickerFrame.frame); + try std.testing.expect(try y("test.output") < try y("test.files")); + try std.testing.expect(try y("test.files") < try y("test.misc")); + + // From the sidebar, the sidebar's view leads instead. + editor.app.layout.openPicker(editor.app.gpa, "Sidebar", null); + for (0..4) |_| _ = try dvui.testing.step(PickerFrame.frame); + try std.testing.expect(try y("test.files") < try y("test.output")); + try std.testing.expect(try y("test.output") < try y("test.misc")); +} + test "a hide_when_empty panel stays while its view is dragged onto main" { var ctx = try shim.init(std.testing.allocator); defer ctx.deinit(std.testing.allocator); From 68f6f9706887af5e08273c3188b47c6872b9d74d Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:33:32 -0500 Subject: [PATCH 28/46] layout: the picker's buttons are named for tapes and agents (#468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changes A tape or an agent can aim at the picker's controls by name, not by position: | Name | What | |---|---| | `picker.mode:one`, `picker.mode:many` | Single / Multiple | | `picker.action:split`, `:clear`, `:defaults`, `:remove` | the actions, each only while it can be pressed (a dimmed Clear has no name) | | `picker.split:vertical`, `picker.split:horizontal` | the rows of the Split menu | These sit beside the cards' existing `picker:`, which is now in `docs/AUTOMATION.md`'s anchor table along with the new names. **Why.** The row's buttons come and go with the place. Remove sits where Split sat on a place with nothing to Clear, so a point read off one place's picker can land on a different action in the next. While driving splits through the agent plugin for fizzyedit/fizzy#467, a click aimed at Split hit Remove twice and closed the place. **How.** - `modeButton` and `actionButton` draw through `namedButton`: `dvui.button`'s body with a `core.anchor.mark`, since `dvui.button` doesn't hand back its widget. - `core.widgets.menuRow` gets an `anchor` option for a row that runs no command (a command row is already `fizzy.command:`). - A dimmed action (Clear with nothing to clear, Defaults with no list) publishes no name: it swallows the click, so `wait_for` / `expect` on a name means the action can run. - `core.anchor.mark` costs nothing unless someone asked for anchors this frame. ## SDK impact - [x] Core-only or additive: `MenuRowOptions.anchor` reaches plugins at the next SDK release (`test-sdk-version` passes; the fingerprint didn't move). ## Verified - [x] macOS: integration test `picker: its controls are named for a tape or an agent to aim at, while they can be pressed` (anchors wanted, the picker drawn over a place: Single, Multiple and Split are named; a dimmed Clear and a shape place's absent Remove are not). - [x] macOS: sandbox fizzy (`--profile`), driven through the agent plugin's `fizzy_run` by name only: - opened Main's picker: `fizzy_screen` lists `picker.mode:one`, `picker.mode:many`, `picker.action:split`, `picker.action:clear` and `picker.action:defaults`; - clicked `picker.action:split`, and the menu listed `picker.split:vertical` and `picker.split:horizontal`; - clicked `picker.split:vertical`, and Main split into Main | Main/r1; - opened Main/r1's picker, `expect`ed `picker.action:remove`, clicked it, and Main was whole again. - `scripts/check-fmt.sh`, `zig build`, `zig build test` (93/93 steps), `zig build test-integration` (53/53 steps), `zig build check-tests-reached`, `zig build check-web`, `zig build test-sdk-version`, `scripts/check-examples.sh`. - [ ] Windows: - [ ] Linux: - [ ] Web: builds ## Follow-ups - `namedButton` repeats `dvui.button`'s body to reach the widget's data, because `dvui.button` doesn't hand it back. A `dvui.button` variant in the dvui-dev fork that exposes it would keep one body (`docs/DEPENDENCIES.md`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- app/layout/Picker.zig | 42 +++++++++++++++++++++++++++++++----------- core/widgets.zig | 4 ++++ docs/AUTOMATION.md | 4 ++++ tests/integration.zig | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 11 deletions(-) diff --git a/app/layout/Picker.zig b/app/layout/Picker.zig index 6553951e5..85ec3ce74 100644 --- a/app/layout/Picker.zig +++ b/app/layout/Picker.zig @@ -160,10 +160,10 @@ pub fn draw(self: *Picker, f: *Layout) void { .padding = .{ .y = 4 }, }); defer controls.deinit(); - if (modeButton(@src(), "Single", region.shows == .one, 1)) { + if (modeButton(@src(), "Single", "picker.mode:one", region.shows == .one, 1)) { setShows(f, ®ion, .one); } - if (modeButton(@src(), "Multiple", region.shows == .many, 2)) { + if (modeButton(@src(), "Multiple", "picker.mode:many", region.shows == .many, 2)) { setShows(f, ®ion, .many); } @@ -189,6 +189,7 @@ pub fn draw(self: *Picker, f: *Layout) void { .style = .control, }); const split_from = split.activeRect(); + core.anchor.mark(split.data(), "picker.action:split", .{}); { var label = dvui.box(@src(), .{ .dir = .horizontal }, .{}); defer label.deinit(); @@ -211,14 +212,14 @@ pub fn draw(self: *Picker, f: *Layout) void { .frost = core.widgets.menuFrost(), }, core.widgets.menuSurfaceOptions()); defer choices.deinit(); - if (core.widgets.menuRow(@src(), "Vertical", .{ .icon = icons.tvg.lucide.@"columns-2" }) != null) { + if (core.widgets.menuRow(@src(), "Vertical", .{ .icon = icons.tvg.lucide.@"columns-2", .anchor = "picker.split:vertical" }) != null) { choices.close(); f.splitNamed(region.name, .horizontal); self.close(gpa); state.discardSnapshots(gpa); return; } - if (core.widgets.menuRow(@src(), "Horizontal", .{ .icon = icons.tvg.lucide.@"rows-2", .id_extra = 1 }) != null) { + if (core.widgets.menuRow(@src(), "Horizontal", .{ .icon = icons.tvg.lucide.@"rows-2", .id_extra = 1, .anchor = "picker.split:horizontal" }) != null) { choices.close(); f.splitNamed(region.name, .vertical); self.close(gpa); @@ -228,17 +229,17 @@ pub fn draw(self: *Picker, f: *Layout) void { } } if (!created) { - if (actionButton(@src(), "Clear", showing, 2)) { + if (actionButton(@src(), "Clear", "picker.action:clear", showing, 2)) { clearRegion(f, region.name); } - if (actionButton(@src(), "Defaults", assigned != null, 4)) { + if (actionButton(@src(), "Defaults", "picker.action:defaults", assigned != null, 4)) { state.unassign(gpa, region.name); state.markDirty(); dvui.refresh(null, @src(), null); } } if (removable) { - if (actionButton(@src(), "Remove", true, 3)) { + if (actionButton(@src(), "Remove", "picker.action:remove", true, 3)) { removeRegion(f, ®ion); self.close(gpa); state.discardSnapshots(gpa); @@ -690,9 +691,9 @@ fn setShows(f: *Layout, region: *const Layout.Region, shows: Layout.Region.Shows dvui.refresh(null, @src(), null); } -fn modeButton(src: std.builtin.SourceLocation, label: []const u8, on: bool, id_extra: usize) bool { +fn modeButton(src: std.builtin.SourceLocation, label: []const u8, name: []const u8, on: bool, id_extra: usize) bool { const theme = dvui.themeGet(); - return dvui.button(src, label, .{}, .{ + return namedButton(src, label, name, .{ .font = actionFont(), .padding = .{ .x = 6, .y = 2, .w = 6, .h = 2 }, .margin = .{ .x = 4 }, @@ -709,9 +710,11 @@ fn actionFont() dvui.Font { /// One of the picker's actions. Disabled, it keeps its place (dimmed, no hover, never clicked) /// rather than leaving a gap the row closes up over. -fn actionButton(src: std.builtin.SourceLocation, label: []const u8, enabled: bool, id_extra: usize) bool { +fn actionButton(src: std.builtin.SourceLocation, label: []const u8, name: []const u8, enabled: bool, id_extra: usize) bool { const theme = dvui.themeGet(); - const clicked = dvui.button(src, label, .{}, .{ + // Named only while it can be pressed: a dimmed one swallows the click, and a driver waiting + // for the name should wait for the action, not for the button. + const clicked = namedButton(src, label, if (enabled) name else null, .{ .font = actionFont(), .padding = .{ .x = 6, .y = 2, .w = 6, .h = 2 }, .margin = .{ .w = 4 }, @@ -724,6 +727,23 @@ fn actionButton(src: std.builtin.SourceLocation, label: []const u8, enabled: boo return enabled and clicked; } +/// `dvui.button`, named `name` (when there is one) for a demo or an agent to aim at (`core.anchor`, `picker.*` in +/// docs/AUTOMATION.md). The row's buttons come and go with the place — Remove sits where Split +/// sat on a place with nothing to Clear — so a point read off one place's picker can land on +/// another action in the next; a name cannot. +fn namedButton(src: std.builtin.SourceLocation, label: []const u8, name: ?[]const u8, opts: dvui.Options) bool { + var bw: dvui.ButtonWidget = undefined; + bw.init(src, .{}, opts); + defer bw.deinit(); + if (name) |n| core.anchor.mark(bw.data(), "{s}", .{n}); + bw.processEvents(); + bw.drawBackground(); + const clicked = bw.clicked(); + dvui.labelNoFmt(@src(), label, .{ .align_x = 0.5, .align_y = 0.5 }, opts.strip().override(bw.style()).override(.{ .gravity_x = 0.5, .gravity_y = 0.5 })); + bw.drawFocus(); + return clicked; +} + fn contains(list: []const *sdk.Surface, id: []const u8) bool { for (list) |s| if (std.mem.eql(u8, s.id, id)) return true; return false; diff --git a/core/widgets.zig b/core/widgets.zig index ac6f1c45d..189beffde 100644 --- a/core/widgets.zig +++ b/core/widgets.zig @@ -141,6 +141,9 @@ pub const MenuRowOptions = struct { /// The command the row runs, if it names one: the row is then a demo's to point at, as /// `fizzy.command:` (`core.anchor`). command: ?[]const u8 = null, + /// A name of its own for a row that runs no command, for a demo or an agent to aim at + /// (`core.anchor`). + anchor: ?[]const u8 = null, }; /// One menu row, the same everywhere: an icon column, the label straight after it, and the @@ -153,6 +156,7 @@ pub fn menuRow(src: std.builtin.SourceLocation, label: []const u8, opts: MenuRow var mi = menuItem(src, .{ .submenu = opts.submenu }, menuRowOptions(.{ .expand = .horizontal, .id_extra = opts.id_extra })); const ret: ?dvui.Rect.Natural = if (opts.enabled) mi.activeRect() else null; if (opts.command) |id| anchor.mark(mi.data(), "fizzy.command:{s}", .{id}); + if (opts.anchor) |name| anchor.mark(mi.data(), "{s}", .{name}); // Closed before `mi`: it is `mi`'s child, and dvui's widget stack is strictly LIFO. var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .id_extra = opts.id_extra }); diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 0472208a7..1367a1f0e 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -152,6 +152,10 @@ user's motion settings, timed in demo time. | `fizzy.window:main` | the main window | | `fizzy.window:dialog:`, `fizzy.window:float:<title>`, `fizzy.window:menu` | an OS window of the app's own: a dialog's, a popped-out view's, a menu's (a second of the same name gets `:2`) | | `region:<name>` | a layout region (`Main`, `Panel`, …) | +| `picker:<view id>` | a view's card in a place's picker | +| `picker.mode:one`, `picker.mode:many` | the picker's Single / Multiple | +| `picker.action:split`, `:clear`, `:defaults`, `:remove` | the picker's actions, each only while it can be pressed (a dimmed Clear has no name) | +| `picker.split:vertical`, `picker.split:horizontal` | the rows of the picker's Split menu | A demo's files live under its keyframe's `root`, so a path is `demo://tour/src/main.zig`. `src/editor/demos/catalog.zig` has helpers (`catalog.file(s, "src/main.zig")`) that build these. diff --git a/tests/integration.zig b/tests/integration.zig index 6389726de..72da1d10e 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -2797,6 +2797,38 @@ const FizzySplitFrame = struct { } }; +test "picker: its controls are named for a tape or an agent to aim at, while they can be pressed" { + var ctx = try shim.init(std.testing.allocator); + defer ctx.deinit(std.testing.allocator); + + const editor = ctx.editor; + editor.app.gpa = std.testing.allocator; + defer editor.app.layout.regions.deinit(editor.app.gpa); + defer editor.app.layout.regions_building.deinit(editor.app.gpa); + defer editor.app.layout.deinitExtents(editor.app.gpa); + defer editor.app.layout.deinitAssignments(editor.app.gpa); + defer editor.app.layout.deinitQualified(editor.app.gpa); + defer editor.app.layout.picker.close(editor.app.gpa); + + PickerFrame.editor = editor; + defer PickerFrame.editor = null; + + try dvui.testing.settle(PickerFrame.frame); + editor.app.layout.picker.open(editor.app.gpa, "Main", null); + _ = try dvui.testing.step(PickerFrame.frame); + _ = try dvui.testing.step(PickerFrame.frame); + + for ([_][]const u8{ "picker.mode:one", "picker.mode:many", "picker.action:split" }) |name| { + if (dvui.tagGet(name) == null) { + std.debug.print("no anchor {s}\n", .{name}); + return error.TestExpectedEqual; + } + } + // Main shows nothing, so Clear is dimmed and has no name; a shape's place has no Remove. + try std.testing.expect(dvui.tagGet("picker.action:clear") == null); + try std.testing.expect(dvui.tagGet("picker.action:remove") == null); +} + test "a fizzy Main split is a removable slot, not main.slot" { var ctx = try shim.init(std.testing.allocator); defer ctx.deinit(std.testing.allocator); From ebada7e2d6da72920deca2b217ed22e8b7486946 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:33:53 -0500 Subject: [PATCH 29/46] layout: a split of a carded place opens even halves (#465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while chasing the uneven nested split reported in fizzyedit/fizzy#464's follow-ups. ## What changes A split of a carded place now opens two even halves. Before, the new half was short by the card's padding on both sides. That covers fizzy's Main and Panel, and every split of their halves. At 2× on macOS: | | before | after | |---|---|---| | Main → Split → Vertical | Main 886 px, Main/r1 854 px | 870, 870 | | then Main/r1 → Split → Horizontal | Main/r1 494 px, Main/r1/b1 462 px | 478, 478 | **Why.** A split's extent is the new half's whole reach, border to border, padding included (`Region.init`). `splitOn` halved the place's recorded `size`, which was its *content* rect. `State.setPlaceMetrics` now records the border box, and it takes the box's `WidgetData`, so all three call sites measure it the same way. A split is the only thing that reads `size` (`placeSize`, and a drag's frozen `Target.size`). **The suspected cause was not it.** #464 suspected the `.branch` arm: it sizes the branch by `extent(b.origin)`, the same name the kept leaf has. But the kept origin is always the leftover half of its own branch (`resize = false`), so it never writes an extent under that name. Inside one tree, `extent(name)` always means the minted leaf's reach along its parent's axis, before and after that leaf is split again. Persistence round-trips too: the nested split came back at the same sizes after a restart. **The 1:3 in #464 comes from its own carry-over.** I reproduced it on #464's head (f927a7f8) with its recipe: a `panes.zon` of `Pane 0` | (`Pane 4` over `Pane 5`), relaunched with `FIZZY_WORKSPACES=1`. Main/r1 came out 248 px over Main/r1/b1 708 px. The stored extent for Main/r1/b1 was 354 pt, which is exactly (718 − 10) × 0.5. 718 pt is Main's content height *without* the 236 pt Panel under it. So `settle` measures Main on a frame before the Panel has its room, and hands that height to `splitOnSized`. That fix belongs in #464. ## SDK impact - [x] None (`test-sdk-version` passes; `app/` only) ## Verified - [x] macOS: - new integration test `a split of a carded place, and of its new half, opens even halves`. It uses fizzy's shape: a carded Main with `pane_cards`, in a column, and compares the halves' drawn rects, not the `size` this PR redefines. Before the fix it failed at 187 vs 171. - sandbox fizzy (`--profile`, driven through the agent plugin): Main → Split → Vertical, then Main/r1 → Split → Horizontal, measured as in the table. - `scripts/check-fmt.sh`, `zig build`, `zig build test` (91/91 steps), `zig build test-integration` (53/53 steps), `zig build check-tests-reached`, `zig build check-web`, `zig build test-sdk-version`. - [ ] Windows: - [ ] Linux: - [ ] Web: builds ## Follow-ups - #464: measure Main once the layout under it has settled (or from the window), not on the first frame `settle` runs. Its `splitOnSized` gets the reach fix from this PR for free. - Removing a half that was split again: fixed in fizzyedit/fizzy#467, stacked on this one. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- app/layout/Region.zig | 12 +++----- app/layout/State.zig | 10 ++++-- app/layout/ViewDrag.zig | 2 +- tests/integration.zig | 67 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 11 deletions(-) diff --git a/app/layout/Region.zig b/app/layout/Region.zig index d269ef9aa..6d72020b3 100644 --- a/app/layout/Region.zig +++ b/app/layout/Region.zig @@ -68,7 +68,8 @@ resize: bool = false, forget_when_empty: bool = false, /// How this place lays its children. dir: dvui.enums.Direction = .vertical, -/// This frame's content size, in points. A menu split halves one axis. +/// This frame's border box, in its parent's units (points, unless something above it scales): +/// the reach a split of it halves along one axis. size: dvui.Size = .{}, /// This frame's border in physical pixels, for hit-testing a view drag. bounds: dvui.Rect.Physical = .{}, @@ -580,8 +581,7 @@ pub fn init(self: *Layout, src: std.builtin.SourceLocation, init_opts: InitOptio const box = dvui.box(src, .{ .dir = init_opts.dir }, box_opts); if (init_opts.resize) Split.recordEdges(id, box.data(), axis); if (init_opts.name.len > 0) { - const cr = box.data().contentRect(); - self.state.setPlaceMetrics(init_opts.name, .{ .w = cr.w, .h = cr.h }, box.data().borderRectScale().r); + self.state.setPlaceMetrics(init_opts.name, box.data()); core.anchor.mark(box.data(), "region:{s}", .{init_opts.name}); } self.containers[self.depth] = .{ @@ -1304,8 +1304,7 @@ fn drawTreeNode( .forget_when_empty = true, .dir = b.dir, }); - const cr = box.data().contentRect(); - self.state.setPlaceMetrics(b.origin, .{ .w = cr.w, .h = cr.h }, box.data().borderRectScale().r); + self.state.setPlaceMetrics(b.origin, box.data()); } self.containers[self.depth] = .{ .dir = b.dir, @@ -1354,8 +1353,7 @@ pub fn fillInBox(self: *Layout, init_opts: InitOptions, box: *dvui.BoxWidget) !v .dir = init_opts.dir, }); - const cr = box.data().contentRect(); - self.state.setPlaceMetrics(init_opts.name, .{ .w = cr.w, .h = cr.h }, box.data().borderRectScale().r); + self.state.setPlaceMetrics(init_opts.name, box.data()); if (self.depth >= Layout.max_nesting) return; self.containers[self.depth] = .{ diff --git a/app/layout/State.zig b/app/layout/State.zig index dcd4cc2a9..e75ca4058 100644 --- a/app/layout/State.zig +++ b/app/layout/State.zig @@ -807,13 +807,17 @@ fn forgetWidgetSize(id: dvui.Id) void { dvui.dataRemove(null, id, "_chooser_press"); } -pub fn setPlaceMetrics(self: *State, name: []const u8, size: dvui.Size, bounds: dvui.Rect.Physical) void { +/// Where place `name` was drawn this frame, from the box it was drawn in: its border box, in +/// points and in physical pixels. The border box, not the content: a split's extent is the whole +/// reach of the place it opens, padding included, so a split measured from the content rect gave +/// a carded place's new half the card's padding twice over. +pub fn setPlaceMetrics(self: *State, name: []const u8, data: *const dvui.WidgetData) void { var i = self.regions_building.items.len; while (i > 0) { i -= 1; if (!std.mem.eql(u8, self.regions_building.items[i].name, name)) continue; - self.regions_building.items[i].size = size; - self.regions_building.items[i].bounds = bounds; + self.regions_building.items[i].size = data.borderRect().size(); + self.regions_building.items[i].bounds = data.borderRectScale().r; return; } } diff --git a/app/layout/ViewDrag.zig b/app/layout/ViewDrag.zig index 612835c91..d72fa1110 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -225,7 +225,7 @@ pub const Target = struct { /// Interned, so it outlives the frame the map was taken on. name: []const u8, bounds: dvui.Rect.Physical, - /// Content size in points, for the extent a landing split settles at. + /// Border-box size in points (`Region.size`), for the extent a landing split settles at. size: dvui.Size, /// The window it is drawn in (`Region.layer`): 0 the main window, `n` the `n`th float. layer: u16 = 0, diff --git a/tests/integration.zig b/tests/integration.zig index 72da1d10e..728f32c54 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -2913,6 +2913,73 @@ test "a fizzy Main split keeps the workspace on the leftover side" { try std.testing.expectEqual(@as(usize, 0), created.len); } +/// fizzy's own Main: a carded place that hands its card to its panes, in a column of the window. +const ColumnCardFrame = struct { + var editor: ?*fizzy.Editor = null; + + fn frame() anyerror!dvui.App.Result { + const e = editor.?; + var layout = fizzy.Editor.Layout.init(&e.app.host, &e.app.layout, e.app.gpa, dvui.currentWindow().arena()); + layout.card = .{ .background = true, .padding = .all(8) }; + { + var column = try layout.region(@src(), .{ .dir = .vertical }, .{ .expand = .both }); + defer column.deinit(); + var main = try layout.region(@src(), .{ .name = "Main", .keywords = fizzy.sdk.keywords.ide.main, .card = true, .pane_cards = true }, .{ .expand = .both }); + defer main.deinit(); + } + e.app.layout.publishRegions(); + return .ok; + } + + /// What the place takes on screen: its border box in physical pixels, measured where it was + /// drawn rather than read back from the `size` a split halves. + fn sizeOf(e: *fizzy.Editor, name: []const u8) !dvui.Size { + for (e.app.layout.regions.items) |r| { + if (std.mem.eql(u8, r.name, name)) return .{ .w = r.bounds.w, .h = r.bounds.h }; + } + return error.TestExpectedEqual; + } +}; + +// A split's extent is the new half's whole reach, card and all. Measured from the place's content +// instead, every split of a carded place came out uneven by the card's padding, twice over. +test "a split of a carded place, and of its new half, opens even halves" { + var ctx = try shim.init(std.testing.allocator); + defer ctx.deinit(std.testing.allocator); + + const editor = ctx.editor; + editor.app.gpa = std.testing.allocator; + defer editor.app.layout.regions.deinit(editor.app.gpa); + defer editor.app.layout.regions_building.deinit(editor.app.gpa); + defer editor.app.layout.deinitExtents(editor.app.gpa); + defer editor.app.layout.deinitAssignments(editor.app.gpa); + defer editor.app.layout.deinitQualified(editor.app.gpa); + + ColumnCardFrame.editor = editor; + defer ColumnCardFrame.editor = null; + + try dvui.testing.settle(ColumnCardFrame.frame); + { + var layout = fizzy.Editor.Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, dvui.currentWindow().arena()); + try std.testing.expectEqualStrings("Main/r1", layout.splitOn("Main", .right) orelse return error.TestExpectedEqual); + } + try dvui.testing.settle(ColumnCardFrame.frame); + const left = try ColumnCardFrame.sizeOf(editor, "Main"); + const right = try ColumnCardFrame.sizeOf(editor, "Main/r1"); + try std.testing.expect(left.w > 40); + try std.testing.expectApproxEqAbs(left.w, right.w, 1); + + { + var layout = fizzy.Editor.Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, dvui.currentWindow().arena()); + try std.testing.expectEqualStrings("Main/r1/b1", layout.splitOn("Main/r1", .bottom) orelse return error.TestExpectedEqual); + } + try dvui.testing.settle(ColumnCardFrame.frame); + const top = try ColumnCardFrame.sizeOf(editor, "Main/r1"); + const bottom = try ColumnCardFrame.sizeOf(editor, "Main/r1/b1"); + try std.testing.expect(top.h > 40); + try std.testing.expectApproxEqAbs(top.h, bottom.h, 1); +} + test "a view-drag split opens on the dropped edge" { var ctx = try shim.init(std.testing.allocator); defer ctx.deinit(std.testing.allocator); From 8a8eac7bda8210125431c8fe555a43207f2c55df Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:33:54 -0500 Subject: [PATCH 30/46] layout: removing a half that was split again keeps its column (#467) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on fizzyedit/fizzy#465 (it reuses that PR's `ColumnCardFrame` test frame); review after it, or retarget to `main` once it merges. This is the follow-up #465 named. ## What changes Take Main | (Main/r1 over Main/r1/b1). Emptying **Main/r1** (carrying its last view to the trash or elsewhere), or picking **Remove** on it, now closes just that half. b1's views move into Main/r1, and the column keeps its name, its width and its link in `layout.zon`. That's the same rule as emptying the half that was split anywhere else (SPLITS.md, "Removing a split"). If b1 is split again too, there's no single place to merge into, and Main/r1 stays, empty. **Before:** Main/r1 is two places under one name: the half Main's split minted, and the half its own split kept. `closeEmptied` saw "minted" and slid shut the branch box registered under that name, which is the whole column. Then `Forest.collapse("Main/r1")` matched the inner *leaf*, so b1 was promoted into Main's split. b1 took the column at an extent that was its height (195 pt in the test). The only link left in `layout.zon` still named the gone `Main/r1`, and b1 had no link, so it was lost on reload. **The model change:** - **`SplitTree.Forest.collapse`** takes the outermost node that carries the name: for a place split again, that's the branch its split made, never the leaf that kept the name. New `isSplit` and `placeLeaves` read the same rule. - **`ViewDrag.closeEmptied`** treats a minted half that was split again as an origin: it merges in its other half, or stays. The **Picker's Remove** goes through it for such a place. - **`Region.persistExtent`** counts a column as kept while *any* place in it holds a view. When the column closes, it forgets every place in it, not only the one whose name the column carries. Before, closing a column with an empty Main/r1 and a full b1 would have left b1's views assigned to a place that no longer existed. It walks the forest only once the place has shut, not every frame. - **The picker** offers no Remove on a half that was split again when its other half is split too: there's nothing to merge, and Remove would only have cleared it. - **A use-after-free while drawing.** When a place was forgotten in the middle of drawing its branch, the tree went on drawing children that `collapse` had just freed. The old code had the same hazard on a rarer path. `persistExtent` now returns whether it dropped the place, and `initTree` and the `.branch` arm draw nothing more of it that frame (a refresh is already queued). The new column-close test caught this: the freed b1 leaf re-stored its extent after the column had cleared it. A leaf that was never split again behaves exactly as before. Every changed path checks `isSplit` first, and `placeLeaves` of a plain leaf is that leaf alone. ## SDK impact - [x] None (`app/` only; `test-sdk-version` passes) ## Verified - [x] macOS: - integration tests (new) on fizzy's forest with a carded Main: - `split: emptying the half that was split again closes only that half`. It failed before the fix: Main/r1 was gone and b1 sat in Main's split. - `split: emptying the half that was split again stays when its other half is split too`. - `split: a column shut by its sash goes only once every place in it is empty`: shut with a view in b1, it stays; emptied, the whole column goes and leaves no assignment or extent behind. - unit tests in `SplitTree.zig`: collapsing a place split again drops its whole branch, and collapsing the minted half of a nested split leaves its origin, with one link. - sandbox fizzy (`--profile`, through the agent plugin, macOS 2×): - Main → Split → Vertical gives 870 \| 870 px. - Main/r1 → Split → Horizontal gives 478 / 478 px, with the column's extent unchanged at 435 pt. - Output into b1, then Main/r1 → Remove gives Main 870 \| Main/r1 870 px. Main/r1 holds Output, and `layout.zon` has one link (`Main/r1` from `Main`, right) and no b1. - Remove on b1 itself still closes just b1, as before. - `scripts/check-fmt.sh`, `zig build`, `zig build test` (91/91 steps), `zig build test-integration` (53/53 steps), `zig build check-tests-reached`, `zig build check-web`, `zig build test-sdk-version`. - [ ] Windows: - [ ] Linux: - [ ] Web: builds ## Follow-ups - **The deeper fix is a name of its own for a branch.** A branch and the half that kept its name share one name, and this PR special-cases that (`isSplit`) where it bites: `closeEmptied`, the picker's Remove and `persistExtent`. Anything else that resolves a place by name (`regionNamed`, which finds the branch box first; `placeSize`) still sees two places. Naming branches separately would need a `layout.zon` migration for saved links, so it's its own change. - Picker buttons named for drivers: fizzyedit/fizzy#468. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- app/layout/Picker.zig | 11 +++- app/layout/Region.zig | 55 +++++++++++------- app/layout/SPLITS.md | 7 +++ app/layout/SplitTree.zig | 104 ++++++++++++++++++++++++++++++--- app/layout/ViewDrag.zig | 14 +++-- tests/integration.zig | 122 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 278 insertions(+), 35 deletions(-) diff --git a/app/layout/Picker.zig b/app/layout/Picker.zig index 85ec3ce74..f564066d9 100644 --- a/app/layout/Picker.zig +++ b/app/layout/Picker.zig @@ -147,7 +147,10 @@ pub fn draw(self: *Picker, f: *Layout) void { // On the tree, any leaf with a sibling can go — a declared place's pin moves to the // sibling. Off it, only a minted leaf can. A float can always go: it closes, and what // it holds goes home. - const removable = created or state.canRemove(region.name) or state.floats.find(region.name) != null; + // A minted half split again goes by merging its other half into it (`ViewDrag.closeEmptied`); + // with that half split too there is nothing to merge, and Remove would only clear it. + const merges = !state.splits.isSplit(region.name) or state.siblingLeaf(region.name) != null; + const removable = (created and merges) or state.canRemove(region.name) or state.floats.find(region.name) != null; const assigned = state.assignment(region.name); const showing = if (assigned) |ids| ids.len > 0 else f.selectedIn(®ion) != null; @@ -789,7 +792,11 @@ fn removeRegion(f: *Layout, region: *const Layout.Region) void { } } const forget = region.forget_when_empty or f.state.splits.canForget(region.name); - if (forget) { + if (forget and f.state.splits.canForget(region.name) and f.state.splits.isSplit(region.name)) { + // Split again, the place under this name is its whole split: the half that kept the + // name goes the way emptying it does, its other half's views merging into it. + _ = ViewDrag.closeEmptied(f, region.name); + } else if (forget) { if (region.id != .zero and (dvui.dataGet(null, region.id, "_size", f32) orelse 0) > 0) { Split.close(region.id); f.extents_changed = true; diff --git a/app/layout/Region.zig b/app/layout/Region.zig index 6d72020b3..8c282e3fe 100644 --- a/app/layout/Region.zig +++ b/app/layout/Region.zig @@ -478,7 +478,8 @@ pub fn init(self: *Layout, src: std.builtin.SourceLocation, init_opts: InitOptio // carries the asymmetry — out with a little overshoot, in without any (`core.anim.slide`). extent = Split.eased(id, target); dvui.dataSet(null, id, "_size", chosen); - if (init_opts.name.len > 0) persistExtent(self, init_opts, id, chosen, extent); + // A leaf draws on regardless: it walks no tree node. + if (init_opts.name.len > 0) _ = persistExtent(self, init_opts, id, chosen, extent); // Pin both ends. A minimum alone is only a floor, so a region whose content wants to be // wider than the size the user dragged it to simply stays wider, and the split appears to @@ -914,29 +915,39 @@ fn drawContentsPhotographed( ViewDrag.keepShot(self, shot, &pic); } -fn persistExtent(self: *Layout, opts: InitOptions, id: dvui.Id, chosen: f32, shown: f32) void { +/// Remember the extent of the place `opts` names, or forget the place when it has closed with +/// nothing in it. Returns true when it forgot it: the split tree no longer has it, and the nodes +/// a caller is walking to draw it may be gone, so it draws nothing more of it this frame. +fn persistExtent(self: *Layout, opts: InitOptions, id: dvui.Id, chosen: f32, shown: f32) bool { _ = shown; - const kept = if (self.state.assignment(opts.name)) |ids| ids.len > 0 else false; // A live drag writes a zero on the way through closed; forgetting here removes the // region and the next frame recreates a sentinel under the pointer — the pop. const dragging = dvui.dataGet(null, id, "_drag", bool) orelse false; - if (opts.forget_when_empty and !kept and !dragging) { - if (chosen <= 0) { - // Still travelling shut: collapsing here drops the leaf and the - // next frame has nothing to ease. `shown` can rest above zero - // after the curve, so "animation finished" is the real signal. - if (dvui.animationGet(id, "_ease") != null) { - if (self.state.setExtent(self.gpa, opts.name, 0)) self.extents_changed = true; - return; - } - if (self.state.clearExtent(self.gpa, opts.name)) self.extents_changed = true; - if (self.state.splits.collapse(self.gpa, opts.name)) self.extents_changed = true; - self.state.unassign(self.gpa, opts.name); - dvui.refresh(null, @src(), id); - return; + if (opts.forget_when_empty and !dragging and chosen <= 0) forget: { + // A place split again is every place in it (`SplitTree.Forest.collapse`): kept while any + // of them holds a view, and all of them forgotten with it. + const covered = self.state.splits.placeLeaves(self.arena, opts.name); + for (covered) |n| { + if (self.state.assignment(n)) |ids| if (ids.len > 0) break :forget; + } + // Still travelling shut: collapsing here drops the leaf and the + // next frame has nothing to ease. `shown` can rest above zero + // after the curve, so "animation finished" is the real signal. + if (dvui.animationGet(id, "_ease") != null) { + if (self.state.setExtent(self.gpa, opts.name, 0)) self.extents_changed = true; + return false; + } + const dropped = self.state.splits.collapse(self.gpa, opts.name); + if (dropped) self.extents_changed = true; + for (covered) |n| { + if (self.state.clearExtent(self.gpa, n)) self.extents_changed = true; + self.state.unassign(self.gpa, n); } + dvui.refresh(null, @src(), id); + return dropped; } if (self.state.setExtent(self.gpa, opts.name, chosen)) self.extents_changed = true; + return false; } /// The region's contents: its own chrome if it declared any, otherwise the active surface. @@ -997,6 +1008,9 @@ fn initTree( const branch = node.kind.branch; var box_opts = opts; var default_extent: f32 = 0; + // Forgotten this frame, the tree `branch` points into may be freed: its children wait for + // the next frame (`persistExtent` asked for it). + var dropped = false; if (init_opts.resize) { const given = opts.min_size_content orelse dvui.Size{}; const default: f32 = switch (axis) { @@ -1014,7 +1028,7 @@ fn initTree( } const extent = Split.eased(id, target); dvui.dataSet(null, id, "_size", chosen); - if (init_opts.name.len > 0) persistExtent(self, init_opts, id, chosen, extent); + if (init_opts.name.len > 0) dropped = persistExtent(self, init_opts, id, chosen, extent); box_opts.min_size_content = switch (axis) { .horizontal => .{ .w = extent, .h = given.h }, .vertical => .{ .w = given.w, .h = extent }, @@ -1071,7 +1085,7 @@ fn initTree( }; self.depth += 1; - try drawTreeChildren(self, src, init_opts, opts, branch); + if (!dropped) try drawTreeChildren(self, src, init_opts, opts, branch); return .{ .name = init_opts.name, @@ -1273,7 +1287,8 @@ fn drawTreeNode( const chosen = dvui.dataGet(null, id, "_size", f32) orelse default; const shown = Split.eased(id, chosen); dvui.dataSet(null, id, "_size", chosen); - persistExtent(self, .{ .name = b.origin, .forget_when_empty = true }, id, chosen, shown); + // Forgotten, its nodes are freed with it: nothing more of it to draw this frame. + if (persistExtent(self, .{ .name = b.origin, .forget_when_empty = true }, id, chosen, shown)) return; box_opts.min_size_content = switch (parent_dir) { .horizontal => .{ .w = shown }, .vertical => .{ .h = shown }, diff --git a/app/layout/SPLITS.md b/app/layout/SPLITS.md index c0a5b5cab..de0797705 100644 --- a/app/layout/SPLITS.md +++ b/app/layout/SPLITS.md @@ -232,6 +232,13 @@ shape-declared place can only be emptied. Removing the last leaf collapses the branch and the origin becomes a whole place again, with its extent and assignment untouched throughout — a split and its undo are symmetric. +A minted half split again is two things under one name: the half its parent split made, and the +half its own split kept. Emptied or removed, it goes the way an origin goes — its other half's +views move into it and that half closes, so the column it stands for keeps its name, its size and +its link in `layout.zon` — and when that other half is split too, it stays, empty. Only the column +as a whole, every place in it empty, closes into its parent's other half (`Forest.collapse` takes +the branch, never the leaf that kept the name). + ## Where this lives | File | Holds | diff --git a/app/layout/SplitTree.zig b/app/layout/SplitTree.zig index 9c94dec7d..199b63145 100644 --- a/app/layout/SplitTree.zig +++ b/app/layout/SplitTree.zig @@ -139,7 +139,12 @@ pub const Forest = struct { return new_leaf.kind.leaf; } - /// Remove an empty new leaf. The sibling takes the branch's place. + /// Remove the place `name` from the split it is a half of. The sibling takes the branch's place. + /// + /// A place split again is the whole branch its split made, not the leaf that kept its name + /// inside it: that is how the split it is a half of sees it, and how it is sized there. Taking + /// only the leaf would hand the place's slot to its other half, sized along the wrong axis and + /// under a name no link records. pub fn collapse(self: *Forest, gpa: std.mem.Allocator, name: []const u8) bool { var it = self.roots.iterator(); while (it.next()) |e| { @@ -307,6 +312,49 @@ pub const Forest = struct { freeNode(gpa, kv.value); } + /// Whether `name` was split again: the origin of a split, so the place under that name is + /// that split's whole branch, its kept leaf one half of it. + pub fn isSplit(self: *const Forest, name: []const u8) bool { + if (self.root(name) != null) return true; + const node = self.findPlace(name) orelse return false; + return node.kind == .branch; + } + + /// Every single place `collapse(name)` would take, in drawing order: `name` alone for a leaf + /// or a name the forest does not know, each leaf of its branch for a place split again. A + /// root's is its own kept half, as `collapse` sees it, not the whole tree it is keyed by. + /// Allocated in `arena`. + pub fn placeLeaves(self: *const Forest, arena: std.mem.Allocator, name: []const u8) []const []const u8 { + const node = self.findPlace(name) orelse { + const one = arena.alloc([]const u8, 1) catch return &.{}; + one[0] = name; + return one; + }; + var out: std.ArrayListUnmanaged([]const u8) = .empty; + leavesIn(node, arena, &out); + return out.items; + } + + /// The node the place `name` is in its parent's split: the outermost that carries the name. + fn findPlace(self: *const Forest, name: []const u8) ?*Node { + var it = self.roots.valueIterator(); + while (it.next()) |n| { + if (placeIn(n.*, name)) |hit| return hit; + } + return null; + } + + fn placeIn(node: *Node, name: []const u8) ?*Node { + switch (node.kind) { + .leaf => return null, + .branch => |b| { + if (isPlaceNamed(b.a, name)) return b.a; + if (isPlaceNamed(b.b, name)) return b.b; + return placeIn(b.a, name) orelse placeIn(b.b, name); + }, + } + } + /// A leaf minted by a split, not a shape-declared root. The picker can Remove these. pub fn canForget(self: *const Forest, name: []const u8) bool { return self.root(name) == null and self.findLeaf(name) != null; @@ -355,8 +403,13 @@ pub const Forest = struct { } }; -fn isLeafNamed(node: *Node, name: []const u8) bool { - return if (leafName(node.*)) |n| std.mem.eql(u8, n, name) else false; +/// Whether `node` is the place `name` in its parent's split: its leaf, or the branch a split of +/// it made. +fn isPlaceNamed(node: *Node, name: []const u8) bool { + return switch (node.kind) { + .leaf => |n| std.mem.eql(u8, n, name), + .branch => |b| std.mem.eql(u8, b.origin, name), + }; } fn tryCollapse(gpa: std.mem.Allocator, slot: **Node, name: []const u8) bool { @@ -364,15 +417,16 @@ fn tryCollapse(gpa: std.mem.Allocator, slot: **Node, name: []const u8) bool { switch (node.kind) { .leaf => return false, .branch => |*b| { - if (isLeafNamed(b.a, name)) { + // Children before descending: the outermost node carrying the name is the place. + if (isPlaceNamed(b.a, name)) { slot.* = b.b; - gpa.destroy(b.a); + freeNode(gpa, b.a); gpa.destroy(node); return true; } - if (isLeafNamed(b.b, name)) { + if (isPlaceNamed(b.b, name)) { slot.* = b.a; - gpa.destroy(b.b); + freeNode(gpa, b.b); gpa.destroy(node); return true; } @@ -517,3 +571,39 @@ test "a split's places know their root, list as its leaves, and go with it" { try std.testing.expect(f.root("Float 1") == null); try std.testing.expect(f.rootOf("Float 1/r1") == null); } + +test "collapsing a place split again drops its whole branch, never just the leaf that kept the name" { + const gpa = std.testing.allocator; + var f: Forest = .{}; + defer f.deinit(gpa); + _ = f.split(gpa, internLiteral, "Center", .right, 80, "Center/r1").?; + _ = f.split(gpa, internLiteral, "Center/r1", .bottom, 40, "Center/r1/b1").?; + try std.testing.expect(f.isSplit("Center/r1")); + try std.testing.expect(!f.isSplit("Center/r1/b1")); + + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const covered = f.placeLeaves(arena.allocator(), "Center/r1"); + try std.testing.expectEqual(@as(usize, 2), covered.len); + try std.testing.expectEqualStrings("Center/r1", covered[0]); + try std.testing.expectEqualStrings("Center/r1/b1", covered[1]); + try std.testing.expectEqual(@as(usize, 1), f.placeLeaves(arena.allocator(), "Center/r1/b1").len); + + try std.testing.expect(f.collapse(gpa, "Center/r1")); + try std.testing.expect(f.root("Center") == null); + try std.testing.expect(f.rootOf("Center/r1/b1") == null); +} + +test "collapsing the minted half of a nested split leaves its origin in the outer split" { + const gpa = std.testing.allocator; + var f: Forest = .{}; + defer f.deinit(gpa); + _ = f.split(gpa, internLiteral, "Center", .right, 80, "Center/r1").?; + _ = f.split(gpa, internLiteral, "Center/r1", .bottom, 40, "Center/r1/b1").?; + try std.testing.expect(f.collapse(gpa, "Center/r1/b1")); + try std.testing.expectEqualStrings("Center", f.siblingLeaf("Center/r1").?); + const links = f.collectLinks(gpa); + defer gpa.free(links); + try std.testing.expectEqual(@as(usize, 1), links.len); + try std.testing.expectEqualStrings("Center/r1", links[0].name); +} diff --git a/app/layout/ViewDrag.zig b/app/layout/ViewDrag.zig index d72fa1110..89208ddb5 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -2918,20 +2918,22 @@ fn closable(l: *Layout, name: []const u8) bool { } /// A merge of two halves of a split: `gone` closed, its views now in `into`. -const Merged = struct { gone: []const u8, into: []const u8 }; +pub const Merged = struct { gone: []const u8, into: []const u8 }; /// Close the emptied place `name`, one half of a split the user made, into the other half. A /// minted half closes outright (`closeMinted`). The half that was split keeps its name — it is -/// the shape's place — so it takes the other half's views and that half closes instead: the same -/// one place either way, under the name the shape knows. Returns that merge, when it was one. -/// A place no user split made, or whose other half is split again, stays as it is. -fn closeEmptied(l: *Layout, name: []const u8) ?Merged { +/// the shape's place, or the name its own parent split knows it by — so it takes the other half's +/// views and that half closes instead: the same one place either way, under the name the layout +/// knows. A minted half split again is that case too: closed outright it would take its whole +/// split with it. Returns that merge, when it was one. A place no user split made, or whose other +/// half is split again, stays as it is. +pub fn closeEmptied(l: *Layout, name: []const u8) ?Merged { // A float nobody split, left with nothing to show, closes: it was the view's window. if (l.state.floats.find(name) != null and l.state.splits.root(name) == null) { Floats.close(l, name, .emptied); return null; } - if (l.state.isMinted(name)) { + if (l.state.isMinted(name) and !l.state.splits.isSplit(name)) { closeMinted(l, name); return null; } diff --git a/tests/integration.zig b/tests/integration.zig index 728f32c54..7df3edaed 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -2980,6 +2980,128 @@ test "a split of a carded place, and of its new half, opens even halves" { try std.testing.expectApproxEqAbs(top.h, bottom.h, 1); } +/// Main | (Main/r1 over Main/r1/b1) in fizzy's split forest, each holding one view. Main/r1 is two +/// things at once: the half Main's split minted, and the half its own split kept. +const NestedSplitCase = struct { + ctx: shim.Ctx, + + fn init() !NestedSplitCase { + var ctx = try shim.init(std.testing.allocator); + errdefer ctx.deinit(std.testing.allocator); + const editor = ctx.editor; + editor.app.gpa = std.testing.allocator; + ColumnCardFrame.editor = editor; + const draw = struct { + fn f(_: ?*anyopaque) anyerror!dvui.App.Result { + return .ok; + } + }.f; + for ([_][]const u8{ "test.left", "test.top", "test.bottom", "test.right" }) |id| { + try editor.app.host.registerSurface(.{ .id = id, .title = id, .keywords = fizzy.sdk.keywords.ide.main, .draw = draw }); + } + try dvui.testing.settle(ColumnCardFrame.frame); + try editor.app.layout.assign(editor.app.gpa, "Main", &.{"test.left"}); + try split(editor, "Main", .right, "Main/r1", "test.top"); + try split(editor, "Main/r1", .bottom, "Main/r1/b1", "test.bottom"); + return .{ .ctx = ctx }; + } + + fn split(editor: *fizzy.Editor, name: []const u8, side: fizzy.Editor.Layout.SplitTree.Side, want: []const u8, view: []const u8) !void { + { + var layout = fizzy.Editor.Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, dvui.currentWindow().arena()); + try std.testing.expectEqualStrings(want, layout.splitOn(name, side) orelse return error.TestExpectedEqual); + } + try editor.app.layout.assign(editor.app.gpa, want, &.{view}); + try dvui.testing.settle(ColumnCardFrame.frame); + } + + fn deinit(self: *NestedSplitCase) void { + const editor = self.ctx.editor; + editor.app.layout.regions.deinit(editor.app.gpa); + editor.app.layout.regions_building.deinit(editor.app.gpa); + editor.app.layout.deinitExtents(editor.app.gpa); + editor.app.layout.deinitAssignments(editor.app.gpa); + editor.app.layout.deinitQualified(editor.app.gpa); + ColumnCardFrame.editor = null; + self.ctx.deinit(std.testing.allocator); + } + + fn trash(self: *NestedSplitCase, name: []const u8) !void { + const editor = self.ctx.editor; + var layout = fizzy.Editor.Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, dvui.currentWindow().arena()); + fizzy.Editor.Layout.ViewDrag.place(&layout, name, name, .remove); + try dvui.testing.settle(ColumnCardFrame.frame); + try dvui.testing.settle(ColumnCardFrame.frame); + } +}; + +test "split: emptying the half that was split again closes only that half" { + var case = try NestedSplitCase.init(); + defer case.deinit(); + const state = &case.ctx.editor.app.layout; + const column = (try ColumnCardFrame.sizeOf(case.ctx.editor, "Main/r1")).w; + + try case.trash("Main/r1"); + + // The column stays, one place again under the name Main's split knows, holding what its + // other half held — the same merge as emptying a split's origin anywhere else. + try std.testing.expectEqualStrings("Main", state.splits.siblingLeaf("Main/r1") orelse return error.TestExpectedEqual); + try std.testing.expect(state.splits.rootOf("Main/r1/b1") == null); + const kept = state.assignment("Main/r1") orelse return error.TestExpectedEqual; + try std.testing.expectEqual(@as(usize, 1), kept.len); + try std.testing.expectEqualStrings("test.bottom", kept[0]); + try std.testing.expectApproxEqAbs(column, (try ColumnCardFrame.sizeOf(case.ctx.editor, "Main/r1")).w, 1); + + const links = state.splits.collectLinks(std.testing.allocator); + defer std.testing.allocator.free(links); + try std.testing.expectEqual(@as(usize, 1), links.len); + try std.testing.expectEqualStrings("Main/r1", links[0].name); +} + +test "split: emptying the half that was split again stays when its other half is split too" { + var case = try NestedSplitCase.init(); + defer case.deinit(); + const editor = case.ctx.editor; + try NestedSplitCase.split(editor, "Main/r1/b1", .right, "Main/r1/b1/r1", "test.right"); + + try case.trash("Main/r1"); + + // No one place to merge into: the column and every split in it stay, Main/r1 empty. + const leaves = editor.app.layout.splits.leavesUnder(dvui.currentWindow().arena(), "Main"); + try std.testing.expectEqual(@as(usize, 4), leaves.len); + const kept = editor.app.layout.assignment("Main/r1") orelse return error.TestExpectedEqual; + try std.testing.expectEqual(@as(usize, 0), kept.len); + try std.testing.expectEqualStrings("test.bottom", editor.app.layout.assignment("Main/r1/b1").?[0]); +} + +test "split: a column shut by its sash goes only once every place in it is empty" { + var case = try NestedSplitCase.init(); + defer case.deinit(); + const editor = case.ctx.editor; + const state = &editor.app.layout; + // The branch registers before the half that kept its name, so the first `Main/r1` is the + // column: the box its parent's sash sizes. + const column = for (state.regions.items) |r| { + if (std.mem.eql(u8, r.name, "Main/r1")) break r.id; + } else return error.TestExpectedEqual; + + // Shut with a view still in its bottom half: the column stays, and so does every place in it. + try state.assign(editor.app.gpa, "Main/r1", &.{}); + fizzy.core.widgets.Split.close(column); + try dvui.testing.settle(ColumnCardFrame.frame); + try dvui.testing.settle(ColumnCardFrame.frame); + try std.testing.expectEqual(@as(usize, 3), state.splits.leavesUnder(dvui.currentWindow().arena(), "Main").len); + try std.testing.expectEqualStrings("test.bottom", state.assignment("Main/r1/b1").?[0]); + + // Nothing left in it, the whole column goes, and nothing it held is left behind. + try state.assign(editor.app.gpa, "Main/r1/b1", &.{}); + try dvui.testing.settle(ColumnCardFrame.frame); + try dvui.testing.settle(ColumnCardFrame.frame); + try std.testing.expect(state.splits.root("Main") == null); + try std.testing.expect(state.assignment("Main/r1/b1") == null); + try std.testing.expectEqual(@as(f32, -1), state.extent("Main/r1/b1", -1)); +} + test "a view-drag split opens on the dropped edge" { var ctx = try shim.init(std.testing.allocator); defer ctx.deinit(std.testing.allocator); From 83304c2afe42a742d828672e1d5e4ea670d2e3d0 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:34:10 -0500 Subject: [PATCH 31/46] layout: the picker lists a view under the place showing it (#470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #466, from its review. ## What changes - **A view reads under the place showing it.** #466 sectioned cards by keywords alone, so Files dragged onto the Panel was highlighted in the Panel's picker but listed under "Sidebar" (and the Sidebar's picker still claimed it). A card now sits under the place holding it (`Layout.matchingIn`), and falls back to its keywords only when no place does. Places resolved by name (a split's leaf, a float) are each their own section instead of being folded into a keyword group, so a view put in one reads under its name. - **The edited place always leads.** A split's new leaf accepts no view's keywords, so its picker opened on other places' headers alone. Its own header now comes first, with "Nothing here yet." (or "No matching views here." under a filter). - Each card's section is computed once per frame rather than once per section. ## SDK impact - [x] None ## Verified - [x] macOS: unit tests for the section rule, including a held view and a slot leaf; the picker integration test now assigns Files to the Panel and checks both pickers list it in the Panel's row beside Output (on `main` the Sidebar picker puts Files in a section of its own, so the row check would fail). All gates pass locally (fmt, build, test, test-integration 53/53 steps, check-web, test-sdk-version, check-tests-reached). Not looked at on screen. - [ ] Windows: - [ ] Linux: - [ ] Web: ## Follow-ups None planned. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- app/layout/Picker.zig | 92 +++++++++++++++++++++++++++++++------------ tests/integration.zig | 11 ++++++ 2 files changed, 77 insertions(+), 26 deletions(-) diff --git a/app/layout/Picker.zig b/app/layout/Picker.zig index f564066d9..932d2050d 100644 --- a/app/layout/Picker.zig +++ b/app/layout/Picker.zig @@ -11,9 +11,10 @@ //! Cards assign a surface here. That is how a sidebar view stays in the //! sidebar, and how a plugin readme lands in the center — keywords are the //! default guess, an assignment is the answer. "Back to defaults" undoes it. -//! The cards are listed under the place their keywords are for — this place -//! first, then each other place by its name, then "Other" — so a view made for -//! the panel reads as one, wherever the picker was opened. +//! The cards are listed under the place holding them, or else the place their +//! keywords are for — this place first, then each other place by its name, then +//! "Other" — so a view made for the panel reads as one, wherever the picker was +//! opened, and a view the user moved reads under where it went. //! //! A card is also a handle. Dragged out of the popup it becomes the same //! floating view the corner button lifts out of a place (`ViewDrag`), and @@ -279,16 +280,24 @@ pub fn draw(self: *Picker, f: *Layout) void { var col: usize = 0; const places = placesFor(f, ®ion); const ranked_all = offered(f, ®ion, &query); - // Each card under the place its keywords make it for, this one first, so what belongs in - // the explorer and what in the panel reads off the headers; a query ranks within each. + // Each card under the place holding it, or else the place its keywords make it for; this one + // first, so what belongs in the explorer and what in the panel reads off the headers. A + // query ranks within each. Sorted once, not per section. + const sections = f.arena.alloc(usize, ranked_all.len) catch return; + for (ranked_all, sections) |ranked, *section| { + const s = &f.host.surfaces.items[ranked.item]; + section.* = sectionOf(places, s.id, s.keywords) orelse places.len; + } var current: ?usize = null; - for (0..places.len + 1) |section| for (ranked_all) |ranked| { + for (0..places.len + 1) |section| for (ranked_all, sections) |ranked, card_section| { // `i` is the surface's place in the registry, not in this list: a card keeps its widget // id while the filter reorders what is around it. const i = ranked.item; const s = &f.host.surfaces.items[i]; - if ((sectionOf(places, s.keywords) orelse places.len) != section) continue; + if (card_section != section) continue; if (current != section) { + // This place's own header was drawn above, empty, when nothing else came first. + if (current == null and section != 0) emptyHome(®ion, &query); current = section; if (row) |r| r.deinit(); row = null; @@ -352,6 +361,7 @@ pub fn draw(self: *Picker, f: *Layout) void { state.markDirty(); dvui.refresh(null, @src(), null); }; + if (current == null) emptyHome(®ion, &query); if (state.store_catalog) |store| drawStoreSection(f, ®ion, store, &row, &col, &query); } @@ -371,34 +381,38 @@ fn offered(f: *Layout, region: *const Layout.Region, query: *const core.fuzzy.Qu return out.items; } -/// A place a card can be listed under: a region's name and the keywords it accepts. +/// A place a card can be listed under: a region's name, the keywords it accepts and the views it +/// holds now. const Place = struct { name: []const u8, keywords: []const []const u8, + holds: []const *sdk.Surface = &.{}, }; -/// The places the picker sorts cards under: `region` itself first, then each other kind of place -/// the shape declared, in its order. One per keyword group — the icon rail and the explorer it -/// chooses for are one place — and never a document pane, which offers only documents. +/// The places the picker sorts cards under: `region` itself first, then each other place the +/// shape declared, in its order — never a document pane, which offers only documents. Places +/// resolved by keywords are one per keyword group (the icon rail and the explorer it chooses for +/// are one place); a place resolved by name (a split's leaf, a float) is its own. fn placesFor(f: *Layout, region: *const Layout.Region) []const Place { var out: std.ArrayListUnmanaged(Place) = .empty; - out.append(f.arena, .{ .name = region.name, .keywords = region.keywords }) catch return &.{}; + out.append(f.arena, .{ .name = region.name, .keywords = region.keywords, .holds = f.matchingIn(region) }) catch return &.{}; for (f.state.regions.items) |*r| { if (r.name.len == 0 or r.kind_slot) continue; - const key = sdk.keywords.groupKey(r.keywords); const seen = for (out.items) |p| { - if (sdk.keywords.groupKey(p.keywords) == key) break true; + if (std.mem.eql(u8, p.name, r.name)) break true; + if (!r.by_name and sdk.keywords.groupKey(p.keywords) == sdk.keywords.groupKey(r.keywords)) break true; } else false; - if (!seen) out.append(f.arena, .{ .name = r.name, .keywords = r.keywords }) catch break; + if (!seen) out.append(f.arena, .{ .name = r.name, .keywords = r.keywords, .holds = f.matchingIn(r) }) catch break; } return out.items; } -/// The place a surface is made for: the one accepting its keywords most strongly, the first of -/// equals — `places[0]`, the place being edited, when it is among them. Null when none accepts it. -/// The same fit `Layout` binds by (`sdk.keywords.strength`), so the header a card sits under is -/// where its keywords would put it. -fn sectionOf(places: []const Place, keywords: []const []const u8) ?usize { +/// The place a card is listed under. The place holding the view, when one does: a view the user +/// moved reads under where it is, not where it came from. Otherwise the place its keywords are +/// for, by the fit `Layout` binds with (`sdk.keywords.strength`); between equals the first, +/// which puts `places[0]`, the place being edited, ahead of the rest. Null when neither. +fn sectionOf(places: []const Place, id: []const u8, keywords: []const []const u8) ?usize { + for (places, 0..) |p, i| if (contains(p.holds, id)) return i; var best: ?usize = null; var best_fit: sdk.keywords.Fit = .none; for (places, 0..) |p, i| { @@ -418,14 +432,40 @@ test "a view is listed under the place its keywords are for, the edited place fi .{ .name = "Sidebar", .keywords = kw.sidebar }, .{ .name = "Main", .keywords = kw.main }, }; - try std.testing.expectEqual(@as(?usize, 1), sectionOf(&places, &.{"explorer"})); - try std.testing.expectEqual(@as(?usize, 0), sectionOf(&places, &.{"output"})); + try std.testing.expectEqual(@as(?usize, 1), sectionOf(&places, "v", &.{"explorer"})); + try std.testing.expectEqual(@as(?usize, 0), sectionOf(&places, "v", &.{"output"})); // Made for both: the place being edited wins the tie. - try std.testing.expectEqual(@as(?usize, 0), sectionOf(&places, &.{ "sidebar", "panel" })); + try std.testing.expectEqual(@as(?usize, 0), sectionOf(&places, "v", &.{ "sidebar", "panel" })); // A specific sub-place still lands under the area that holds it. - try std.testing.expectEqual(@as(?usize, 2), sectionOf(&places, &.{"main.document"})); - try std.testing.expectEqual(@as(?usize, null), sectionOf(&places, &.{"inspector"})); - try std.testing.expectEqual(@as(?usize, null), sectionOf(&places, &.{})); + try std.testing.expectEqual(@as(?usize, 2), sectionOf(&places, "v", &.{"main.document"})); + try std.testing.expectEqual(@as(?usize, null), sectionOf(&places, "v", &.{"inspector"})); + try std.testing.expectEqual(@as(?usize, null), sectionOf(&places, "v", &.{})); +} + +test "a view a place holds is listed under that place, whatever its keywords" { + var files: sdk.Surface = .{ .id = "files", .title = "Files", .keywords = sdk.keywords.ide.sidebar, .draw = undefined }; + const holding = [_]*sdk.Surface{&files}; + const places = [_]Place{ + .{ .name = "Sidebar", .keywords = sdk.keywords.ide.sidebar }, + .{ .name = "Panel", .keywords = sdk.keywords.ide.panel, .holds = &holding }, + }; + try std.testing.expectEqual(@as(?usize, 1), sectionOf(&places, "files", files.keywords)); + // A split's new leaf accepts no view's keywords, and still takes the one put in it. + const leaf = [_]Place{ + .{ .name = "Panel/r1", .keywords = &.{"slot"}, .holds = &holding }, + .{ .name = "Sidebar", .keywords = sdk.keywords.ide.sidebar }, + }; + try std.testing.expectEqual(@as(?usize, 0), sectionOf(&leaf, "files", files.keywords)); +} + +/// The place being edited, when no card falls under it: its header still leads, so the popup +/// says which place it fills — a split's new leaf accepts no view's keywords until one is put in. +fn emptyHome(region: *const Layout.Region, query: *const core.fuzzy.Query) void { + sectionHeader(region.name, 0); + dvui.labelNoFmt(@src(), if (query.isEmpty()) "Nothing here yet." else "No matching views here.", .{}, .{ + .padding = .{ .x = 4, .h = 4 }, + .color_text = .{ .color = dvui.themeGet().color(.window, .text).opacity(0.45) }, + }); } /// A heading over a run of cards — a place's name, or the store. diff --git a/tests/integration.zig b/tests/integration.zig index 7df3edaed..d6ea92919 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -2513,6 +2513,17 @@ test "the picker lists each view under the place it is made for, the open place for (0..4) |_| _ = try dvui.testing.step(PickerFrame.frame); try std.testing.expect(try y("test.files") < try y("test.output")); try std.testing.expect(try y("test.output") < try y("test.misc")); + + // Files moved into the panel reads under the panel, in one row with Output, from either + // picker. + try editor.app.layout.assign(editor.app.gpa, "Panel", &.{ "test.output", "test.files" }); + for (0..4) |_| _ = try dvui.testing.step(PickerFrame.frame); + try std.testing.expectEqual(try y("test.output"), try y("test.files")); + try std.testing.expect(try y("test.output") < try y("test.misc")); + editor.app.layout.openPicker(editor.app.gpa, "Panel", null); + for (0..4) |_| _ = try dvui.testing.step(PickerFrame.frame); + try std.testing.expectEqual(try y("test.output"), try y("test.files")); + try std.testing.expect(try y("test.output") < try y("test.misc")); } test "a hide_when_empty panel stays while its view is dragged onto main" { From f6a8f2d33c17525baa8c41306e8344e3eb71814d Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:34:33 -0500 Subject: [PATCH 32/46] app: workspaces carry an old panes.zon over into the layout (#464) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #260 (step 4 of `plans/WORKSPACES_PLAN.md`). Stacked on #462, which is stacked on #461. ## What changes Behind `FIZZY_WORKSPACES=1`, a saved layout comes back whole. - **An old `panes.zon` becomes the app's splits.** The first time fizzy runs with the flag, the workbench's saved tree is laid out split for split from the place the first workspace is in (Main). Each pane's workspace goes in its half, at the share it had. Then `panes.zon` is deleted: the arrangement is `layout.zon`'s from then on. A subtree whose panes didn't come back is skipped, so its sibling takes the room. - **A place whose workspace didn't come back closes.** A workspace that held no document at quit, or none that could open, isn't restored. Its place used to come back empty; now it closes, as it would have had it emptied. - **`Region.splitOnSized`:** `splitOn` for a place that hasn't been drawn yet, with a share for the new half. A nested tree splits a half made in the same frame. `splitOn` now calls it with the drawn size and half. What's already kept and needed no change: which place holds which workspace and each workspace's documents in order (`layout.zon`, since #461), and each workspace's selected tab (the session, `KeptDocuments.applySelections`). Older fizzy reads the same `layout.zon` it always did; nothing new is written. With the flag off, nothing changes. (The workbench still reads `panes.zon` as it did; only the carry-over deletes it.) ## SDK impact - [x] None (`test-sdk-version` passes). ## Verified - [x] macOS: in a sandbox profile: - ran without the flag and split Main (`Pane 0 | Pane 4`); - hand-edited the saved `panes.zon` into a nested tree (horizontal 0.6: `Pane 0` | vertical 0.5 of `Pane 4`, `Pane 5`) and gave `Pane 5` a document in `layout.zon`; - relaunched with `FIZZY_WORKSPACES=1`: Main came back as `Main` | (`Main/r1` over `Main/r1/b1`), each half showing its workspace's document, the horizontal share 60/40, and `panes.zon` gone. - `zig build`, `zig build test`, `zig build test-integration` (53/53 steps), `zig build check-web`, `zig build test-sdk-version`, `scripts/check-fmt.sh`. - [ ] Windows: - [ ] Linux: - [ ] Web: builds; no `panes.zon` there, and the flag is never on. ## Follow-ups - **A split of a split's new half comes out uneven.** The vertical 0.5 above settled at about 1:3, and splitting a nested half by hand came out uneven the same way. A branch sizes itself in its parent by its origin's extent (`Region`, `.branch`), the same name its kept half reads its own size from along the other axis. That's the app's split code with or without this flag, so it's a separate fix. - Step 5: tear a tab off into a float holding a new workspace (`State.documents` with a float), and back. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- app/layout/Region.zig | 9 ++- plugins/workbench/src/Workbench.zig | 4 + plugins/workbench/src/workbench_layout.zig | 13 ++- src/editor/Workspaces.zig | 94 ++++++++++++++++++++++ 4 files changed, 116 insertions(+), 4 deletions(-) diff --git a/app/layout/Region.zig b/app/layout/Region.zig index 8c282e3fe..d2d764243 100644 --- a/app/layout/Region.zig +++ b/app/layout/Region.zig @@ -1412,8 +1412,13 @@ pub fn splitOn(self: *Layout, name: []const u8, side: SplitTree.Side) ?[]const u if (self.state.floatRoot(name) == null) { if (self.state.dock) |*dock| return splitDock(self, dock, name, side); } + return splitOnSized(self, name, side, ViewDrag.placeSize(self.state, name) orelse return null, 0.5); +} - const size = ViewDrag.placeSize(self.state, name) orelse return null; +/// `splitOn` for a place of `size` that need not have been drawn yet — a split of a half another +/// split made this frame, as a saved arrangement is rebuilt — with `share` of it for the new half. +/// Only the split forest's: a seed's dock tree splits its own drawn leaves (`splitOn`). +pub fn splitOnSized(self: *Layout, name: []const u8, side: SplitTree.Side, size: dvui.Size, share: f32) ?[]const u8 { const span = switch (SplitTree.axisOf(side)) { .horizontal => size.w, .vertical => size.h, @@ -1428,7 +1433,7 @@ pub fn splitOn(self: *Layout, name: []const u8, side: SplitTree.Side) ?[]const u intern.state = self.state; // Halved the way the preview halves it — the sash out of the middle, both // sides the same — so what slid open is the size that lands. - const want = @max(1, (span - Split.handle_size) / 2); + const want = @max(1, (span - Split.handle_size) * std.math.clamp(share, 0, 1)); const new = self.state.splits.split(self.gpa, intern.go, name, side, want, null) orelse return null; if (self.state.setExtent(self.gpa, new, want)) self.extents_changed = true; self.state.assign(self.gpa, new, &.{}) catch {}; diff --git a/plugins/workbench/src/Workbench.zig b/plugins/workbench/src/Workbench.zig index 6029b8270..cdc7d6699 100644 --- a/plugins/workbench/src/Workbench.zig +++ b/plugins/workbench/src/Workbench.zig @@ -387,6 +387,10 @@ pub fn rebuildWorkspaces(self: *Workbench) !void { return workbench_layout.rebuildWorkspaces(self); } +pub fn forgetPanes(self: *Workbench) void { + workbench_layout.forgetPanes(self); +} + pub fn drawWorkspaces(self: *Workbench, index: usize) !dvui.App.Result { return workbench_layout.drawWorkspaces(self, index); } diff --git a/plugins/workbench/src/workbench_layout.zig b/plugins/workbench/src/workbench_layout.zig index f1c4998a1..26d70dc03 100644 --- a/plugins/workbench/src/workbench_layout.zig +++ b/plugins/workbench/src/workbench_layout.zig @@ -21,8 +21,9 @@ pub fn rebuildWorkspaces(wb: *Workbench) !void { if (!wb.restored) { wb.restored = true; - // Where the app arranges the panes, its own layout brought them back. - if (wb.places == null) loadPanes(wb); + // Where the app arranges the panes, it brought them back itself — and carries an + // arrangement saved before it did over into its own (fizzy's `Workspaces.carryOver`). + loadPanes(wb); for (host.assignedRegionNames()) |region_name| { const grouping = Workspace.groupingOfName(region_name) orelse continue; // A copy: the assignment is borrowed until the next `assignSurfaces`, and each open @@ -228,6 +229,14 @@ fn loadPanes(wb: *Workbench) void { } } +/// Delete the saved arrangement: the app has taken it over (`Workbench.places`). +pub fn forgetPanes(wb: *Workbench) void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + const path = panesPath(wb) orelse return; + defer wb.allocator.free(path); + std.Io.Dir.cwd().deleteFile(dvui.io, path) catch {}; +} + fn panesPath(wb: *Workbench) ?[]u8 { if (comptime builtin.target.cpu.arch == .wasm32) return null; const dir = runtime.host().pluginInstallDir("workbench") orelse return null; diff --git a/src/editor/Workspaces.zig b/src/editor/Workspaces.zig index 51f60b644..b8da92f82 100644 --- a/src/editor/Workspaces.zig +++ b/src/editor/Workspaces.zig @@ -22,6 +22,9 @@ const Workspaces = @This(); /// Each pane's view, by its grouping. views: std.AutoArrayHashMapUnmanaged(u64, *View) = .empty, +/// Whether last session's layout has been squared with the panes that came back, once the +/// layout has drawn (`settle`). +settled: bool = false, const View = struct { editor: *Editor, @@ -111,6 +114,10 @@ pub fn sync(self: *Workspaces) void { // Every pane but one is somewhere it was put; the one is where the keywords show it. More // than one with nowhere to be would all be Main's, and Main shows one. if (editor.app.layout.regions.items.len == 0) return; + if (!self.settled) { + self.settled = true; + self.settle(); + } var first: ?u64 = null; for (wb.workspaces.keys()) |g| { if (self.assigned(g)) continue; @@ -151,6 +158,93 @@ fn seat(self: *Workspaces) void { } } +/// Square last session's layout with the panes that came back, the first frame there are places +/// to read. A place whose workspace did not come back (it held no document at quit, or none that +/// could open) closes, as it would have had it emptied. And an arrangement the workbench saved +/// before the app arranged its panes (`panes.zon`) becomes the app's: its tree, split for split, +/// out of the place the first workspace is in, each pane's workspace in its half. Then the file +/// goes, the arrangement being the layout's to keep from here on. +fn settle(self: *Workspaces) void { + const editor = self.editorOf(); + const wb = &editor.workbench; + var l = layoutOf(editor); + + var gone: std.ArrayListUnmanaged([]const u8) = .empty; + var any_placed = false; + var it = editor.app.layout.assignments.valueIterator(); + while (it.next()) |ids| for (ids.*) |id| { + if (!std.mem.startsWith(u8, id, id_prefix)) continue; + const g = std.fmt.parseInt(u64, id[id_prefix.len..], 10) catch continue; + if (wb.workspaces.contains(g)) { + any_placed = true; + } else gone.append(l.arena, l.arena.dupe(u8, id) catch continue) catch {}; + }; + for (gone.items) |id| _ = Layout.ViewDrag.withdraw(&l, id); + + const panes = &wb.panes; + const root = panes.nodes.items[panes.root]; + // Placed already, the layout is the app's; a single pane is no arrangement. + if (!any_placed and root == .split) carry: { + const first = firstLive(wb, panes.root) orelse break :carry; + var buf: [64]u8 = undefined; + const place = Layout.ViewDrag.holderOf(&l, viewId(&buf, first)) orelse break :carry; + const size = Layout.ViewDrag.placeSize(l.state, place) orelse break :carry; + carryOver(self, &l, panes.root, place, size); + } + wb.forgetPanes(); +} + +/// Lay `node` of the workbench's old tree out in place `name`, `size` big: a pane's workspace is +/// put there, and a split splits it, the second half as big a share as it had. +fn carryOver(self: *Workspaces, l: *Layout, node: Workbench.Panes.NodeIndex, name: []const u8, size: dvui.Size) void { + const wb = &self.editorOf().workbench; + switch (wb.panes.nodes.items[node]) { + .free => {}, + .leaf => { + const g = firstLive(wb, node) orelse return; + var buf: [64]u8 = undefined; + const id = l.arena.dupe(u8, viewId(&buf, g)) catch return; + l.state.assign(l.gpa, name, &.{id}) catch {}; + }, + .split => |sp| { + if (firstLive(wb, sp.first) == null) return carryOver(self, l, sp.second, name, size); + if (firstLive(wb, sp.second) == null) return carryOver(self, l, sp.first, name, size); + const across = sp.dir == .horizontal; + const share = 1 - std.math.clamp(sp.ratio, 0, 1); + const owned = l.arena.dupe(u8, name) catch return; + const new = Layout.Region.splitOnSized(l, owned, if (across) .right else .bottom, size, share) orelse + return carryOver(self, l, sp.first, name, size); + const second = l.arena.dupe(u8, new) catch return; + var a = size; + var b = size; + if (across) { + a.w = size.w * (1 - share); + b.w = size.w * share; + } else { + a.h = size.h * (1 - share); + b.h = size.h * share; + } + carryOver(self, l, sp.first, owned, a); + carryOver(self, l, sp.second, second, b); + }, + } +} + +/// The first pane under `node` of the old tree that came back, by its grouping. +fn firstLive(wb: *Workbench, node: Workbench.Panes.NodeIndex) ?u64 { + switch (wb.panes.nodes.items[node]) { + .free => return null, + .leaf => |leaf| { + for (leaf.tabs.items) |t| { + const g = Editor.Workspace.groupingOfName(t) orelse continue; + if (wb.workspaces.contains(g)) return g; + } + return null; + }, + .split => |sp| return firstLive(wb, sp.first) orelse firstLive(wb, sp.second), + } +} + fn register(self: *Workspaces, grouping: u64) !void { const editor = self.editorOf(); const gpa = editor.app.gpa; From 88233a9c283bd54236ea8912d89cb0332cccd3c2 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:51:03 -0500 Subject: [PATCH 33/46] layout: test the picker's empty header (#473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #470, from its review. ## What changes - The place being edited, when empty, draws its header and "Nothing here yet." inside a box anchored `picker.empty`, so a test or a demo tape can find it. - The picker integration test checks it: once Files and Output both sit in the Panel, the Sidebar's picker leads with `picker.empty` above the Panel's cards, and the Panel's picker has none. - A comment said the sections were "sorted"; they are computed once. ## SDK impact - [x] None ## Verified - [x] macOS: the picker integration test passes, and fails without the anchor; all gates pass locally (fmt, build, test, test-integration 53/53 steps, check-web, test-sdk-version). - [ ] Windows: - [ ] Linux: - [ ] Web: ## Follow-ups None planned. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- app/layout/Picker.zig | 5 ++++- tests/integration.zig | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/layout/Picker.zig b/app/layout/Picker.zig index 932d2050d..52c562d15 100644 --- a/app/layout/Picker.zig +++ b/app/layout/Picker.zig @@ -282,7 +282,7 @@ pub fn draw(self: *Picker, f: *Layout) void { const ranked_all = offered(f, ®ion, &query); // Each card under the place holding it, or else the place its keywords make it for; this one // first, so what belongs in the explorer and what in the panel reads off the headers. A - // query ranks within each. Sorted once, not per section. + // query ranks within each. Each card's section is worked out once, not once per section. const sections = f.arena.alloc(usize, ranked_all.len) catch return; for (ranked_all, sections) |ranked, *section| { const s = &f.host.surfaces.items[ranked.item]; @@ -461,6 +461,9 @@ test "a view a place holds is listed under that place, whatever its keywords" { /// The place being edited, when no card falls under it: its header still leads, so the popup /// says which place it fills — a split's new leaf accepts no view's keywords until one is put in. fn emptyHome(region: *const Layout.Region, query: *const core.fuzzy.Query) void { + var box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal }); + defer box.deinit(); + core.anchor.mark(box.data(), "picker.empty", .{}); sectionHeader(region.name, 0); dvui.labelNoFmt(@src(), if (query.isEmpty()) "Nothing here yet." else "No matching views here.", .{}, .{ .padding = .{ .x = 4, .h = 4 }, diff --git a/tests/integration.zig b/tests/integration.zig index d6ea92919..6892edcd6 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -2520,10 +2520,13 @@ test "the picker lists each view under the place it is made for, the open place for (0..4) |_| _ = try dvui.testing.step(PickerFrame.frame); try std.testing.expectEqual(try y("test.output"), try y("test.files")); try std.testing.expect(try y("test.output") < try y("test.misc")); + // The sidebar holds nothing now, and still leads, empty, under its own name. + try std.testing.expect((try dvui.testing.tagGet("picker.empty")).rect.y < try y("test.files")); editor.app.layout.openPicker(editor.app.gpa, "Panel", null); for (0..4) |_| _ = try dvui.testing.step(PickerFrame.frame); try std.testing.expectEqual(try y("test.output"), try y("test.files")); try std.testing.expect(try y("test.output") < try y("test.misc")); + try std.testing.expect(dvui.tagGet("picker.empty") == null); } test "a hide_when_empty panel stays while its view is dragged onto main" { From 3a8031fa0ee44d73b344def84180b23895fe542a Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:54:51 -0500 Subject: [PATCH 34/46] app: undo and redo show a document's transport (#472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 5, first half: the transport. The hot zone and pixi's history line follow. ## What changes **Undo and redo show a document's transport** (`app.HistoryTransport`): a bar of glass at the bottom centre of that document's own pane. - **What's on it:** its history as a line with a tick per step (while they stand 6 points apart) and a knob where the document is; undo and redo bubbles either side; the step's title with how far along it is ("Edit 2 / 3"). - **The look:** three bubbles of one liquid glass (`core.dialogs.glassBubbles`), as the demo player's transport is. - **The line:** a press on the line's band goes to the step under it, and a drag follows the pointer. That's at most one `historyGoTo` a frame, so a drag across a thousand steps costs a jump a frame. A press on the title does nothing. A drag lasts only while the bar holds the mouse: if the bar stopped drawing mid-drag (a claim came), the drag is over. The bubbles step one at a time. - **Coming and going:** the bar fades in, stays while the pointer is on it or a drag holds it, and goes 3 s after it was last touched. While it shows, it sets one timer for its next change (fully in, starting to go, gone); once gone it sets none and asks nothing of the owner. - **A held undo or redo goes further each press:** one step a press for the first six, then 2, 4, 8, … up to 32. A press the other way starts a new run. Each step is the owner's own `undo`/`redo` command, so anything an owner does around an undo still happens. The bar doesn't show on a claimed document, or when there's nothing to undo or redo. - **The activity feed stays above the bar,** as it does above a claim's pill. The bar's place is computed before either draws (`place`), so they don't overlap even on its first frame. A first version used the bar's rect from the frame before, and they overlapped on the frame it came in. - **Anchors** `history.line:<path>` (its `x` is that fraction of the history), `history.back:<path>` and `history.forward:<path>`, unprefixed like `app/`'s others (`region:`, `picker:`), for tapes and tests. Documented in `docs/AUTOMATION.md`. **For any owner** with `historyLine` and `historyGoTo`, so text has it now. Pixi needs both (it has step ids since fizzyedit/pixi#45). That's in the next half, with the hot zone: the pointer near a view's bottom centre, no button held, a short dwell, bringing the bar up. **The rules** are std-only and tested on their own (`app/history_transport.zig`, `fizzy-history-transport-tests`): when the bar shows and for how long, how far a held key goes, and which step a point on the line is. **Not built yet:** - **Going back then editing still clears redo,** as undo always has. The timeline plan's "going back is a step" (`HISTORY_TIMELINE_PLAN.md` step 4) is what keeps it on the line. - **Authors and times on the line.** Authors come with `SHARED_HISTORY_PLAN`'s step 2; times are in the tracks already. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build check-web`, `zig build test-sdk-version` and `zig build check-tests-reached` pass. - `zig build test`: 572/573 (1 skipped), including the transport rules. `zig build test-integration`: 53/53 steps, 542/542 tests. - The new integration test makes 40 steps in a text document and undoes. It checks: - the bar shows, at 39; - a live tape clicking the title over the line's start leaves it at 39; - clicking the line's start goes back before every change (0), and clicking redo goes to 1; - a drag along the whole line ends at 40; - ten undos in one frame go 14 steps, landing at 26. - Seen in a running fizzy (Debug, driven through the agent plugin's bridge): the bar over the document undo was pressed in, its label, ticks and knob, and the feed's bubbles above it once its place was computed first. - [ ] Windows: not run. - [ ] Linux: CI. - [ ] Web: `check-web` builds. Not seen there yet. ## Follow-ups - The hot zone, and `historyLine` / `historyGoTo` for pixi (the second half of step 5). - "Going back is a step", so scrubbing back and editing loses nothing. - **The line is asked for each frame the bar shows.** `HistoryLine.version` can't save that, because reading the version means building the line. A `historyVersion` hook (one number, no allocation) is the seam, for when long histories make it show up in the profiler. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- app/HistoryTransport.zig | 215 ++++++++++++++++++++++++++++++++++++++ app/history_transport.zig | 146 ++++++++++++++++++++++++++ app/root.zig | 3 + build/app.zig | 4 + docs/AUTOMATION.md | 2 + src/editor/Editor.zig | 29 ++++- tests/integration.zig | 90 ++++++++++++++++ 7 files changed, 485 insertions(+), 4 deletions(-) create mode 100644 app/HistoryTransport.zig create mode 100644 app/history_transport.zig diff --git a/app/HistoryTransport.zig b/app/HistoryTransport.zig new file mode 100644 index 000000000..f00163b02 --- /dev/null +++ b/app/HistoryTransport.zig @@ -0,0 +1,215 @@ +//! A document's transport: a bar of glass at the bottom of its pane, its history as a line with a +//! knob where the document is, and undo and redo either side (`plans/RECORDING_PLAN.md`, "Undo is +//! a seek"). Undo and redo show it (`stir`); it stays while the pointer is on it and goes a few +//! seconds after (`history_transport.Shown`). A press or a drag on the line goes to the step under +//! it, one `historyGoTo` a frame at most, so a drag across a thousand steps costs a jump a frame. +//! +//! Over any owner's history (`Plugin.historyLine`, `historyGoTo`); an owner without them has no +//! transport. Nothing runs for a bar that is not showing: no history is asked for, no timer is +//! set. One per document pane, kept by the app beside the pane. +const HistoryTransport = @This(); + +const std = @import("std"); +const dvui = @import("dvui"); +const core = @import("core"); +const sdk = @import("fizzy_sdk"); +const icons = @import("icons"); +const rules = @import("history_transport.zig"); + +shown: rules.Shown = .{}, +repeat: rules.Repeat = .{}, +/// The stop the pointer holds the knob at while it drags the line, until the drag ends. Only +/// while the bar holds the mouse: a drag whose capture went (the bar stopped drawing) is over. +drag: ?usize = null, + +const bar_h: f32 = 40; +const button_d: f32 = 34; +const gap: f32 = 10; +const max_w: f32 = 460; + +/// Undo or redo was pressed at `now_ns`, or something else asked for the bar: it shows. +pub fn stir(self: *HistoryTransport, now_ns: i128) void { + self.shown.stir(now_ns); + dvui.refresh(null, @src(), null); +} + +/// A press of undo or redo (`forward`) at `now_ns` on this document: the bar shows, and the +/// press moves this many steps (`history_transport.Repeat`: more the longer the key is held). +pub fn press(self: *HistoryTransport, now_ns: i128, forward: bool) usize { + self.stir(now_ns); + return self.repeat.press(now_ns, forward); +} + +/// Where the bar is over `area` (a document's pane, physical) while it shows, as far as the +/// pointer keeps it: what else drawn at the bottom of the pane (the activity feed) stays above. +/// Null when it is not showing at `now_ns`, or the pane is too narrow for it. +pub fn place(self: *const HistoryTransport, area: dvui.Rect.Physical, now_ns: i128) ?dvui.Rect.Physical { + if (self.drag == null and self.shown.openness(now_ns) <= 0) return null; + const whole = geometry(area.toNatural()) orelse return null; + return dvui.windowRectScale().rectToPhysical(whole.outsetAll(4)); +} + +/// The bar's rect over a pane's natural rect, or null when the pane is too narrow for it. +fn geometry(nat: dvui.Rect.Natural) ?dvui.Rect { + const w = @min(max_w, nat.w - 24); + if (w < 3 * button_d) return null; + const center: dvui.Point = .{ .x = nat.x + nat.w / 2, .y = nat.y + nat.h - 14 - bar_h / 2 }; + return .{ .x = center.x - w / 2, .y = center.y - bar_h / 2, .w = w, .h = bar_h }; +} + +/// The id `historyGoTo` takes for stop `i`: 0 before the first step, else the step's. +fn stopId(line: sdk.Plugin.HistoryLine, i: usize) u64 { + return if (i == 0) 0 else line.entries[i - 1].id; +} + +/// Draw the bar over `area` (the document's pane, physical) when it shows, and take its input. +/// `id_extra` keeps one pane's bar apart from another's. +pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physical, id_extra: usize) void { + const now = dvui.frameTimeNS(); + // The pointer on it, or a drag in it, keeps it. + if (self.drag != null) self.shown.stir(now) else if (self.place(area, now)) |b| if (b.contains(dvui.currentWindow().mouse_pt)) self.shown.stir(now); + const p = self.shown.openness(now); + if (p <= 0) return; + const arena = dvui.currentWindow().arena(); + const line = doc.owner.historyLine(doc, arena) orelse return; + const n = line.entries.len; + + const wrs = dvui.windowRectScale(); + const s = wrs.s; + const whole = geometry(area.toNatural()) orelse return; + const w = whole.w; + const center = whole.center(); + const back: dvui.Rect = .{ .x = whole.x, .y = center.y - button_d / 2, .w = button_d, .h = button_d }; + const fwd: dvui.Rect = .{ .x = whole.x + w - button_d, .y = back.y, .w = button_d, .h = button_d }; + const track: dvui.Rect = .{ .x = back.x + button_d + gap, .y = whole.y, .w = w - 2 * (button_d + gap), .h = bar_h }; + const hit = wrs.rectToPhysical(whole.outsetAll(4)); + + var float: dvui.FloatingWidget = undefined; + float.init(@src(), .{}, .{ .rect = whole.outsetAll(16), .id_extra = id_extra }); + defer float.deinit(); + const bounds = float.data().rect; + const fid = float.data().id; + // A drag lives as long as the bar holds the mouse: drawn again after it was not (a claim came + // and went), it holds nothing, and the knob is the document's again. + if (self.drag != null and !dvui.captured(fid)) self.drag = null; + + const phys = [3]dvui.Rect.Physical{ wrs.rectToPhysical(back), wrs.rectToPhysical(track), wrs.rectToPhysical(fwd) }; + const pad = 14; + const x0 = phys[1].x + pad * s; + const x1 = phys[1].x + phys[1].w - pad * s; + // Where a press goes to a step: the line's band, under the title. A press on the title or + // the capsule's ends is the bar's, and does nothing. + const line_hit: dvui.Rect.Physical = .{ .x = x0 - 8 * s, .y = phys[1].y + phys[1].h * 0.45, .w = x1 - x0 + 16 * s, .h = phys[1].h * 0.55 }; + // Named for a tape or a test to aim at: the line from its first stop to its last, so a point + // a fraction along it is that fraction of the history. + const path = doc.owner.documentPath(doc); + core.anchor.markRect(fid, phys[0], true, "history.back:{s}", .{path}); + core.anchor.markRect(fid, .{ .x = x0, .y = phys[1].y, .w = x1 - x0, .h = phys[1].h }, true, "history.line:{s}", .{path}); + core.anchor.markRect(fid, phys[2], true, "history.forward:{s}", .{path}); + + // Input: the buttons step, the line goes where it is pressed and follows a drag. + var go: ?usize = null; + for (dvui.events()) |*e| { + if (!dvui.eventMatch(e, .{ .id = fid, .r = hit })) continue; + const me = switch (e.evt) { + .mouse => |m| m, + else => continue, + }; + switch (me.action) { + .press => if (me.button.pointer()) { + e.handle(@src(), float.data()); + if (phys[0].contains(me.p)) { + go = line.at -| 1; + } else if (phys[2].contains(me.p)) { + go = @min(n, line.at + 1); + } else if (line_hit.contains(me.p)) { + dvui.captureMouse(float.data(), e.num); + self.drag = rules.stopAt(me.p.x, x0, x1, n); + } + }, + .motion => if (self.drag != null and dvui.captured(fid)) { + e.handle(@src(), float.data()); + self.drag = rules.stopAt(me.p.x, x0, x1, n); + }, + .release => if (me.button.pointer() and dvui.captured(fid)) { + e.handle(@src(), float.data()); + dvui.captureMouse(null, e.num); + self.drag = null; + }, + else => {}, + } + } + if (self.drag) |d| go = d; + var at = line.at; + if (go) |to| if (to != at) { + if (doc.owner.historyGoTo(doc, stopId(line, to))) at = to; + dvui.refresh(null, @src(), fid); + }; + + const prev_alpha = dvui.alpha(p); + defer dvui.alphaSet(prev_alpha); + const pt = dvui.currentWindow().mouse_pt; + var bubbles: [3]core.dialogs.Bubble = undefined; + for (phys, &bubbles, 0..) |r, *b, i| b.* = .{ + .rect = r, + .light = if (i != 1 and r.contains(pt) and (if (i == 0) at > 0 else at < n)) core.dialogs.bubble_hover else 0, + }; + core.dialogs.glassBubbles(fid, &bubbles, s, .{ .merge = 14, .shadow = 1 }); + + const ink = dvui.themeGet().color(.window, .text); + button(bounds, back, "history_back", icons.tvg.lucide.undo, ink.opacity(if (at > 0) 0.85 else 0.3)); + button(bounds, fwd, "history_forward", icons.tvg.lucide.redo, ink.opacity(if (at < n) 0.85 else 0.3)); + + // What the document is at, over the line: the step's title, and how far along. + const font = dvui.Font.theme(.body).larger(-2); + var buf: [96]u8 = undefined; + const title = if (at == 0) "Before any change" else line.entries[at - 1].title; + const label = std.fmt.bufPrint(&buf, "{s} {d} / {d}", .{ title, at, n }) catch title; + dvui.labelNoFmt(@src(), label, .{}, .{ + .rect = .{ .x = track.x + pad - bounds.x, .y = track.y + 4 - bounds.y, .w = track.w - 2 * pad, .h = font.lineHeight() }, + .padding = .{}, + .margin = .{}, + .font = font, + .color_text = .{ .color = ink.opacity(0.85) }, + }); + paintLine(.{ .x = x0, .y = phys[1].y + phys[1].h - 16 * s, .w = @max(1, x1 - x0), .h = 10 * s }, at, n, ink, s); + + // Comes in, goes: a frame each while it fades, and one wake for when it next changes on its + // own (it starts to go, it is gone). + if (p < 1) dvui.refresh(null, @src(), fid); + if (self.shown.nextChange(now)) |t| dvui.timer(fid, @intCast(@min(@divTrunc(t - now, 1000) + 1, std.math.maxInt(i32)))); +} + +/// A step button's icon, in the middle of its bubble `r` (window-natural; `bounds` the layer's). +fn button(bounds: dvui.Rect, r: dvui.Rect, name: []const u8, tvg: []const u8, color: dvui.Color) void { + const d = r.w * 0.44; + core.icon.icon(@src(), name, tvg, .{ + .stroke_color = .{ .color = color }, + .fill_color = .{ .color = color }, + }, .{ + .id_extra = @intFromPtr(name.ptr), + .rect = .{ .x = r.x + (r.w - d) / 2 - bounds.x, .y = r.y + (r.h - d) / 2 - bounds.y, .w = d, .h = d }, + }); +} + +/// The line `r` (physical): the history to `at` lit, a tick per step while they fit, the knob. +fn paintLine(r: dvui.Rect.Physical, at: usize, n: usize, ink: dvui.Color, s: f32) void { + const h = 4 * s; + const line: dvui.Rect.Physical = .{ .x = r.x, .y = r.y + (r.h - h) / 2, .w = r.w, .h = h }; + line.fill(.all(h / 2), .{ .color = .{ .color = ink.opacity(0.15) } }); + const accent = dvui.themeGet().color(.highlight, .fill); + const knob_x = rules.stopX(at, r.x, r.x + r.w, n); + var done = line; + done.w = knob_x - line.x; + if (done.w > 0) done.fill(.all(h / 2), .{ .color = .{ .color = accent } }); + // A tick per step while they stand 6 points apart; past that the line is its own scale. + if (n > 0 and r.w / @as(f32, @floatFromInt(n)) >= 6 * s) for (1..n) |i| { + const x = rules.stopX(i, r.x, r.x + r.w, n); + const tick: dvui.Rect.Physical = .{ .x = x - 0.5 * s, .y = r.y + 1 * s, .w = 1 * s, .h = r.h - 2 * s }; + tick.fill(.{}, .{ .color = .{ .color = ink.opacity(0.25) } }); + }; + var knob: dvui.Path.Builder = .init(dvui.currentWindow().lifo()); + defer knob.deinit(); + knob.addArc(.{ .x = knob_x, .y = line.y + h / 2 }, 6 * s, std.math.tau, 0, true); + knob.build().fillConvex(.{ .color = .{ .color = accent }, .fade = 1 }); +} diff --git a/app/history_transport.zig b/app/history_transport.zig new file mode 100644 index 000000000..7aae14312 --- /dev/null +++ b/app/history_transport.zig @@ -0,0 +1,146 @@ +//! The rules of a document's transport (`HistoryTransport`): when its bar shows and for how long, +//! how far a held undo or redo goes each press, and which step a point on its line is. +//! +//! Pure decisions over times and sizes, std-only, so they test without a window +//! (`fizzy-history-transport-tests`). +const std = @import("std"); + +/// How long the bar stays once nothing touches it (`plans/RECORDING_PLAN.md`, "Undo is a seek"). +pub const linger_ns: i128 = 3 * std.time.ns_per_s; +/// How long it takes to come in, and to go. +pub const fade_ns: i128 = 160 * std.time.ns_per_ms; + +/// When the bar was last asked for: an undo, a redo, the pointer on it. +pub const Shown = struct { + at_ns: ?i128 = null, + + /// Asked for at `now_ns`. Still coming in, it carries on; showing, it stays in and its linger + /// starts over; gone, it comes in from nothing. + pub fn stir(self: *Shown, now_ns: i128) void { + if (self.at_ns) |at| if (now_ns - at < fade_ns) return; + self.at_ns = if (self.openness(now_ns) > 0) now_ns - fade_ns else now_ns; + } + + /// How much of the bar shows at `now_ns`, 0 gone to 1 in: in over `fade_ns`, held, and out over + /// `fade_ns` once it has lingered. + pub fn openness(self: Shown, now_ns: i128) f32 { + const at = self.at_ns orelse return 0; + const since = now_ns - at; + if (since < 0) return 1; + if (since < fade_ns) return frac(since, fade_ns); + const left = linger_ns + fade_ns - since; + if (left <= 0) return 0; + return if (left < fade_ns) frac(left, fade_ns) else 1; + } + + /// When the bar next changes on its own (comes fully in, starts to go, is gone): what its timer + /// waits for. Null when it is gone, so a bar not showing wakes nothing. + pub fn nextChange(self: Shown, now_ns: i128) ?i128 { + const at = self.at_ns orelse return null; + for ([_]i128{ at + fade_ns, at + linger_ns, at + linger_ns + fade_ns }) |t| if (t > now_ns) return t; + return null; + } + + fn frac(n: i128, d: i128) f32 { + return @floatCast(@as(f64, @floatFromInt(n)) / @as(f64, @floatFromInt(d))); + } +}; + +/// Presses closer together than this are a key held down, its repeats. +pub const repeat_gap_ns: i128 = 150 * std.time.ns_per_ms; + +/// How far each press of a held undo or redo goes: one step at a time for the first few, then +/// further, so holding the key scrubs a long history in a second or two. +pub const Repeat = struct { + last_ns: ?i128 = null, + /// Which way the run goes: a press the other way starts a new one. + forward: bool = false, + /// Presses in this run, the first one included. + run: u32 = 0, + + /// A press at `now_ns`, of redo (`forward`) or undo: how many steps it moves. + pub fn press(self: *Repeat, now_ns: i128, forward: bool) usize { + const held = if (self.last_ns) |l| now_ns - l < repeat_gap_ns and forward == self.forward else false; + self.run = if (held) self.run + 1 else 1; + self.last_ns = now_ns; + self.forward = forward; + return stepsAt(self.run); + } + + /// The step a run of `run` presses takes: 1 for the first six (a deliberate few undos stay one + /// at a time), then doubling every four presses, up to 32. + pub fn stepsAt(run: u32) usize { + if (run <= 6) return 1; + const doublings: u5 = @intCast(@min(5, (run - 7) / 4 + 1)); + return @as(usize, 1) << doublings; + } +}; + +/// The step a point `x` on a line from `x0` to `x1` means, in a history of `n` steps: how many +/// of them are applied, 0 (before the first) to `n`. The line is split into `n + 1` equal stops, +/// the point taking the nearest. +pub fn stopAt(x: f32, x0: f32, x1: f32, n: usize) usize { + if (n == 0 or x1 <= x0) return 0; + const f = std.math.clamp((x - x0) / (x1 - x0), 0, 1); + return @intFromFloat(@round(f * @as(f32, @floatFromInt(n)))); +} + +/// Where stop `i` of `n` sits on the line from `x0` to `x1`. +pub fn stopX(i: usize, x0: f32, x1: f32, n: usize) f32 { + if (n == 0) return x0; + return x0 + (x1 - x0) * @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(n)); +} + +// ---- tests --------------------------------------------------------------------------------- + +const testing = std.testing; +const ms = std.time.ns_per_ms; + +test "the bar comes in, lingers, goes, and wakes nothing once gone" { + var s: Shown = .{}; + try testing.expectEqual(@as(f32, 0), s.openness(0)); + try testing.expect(s.nextChange(0) == null); + s.stir(1000 * ms); + try testing.expectEqual(@as(f32, 0.5), s.openness(1080 * ms)); + try testing.expectEqual(@as(f32, 1), s.openness(2000 * ms)); + try testing.expectEqual(@as(?i128, 1000 * ms + linger_ns), s.nextChange(2000 * ms)); + try testing.expect(s.openness(1000 * ms + linger_ns + fade_ns / 2) < 1); + try testing.expectEqual(@as(f32, 0), s.openness(1000 * ms + linger_ns + fade_ns)); + try testing.expect(s.nextChange(1000 * ms + linger_ns + fade_ns) == null); +} + +test "asked for again while it shows, it stays in rather than fading in again" { + var s: Shown = .{}; + s.stir(0); + try testing.expectEqual(@as(f32, 1), s.openness(1000 * ms)); + s.stir(2500 * ms); + try testing.expectEqual(@as(f32, 1), s.openness(2500 * ms)); + // Its linger starts again from the stir. + try testing.expectEqual(@as(f32, 1), s.openness(2500 * ms + linger_ns - fade_ns)); + try testing.expectEqual(@as(f32, 0), s.openness(2500 * ms + linger_ns + 2 * fade_ns)); + // Gone, a stir brings it in from nothing. + s.stir(60_000 * ms); + try testing.expect(s.openness(60_000 * ms) < 0.01); +} + +test "a held key goes one step at a time, then further" { + var r: Repeat = .{}; + var got: [16]usize = undefined; + for (&got, 0..) |*g, i| g.* = r.press(@as(i128, @intCast(i)) * 50 * ms, false); + try testing.expectEqualSlices(usize, &.{ 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 8, 8 }, &got); + try testing.expectEqual(@as(usize, 32), Repeat.stepsAt(1000)); + // The other way, at once, is a first press: overshooting and coming back is one step. + try testing.expectEqual(@as(usize, 1), r.press(16 * 50 * ms, true)); + // A pause, and the next press is a first one again. + try testing.expectEqual(@as(usize, 1), r.press(10_000 * ms, true)); +} + +test "a point on the line is the nearest step" { + try testing.expectEqual(@as(usize, 0), stopAt(0, 0, 100, 10)); + try testing.expectEqual(@as(usize, 10), stopAt(100, 0, 100, 10)); + try testing.expectEqual(@as(usize, 5), stopAt(52, 0, 100, 10)); + try testing.expectEqual(@as(usize, 0), stopAt(-40, 0, 100, 10)); + try testing.expectEqual(@as(usize, 10), stopAt(140, 0, 100, 10)); + try testing.expectEqual(@as(usize, 0), stopAt(50, 0, 100, 0)); + for (0..11) |i| try testing.expectEqual(i, stopAt(stopX(i, 20, 220, 10), 20, 220, 10)); +} diff --git a/app/root.zig b/app/root.zig index d0fb148b5..e87fbf614 100644 --- a/app/root.zig +++ b/app/root.zig @@ -42,6 +42,8 @@ pub const Recents = @import("Recents.zig"); pub const ActivityFeed = @import("ActivityFeed.zig"); /// A track per open document: its history with the time put back in. pub const Tracks = @import("Tracks.zig"); +/// A document's transport: its history as a line at the bottom of its pane, undo and redo as seeks. +pub const HistoryTransport = @import("HistoryTransport.zig"); /// Key chords → command ids: the table, its ZON form, chord matching, and the dvui adapter. pub const keymap = struct { @@ -168,4 +170,5 @@ test { _ = watch.ExecutableWatcher; _ = store.Loader; _ = Tracks; + _ = HistoryTransport; } diff --git a/build/app.zig b/build/app.zig index 81d0e95e5..8a213a70c 100644 --- a/build/app.zig +++ b/build/app.zig @@ -647,6 +647,10 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. // A document state as the bytes that differ from another: how a track's checkpoints // stay small. std-only (see state_delta.zig); `Tracks` keeps its checkpoints with it. .{ "fizzy-state-delta-tests", "app/state_delta.zig" }, + // When a document's transport shows and for how long, how far a held undo goes, which + // step a point on its line is. std-only (see history_transport.zig); `HistoryTransport` + // draws it. + .{ "fizzy-history-transport-tests", "app/history_transport.zig" }, // One material on two sliders: what every glass is at the window's opacity and roughness, // the OS's form and the app's. std-only (see glass_look.zig); `LiquidField` and `Popout` // apply it. diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 1367a1f0e..e9cf18e17 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -149,6 +149,8 @@ user's motion settings, timed in demo time. | `fizzy.command:<command id>` | a menu row that runs a command | | `fizzy.dialog.close` | the close button in a dialog's header | | `fizzy.dialog:<title>:<label>` | a button in a dialog: its footer's, or one drawn with `core.dialogs.bodyButton` (`fizzy.dialog:About Fizzy:Close`) | +| `history.line:<abs path>` | a document's transport line, from its first stop to its last, while the transport shows: `x` is that fraction of its history (`x = 0` before any change) | +| `history.back:<abs path>`, `history.forward:<abs path>` | its undo and redo buttons | | `fizzy.window:main` | the main window | | `fizzy.window:dialog:<title>`, `fizzy.window:float:<title>`, `fizzy.window:menu` | an OS window of the app's own: a dialog's, a popped-out view's, a menu's (a second of the same name gets `:2`) | | `region:<name>` | a layout region (`Main`, `Panel`, …) | diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index e5fba8cdd..58e265248 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -2731,6 +2731,8 @@ const DocSurface = struct { focus_inside: bool = false, /// The commands just run on it, shown on its pane. feed: @import("app").ActivityFeed = .{}, + /// Its history as a line at the bottom of the pane: undo and redo show it. + transport: @import("app").HistoryTransport = .{}, }; pub fn insertOpenDoc(editor: *Editor, doc_buf: *anyopaque, owner: *sdk.Plugin, id: u64) !void { @@ -2947,9 +2949,12 @@ fn drawDocSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result { if (editor.app.host.activity_muted) ds.feed.clear() else { const now = dvui.frameTimeNS(); ds.feed.take(&editor.app.host, doc.id, now); - // Above the pill, while someone holds the document. + // Above the pill while someone holds the document, and above the transport while it shows. var area = canvas.data().borderRectScale().r; if (claim_pill) |pill| area.h = pill.physical.y - area.y + 6 * dvui.windowRectScale().s; + if (claim_pill == null) if (ds.transport.place(area, now)) |bar| { + area.h = @min(area.h, bar.y - area.y + 6 * dvui.windowRectScale().s); + }; ds.feed.draw(area, @truncate(ds.doc_id), now); } @@ -2973,7 +2978,8 @@ fn drawDocSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result { }, false); } } - if (claim) |c| drawClaim(editor, c, claim_pill.?, doc.owner.documentPath(doc)); + // Its history, while undo or redo or the pointer keep it: not while someone else holds it. + if (claim) |c| drawClaim(editor, c, claim_pill.?, doc.owner.documentPath(doc)) else ds.transport.draw(doc, canvas.data().borderRectScale().r, @truncate(ds.doc_id)); return .ok; } @@ -6061,11 +6067,26 @@ pub fn processPendingSaveAs(editor: *Editor) void { } pub fn undo(editor: *Editor) !void { - try editor.runActiveDocCommand("undo"); + try editor.historyStep(.undo); } pub fn redo(editor: *Editor) !void { - try editor.runActiveDocCommand("redo"); + try editor.historyStep(.redo); +} + +/// An undo or a redo of the active document, through its owner's command: its transport shows, +/// and a key held down goes further each press (`HistoryTransport.press`), the command run that +/// many times. Nothing to undo, or a claimed document: the command as ever, and no bar. +fn historyStep(editor: *Editor, action: enum { undo, redo }) !void { + const doc = editor.activeDoc() orelse return; + const name = @tagName(action); + const shows = editor.app.host.documentClaim(doc.id) == null and editor.activeDocCommandEnabled(name); + const ds = if (shows) editor.doc_surfaces.get(doc.id) else null; + const by = if (ds) |d| d.transport.press(dvui.frameTimeNS(), action == .redo) else 1; + for (0..by) |_| { + if (!editor.activeDocCommandEnabled(name)) break; + try editor.runActiveDocCommand(name); + } } pub fn openInFileBrowser(editor: *Editor, path: []const u8) !void { diff --git a/tests/integration.zig b/tests/integration.zig index 6892edcd6..e86cf045d 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7711,6 +7711,96 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try dvui.testing.settle(headlessFrame); } + // A document's transport (`app.HistoryTransport`): undo shows it over the document's pane; a + // press on its line goes to that step, its buttons step, a drag along it follows; and an undo + // held down goes further each press. + { + const host = &editor.app.host; + const was = editor.activeDoc() orelse return error.TestUnexpectedResult; + try host.runCommand("fizzy.newFile"); + for (0..200) |_| { + _ = try dvui.testing.step(headlessFrame); + const d = editor.activeDoc() orelse continue; + if (d.id != was.id and std.mem.startsWith(u8, std.fs.path.basename(d.owner.documentPath(d)), "untitled")) break; + } + const doc = editor.activeDoc() orelse return error.TestUnexpectedResult; + try std.testing.expect(doc.id != was.id); + const path = doc.owner.documentPath(doc); + for (0..40) |_| try std.testing.expect(host.callCommand("text.insert", ".{ .text = \"x\", .line = 1 }", host.arena()) == .ok); + try dvui.testing.settle(headlessFrame); + const at = struct { + fn at(h: *sdk.Host, d: sdk.DocHandle) !usize { + return (d.owner.historyLine(d, h.arena()) orelse return error.TestUnexpectedResult).at; + } + }.at; + const ds = editor.doc_surfaces.get(doc.id) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(f32, 0), ds.transport.shown.openness(dvui.frameTimeNS())); + + try editor.undo(); + for (0..4) |_| _ = try dvui.testing.step(headlessFrame); + try std.testing.expect(ds.transport.shown.openness(dvui.frameTimeNS()) > 0); + try std.testing.expectEqual(@as(usize, 39), try at(host, doc)); + + const play = struct { + fn play(ed: *fizzy.Editor, script: *automation.Script) !void { + try ed.demo.live.play(try script.finish()); + for (0..300) |_| { + if (!ed.demo.live.playing()) break; + _ = try dvui.testing.step(headlessFrame); + } + try std.testing.expect(ed.demo.live.outcome.? == .finished); + } + }.play; + const line_tag = try std.fmt.allocPrint(gpa, "history.line:{s}", .{path}); + defer gpa.free(line_tag); + const forward_tag = try std.fmt.allocPrint(gpa, "history.forward:{s}", .{path}); + defer gpa.free(forward_tag); + { + // A press on the title, over the line's start, is the bar's and goes nowhere. + var s: automation.Script = .init(gpa, "transport-title", ""); + s.check = automation.Input.check; + s.check.live = true; + try s.click(.{ .tag = line_tag, .x = 0, .y = 0.15 }, .{}); + try play(editor, &s); + } + try std.testing.expectEqual(@as(usize, 39), try at(host, doc)); + { + // The line's start: before any change. + var s: automation.Script = .init(gpa, "transport-start", ""); + s.check = automation.Input.check; + s.check.live = true; + try s.click(.{ .tag = line_tag, .x = 0 }, .{}); + try play(editor, &s); + } + try std.testing.expectEqual(@as(usize, 0), try at(host, doc)); + { + // Redo: one step on. + var s: automation.Script = .init(gpa, "transport-forward", ""); + s.check = automation.Input.check; + s.check.live = true; + try s.click(.{ .tag = forward_tag }, .{}); + try play(editor, &s); + } + try std.testing.expectEqual(@as(usize, 1), try at(host, doc)); + { + // A drag from the start to the end follows the pointer there. + var s: automation.Script = .init(gpa, "transport-drag", ""); + s.check = automation.Input.check; + s.check.live = true; + try s.drag(.{ .tag = line_tag, .x = 0 }, .{ .tag = line_tag, .x = 1 }, .{}); + try play(editor, &s); + } + try std.testing.expectEqual(@as(usize, 40), try at(host, doc)); + + // Held down, ten presses in a frame: one step each for six, then two each. + for (0..10) |_| try editor.undo(); + try std.testing.expectEqual(@as(usize, 40 - 14), try at(host, doc)); + + try editor.rawCloseFileID(doc.id); + editor.workbench.setActiveDocIndex(editor.app.open_files.getIndex(was.id) orelse return error.TestUnexpectedResult); + try dvui.testing.settle(headlessFrame); + } + // Revealing a view (`Host.revealSurface`): with the bottom panel shut, revealing Output opens // the panel with Output selected, and a view no region shows is not revealed. { From ef30c0cc5e187fedba89b2d7f2f333bfaa2b358d Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:07:54 -0500 Subject: [PATCH 35/46] sdk: who made each step: Author, act, and the host's author map (#474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of fizzyedit/plans#14: `plans/SHARED_HISTORY_PLAN.md`, step 2, as amended in fizzyedit/plans#21. This is the SDK half; the app half (the frame's input author, tapes acting, activity bubbles naming others, the integration check) follows as a PR stacked on this one. ## What changes The host learns who made each step of a document's history. It keeps this on the channel owners already report steps through (`Host.recordStep`, #456), not a second one. - **`sdk.Author`:** the person, an agent, a tape, or nobody known (`unattributed`, a missed site). Identity is kind, plugin and session; the label ("Claude", "the tour") is display only. - **Who is acting:** - `act(author)` and `endAct(outer)`: the innermost act wins. An outermost `act` starts a run (one agent call, one tape op). - `acted()` and `actAgain(stored)`: work put off (pixi's call that waits out the person's stroke) runs as the author and run it was asked in. - `author()`: the innermost act, else the frame's input author (`setInputAuthor`, for the app to set where input is consumed), else `unattributed`. - **`recordStep`:** - keeps who made every step that isn't the person's, per document, by step id, plus the document's top step; - counts steps made with nobody acting (`unattributed_steps`, which the app's tests will assert stays 0); - forwards a step to the recorder only while it records, as before. - Working alone, nothing is kept: a document gets an entry the first time someone else acts on it. - **Queries:** `stepAuthor`, `topStep` and `mayMerge` (whether the acting author may fold an edit into a step; it keeps an agent's keystrokes from joining the person's run of typing). `forgetDocumentAuthors` runs when a document closes, beside `releaseDocumentClaims`. - **`Step.top`:** the entry on top after the step, so Undo can name whose step it takes back. An owner that leaves it 0 still gets a sensible top after `added` and `joined`. - **`docs/PLUGINS.md`:** - owners report every step and gate only the encoding on `recording`; - an **Authors** entry: `act` around commands run on someone's behalf, `actAgain` for deferred work, `mayMerge` for runs. Nothing calls `act` yet. Owners report steps only while recording until the plan's step 3. So nothing a person sees changes in this PR. ## SDK impact - [ ] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [x] Fingerprint moved: recorded in `sdk/src/version.zig` (`0x78f2a3cc371828ef`), `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag **For plugin authors:** additive. `Step` has a new defaulted field `top`, and the host has new functions. Nothing existing changes shape. After the release that carries this: - agent's `agentCall` acts as its connection; - chat acts around its ACP writes; - pixi's waiting calls store `acted()` and run in `actAgain`. ## Verified - [x] macOS: - **Unit tests** in `sdk/src/Host.zig` and `sdk/src/Author.zig`: - authors and the top through added, person, undone and restarted steps; - nesting, runs and `actAgain`; - interning and late labels; - `mayMerge` both ways; - the unattributed count. - `zig build test`: 568/569 passed (1 skipped). `check-tests-reached` passes. - **Builds:** `zig build`, `test-sdk-version`, `check-web` and `test-integration`. Results are in the comment below once they finish. - **Store plugins:** pixi and agent build against this `sdk/`. - [ ] Windows: CI cross-compile. - [ ] Linux: CI. - [ ] Web: `check-web`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- app/App.zig | 2 + docs/PLUGINS.md | 15 ++- sdk/src/Author.zig | 38 ++++++ sdk/src/Host.zig | 300 +++++++++++++++++++++++++++++++++++++++++++- sdk/src/Step.zig | 5 + sdk/src/sdk.zig | 2 + sdk/src/version.zig | 2 +- 7 files changed, 357 insertions(+), 7 deletions(-) create mode 100644 sdk/src/Author.zig diff --git a/app/App.zig b/app/App.zig index c8f19265d..12d316d5c 100644 --- a/app/App.zig +++ b/app/App.zig @@ -932,6 +932,8 @@ pub fn closeDocumentResources(app: *App, doc: sdk.DocHandle) void { if (app.tracks) |*t| t.closed(doc.id); // Whoever held it is holding nothing now (`sdk.Claim`). app.host.releaseDocumentClaims(doc.id); + // Nor are its steps anyone's now (`Host.stepAuthor`). + app.host.forgetDocumentAuthors(doc.id); _ = doc.owner.closeDocument(doc); doc.owner.unregisterDocument(doc.id); } diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index a622b6f2d..3ad29197c 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -667,8 +667,9 @@ gets the same, and `DropZones.wheel`/`at`/`draw` give a plugin the same drop for document open just is not snapshotted, and seeks through it replay from the keyframe. `text` implements all three. - **Steps** (optional) — what a document's history records and replays (`sdk.Step`, - `plans/RECORDING_PLAN.md`). While `host.recording(doc.id)` says so, the owner reports each - change as it happens with `host.recordStep(doc.id, step)`: a new history entry (`added`), more of + `plans/RECORDING_PLAN.md`). The owner reports each change as it happens with + `host.recordStep(doc.id, step)`, and encodes its `effect` only while `host.recording(doc.id)` + says so: the host keeps who made every step (below), recorded or not. Each is a new history entry (`added`), more of the entry on top (`joined`, a run of typing), an entry taken back (`undone`, its effect the reverse) or put back (`redone`), and the history starting over (`restarted`, a reload). Every step's `effect` applies **forward**, in the owner's own encoding, numbered by the plugin's @@ -676,7 +677,15 @@ gets the same, and `DropZones.wheel`/`at`/`draw` give a plugin the same drop for when the owner knows. `applyStep(doc, effect, motion, ms)` lands one as a new history entry, shown being made over `ms` (0: at once), and refuses one that does not fit what the document holds, leaving it as it was. Nobody recording, nothing is encoded. `text` implements both: - each step is a transaction's edits, removed and inserted bytes, and the selection after. + each step is a transaction's edits, removed and inserted bytes, and the selection after. Fill + `top` (the entry on top once the step is done) so Undo can say whose step it takes back. +- **Authors** — who made each step (`sdk.Author`: the person, an agent, a tape; + `plans/SHARED_HISTORY_PLAN.md`). The host keeps it by step id; owners never store one. A plugin + changing a document on someone's behalf acts as them around the commands it runs + (`const outer = host.act(author); defer host.endAct(outer);`), and work it puts off stores + `host.acted()` and runs inside `host.actAgain(stored)`. An owner whose edits join a run (typing) + asks `host.mayMerge(doc.id, top_step)` and starts a new step when it says no, so one hand's + edits never fold into another's. `host.stepAuthor` and `host.topStep` say who made a step. - **Rendering** — `drawDocument(doc)` (the document's content, called through the document's surface wherever a region accepting `document` shows it), `infobarEntries(active_doc)` (icon + text chips Fizzy draws in the infobar; plugins do not draw into the bar). diff --git a/sdk/src/Author.zig b/sdk/src/Author.zig new file mode 100644 index 000000000..de3d51707 --- /dev/null +++ b/sdk/src/Author.zig @@ -0,0 +1,38 @@ +//! Who made a change to a document: the person, an agent, a tape, or nobody known +//! (`plans/SHARED_HISTORY_PLAN.md`). The host keeps a step's author by the step's id +//! (`Host.recordStep`); owners never store one. +const std = @import("std"); +const Author = @This(); + +kind: Kind, +/// Which one: the plugin acting, and its own id for the session or connection (the agent +/// plugin's connection, chat's session). Two agents both called "Claude" are two authors. +plugin: []const u8 = "", +session: u64 = 0, +/// As the person reads it: "Claude", "the tour". Display only, never identity. +label: []const u8 = "", + +pub const Kind = enum { + /// Whoever is at the keyboard and pointer. Their steps are never stored: a step with no + /// author kept is theirs. + person, + agent, + tape, + /// A step recorded where nobody acts: a missed site, never quietly the person's. + unattributed, +}; + +pub const person: Author = .{ .kind = .person }; +pub const unattributed: Author = .{ .kind = .unattributed }; + +/// The same author: kind, plugin and session. The label is not identity. +pub fn same(a: Author, b: Author) bool { + return a.kind == b.kind and a.session == b.session and std.mem.eql(u8, a.plugin, b.plugin); +} + +test "an author is its kind, plugin and session, not its label" { + const a: Author = .{ .kind = .agent, .plugin = "agent", .session = 1, .label = "Claude" }; + try std.testing.expect(a.same(.{ .kind = .agent, .plugin = "agent", .session = 1, .label = "An agent" })); + try std.testing.expect(!a.same(.{ .kind = .agent, .plugin = "agent", .session = 2, .label = "Claude" })); + try std.testing.expect(!a.same(person)); +} diff --git a/sdk/src/Host.zig b/sdk/src/Host.zig index 494ea31fc..de155910f 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -36,6 +36,7 @@ pub const accounts = @import("accounts.zig"); pub const Command = @import("Command.zig"); pub const Claim = @import("Claim.zig"); pub const Step = @import("Step.zig"); +pub const Author = @import("Author.zig"); pub const events = @import("events.zig"); /// Per-plugin opaque settings blobs pending a write: plugin id -> serialized zon text, or `null` @@ -291,6 +292,22 @@ claims: std.ArrayListUnmanaged(Claim) = .empty, /// Who records documents' steps (`recordStep`): the app's tracks. Null, nobody does, and owners /// encode nothing. recorder: ?Recorder = null, +/// Every author that has acted, interned by identity (`Author.same`); `Acting` and the step map +/// hold indices into it. Strings are copies, so a label outlives the plugin that gave it. +authors: std.ArrayListUnmanaged(Author) = .empty, +/// Who is acting now (`act`): an index into `authors`, and the run it belongs to. Null: nobody. +acting: Acting = .{}, +/// The frame's input author, set by the app where input is consumed (`setInputAuthor`): the +/// person's, or a tape's while one drives. Null outside those places. +input_author: ?u32 = null, +/// Runs started (`act`): one per outermost `act`. +runs: u64 = 0, +/// Who made each step not the person's, per document (`recordStep`). A document gets an entry only +/// once someone other than the person acts on it, so working alone this stays empty. +step_authors: std.AutoHashMapUnmanaged(u64, DocAuthors) = .empty, +/// Steps recorded where nobody acts (`Author.Kind.unattributed`): a missed site. The integration +/// tests assert it stays 0. +unattributed_steps: u64 = 0, next_claim_id: u32 = 1, /// Pluggable language/format support (syntax highlighting, preview panes). language_support: std.ArrayListUnmanaged(LanguageSupport) = .empty, @@ -333,6 +350,14 @@ pub fn deinit(self: *Host) void { for (self.claims.items) |c| self.allocator.free(c.label); self.claims.deinit(self.allocator); + for (self.authors.items) |a| { + self.allocator.free(a.plugin); + self.allocator.free(a.label); + } + self.authors.deinit(self.allocator); + var docs = self.step_authors.valueIterator(); + while (docs.next()) |d| d.steps.deinit(self.allocator); + self.step_authors.deinit(self.allocator); self.plugins.deinit(self.allocator); self.services.deinit(self.allocator); self.selections.deinit(self.allocator); @@ -1586,10 +1611,180 @@ pub fn recording(self: *const Host, doc_id: u64) bool { } /// A step of `doc_id`'s history, from its **owner**, as it happens (`Step`): each entry added or -/// joined, undone or redone, and the history starting over. Only while `recording(doc_id)`. +/// joined, undone or redone, and the history starting over. Every step, recorded or not: the host +/// keeps who made it (`stepAuthor`) and the document's top (`topStep`). An owner encodes `effect` +/// only while `recording(doc_id)`; the recorder hears the step only then. pub fn recordStep(self: *Host, doc_id: u64, step: Step) void { - const r = self.recorder orelse return; - r.vtable.step(r.ctx, doc_id, step); + self.noteAuthor(doc_id, step); + if (self.recording(doc_id)) { + const r = self.recorder.?; + r.vtable.step(r.ctx, doc_id, step); + } +} + +// ---- authors (`plans/SHARED_HISTORY_PLAN.md`) ------------------------------------------------ + +/// Who is acting, to restore with `endAct` or to store and act as again later (`actAgain`): an +/// index into the host's authors, and the run. +pub const Acting = struct { + author: ?u32 = null, + run: u64 = 0, +}; + +/// One document's authors: who made each step that is not the person's, by step id, and the step +/// on top. +pub const DocAuthors = struct { + steps: std.AutoHashMapUnmanaged(u64, StepAuthor) = .empty, + top: u64 = 0, +}; + +pub const StepAuthor = struct { + /// Index into the host's authors. + author: u32, + /// The run it was made in (`act`): one MCP call of an agent's, one op of a tape's. + run: u64, +}; + +/// Act as `author` until `endAct` with what this returns: the steps recorded meanwhile are +/// theirs. The outermost `act` starts a run; one inside it keeps it. Innermost wins. UI thread +/// only. +pub fn act(self: *Host, who: Author) Acting { + const outer = self.acting; + const index = self.intern(who); + self.acting = .{ + .author = index, + .run = if (outer.author == null) run: { + self.runs += 1; + break :run self.runs; + } else outer.run, + }; + return outer; +} + +/// Act as before: `stored` is what `acted` said when a call was put off (pixi's call waiting out +/// the person's stroke), so its steps are still that author's, in that run. +pub fn actAgain(self: *Host, stored: Acting) Acting { + const outer = self.acting; + self.acting = stored; + return outer; +} + +/// Who is acting now, to store with work done later (`actAgain`). +pub fn acted(self: *const Host) Acting { + return self.acting; +} + +pub fn endAct(self: *Host, outer: Acting) void { + self.acting = outer; +} + +/// Who is acting now: the innermost `act`, else the frame's input author where input is being +/// consumed, else nobody known. +pub fn author(self: *const Host) Author { + const index = self.acting.author orelse self.input_author orelse return .unattributed; + return self.authors.items[index]; +} + +/// The frame's input author (the app's to set, around where input is consumed): the person's, or +/// a tape's while one drives. Null where no input is consumed. +pub fn setInputAuthor(self: *Host, who: ?Author) void { + self.input_author = if (who) |a| self.intern(a) else null; +} + +/// Who made step `id` of `doc_id`: the person when none other is kept. +pub fn stepAuthor(self: *const Host, doc_id: u64, id: u64) Author { + const doc = self.step_authors.getPtr(doc_id) orelse return .person; + const s = doc.steps.get(id) orelse return .person; + return self.authors.items[s.author]; +} + +pub const TopStep = struct { id: u64, author: Author }; + +/// The step on top of `doc_id`'s history and who made it, when the host knows it: once someone +/// other than the person has acted on the document, from the steps its owner reports. +pub fn topStep(self: *const Host, doc_id: u64) ?TopStep { + const doc = self.step_authors.getPtr(doc_id) orelse return null; + if (doc.top == 0) return null; + return .{ .id = doc.top, .author = self.stepAuthor(doc_id, doc.top) }; +} + +/// Whether the acting author may fold an edit into step `id` of `doc_id`: when `id` is theirs (a +/// step with no author kept is the person's). False ends a run of typing: the owner starts a new +/// step, so an agent's keystrokes never join the person's. +pub fn mayMerge(self: *const Host, doc_id: u64, id: u64) bool { + const who = self.author(); + const made = self.stepAuthor(doc_id, id); + // Nobody known acting is a missed site (`unattributed_steps`); it merges as the person would. + if (who.kind == .unattributed) return made.kind == .person; + return who.same(made); +} + +/// Forget `doc_id`'s authors: the document closed. +pub fn forgetDocumentAuthors(self: *Host, doc_id: u64) void { + var entry = self.step_authors.fetchRemove(doc_id) orelse return; + entry.value.steps.deinit(self.allocator); +} + +fn intern(self: *Host, who: Author) u32 { + for (self.authors.items, 0..) |*a, i| { + if (!a.same(who)) continue; + // The label may come later (an agent's name, known once its client says hello). + if (who.label.len > 0 and !std.mem.eql(u8, a.label, who.label)) { + if (self.allocator.dupe(u8, who.label)) |label| { + self.allocator.free(a.label); + a.label = label; + } else |_| {} + } + return @intCast(i); + } + const plugin = self.allocator.dupe(u8, who.plugin) catch return self.internFailed(); + const label = self.allocator.dupe(u8, who.label) catch { + self.allocator.free(plugin); + return self.internFailed(); + }; + self.authors.append(self.allocator, .{ .kind = who.kind, .plugin = plugin, .session = who.session, .label = label }) catch { + self.allocator.free(plugin); + self.allocator.free(label); + return self.internFailed(); + }; + return @intCast(self.authors.items.len - 1); +} + +/// Out of memory interning: nobody known, rather than wrongly the person. Its strings are static, +/// so `deinit` frees nothing it did not allocate: an empty slice frees as a no-op. +fn internFailed(self: *Host) u32 { + for (self.authors.items, 0..) |a, i| if (a.kind == .unattributed and a.plugin.len == 0) return @intCast(i); + self.authors.append(self.allocator, .unattributed) catch {}; + return @intCast(self.authors.items.len -| 1); +} + +/// Keep who made `step`, and `doc_id`'s top (`recordStep`). +fn noteAuthor(self: *Host, doc_id: u64, step: Step) void { + const who = self.author(); + if (who.kind == .unattributed and step.kind != .restarted) self.unattributed_steps += 1; + const theirs = who.kind != .person; + // Working alone, nothing is kept: a document gets an entry the first time someone else acts. + const doc = if (self.step_authors.getPtr(doc_id)) |d| d else if (theirs) blk: { + const gop = self.step_authors.getOrPut(self.allocator, doc_id) catch return; + gop.value_ptr.* = .{}; + break :blk gop.value_ptr; + } else return; + switch (step.kind) { + .restarted => { + doc.steps.clearRetainingCapacity(); + doc.top = 0; + }, + .added => { + if (theirs) { + const index = self.acting.author orelse self.input_author orelse self.internFailed(); + doc.steps.put(self.allocator, step.id, .{ .author = index, .run = self.acting.run }) catch {}; + } + doc.top = if (step.top != 0) step.top else step.id; + }, + // A step joined keeps the author it began with (`mayMerge` decided it could join). + .joined => doc.top = if (step.top != 0) step.top else step.id, + .undone, .redone => doc.top = step.top, + } } // ---- activity -------------------------------------------------------------------------------- @@ -2860,3 +3055,102 @@ test "a surface's title can change: the Host keeps the copy, and frees it with t host.unregisterPlugin(&plugin); try testing.expectEqual(@as(u32, 0), host.surface_titles.count()); } + +test "a step is its author's, the person's when nobody else acts, and the top follows the owner" { + var host: Host = .init(std.testing.allocator); + defer host.deinit(); + const doc: u64 = 7; + const claude: Author = .{ .kind = .agent, .plugin = "agent", .session = 1, .label = "Claude" }; + + // Working alone: nothing kept. + host.setInputAuthor(.person); + host.recordStep(doc, .{ .kind = .added, .id = 1 }); + try std.testing.expectEqual(@as(usize, 0), host.step_authors.count()); + try std.testing.expect(host.topStep(doc) == null); + + // An agent's call: its step is its own, in one run, and on top. + const outer = host.act(claude); + host.recordStep(doc, .{ .kind = .added, .id = 2 }); + host.recordStep(doc, .{ .kind = .added, .id = 3 }); + host.endAct(outer); + try std.testing.expect(host.stepAuthor(doc, 2).same(claude)); + try std.testing.expectEqualStrings("Claude", host.stepAuthor(doc, 3).label); + try std.testing.expectEqual(host.step_authors.getPtr(doc).?.steps.get(2).?.run, host.step_authors.getPtr(doc).?.steps.get(3).?.run); + try std.testing.expectEqual(@as(u64, 3), host.topStep(doc).?.id); + + // The person's step after it is theirs, and the top. + host.recordStep(doc, .{ .kind = .added, .id = 4 }); + try std.testing.expectEqual(Author.Kind.person, host.stepAuthor(doc, 4).kind); + try std.testing.expectEqual(Author.Kind.person, host.topStep(doc).?.author.kind); + + // The person undoes theirs: the agent's is on top again, as the owner says. + host.recordStep(doc, .{ .kind = .undone, .id = 4, .top = 3 }); + try std.testing.expect(host.topStep(doc).?.author.same(claude)); + + // A history started over knows no authors. + host.recordStep(doc, .{ .kind = .restarted, .id = 0 }); + try std.testing.expectEqual(Author.Kind.person, host.stepAuthor(doc, 2).kind); + try std.testing.expect(host.topStep(doc) == null); + + host.forgetDocumentAuthors(doc); + try std.testing.expectEqual(@as(usize, 0), host.step_authors.count()); + try std.testing.expectEqual(@as(u64, 0), host.unattributed_steps); +} + +test "acting: innermost wins, a run is the outermost act, and work put off acts again" { + var host: Host = .init(std.testing.allocator); + defer host.deinit(); + const claude: Author = .{ .kind = .agent, .plugin = "agent", .session = 1 }; + const tour: Author = .{ .kind = .tape, .plugin = "fizzy", .session = 9, .label = "the tour" }; + + try std.testing.expectEqual(Author.Kind.unattributed, host.author().kind); + host.setInputAuthor(.person); + try std.testing.expectEqual(Author.Kind.person, host.author().kind); + + const a = host.act(claude); + const run = host.acted().run; + const b = host.act(tour); + try std.testing.expect(host.author().same(tour)); + try std.testing.expectEqual(run, host.acted().run); + host.endAct(b); + try std.testing.expect(host.author().same(claude)); + const stored = host.acted(); + host.endAct(a); + try std.testing.expectEqual(Author.Kind.person, host.author().kind); + + // A second call is a second run; the stored one acts again as itself. + const c = host.act(claude); + try std.testing.expect(host.acted().run != run); + host.endAct(c); + const d = host.actAgain(stored); + try std.testing.expectEqual(run, host.acted().run); + host.endAct(d); + + // The same author interned once; a label given later is kept. + _ = host.act(.{ .kind = .agent, .plugin = "agent", .session = 1, .label = "Claude" }); + try std.testing.expectEqual(@as(usize, 3), host.authors.items.len); + try std.testing.expectEqualStrings("Claude", host.author().label); +} + +test "mayMerge: only the author's own step, so an agent never joins the person's run of typing" { + var host: Host = .init(std.testing.allocator); + defer host.deinit(); + const doc: u64 = 3; + const claude: Author = .{ .kind = .agent, .plugin = "agent", .session = 1 }; + host.setInputAuthor(.person); + host.recordStep(doc, .{ .kind = .added, .id = 1 }); + try std.testing.expect(host.mayMerge(doc, 1)); + + const outer = host.act(claude); + try std.testing.expect(!host.mayMerge(doc, 1)); + host.recordStep(doc, .{ .kind = .added, .id = 2 }); + try std.testing.expect(host.mayMerge(doc, 2)); + host.endAct(outer); + try std.testing.expect(!host.mayMerge(doc, 2)); + + // Nobody known acting is counted, and merges as the person would. + host.setInputAuthor(null); + try std.testing.expect(host.mayMerge(doc, 1)); + host.recordStep(doc, .{ .kind = .added, .id = 3 }); + try std.testing.expectEqual(@as(u64, 1), host.unattributed_steps); +} diff --git a/sdk/src/Step.zig b/sdk/src/Step.zig index 76a23540e..5b0a7b7cd 100644 --- a/sdk/src/Step.zig +++ b/sdk/src/Step.zig @@ -19,6 +19,11 @@ effect: []const u8 = "", motion: []const u8 = "", /// How long it took to make, as the owner saw it (a stroke from press to release). 0: at once. ms: u32 = 0, +/// The entry on top once this step is done (the last one applied), 0 when none is. The host keeps +/// each document's top from it, so Undo's label can name whose step it would take back without +/// asking for the line. An owner that does not fill it leaves it 0: the host then takes an +/// `added` or `joined` step's own `id` as the top, and knows no top after an undo or redo. +top: u64 = 0, pub const Kind = enum { /// A new entry on the history. diff --git a/sdk/src/sdk.zig b/sdk/src/sdk.zig index b4afc7dc1..71f97f854 100644 --- a/sdk/src/sdk.zig +++ b/sdk/src/sdk.zig @@ -73,6 +73,8 @@ pub const Command = @import("Command.zig"); pub const Claim = @import("Claim.zig"); /// One step of a document's history, recorded (`Host.recordStep`) and applied (`Plugin.applyStep`). pub const Step = @import("Step.zig"); +/// Who made a change to a document (`Host.act`, `Host.stepAuthor`). +pub const Author = @import("Author.zig"); /// Events: a plugin announces, any plugin listens (`Host.emit`, `Host.subscribe`). pub const events = @import("events.zig"); pub const menu = @import("menu.zig"); diff --git a/sdk/src/version.zig b/sdk/src/version.zig index 8bc456baa..ad46c1417 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -75,7 +75,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the /// next SDK release. -pub const recorded_sdk_shape_fingerprint: u64 = 0x6fc6a45bf3a13b00; +pub const recorded_sdk_shape_fingerprint: u64 = 0x78f2a3cc371828ef; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { From 6de2df8f1381cb1e73e649be791001d874907b34 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:38:29 -0500 Subject: [PATCH 36/46] app: whose frame it is, and bubbles that say who (#475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #474 (the SDK half); review that first. Part of fizzyedit/plans#14: `plans/SHARED_HISTORY_PLAN.md`, step 2, as amended in fizzyedit/plans#21. ## What changes The app now says who is acting, using #474's `Host.act` and `setInputAuthor`. - **The frame's input author.** - `Editor.tick` sets it where input is consumed: around the active document's tick, native menu actions, the layout's draw and floats, then around plugin keybinds, the keymap, overlays and the palette. - It clears it around plugins' `endFrame`. Plugins' `beginFrame` and event delivery run with none, as the plan says: they act for themselves, or are nobody's. - The author is the person's, or a tape's while one drives (`Demo.author`). The demo player and the live driver stop for a real press or key before the frame's input is consumed, so a frame a person takes over is theirs. - **Tapes act.** A tape's command ops run inside `act` as the tape (`Demo`'s stage `command`), and a live tape's as whoever it plays for. A demo's author is its title; a live tape played by nobody in particular is "a tape". - **`automation.Api.playAs(bytes, author)`** (service version 6): a live tape played for an author. An agent's pointer tape (`fizzy_act`) is then the agent's. Plain `play` leaves the tape its own author. The service tells the app through a new `OnAuthor` hook, beside its `Other`. - **Bubbles name agents.** An edit made while an agent acts shows as "Claude · Draw". The person's own edits stay plain, as the plan has it, and so does a demo narrating itself. The plan says "others", but prefixing every bubble of a playing demo with its title cluttered the demo; that's a judgement call for review. - **The integration check.** The whole-editor test asserts the tour adds no unattributed step. Owners report every step only from the plan's step 3, so today it guards the steps reported while recording. ## SDK impact - [ ] None - [x] Core-only or additive: reaches plugins at the next SDK release. The automation service is versioned on its own (`service_version` 5 → 6), so a plugin built for version 5 gets null from `getServiceTyped` on this host and degrades. The agent plugin is the one consumer: it moves to `playAs` with its repin. - [ ] Fingerprint moved - [ ] SDK release ## Verified - [x] macOS: - **Unit test:** an agent-acting edit's activity title is "Claude · Draw", the person's "Draw". - **Gates:** `zig build`, `zig build test`, `check-fmt`. `test-integration`, `check-web` and `test-sdk-version` results are in a comment below once they finish. - [ ] Not tried by hand: a demo's edits attributed while it plays, and an agent's pointer tape through `playAs`. The agent plugin calls `playAs` after the SDK release; that PR will show it end to end. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- app/automation/Service.zig | 20 ++++++++++++- sdk/src/Host.zig | 25 +++++++++++++++- sdk/src/services/automation.zig | 12 +++++++- src/editor/Demo.zig | 51 ++++++++++++++++++++++++++++++++- src/editor/Editor.zig | 7 +++++ tests/integration.zig | 6 ++++ 6 files changed, 117 insertions(+), 4 deletions(-) diff --git a/app/automation/Service.zig b/app/automation/Service.zig index a301e0ad6..77c2eaf1d 100644 --- a/app/automation/Service.zig +++ b/app/automation/Service.zig @@ -27,6 +27,8 @@ gpa: std.mem.Allocator, driver: *LiveDriver, /// Something else that drives the app, refusing a tape while it does — fizzy's demos. other: ?Other = null, +/// Told who each live tape plays for (`OnAuthor`). Null: nobody listens, and authors are dropped. +on_author: ?OnAuthor = null, /// What `snapshot` takes, framed by the app (`Snapshot.beginFrame` / `endFrame`). Null: no /// snapshots, and `snapshotText` is always null. snapshots: ?*Snapshot = null, @@ -51,6 +53,13 @@ pub const Other = struct { driving: *const fn (ctx: *anyopaque) bool, }; +/// Who the next live tape plays for (`Api.playAs`; null: itself), told to the app that runs its +/// commands and frames: fizzy's demo, which acts as them (`Host.act`). +pub const OnAuthor = struct { + ctx: *anyopaque, + set: *const fn (ctx: *anyopaque, author: ?sdk.Author) void, +}; + const Past = struct { ticket: Api.Ticket, outcome: Api.Outcome, @@ -63,6 +72,7 @@ pub fn bind(self: *Service) void { const vtable: Api.VTable = .{ .play = play, + .playAs = playAs, .stop = stop, .outcome = outcome, .settled = settled, @@ -82,13 +92,21 @@ fn othersDriving(self: *Service) bool { } fn play(ctx: *anyopaque, bytes: []const u8) Api.Play { - const self = from(ctx); + return playFor(from(ctx), bytes, null); +} + +fn playAs(ctx: *anyopaque, bytes: []const u8, author: sdk.Author) Api.Play { + return playFor(from(ctx), bytes, author); +} + +fn playFor(self: *Service, bytes: []const u8, author: ?sdk.Author) Api.Play { if (self.driver.playing() or self.othersDriving()) return .busy; var check = Input.check; check.live = true; const owned = Tape.load(self.gpa, bytes, check) catch |err| return .{ .invalid = @errorName(err) }; // Read before the driver starts over and forgets it. const last = self.driverOutcome(); + if (self.on_author) |o| o.set(o.ctx, author); self.driver.play(owned) catch |err| return switch (err) { error.Busy => .busy, else => .{ .invalid = @errorName(err) }, diff --git a/sdk/src/Host.zig b/sdk/src/Host.zig index de155910f..d881e227f 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -1809,7 +1809,14 @@ pub fn documentEdited(self: *Host, doc_id: u64) void { if (self.activity.getLastOrNull()) |last| { if (self.activity_run == self.command_runs and last.doc_id == doc_id) return; } - const title = self.allocator.dupe(u8, std.mem.trimEnd(u8, c.title, "…")) catch return; + const what = std.mem.trimEnd(u8, c.title, "…"); + // An agent's edit says whose ("Claude · Draw"); the person's own, and a demo narrating itself, + // stay plain. + const who = self.author(); + const title = (if (who.kind == .agent and who.label.len > 0) + std.fmt.allocPrint(self.allocator, "{s} · {s}", .{ who.label, what }) + else + self.allocator.dupe(u8, what)) catch return; if (self.activity.items.len == activity_cap) { self.allocator.free(self.activity.items[0].title); _ = self.activity.orderedRemove(0); @@ -3154,3 +3161,19 @@ test "mayMerge: only the author's own step, so an agent never joins the person's host.recordStep(doc, .{ .kind = .added, .id = 3 }); try std.testing.expectEqual(@as(u64, 1), host.unattributed_steps); } + +test "an agent's edit says whose in the activity; the person's stays plain" { + var host: Host = .init(std.testing.allocator); + defer host.deinit(); + const draw: Command = .{ .id = "pixi.draw", .title = "Draw" }; + host.running_command = &draw; + host.setInputAuthor(.person); + host.documentEdited(1); + try std.testing.expectEqualStrings("Draw", host.activity.items[0].title); + + host.command_runs += 1; + const outer = host.act(.{ .kind = .agent, .plugin = "agent", .session = 1, .label = "Claude" }); + host.documentEdited(1); + host.endAct(outer); + try std.testing.expectEqualStrings("Claude · Draw", host.activity.items[1].title); +} diff --git a/sdk/src/services/automation.zig b/sdk/src/services/automation.zig index 0cdf45c02..f469a8caa 100644 --- a/sdk/src/services/automation.zig +++ b/sdk/src/services/automation.zig @@ -48,10 +48,12 @@ //! //! Optional, like every service: an app with nothing to play tapes on registers none. +const Author = @import("../Author.zig"); + pub const Api = struct { /// Bump whenever this struct's layout changes: `getServiceTyped` refuses a provider whose /// version differs rather than reinterpreting one shape as another across `dlopen`. - pub const service_version: u32 = 5; + pub const service_version: u32 = 6; pub const service_name = "automation"; ctx: *anyopaque, @@ -89,6 +91,8 @@ pub const Api = struct { pub const VTable = struct { play: *const fn (ctx: *anyopaque, bytes: []const u8) Play, + /// `play`, the tape acting for `author` (`Host.act`): an agent's pointer tape is the agent's. + playAs: *const fn (ctx: *anyopaque, bytes: []const u8, author: Author) Play, stop: *const fn (ctx: *anyopaque, ticket: Ticket) void, outcome: *const fn (ctx: *anyopaque, ticket: Ticket) Outcome, settled: *const fn (ctx: *anyopaque) bool, @@ -118,6 +122,12 @@ pub const Api = struct { return self.vtable.play(self.ctx, bytes); } + /// `play`, with what the tape changes made by `author` (an agent playing pointer input on the + /// person's behalf): `play` alone leaves the tape its own author. + pub fn playAs(self: Api, bytes: []const u8, author: Author) Play { + return self.vtable.playAs(self.ctx, bytes, author); + } + /// Stop the tape `ticket` names where it is. Nothing when that tape is not the one playing: /// a caller can stop its own tape, never someone else's. pub fn stop(self: Api, ticket: Ticket) void { diff --git a/src/editor/Demo.zig b/src/editor/Demo.zig index 1ca73df43..19618c326 100644 --- a/src/editor/Demo.zig +++ b/src/editor/Demo.zig @@ -73,6 +73,9 @@ quitting: bool = false, arrange: ?automation.Tape.Keyframe.Layout = null, /// Every plugin's settings as the demo found them, as ZON, to give back at the end. settings_saved: std.ArrayListUnmanaged(SavedSettings) = .empty, +/// Who a live tape plays for (`automation.Api.playAs`): an agent's pointer tape is the agent's. +/// Strings owned here. Null: the live tape is its own author. +live_author: ?sdk.Author = null, const SavedSettings = struct { owner: []u8, @@ -118,7 +121,7 @@ pub fn attach(self: *Demo, editor: *Editor) void { .editor = editor, .player = .init(gpa, stage), .live = .init(stage), - .service = .{ .gpa = gpa, .driver = &self.live, .other = .{ .ctx = self, .driving = demoLoaded }, .snapshots = &self.snapshot, .captures = &self.picture }, + .service = .{ .gpa = gpa, .driver = &self.live, .other = .{ .ctx = self, .driving = demoLoaded }, .on_author = .{ .ctx = self, .set = liveAuthorSet }, .snapshots = &self.snapshot, .captures = &self.picture }, .snapshot = .init(gpa), .picture = .init(gpa), }; @@ -133,6 +136,7 @@ fn demoLoaded(ctx: *anyopaque) bool { } pub fn deinit(self: *Demo) void { + self.setLiveAuthor(null); self.quitting = true; self.snapshot.deinit(); self.picture.deinit(); @@ -354,6 +358,48 @@ const stage_vtable: automation.Stage.VTable = .{ .fingerprint = fingerprint, }; +/// A live tape played by nobody in particular, or a demo with no name. +const tape_author: sdk.Author = .{ .kind = .tape, .plugin = "fizzy", .label = "a tape" }; + +/// Whose frame this is when a tape drives it: the live tape's author (`live_author`, else the +/// tape itself), or the demo being played or sought through. Null when no tape drives: a real +/// press or key stops either before the frame's input is consumed, so the frame is the person's. +pub fn author(self: *const Demo) ?sdk.Author { + if (self.live.playing()) return self.live_author orelse tape_author; + switch (self.player.state) { + .playing, .seeking => {}, + else => return null, + } + const tape = (self.player.owned orelse return tape_author).tape; + return .{ + .kind = .tape, + .plugin = "fizzy", + .session = std.hash.Wyhash.hash(0, tape.name), + .label = if (tape.title.len > 0) tape.title else tape.name, + }; +} + +fn liveAuthorSet(ctx: *anyopaque, who: ?sdk.Author) void { + from(ctx).setLiveAuthor(who); +} + +/// Play the next live tape for `who` (`automation.Api.playAs`); null, for no one in particular. +pub fn setLiveAuthor(self: *Demo, who: ?sdk.Author) void { + const gpa = self.editor.app.gpa; + if (self.live_author) |a| { + gpa.free(a.plugin); + gpa.free(a.label); + } + self.live_author = null; + const a = who orelse return; + const plugin = gpa.dupe(u8, a.plugin) catch return; + const label = gpa.dupe(u8, a.label) catch { + gpa.free(plugin); + return; + }; + self.live_author = .{ .kind = a.kind, .plugin = plugin, .session = a.session, .label = label }; +} + fn from(ctx: *anyopaque) *Demo { return @ptrCast(@alignCast(ctx)); } @@ -628,6 +674,9 @@ fn idle(ctx: *anyopaque) bool { /// palette does once they are given; what it returns is not the demo's business. fn command(ctx: *anyopaque, id: []const u8, args: []const u8) void { const host = &from(ctx).editor.app.host; + // A tape's command is the tape's (or, for a live tape played for someone, theirs). + const outer = host.act(from(ctx).author() orelse tape_author); + defer host.endAct(outer); if (args.len == 0) { host.runCommand(id) catch |err| dvui.log.warn("demo: command {s} failed: {t}", .{ id, err }); return; diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 58e265248..19b47b377 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -4321,6 +4321,10 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // layout needed these five calls verbatim, and getting one wrong is a bug an app // author has no way to diagnose. A shape declares regions; it does not run the // frame. + // The frame's input is consumed from here to the palette: whatever it changes is the + // person's, or a tape's while one drives (`Host.setInputAuthor`). Plugins' frame hooks + // outside it act for themselves, or are nobody's. + editor.app.host.setInputAuthor(editor.demo.author() orelse .person); var layout_root = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .background = false }); for (editor.app.host.plugins.items) |plugin| plugin.tickActiveDocument(layout_root.data().id); editor.flushQueuedNativeMenuActions(); @@ -4373,6 +4377,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { .{layout.depth}, ); + editor.app.host.setInputAuthor(null); for (editor.app.host.plugins.items) |plugin| plugin.endFrame(); layout_root.deinit(); @@ -4380,6 +4385,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result { } { // Plugin keybinds + per-frame overlays (e.g. pixel-art's radial menu) + editor.app.host.setInputAuthor(editor.demo.author() orelse .person); + defer editor.app.host.setInputAuthor(null); // 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. diff --git a/tests/integration.zig b/tests/integration.zig index e86cf045d..f99366bfc 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7226,12 +7226,18 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes const lookback = fizzy.core.profile.lookback() orelse return error.TestUnexpectedResult; lookback.budget_ns = 0; defer lookback.budget_ns = std.time.ns_per_s / 60; + const unattributed_before = editor.app.host.unattributed_steps; try editor.demo.play("tour"); var frames: usize = 0; while (editor.demo.player.state != .ended and frames < 5000) : (frames += 1) { _ = try dvui.testing.step(headlessFrame); } try std.testing.expectEqual(app.automation.Player.State.ended, editor.demo.player.state); + // Every step the tour made has an author: its own where it drives the frame, or its commands' + // (`Host.act` in the demo's stage). A step nobody acted for is a missed site + // (`plans/SHARED_HISTORY_PLAN.md`). Owners report every step once that plan's step 3 lands; + // until then this guards the steps reported while recording. + try std.testing.expectEqual(unattributed_before, editor.app.host.unattributed_steps); { var out: std.Io.Writer.Allocating = .init(std.testing.allocator); defer out.deinit(); From 0d071c32090fdb4b68e8a4f56f0bd1b7e0b4e3dd Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:58:57 -0500 Subject: [PATCH 37/46] layout: a pane writing its tabs back no longer reads freed ids (#476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A crash fix, with no plan behind it. ## What changes Fizzy crashed on 2026-10-16. It was a Debug build of `main` around #461, at SDK 0.2.28, and it segfaulted in `mem.eql` inside `Layout.State.evictIdFromOthers`. The path was `assign` ← `fizzyAssignSurfaces` ← `Workbench.showBeside` ← `Editor.newFile` ← pixi's `createDocument`. An agent's `pixi.create` was opening a sprite beside the person (`host.openBeside`, #442), into a pane that already had tabs. **Cause:** `State.assign` called `setAssignment`, which frees the region's old id strings. It then evicted each new id from every other region, reading the ids from the **caller's** list. A pane adds a tab by reading its assignment, appending to it and writing it back (`Workspace.addTab`; `removeTab`, `insertTab`, `swapTabId` and `Workspaces.close` work the same way). So the caller's list borrows the strings that were just freed, and the eviction compared against freed memory. Most of the time that read only returns garbage. It faults once the allocator has handed the freed slot's page back to the OS, which is why it took a long session with tabs opened and closed. **Fix:** `assign` now evicts by the region's new, owned copy and its stored key. Nothing it reads after the free can be borrowed from what was freed. A caller's list may therefore borrow from any assignment, its own included. The SDK doc for `assignedSurfaces` (Host and EditorAPI) now says so: "read, edit, write" was already how every caller used it. It is a comment change only, and the fingerprint does not move. The ownership is fixed in `State`, not with a guard in `evictIdFromOthers`, so every caller of the read-edit-write pattern is safe without each one copying first. **Found in review, fixed here too:** - `assign` now takes its own entry's key and list out of the map before evicting. Rewriting another region runs `getOrPut`, which grows the map *before* it looks up even an existing key, so a pointer into the entry could go stale mid-loop. This was a second use-after-free of the same kind. - `evictIdFromOthers` kept at most 32 regions and 32 tabs in fixed buffers and swallowed `setAssignment` errors. A pane of 40 tabs that gave one up came back with 32, with no error. It now uses lists, and its errors reach `assign`'s caller. ## SDK impact - [x] None (doc comments on `assignedSurfaces` only) - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: - **New test:** "assigning a region a list borrowed from the assignments it replaces" (`tests/integration.zig`). It uses `std.heap.page_allocator`, so every free unmaps and a read of a freed id faults every time. Without the fix it crashes with the same stack as the report (`mem.eql` ← `evictIdFromOthers` ← `assign`). With the fix it passes. - **Two more tests**, each failing before its fix: - "assigning a region whose ids other regions hold, whatever the map's fill" tries every count of regions up to 63, so some count leaves the map full at the rewrite. On the page allocator it segfaulted at the stale `key_ptr`. - "taking one id out of a region of many keeps the rest" expected 39 tabs and found 32. - **Whole-editor test:** the headless test now creates four documents with `host.openBeside()` into two panes, closing every other one. It checks that the person stays in their pane and that the last document is active beside them. That test runs on `smp_allocator`, so it exercises the path but would not reliably catch the read on its own. - **Real run:** a Debug build with `--profile` set to a fresh directory, with pixi and agent `main` built against this tree (`--fork`). Through the agent's `fizzy-mcp`, I ran 8 `pixi.create` calls with paths and 3 `fizzy.close` calls. That ended with two panes, holding `s2, s4` and `s6, s7, s8`, and fizzy kept running. The same sequence run 3 times on a build without the fix did not crash either, because the freed slots' pages weren't unmapped; the page-allocator test is the deterministic proof. - **Gates:** `check-fmt`, `zig build`, `test` and `test-integration` (53/53 steps succeeded, 558 tests), plus `check-web`, `test-sdk-version` and `check-tests-reached`. - [ ] Windows: - [ ] Linux: (CI) - [ ] Web: ## Follow-ups None. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- app/layout/State.zig | 43 ++++++++++---------- sdk/src/EditorAPI.zig | 4 +- sdk/src/Host.zig | 4 +- tests/integration.zig | 94 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 26 deletions(-) diff --git a/app/layout/State.zig b/app/layout/State.zig index e75ca4058..cc6acba3f 100644 --- a/app/layout/State.zig +++ b/app/layout/State.zig @@ -473,9 +473,17 @@ pub fn assignment(self: *State, name: []const u8) ?[]const []const u8 { /// Set what region `name` shows. An empty list is a real choice — "nothing here" — distinct from /// `unassign`, which hands the region back to its keywords. A surface lives in one /// place: putting it here takes it out of every other assignment. +/// +/// `surfaces` may borrow from any assignment, this region's included — read, edit, write back +/// is how a pane adds a tab. So past `setAssignment`, which frees the old lists, only this +/// region's new, owned copy is read: its key and list, taken out of the map first, since +/// rewriting the other regions can grow the map and move its entries. pub fn assign(self: *State, gpa: std.mem.Allocator, name: []const u8, surfaces: []const []const u8) !void { try self.setAssignment(gpa, name, surfaces); - for (surfaces) |id| self.evictIdFromOthers(gpa, name, id); + const owned = self.assignments.getEntry(name).?; + const key = owned.key_ptr.*; + const ids = owned.value_ptr.*; + for (ids) |id| try self.evictIdFromOthers(gpa, key, id); } fn setAssignment(self: *State, gpa: std.mem.Allocator, name: []const u8, surfaces: []const []const u8) !void { @@ -500,33 +508,24 @@ fn setAssignment(self: *State, gpa: std.mem.Allocator, name: []const u8, surface gop.value_ptr.* = owned; } -fn evictIdFromOthers(self: *State, gpa: std.mem.Allocator, keep: []const u8, id: []const u8) void { - var names: [32][]const u8 = undefined; - var n: usize = 0; +fn evictIdFromOthers(self: *State, gpa: std.mem.Allocator, keep: []const u8, id: []const u8) !void { + var names: std.ArrayListUnmanaged([]const u8) = .empty; + defer names.deinit(gpa); var it = self.assignments.iterator(); while (it.next()) |e| { if (std.mem.eql(u8, e.key_ptr.*, keep)) continue; - for (e.value_ptr.*) |x| { - if (!std.mem.eql(u8, x, id)) continue; - if (n < names.len) { - names[n] = e.key_ptr.*; - n += 1; - } + for (e.value_ptr.*) |x| if (std.mem.eql(u8, x, id)) { + try names.append(gpa, e.key_ptr.*); break; - } + }; } - for (names[0..n]) |region| { + var kept: std.ArrayListUnmanaged([]const u8) = .empty; + defer kept.deinit(gpa); + for (names.items) |region| { const ids = self.assignment(region) orelse continue; - var kept: [32][]const u8 = undefined; - var k: usize = 0; - for (ids) |x| { - if (std.mem.eql(u8, x, id)) continue; - if (k < kept.len) { - kept[k] = x; - k += 1; - } - } - self.setAssignment(gpa, region, kept[0..k]) catch {}; + kept.clearRetainingCapacity(); + for (ids) |x| if (!std.mem.eql(u8, x, id)) try kept.append(gpa, x); + try self.setAssignment(gpa, region, kept.items); } } diff --git a/sdk/src/EditorAPI.zig b/sdk/src/EditorAPI.zig index 6ded8415c..ed07e9559 100644 --- a/sdk/src/EditorAPI.zig +++ b/sdk/src/EditorAPI.zig @@ -306,8 +306,8 @@ pub const VTable = struct { /// plugin can address a region it is not drawing right now: a tab dropped on another pane, /// a pane that does not exist yet, a session being restored before anything has drawn. assignSurfaces: *const fn (ctx: *anyopaque, region: []const u8, ids: ?[]const []const u8) anyerror!void, - /// The assignment for a region name, or null if the user never chose. Borrowed from the app; - /// copy before the next assignment write. + /// The assignment for a region name, or null if the user never chose. Borrowed from the app + /// until the next assignment write, which may itself be given these ids: read, edit, write. assignedSurfaces: *const fn (ctx: *anyopaque, region: []const u8) ?[]const []const u8, /// Every region name that has an assignment, arena-allocated. How a plugin finds the panes /// it declared last session before it has declared any this one. diff --git a/sdk/src/Host.zig b/sdk/src/Host.zig index d881e227f..6cdd7996b 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -606,8 +606,8 @@ pub fn assignSurfaces(self: *Host, region_name: []const u8, ids: ?[]const []cons if (self.fizzy_api) |a| return a.assignSurfaces(region_name, ids); } -/// The assignment under `region_name`, or null when the user never chose. Borrowed; copy before -/// the next `assignSurfaces`. +/// The assignment under `region_name`, or null when the user never chose. Borrowed until the next +/// `assignSurfaces`, which may itself be given these ids: read, edit, write. pub fn assignedSurfaces(self: *Host, region_name: []const u8) ?[]const []const u8 { return if (self.fizzy_api) |a| a.assignedSurfaces(region_name) else null; } diff --git a/tests/integration.zig b/tests/integration.zig index f99366bfc..17c65ba61 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -1901,6 +1901,72 @@ test "assigning a region overrides keyword matching, duplicates and empties" { try std.testing.expectEqualStrings("test.other", layout.matching(sidebar)[0].id); } +// A pane adds or drops a tab by reading its assignment, editing the list and writing it back +// (`Workspace.addTab`), so the list it writes borrows the ids it replaces. On 2026-10-16 that +// crashed fizzy in `evictIdFromOthers`, reading ids the write had just freed, on a document an +// agent opened beside the person. The page allocator unmaps every free, so a read of one faults +// here every time rather than whenever the freed slot's page happened to go. +test "assigning a region a list borrowed from the assignments it replaces" { + const gpa = std.heap.page_allocator; + var state: fizzy.Editor.Layout.State = .{}; + defer state.deinitAssignments(gpa); + + try state.assign(gpa, "pane.1", &.{ "doc.a", "doc.b" }); + try state.assign(gpa, "pane.2", &.{ "doc.c", "doc.d" }); + + // Pane 1 takes doc.c as a tab: its own ids, then the new one borrowed from pane 2's list. + const one = state.assignment("pane.1").?; + const two = state.assignment("pane.2").?; + try state.assign(gpa, "pane.1", &.{ one[0], one[1], two[0] }); + try std.testing.expectEqual(@as(usize, 3), state.assignment("pane.1").?.len); + try std.testing.expectEqualStrings("doc.c", state.assignment("pane.1").?[2]); + try std.testing.expectEqual(@as(usize, 1), state.assignment("pane.2").?.len); + try std.testing.expectEqualStrings("doc.d", state.assignment("pane.2").?[0]); + + // Pane 2 drops back to its first tab, written from its own list, under its own key. + const keyed = state.assignments.getEntry("pane.2").?; + try state.assign(gpa, keyed.key_ptr.*, keyed.value_ptr.*[0..1]); + try std.testing.expectEqualStrings("doc.d", state.assignment("pane.2").?[0]); +} + +// Taking a region's ids out of the others rewrites them, and rewriting even an existing entry +// can grow the map (`getOrPut` makes room first), moving every entry. Whatever `assign` holds of +// its own entry must outlive that. Some count of regions leaves the map full at the rewrite; +// trying every count up to well past a resize reaches it, and the page allocator faults on a +// stale read. +test "assigning a region whose ids other regions hold, whatever the map's fill" { + const gpa = std.heap.page_allocator; + for (1..64) |count| { + var state: fizzy.Editor.Layout.State = .{}; + defer state.deinitAssignments(gpa); + var names: [64][16]u8 = undefined; + var ids: [64][]const u8 = undefined; + for (0..count) |i| { + const name = try std.fmt.bufPrint(&names[i], "pane.{d}", .{i}); + try state.assign(gpa, name, &.{name}); + ids[i] = name; + } + // One region takes every id: each other region is emptied, none twice. + try state.assign(gpa, "all", ids[0..count]); + try std.testing.expectEqual(count, state.assignment("all").?.len); + for (ids[0..count]) |name| try std.testing.expectEqual(@as(usize, 0), state.assignment(name).?.len); + } +} + +// A place of many tabs keeps every one when another place takes one of them. +test "taking one id out of a region of many keeps the rest" { + const gpa = std.testing.allocator; + var state: fizzy.Editor.Layout.State = .{}; + defer state.deinitAssignments(gpa); + var bufs: [40][16]u8 = undefined; + var ids: [40][]const u8 = undefined; + for (&ids, 0..) |*id, i| id.* = try std.fmt.bufPrint(&bufs[i], "doc.{d}", .{i}); + try state.assign(gpa, "pane.1", &ids); + try state.assign(gpa, "pane.2", &.{ids[0]}); + try std.testing.expectEqual(@as(usize, 39), state.assignment("pane.1").?.len); + try std.testing.expectEqualStrings("doc.39", state.assignment("pane.1").?[38]); +} + // -- regions inside regions ---------------------------------------------------------------------- // A region declared inside another names a place *within* it: a document pane inside the main area @@ -7475,6 +7541,34 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try std.testing.expect(std.mem.indexOf(u8, try docText(host), ".text = \"typed\"") != null); } + // Documents a command creates open beside the person (`Host.openBeside`), as an agent's + // `pixi.create` does, into panes whose tabs have come and gone. Each pane's tab list is + // rewritten from itself (`Workspace.addTab`), the crash of 2026-10-16 (`Layout.State.assign`). + { + const host = &editor.app.host; + const here = editor.workbench.open_workspace_grouping; + const grid: sdk.EditorAPI.NewDocGrid = .{ .column_width = 16, .row_height = 16 }; + var made: [4]u64 = undefined; + for (&made, 0..) |*id, i| { + const p = try std.fmt.allocPrint(gpa, "{s}/beside-{d}.txt", .{ project, i }); + defer gpa.free(p); + host.openBeside(); + defer host.endOpenBeside(); + const doc = try editor.newFile(p, grid); + id.* = doc.id; + try dvui.testing.settle(headlessFrame); + try std.testing.expectEqual(here, editor.workbench.open_workspace_grouping); + // Every other one goes again, leaving its pane's list shorter for the next. + if (i % 2 == 1) try editor.closeFileID(made[i - 1]); + try dvui.testing.settle(headlessFrame); + } + const last = editor.app.docById(made[made.len - 1]) orelse return error.TestUnexpectedResult; + const beside = for (editor.workbench.workspaces.values()) |ws| { + if (ws.grouping != here) if (ws.active) |a| if (a.id == last.id) break true; + } else false; + try std.testing.expect(beside); + } + // A document's history as a line (`Plugin.historyLine`): each step keeps its id through // undo and redo, and going to one (`historyGoTo`) is so many undos or redos. { From 2606073300f418a600f173c66c759b94f6918770 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:42:11 -0500 Subject: [PATCH 38/46] text: every step is reported, with the top, and only one hand's typing joins (#479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of fizzyedit/plans#14: `plans/SHARED_HISTORY_PLAN.md`, step 3 (text's half; pixi's follows in its repo). ## What changes - **Text reports every step** through `Host.recordStep`, not only while recording. The host keeps who made each one (#474); working alone, it keeps nothing. Encoding a step's `effect` still happens only while the document is recorded, so a document nobody records costs one call per step. - **Each step carries `top`:** the last applied entry after it (`History.lastApplied`), for added, joined, undone and redone alike. The host knows the top step and its author without asking for the line, which Undo's "Undo Claude's …" label (step 5) needs. - **One hand's typing never joins another's run.** `History.end` takes `may_join`. The editor's edit hook fills it from `Host.mayMerge` on the step on top. When that step is someone else's, VSCode's grouping rule doesn't get to fold the new keystroke into it: an agent typing doesn't extend the person's step, nor the person an agent's. - **Typing calls `documentEdited`,** as its doc comment always said it did. A person's typing runs no command, so it still raises no activity bubble. ## SDK impact - [x] None. Text is built in, and #474's API is used as is. ## Verified - [x] macOS: - **New unit test:** an edit with `may_join = false` starts a step of its own where VSCode's rule would have joined it. The existing history tests pass `true` and behave as before. - **`zig build test`:** 573/574 (1 skipped). `check-tests-reached` passes. - **`test-integration`:** 53/53 steps, 556/556 tests. The whole-editor test's check from #475, that the tour adds no unattributed step, now covers real steps: the tour's typing and commands are each attributed to the tape. Two warnings print, and both are on unmodified `main` too (a demo replay mismatch and a float position). - `check-fmt`, `check-web`, `test-sdk-version`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- plugins/text/src/Document.zig | 10 +++---- plugins/text/src/TextEditor.zig | 8 +++++- plugins/text/src/textcore/History.zig | 41 ++++++++++++++++++++------- 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/plugins/text/src/Document.zig b/plugins/text/src/Document.zig index f0632c505..d1153b4e9 100644 --- a/plugins/text/src/Document.zig +++ b/plugins/text/src/Document.zig @@ -576,9 +576,7 @@ pub const step_format = 1; /// Report a step of this document's history to whoever records it (`sdk.Host.recordStep`), when /// anyone does: nothing is encoded otherwise. fn recordStep(self: *const Document, step: sdk.Step) void { - const host = sdk.host(); - if (!host.recording(self.id)) return; - host.recordStep(self.id, step); + sdk.host().recordStep(self.id, step); } /// What `history.end` or `pushComplete` did, reported as a step. @@ -589,7 +587,9 @@ pub fn recordCommitted(self: *const Document, c: tc.History.Committed) void { /// `edits` of history step `id` reported as a step, forward or (`reverse`) as their undo, `sel` /// the selection they leave. fn recordEdits(self: *const Document, kind: sdk.Step.Kind, id: u64, edits: []const tc.Transaction.Edit, sel: tc.Range, reverse: bool) void { - if (!sdk.host().recording(self.id)) return; + // Every step reaches the host, which keeps who made it; its bytes only while it is recorded. + const top: u64 = if (self.history.lastApplied()) |r| r.step else 0; + if (!sdk.host().recording(self.id)) return self.recordStep(.{ .kind = kind, .id = id, .top = top }); const gpa = sdk.allocator(); var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); @@ -597,7 +597,7 @@ fn recordEdits(self: *const Document, kind: sdk.Step.Kind, id: u64, edits: []con std.log.err("text: a step of {s} was not recorded: out of memory", .{self.path}); return; }; - self.recordStep(.{ .kind = kind, .id = id, .effect = out.written() }); + self.recordStep(.{ .kind = kind, .id = id, .effect = out.written(), .top = top }); } /// Apply a recorded step (`step_format`) as a new step of this document's history: the edits diff --git a/plugins/text/src/TextEditor.zig b/plugins/text/src/TextEditor.zig index b26450361..fcc52121e 100644 --- a/plugins/text/src/TextEditor.zig +++ b/plugins/text/src/TextEditor.zig @@ -1831,7 +1831,13 @@ fn editNotifyInserted(ctx: *anyopaque, pos: usize, bytes: []const u8) void { } fn editNotifyEnd(ctx: *anyopaque, sel_after: tc.Range) void { const doc = docFromCtx(ctx); - if (doc.history.end(sdk.allocator(), sel_after)) |c| doc.recordCommitted(c); + // One hand's typing never joins another's run (`sdk.Host.mayMerge`). + const host = sdk.host(); + const may_join = if (doc.history.lastApplied()) |top| host.mayMerge(doc.id, top.step) else true; + if (doc.history.end(sdk.allocator(), sel_after, may_join)) |c| { + doc.recordCommitted(c); + host.documentEdited(doc.id); + } } const max_text_bytes: usize = 64 * 1024 * 1024; diff --git a/plugins/text/src/textcore/History.zig b/plugins/text/src/textcore/History.zig index db242a585..5f00dbebb 100644 --- a/plugins/text/src/textcore/History.zig +++ b/plugins/text/src/textcore/History.zig @@ -223,9 +223,11 @@ pub fn lastUndone(self: *const History) ?StepRef { return if (items.len == 0) null else .of(&items[items.len - 1]); } -/// Commit the pending edit, merging it into the current group when VSCode's rule says to. Returns -/// what it did, or null when there was nothing to commit. -pub fn end(self: *History, gpa: Allocator, sel_after: Range) ?Committed { +/// Commit the pending edit, merging it into the current group when VSCode's rule says to and +/// `may_join` allows: false when the step on top is another hand's (`sdk.Host.mayMerge`), so one +/// hand's typing never joins another's run. Returns what it did, or null when there was nothing to +/// commit. +pub fn end(self: *History, gpa: Allocator, sel_after: Range, may_join: bool) ?Committed { var p = self.pending orelse return null; self.pending = null; @@ -261,7 +263,7 @@ pub fn end(self: *History, gpa: Allocator, sel_after: Range) ?Committed { self.clearRedo(gpa); - const can_merge = self.group_open and + const can_merge = may_join and self.group_open and self.undo_stack.items.len > 0 and !shouldBreakGroup(prev_kind, kind); @@ -473,7 +475,7 @@ const Harness = struct { 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)); + _ = self.hist.end(self.gpa, .collapsed(self.cursor), true); } } @@ -485,7 +487,7 @@ const Harness = struct { 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)); + _ = self.hist.end(self.gpa, .collapsed(self.cursor), true); } } @@ -584,7 +586,7 @@ test "undo restores the selection, not just an offset" { hist.begin(.init(6, 11)); hist.note(a, 6, "world", "X"); try text.replaceRange(a, 6, 5, "X"); - _ = hist.end(a, .collapsed(7)); + _ = hist.end(a, .collapsed(7), true); const sel = hist.applyUndo(a, &text).?; try t.expectEqualStrings("hello world", text.items); @@ -673,7 +675,7 @@ test "cut-shaped deletion is one undoable step" { 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)); + _ = h.hist.end(h.gpa, .collapsed(6), true); try t.expectEqualStrings("hello ", h.text.items); _ = h.undo(); @@ -716,13 +718,13 @@ test "a commit says whether it started a step or joined one, and what it added" hist.begin(.collapsed(0)); hist.note(a, 0, "", "a"); - const first = hist.end(a, .collapsed(1)).?; + const first = hist.end(a, .collapsed(1), true).?; try t.expect(!first.joined); try t.expectEqual(@as(usize, 1), first.edits.len); hist.begin(.collapsed(1)); hist.note(a, 1, "", "b"); - const second = hist.end(a, .collapsed(2)).?; + const second = hist.end(a, .collapsed(2), true).?; // Typing runs on: the same step, and only the new keystroke. try t.expect(second.joined); try t.expectEqual(first.step, second.step); @@ -731,7 +733,7 @@ test "a commit says whether it started a step or joined one, and what it added" // An empty commit commits nothing. hist.begin(.collapsed(2)); - try t.expect(hist.end(a, .collapsed(2)) == null); + try t.expect(hist.end(a, .collapsed(2), true) == null); var text: std.ArrayList(u8) = .empty; defer text.deinit(a); @@ -742,3 +744,20 @@ test "a commit says whether it started a step or joined one, and what it added" _ = hist.applyRedo(a, &text).?; try t.expectEqual(first.step, hist.lastApplied().?.step); } + +test "an edit another hand may not join starts a step of its own" { + const a = t.allocator; + var hist: History = .{}; + defer hist.deinit(a); + + hist.begin(.collapsed(0)); + hist.note(a, 0, "", "a"); + const first = hist.end(a, .collapsed(1), true).?; + hist.begin(.collapsed(1)); + hist.note(a, 1, "", "b"); + // Typing on, which VSCode's rule would join, but the step on top is another hand's. + const second = hist.end(a, .collapsed(2), false).?; + try t.expect(!second.joined); + try t.expect(second.step != first.step); + try t.expectEqual(@as(usize, 2), hist.undo_stack.items.len); +} From 6eec45700d43552426320c7c48d0cd74c7753c57 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:42:40 -0500 Subject: [PATCH 39/46] sdk: release 0.2.29 (#477) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changes `sdk_version` goes from 0.2.28 to 0.2.29. Merging this tags `sdk-v0.2.29` and publishes its tarball. **Why now:** authors (#474, #475) need a release before plugins can use them: - the agent acts as its connection around each command, and plays its pointer tapes with `playAs`; - chat acts around its writes; - pixi's calls that wait out the person's stroke keep their author (`plans/SHARED_HISTORY_PLAN.md` step 2's plugin PRs). ## SDK impact - [ ] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [x] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag **`sdk` PRs since `sdk-v0.2.28`:** - **#474:** who made each step. - `sdk.Author`; `Host.act` / `endAct` / `acted` / `actAgain` / `author` / `setInputAuthor`. - The host keeps a per-document author map filled in `recordStep`. - `stepAuthor`, `topStep`, `mayMerge`, `forgetDocumentAuthors`, and `Step.top`. - The fingerprint moved. Everything is additive (a defaulted field, new functions). **Reaching plugins, no fingerprint move:** - **#475:** the automation service's `playAs(bytes, author)` (`service_version` 5 → 6). Activity titles name an acting agent ("Claude · Draw"). - **#468:** `core`: `MenuRowOptions.anchor`, a stable name for a menu row's button. ## Verified - [x] macOS: - On this head, `zig build test-sdk-version` passes, and `scripts/pack-sdk.sh` packs `fizzy-sdk-v0.2.29.tar.gz`. - fizzyedit/pixi, fizzyedit/agent and fizzyedit/atlas `main` build against this `sdk/` (`--fork`) with no changes. - [ ] Windows: - [ ] Linux: CI. - [ ] Web: ## Follow-ups - The repin automation opens `sdk: repin to fizzy SDK 0.2.29` PRs in the store plugins whose fingerprint moved. - The agent has no repin automation: its repin is a hand-made PR, carrying the agent's `act` around `agentCall` and `playAs` for its pointer tapes. - Chat's repin carries its `act` around ACP writes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- sdk/sdk_version.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/sdk_version.zig b/sdk/sdk_version.zig index cc8205b44..e101069a0 100644 --- a/sdk/sdk_version.zig +++ b/sdk/sdk_version.zig @@ -23,5 +23,5 @@ const std = @import("std"); pub const sdk_version = std.SemanticVersion{ .major = 0, .minor = 2, - .patch = 28, + .patch = 29, }; From 7b538aaade9984ffa706129990ad67a6b328f544 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:48:50 -0500 Subject: [PATCH 40/46] ci: compile the unit tests before running them (#469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changes `Tests (Windows)` failed about one run in thirty (4 of the last 140 jobs) with `test runner failed to respond for 1m…` on whichever tiny std-only test was unlucky: `fizzy-glass-look-tests`, `backend-titlebar-tests`, `fizzy-profile-causes-tests`, `fizzy-text-doc-state-tests`, `fizzy-profile-spans-tests`, `fizzy-settings-plugins-zon-tests`. CI now compiles every unit test (`zig build check`) in a step of its own before `zig build test` runs them, so no compiler is alive while a test runs. **The cause is in Zig 0.16's std, not in fizzy.** `processSpawnWindows` (`std/Io/Threaded.zig`) creates each child's pipe ends inheritable and calls `CreateProcess` with handle inheritance on, with no `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` and no lock around it. So a child inherits *every* inheritable handle the build runner holds at that moment, including the pipe ends it is making for its other children. A compiler spawned while a test's pipes exist keeps a copy of that test's stdout write end. After the last test, the runner sends `exit` and waits for stdout EOF under its 60 s response timeout (`std/Build/Step/Run.zig`, `waitZigTest`/`requestNextTest`). The test has passed and exited, but EOF comes only when the compiler exits, so the step fails with "failed to respond" and reports no test as failed. Every failing run had a test compile that took ≥1 min (`fizzy-crash-tests`, `fizzy-singleton-unix-tests`, `fizzy-plugin-store-tests`). After `check`, `zig build test` finds every compile cached and spawns only test binaries, which live for milliseconds. **The transport tests were a red herring.** `fizzy-native-transport-tests` passed (21/21) in every failing run. In the PR #460 failure it ran a minute *after* the hung tests had started. Its `error.Unexpected NTSTATUS=0xc000020d (CONNECTION_RESET)` trace comes from the cancel test: its holder closed the accepted socket with the request unread, Windows answers that with a reset, and Zig 0.16's `netReadWindows` maps every status it doesn't list (this one included, which should be `error.ConnectionResetByPeer`) to `error.Unexpected`, dumping a stack trace on stderr. The holder now reads the request first, so the close is a clean FIN and the trace is gone. **How it was found** (throwaway workflow on `ci/windows-flake-diag`, since deleted): - 40 test-only `zig build test` loops on 4 Windows runners: 0 hangs. 96 loops recompiling every test binary each time (from PowerShell, then again exactly as CI runs it, through Git Bash and `ci-retry.sh`'s `tee`): 0 hangs. Those recompiles take ~4 s each and never produce the minute-long compilers. - A standalone repro with no fizzy code: 8 empty tests, each run beside a 75 s `ping`. On `windows-latest`, `instant-2-tests` failed with `test runner failed to respond for 1m5.202ms`. Without the sleepers it passed, and on `ubuntu-latest` it passed both ways (posix pipes are `O_CLOEXEC`). - Defender real-time protection is off on the runners, so scanning is ruled out. Tried and dropped: `--test-timeout` (it raises the response timeout too, but only hides the wait), `-j1` (slow), and retries (CONTRIBUTING: fix the cause). ## SDK impact - [x] None ## Verified - [x] macOS: `zig build test -Dtest-filter=cancel` passes with the transport change. - [x] Windows: [run 38968162178](https://github.com/fizzyedit/fizzy/actions/runs/38968162178) plus 5 reruns of `Tests (Windows)`, all green: 563/568 passed (5 skipped) each time. `Compile tests` did the compiling, and in `Run tests` every compile was `cached` (the step takes ~12 s), so no compiler ran beside a test. No `NTSTATUS`/`failed command` lines in any of the six logs. At the old ~3% rate, six green runs alone prove little; the evidence is the repro plus that invariant. - [x] Linux: `Tests (Linux)` and the rest of CI green. - [ ] Web: not affected. ## Follow-ups None. The cause is in Zig 0.16's std; this is fizzy's workaround, and the pitfall in `docs/PITFALLS.md` keeps the two steps apart. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- .github/workflows/ci.yml | 11 +++++++++++ core/transport/NativeTransport.zig | 9 +++++++++ docs/PITFALLS.md | 7 +++++++ 3 files changed, 27 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d4506360..b94414be8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,6 +184,17 @@ jobs: - name: Fetch dependencies (with retries) shell: bash run: bash scripts/ci-retry.sh zig build --fetch + # Every test binary is compiled before any of them runs, so no compiler is alive beside a + # running test. On Windows, Zig 0.16 spawns each child inheriting every inheritable handle + # the build runner holds, the pipes it is making for its other children included. A + # compiler spawned while a test's pipes exist keeps a copy of that test's stdout: the test + # passes and exits, but its stdout reaches EOF only when the compiler exits, and the runner + # gives up after a minute with "test runner failed to respond", on whichever tiny test was + # unlucky, about one run in thirty. `check` is exactly the compiles `test` needs, so the + # step after it spawns only test binaries, which live for milliseconds. + - name: Compile tests + shell: bash + run: bash scripts/ci-retry.sh zig build check --summary all - name: Run tests shell: bash run: bash scripts/ci-retry.sh zig build test --summary all diff --git a/core/transport/NativeTransport.zig b/core/transport/NativeTransport.zig index f62f70090..5b8cf3262 100644 --- a/core/transport/NativeTransport.zig +++ b/core/transport/NativeTransport.zig @@ -441,6 +441,15 @@ test "cancel returns at once while the server is still holding the connection" { const Hold = struct { fn run(s: *std.Io.net.Server, held_io: std.Io, release: *std.atomic.Value(bool)) void { const stream = s.accept(held_io) catch return; + // Read the request before holding: a socket closed with bytes still unread is reset, + // not closed, and Zig 0.16 reads a reset on Windows as `error.Unexpected`, dumping a + // stack trace into the test's stderr that reads as a failure. + var in_buf: [1024]u8 = undefined; + var out_buf: [64]u8 = undefined; + var reader = stream.reader(held_io, &in_buf); + var writer = stream.writer(held_io, &out_buf); + var http_server = std.http.Server.init(&reader.interface, &writer.interface); + _ = http_server.receiveHead() catch {}; while (!release.load(.acquire)) std.Io.sleep(held_io, .fromMicroseconds(1000), .awake) catch {}; stream.close(held_io); } diff --git a/docs/PITFALLS.md b/docs/PITFALLS.md index 7f01b1c27..ea08b5a11 100644 --- a/docs/PITFALLS.md +++ b/docs/PITFALLS.md @@ -15,6 +15,13 @@ goes beside the code if it has an area, here if not. for function-local structs with `var`s inside generic functions. - **Tests run only from files their root module analyzes** (`tests/AGENTS.md`). `zig build check-tests-reached` names any that don't. +- **On Windows, "test runner failed to respond" with every test passed means a leaked pipe.** + Zig 0.16's Windows spawn makes each child inherit every inheritable handle the build runner + holds, its other children's pipes among them. A long-lived child (a compiler, a sleeper) + spawned beside a test keeps that test's stdout open, so after the test exits the runner waits a + minute for EOF and fails it. CI compiles the tests (`zig build check`) before running them for + this; don't fold the two steps back together, or start anything long-lived inside `zig build + test`. ## Running a second fizzy From 0ee96b6faa15c76151a82d45a728856cee65c7f8 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:17:05 -0500 Subject: [PATCH 41/46] app: a document's track exported as a tape, and played (#478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 6, first half: export a track and play it. The transport's Share button, the export summary and the one-view player follow. ## What changes **`fizzy.tracks.export { path, out, real_time }`** writes a document's track as a deflated `.tape` (`app.Tracks.exportTape`): - **The tape:** a keyframe holding the document's file as it was when the track began, a wait for it to open, then every step at the moment it came. - **Pauses** longer than 2 s play as 0.6 s, so a tape of an hour's drawing is the drawing, not the hour. `real_time` keeps them as they were. Each pause runs from where the step before it ended, recorded and played, so a stroke drawn over 1.5 s is never cut short by the next step arriving early. - The plan put this in the player. Doing it at export makes every player, the web's included, play it the same with no player change. The file then holds the shortened times, unless `real_time` is set. - **Where the file comes from:** the keyframe needs the document's own file bytes as the track began, and a track's base is a restore-in-place state that can't create a document elsewhere. So the export: - takes the live document back to the track's start through its history (`historyGoTo` to the step the track noted when it began, `Track.base_top`); - writes it with its owner's `documentBytes`; - brings it forward again in the same call. Nothing is drawn between, the track is muted, and so is the activity feed. The person's document and its history end where they were (checked by the test). The move forward is checked: if it fails, the export fails (`NotReturned`) and says the rest of the history is a redo away, rather than returning a tape while the document sits at the track's start. - **The caret moves:** going back and forth runs the owner's undo and redo, which leave the selection where the last redone step put it. Keeping the person's view through the rewind needs an owner hook (a `historyGoTo` that leaves the view alone), a follow-up. - **Considered and dropped:** encoding file bytes whenever a track begins, which is a second copy of every open document plus an encode on the frame it opens. And restoring the base into the live document, which drops text's undo history. - **Refused, with the reason,** when the track's start can't be reached: undone past and edited over (checked on the history line before anything moves), a keystroke that joined the step the track began in, or an owner with no history line. - **An untitled document's file** gets its owner's first extension, so it opens with the right owner. **Fizzy plays tapes with steps:** - `playTape` checks each step's owner is loaded and writes that step format (`Demo.stepsCheck`, the `Tape.Check.steps` from #453). - The demo stage applies each step to the document its keyframe opened (`Stage.step`, through the owner's `applyStep`). A step that no longer fits its document pauses the tape with a notice, rather than playing on against a document that has diverged. **Text works now. Pixi needs `historyGoTo`** for the rewind; it gets that with the transport's pixi half (5b). Until then an export of a sprite is refused with `NoHistory`. **What playing still asks:** the demo stage replaces the open documents while it plays, so it waits for unsaved changes first, as every demo does. The one-view player in the next half plays a tape in a tab of its own instead, over nothing of the person's. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build check-web`, `zig build test-sdk-version` and `zig build check-tests-reached` pass. - `zig build test`: 572/573 (1 skipped). `zig build test-integration`: 53/53 steps, 554/554 tests (after review fixes). - The new integration test records a document (five inserts and an undo) and exports it. It checks: - the export holds 6 steps; - the document's text and history line are as before; - the tape loads under fizzy's steps check, and its keyframe file is the document as the track began (empty, for a new one); - its steps applied in order to a fresh document make the same text; - a track whose start was undone past and edited over is refused, and that document's text is unchanged. (The demo stage itself can't start in that run, which has other unsaved documents.) - **Played on the demo stage in a running fizzy** (Debug, its own profile, driven through the agent plugin's bridge): `notes.txt` opened from disk with three lines; four steps recorded (a line, a mistake, its undo, another line); exported; saved; played with `fizzy.demo.playTape`. The replayed document matched the saved file 4 s in, with the pauses shortened. - [ ] Windows: not run. - [ ] Linux: CI. - [ ] Web: `check-web` builds. Exporting there is refused (no file to write yet). ## Follow-ups - **The second half of step 6:** - a Share button on the transport; - an export summary saying what's in the tape before it's written; - opening a `.tape` as a tab on its own stage, the one-view player, so playing doesn't replace the person's documents or wait for their unsaved changes; - a range of the track rather than all of it. - **Pixi's `historyLine`/`historyGoTo`** (with 5b), which makes its sprites exportable. - **Keeping the person's caret through the rewind:** an owner hook to go to a step without moving the view. - **A demo's document survived a restart** as an ordinary open document (`demo://share/…` came back in the restored session). Noticed while testing; not caused by this change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- app/Tracks.zig | 120 ++++++++++++++++++++++++++++++++++++++ docs/AUTOMATION.md | 8 ++- src/editor/Demo.zig | 27 +++++++++ src/editor/Keybinds.zig | 30 ++++++++++ tests/integration.zig | 124 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 308 insertions(+), 1 deletion(-) diff --git a/app/Tracks.zig b/app/Tracks.zig index 836462eb2..846d553cc 100644 --- a/app/Tracks.zig +++ b/app/Tracks.zig @@ -28,6 +28,7 @@ const dvui = @import("dvui"); const core = @import("core"); const sdk = @import("fizzy_sdk"); const state_delta = @import("state_delta.zig"); +const tape = @import("tape"); gpa: std.mem.Allocator, /// The frame clock: what a step's moment is read from. A bench gives its own. @@ -83,6 +84,9 @@ pub const Track = struct { format: u32, /// The document when the track began; null until the next frame takes it. base: ?State = null, + /// Where the document's history stood when the base was taken (`HistoryLine`'s step id, 0 + /// before any): what an export rewinds the document to for its file (`exportTape`). + base_top: u64 = 0, steps: std.ArrayListUnmanaged(Rec) = .empty, /// Every step's effect and motion, one after another. bytes: std.ArrayListUnmanaged(u8) = .empty, @@ -240,6 +244,7 @@ pub fn tick(self: *Tracks) void { t.steps.clearRetainingCapacity(); t.bytes.clearRetainingCapacity(); t.base = self.take(t.doc, 0) orelse continue; + t.base_top = historyTop(t.doc, self.gpa) orelse 0; } else if (t.checkpoint_due) { var s = self.take(t.doc, t.steps.items.len) orelse continue; // As what differs from the latest whole state, unless that is half the document or @@ -329,6 +334,14 @@ pub fn shed(self: *Tracks) void { } } +/// The step `doc`'s history is at (0 before any), or null when its owner keeps no line. +fn historyTop(doc: sdk.DocHandle, gpa: std.mem.Allocator) ?u64 { + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + const line = doc.owner.historyLine(doc, arena.allocator()) orelse return null; + return if (line.at == 0) 0 else line.entries[line.at - 1].id; +} + fn take(self: *Tracks, doc: sdk.DocHandle, count: usize) ?Track.State { const bytes = doc.owner.captureDocumentState(doc, self.gpa) orelse return null; return .{ .at = self.now(), .step = count, .bytes = bytes, .fingerprint = doc.owner.documentFingerprint(doc) }; @@ -425,6 +438,113 @@ pub fn replayInto(self: *Tracks, doc_id: u64, target: sdk.DocHandle, count: usiz }; } +pub const ExportOptions = struct { + /// Keep every pause as long as it was. + real_time: bool = false, +}; +/// A pause longer than this plays as `short_gap_ms`. +pub const gap_ms = 2000; +pub const short_gap_ms = 600; + +pub const ExportError = error{ + /// No open document has a track by that id: a tape is made from the document itself. + NoTrack, + /// The track has nothing yet: its base is taken at the next frame. + NoBase, + /// The owner keeps no history to go back through. + NoHistory, + /// Where the track began is no longer on the document's history (undone past and edited + /// over), or a step joined the entry it began in: the document cannot be put back there. + StartNotInHistory, + /// The owner could not write the document as a file. + NoBytes, + /// The document went back to the track's start for its file and could not come forward + /// again: it is left there, the rest of its history a redo away. + NotReturned, + OutOfMemory, +}; + +/// `doc`'s track as a tape, in the binary form, deflated (`tape.binary`): a keyframe holding +/// the document's file as it was when the track began, a wait for it to open, then every step +/// at the moment it came, a replay of how the document was made that plays anywhere. +/// +/// The file is the document's own, written by its owner (`Plugin.documentBytes`), as it stood at +/// the track's start: the document is taken back there through its history (`historyGoTo`), +/// written, and brought forward again in the same call, with nothing drawn between and neither +/// the track nor the activity feed hearing of it. The person's history is as it was. Named +/// `name` (the document's file name, with its owner's extension when it has none), under +/// `demo://share`. Owned by `gpa`. +/// +/// Pauses longer than `ExportOptions.gap_ms` play as `short_gap_ms` (`real_time` keeps them as +/// they were): a tape of an hour's drawing is the drawing, not the hour, and the moment before +/// the first step is a beat rather than however long the document sat open. +pub fn exportTape(self: *Tracks, host: *sdk.Host, doc: sdk.DocHandle, gpa: std.mem.Allocator, opts: ExportOptions) ExportError![]u8 { + const prof = core.profile.begin("fizzy", "tracks.export"); + defer prof.end(); + const t = self.open.get(doc.id) orelse return error.NoTrack; + const base = t.base orelse return error.NoBase; + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + // A step that joined the entry the track began in changed that entry after the base: going + // back to the entry would keep it. + for (t.steps.items) |r| if (r.kind == .joined and r.id == t.base_top and t.base_top != 0) return error.StartNotInHistory; + const line = doc.owner.historyLine(doc, arena) orelse return error.NoHistory; + const now_top = if (line.at == 0) 0 else line.entries[line.at - 1].id; + // Gone from the line (undone past, and edited over): refused before anything moves. + if (t.base_top != 0) for (line.entries) |e| { + if (e.id == t.base_top) break; + } else return error.StartNotInHistory; + + const name = blk: { + const base_name = std.fs.path.basename(doc.owner.documentPath(doc)); + if (std.fs.path.extension(base_name).len > 0) break :blk base_name; + const types = doc.owner.fileTypes(); + if (types.len == 0) break :blk base_name; + break :blk try std.mem.concat(arena, u8, &.{ base_name, types[0] }); + }; + + // Back to where the track began, the file written there, and forward again: that last + // move is checked, since a failure would leave the person's document at the track's start. + const bytes = bytes: { + const was_track_muted = self.muted; + self.muted = doc.id; + defer self.muted = was_track_muted; + const was_muted = host.activity_muted; + host.activity_muted = true; + defer host.activity_muted = was_muted; + const moved = now_top != t.base_top; + if (moved and !doc.owner.historyGoTo(doc, t.base_top)) return error.StartNotInHistory; + const written = doc.owner.documentBytes(doc, name, arena) catch null; + if (moved and !doc.owner.historyGoTo(doc, now_top)) { + std.log.err("tracks: {s} went back to its track's start and could not come forward; redo brings the rest back", .{doc.owner.documentPath(doc)}); + return error.NotReturned; + } + break :bytes written orelse return error.NoBytes; + }; + + const files = try arena.alloc(tape.Tape.File, 1); + files[0] = .{ .path = name, .text = bytes, .owner = doc.owner.id, .format = t.format }; + const keyframes = try arena.alloc(tape.Tape.Keyframe, 1); + keyframes[0] = .{ .root = "demo://share", .files = files, .open = try arena.dupe([]const u8, &.{name}), .layout = .focused }; + const ops = try arena.alloc(tape.Tape.Op, t.steps.items.len + 2); + ops[0] = .{ .at = 0, .do = .{ .keyframe = 0 } }; + ops[1] = .{ .at = 0, .do = .{ .wait = .{ .until = .idle } } }; + // Each pause runs from where the step before it ended, recorded and played: a stroke drawn + // over a second and a half is never cut short by the next step arriving early. + var played_end: u32 = 0; + var recorded_end = base.at; + for (t.steps.items, ops[2..]) |r, *op| { + const gap = r.at -| recorded_end; + const at = played_end + if (!opts.real_time and gap > gap_ms) short_gap_ms else gap; + op.* = .{ .at = at, .ms = r.ms, .do = .{ .step = .{ .file = name, .effect = t.effect(r), .motion = t.motion(r) } } }; + played_end = at + r.ms; + recorded_end = @max(recorded_end, r.at + r.ms); + } + const title = try std.fmt.allocPrint(arena, "How {s} was made", .{name}); + return tape.binary.encode(gpa, .{ .name = "share", .title = title, .ops = ops, .keyframes = keyframes }, .{ .deflate = true }) catch return error.OutOfMemory; +} + /// The open document's track, or else the latest that ended for it. pub fn trackOf(self: *Tracks, doc_id: u64) ?*Track { if (self.open.get(doc_id)) |t| return t; diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index e9cf18e17..bdd003dd5 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -305,7 +305,13 @@ plugin that applies it and its encoding: `.{ .path = "sprite.pixi", .text = <the included (ZON escapes them). `Check.steps` says whose steps, in which format, the app can apply; without it a tape with steps is refused (`error.StepsUnsupported`), since a step nobody applied would play a document that never changes. The app applies them through its stage -(`Stage.step`). Fizzy does not apply steps yet. +(`Stage.step`). + +Fizzy applies them: a tape's steps are checked against the plugins loaded (`Demo.stepsCheck`: the +owner loaded, writing that step format) and played on the demo stage into the documents its +keyframe opened. A document's own history becomes such a tape with `fizzy.tracks.export` (while +tracks record, `FIZZY_TRACKS=1` or `fizzy.tracks.record`): its file as the track began, then its +steps, pauses past 2 s played as 0.6 s unless `real_time`. `fizzy.demo.playTape` plays it. ### The binary form (`.tape`) diff --git a/src/editor/Demo.zig b/src/editor/Demo.zig index 19618c326..63fc1847d 100644 --- a/src/editor/Demo.zig +++ b/src/editor/Demo.zig @@ -171,6 +171,7 @@ pub fn play(self: *Demo, name: []const u8) !void { pub fn playTape(self: *Demo, bytes: []const u8) !void { const gpa = self.editor.app.gpa; var check = automation.Input.check; + check.steps = stepsCheck; var owned = automation.Tape.load(gpa, bytes, check) catch |err| switch (err) { error.NoKeyframeAtStart => { check.live = true; @@ -356,6 +357,7 @@ const stage_vtable: automation.Stage.VTable = .{ .restore = restore, .release = release, .fingerprint = fingerprint, + .step = step, }; /// A live tape played by nobody in particular, or a demo with no name. @@ -400,6 +402,31 @@ pub fn setLiveAuthor(self: *Demo, who: ?sdk.Author) void { self.live_author = .{ .kind = a.kind, .plugin = plugin, .session = a.session, .label = label }; } +/// What a tape's steps need of fizzy (`Tape.Check.steps`): the owner they name, loaded, writing +/// steps in the format they were recorded in. +pub fn stepsCheck(owner: []const u8, format: u32) bool { + const plugin = fizzy.editor().app.host.pluginById(owner) orelse return false; + return plugin.step_format != 0 and plugin.step_format == format and plugin.vtable.applyStep != null; +} + +/// A step of a document the keyframe opened, applied by its owner (`Tape.Step`). +fn step(ctx: *anyopaque, st: automation.Tape.Step, ms: u32) void { + const self = from(ctx); + const editor = self.editor; + const root = self.mount orelse return; + const path = core.paths.join(editor.app.gpa, root, st.file) catch return; + defer editor.app.gpa.free(path); + const doc = editor.docFromPath(path) orelse { + dvui.log.warn("demo: a step of {s}, which is not open", .{path}); + return; + }; + if (doc.owner.applyStep(doc, st.effect, st.motion, ms)) return; + // The document no longer is what the step was made on: every step after would miss too, so + // the tape stops where the viewer can see it went wrong. + self.player.pause(); + dvui.toast(@src(), .{ .message = "This tape's steps no longer fit its document: it stopped here." }); +} + fn from(ctx: *anyopaque) *Demo { return @ptrCast(@alignCast(ctx)); } diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index e742b485d..3573e608d 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -178,6 +178,7 @@ const fizzy_commands = [_]FizzyCommand{ .{ .id = "fizzy.capture", .title = "Capture the Last 30 Seconds", .bind = null, .run = cmdCapture, .params = CaptureArgs.params, .runWith = CaptureArgs.bind(cmdCaptureWith), .icon = icons.tvg.lucide.gauge }, .{ .id = "fizzy.tracks.record", .title = "Tracks: Record…", .bind = null, .run = cmdTracks, .params = TracksRecord.params, .runWith = TracksRecord.bind(cmdTracksRecord), .icon = icons.tvg.lucide.gauge }, .{ .id = "fizzy.tracks.replay", .title = "Tracks: Replay…", .bind = null, .run = cmdTracks, .params = TracksReplay.params, .runWith = TracksReplay.bind(cmdTracksReplay), .icon = icons.tvg.lucide.gauge }, + .{ .id = "fizzy.tracks.export", .title = "Tracks: Export as a Tape…", .bind = null, .run = cmdTracks, .params = TracksExport.params, .runWith = TracksExport.bind(cmdTracksExport), .icon = icons.tvg.lucide.gauge }, .{ .id = "fizzy.tracks", .title = "Tracks: Report", .bind = null, .run = cmdTracks, .params = TracksArgs.params, .runWith = TracksArgs.bind(cmdTracksWith), .icon = icons.tvg.lucide.gauge }, .{ .id = "fizzy.writeTrace", .title = "Write Trace", .bind = null, .run = cmdWriteTrace, .params = TraceArgs.params, .runWith = TraceArgs.bind(cmdWriteTraceTo), .icon = icons.tvg.lucide.gauge }, } ++ open_folder_commands ++ demo_commands ++ automation_commands; @@ -612,6 +613,35 @@ fn cmdTracksReplay(state: *anyopaque, args: TracksReplay.Args, call: *sdk.Comman try call.returns(.{ .steps = @min(n, track.steps.items.len), .checkpoints = track.checkpoints.items.len }); } +const TracksExport = sdk.Command.Params(struct { + path: sdk.Command.Arg([]const u8, .{ .description = "The open document whose track to export, by its path. Empty: the active one." }) = .init(""), + out: sdk.Command.Arg([]const u8, .{ .description = "Where to write the tape. Empty: the config folder's tapes/, named for the document and the time." }) = .init(""), + real_time: sdk.Command.Arg(bool, .{ .description = "Keep every pause as long as it was. Off: a pause past 2 s plays as 0.6 s." }) = .init(false), +}); + +/// A document's track as a tape (`Tracks.exportTape`): how it was made, from where its track +/// began, to play anywhere (`fizzy.demo.playTape`). Returns where it went and what it holds. +fn cmdTracksExport(state: *anyopaque, args: TracksExport.Args, call: *sdk.Command.Call) anyerror!void { + if (comptime @import("builtin").target.cpu.arch == .wasm32) return call.fail("the web has no file to write a tape to yet", .{}); + const editor = editorFromState(state); + const t = &(editor.app.tracks orelse return call.fail("no tracks in this app", .{})); + const doc = (if (args.path.len == 0) editor.activeDoc() else editor.docFromPath(args.path)) orelse + return call.fail("no open document at {s}", .{if (args.path.len == 0) "(active)" else args.path}); + const gpa = editor.app.gpa; + const bytes = t.exportTape(&editor.app.host, doc, gpa, .{ .real_time = args.real_time }) catch |err| return call.fail("could not export {s}: {t}", .{ doc.owner.documentPath(doc), err }); + defer gpa.free(bytes); + const io = dvui.io; + const out = if (args.out.len > 0) args.out else out: { + const stem = std.fs.path.stem(doc.owner.documentPath(doc)); + const file = try std.fmt.allocPrint(call.arena, "{s}-{d}.tape", .{ stem, std.Io.Clock.real.now(io).toSeconds() }); + break :out try std.fs.path.join(call.arena, &.{ editor.app.config_folder, "tapes", file }); + }; + if (std.fs.path.dirname(out)) |dir| std.Io.Dir.cwd().createDirPath(io, dir) catch |err| return call.fail("could not make {s}: {t}", .{ dir, err }); + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = out, .data = bytes }) catch |err| return call.fail("could not write {s}: {t}", .{ out, err }); + const track = t.trackOf(doc.id).?; + try call.returns(.{ .path = out, .bytes = bytes.len, .steps = track.steps.items.len }); +} + fn cmdTracksRecord(state: *anyopaque, args: TracksRecord.Args, call: *sdk.Command.Call) anyerror!void { const editor = editorFromState(state); editor.setTracking(args.on); diff --git a/tests/integration.zig b/tests/integration.zig index 17c65ba61..0bd16c83f 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7901,6 +7901,130 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try dvui.testing.settle(headlessFrame); } + // A document's track as a tape (`fizzy.tracks.export`): its file as the track began, then its + // steps; the person's document and history as they were; played (`Demo.playTape`), it makes + // the same text in a document of its own. + { + const host = &editor.app.host; + const was = editor.activeDoc() orelse return error.TestUnexpectedResult; + editor.setTracking(true); + defer editor.setTracking(false); + try host.runCommand("fizzy.newFile"); + for (0..200) |_| { + _ = try dvui.testing.step(headlessFrame); + const d = editor.activeDoc() orelse continue; + if (d.id != was.id and std.mem.startsWith(u8, std.fs.path.basename(d.owner.documentPath(d)), "untitled")) break; + } + const doc = editor.activeDoc() orelse return error.TestUnexpectedResult; + try std.testing.expect(doc.id != was.id); + try dvui.testing.settle(headlessFrame); + for ([_][]const u8{ "one", "two", "three", "four", "five" }) |word| { + const args = try std.fmt.allocPrint(host.arena(), ".{{ .text = \"{s}\\n\", .line = 1 }}", .{word}); + try std.testing.expect(host.callCommand("text.insert", args, host.arena()) == .ok); + } + try editor.undo(); + try dvui.testing.settle(headlessFrame); + const textOf = struct { + fn textOf(h: *sdk.Host, path: []const u8) ![]const u8 { + const args = try std.fmt.allocPrint(h.arena(), ".{{ .path = \"{s}\" }}", .{path}); + const zon = switch (h.callCommand("text.read", args, h.arena())) { + .ok => |r| r orelse return error.TestUnexpectedResult, + else => return error.TestUnexpectedResult, + }; + const at = std.mem.indexOf(u8, zon, ".text = \"") orelse return error.TestUnexpectedResult; + const rest = zon[at + ".text = \"".len ..]; + return rest[0 .. std.mem.indexOfScalar(u8, rest, '"') orelse return error.TestUnexpectedResult]; + } + }.textOf; + const path = doc.owner.documentPath(doc); + const want = try gpa.dupe(u8, try textOf(host, path)); + defer gpa.free(want); + const line_before = (doc.owner.historyLine(doc, host.arena()) orelse return error.TestUnexpectedResult); + + const out = try std.fmt.allocPrint(gpa, "{s}/tapes/export-test.tape", .{editor.app.config_folder}); + defer gpa.free(out); + const args = try std.fmt.allocPrint(host.arena(), ".{{ .path = \"{s}\", .out = \"{s}\" }}", .{ path, out }); + const result = switch (host.callCommand("fizzy.tracks.export", args, host.arena())) { + .ok => |r| r orelse return error.TestUnexpectedResult, + else => |r| { + std.debug.print("export: {any}\n", .{r}); + return error.TestUnexpectedResult; + }, + }; + try std.testing.expect(std.mem.indexOf(u8, result, ".steps = 6") != null); + // The person's document is where it was, its history too. + try std.testing.expectEqualStrings(want, try textOf(host, path)); + const line_after = (doc.owner.historyLine(doc, host.arena()) orelse return error.TestUnexpectedResult); + try std.testing.expectEqual(line_before.at, line_after.at); + try std.testing.expectEqual(line_before.entries.len, line_after.entries.len); + + const bytes = try std.Io.Dir.cwd().readFileAlloc(dvui.io, out, gpa, .limited(1 << 20)); + defer gpa.free(bytes); + // What it holds: fizzy reads it (its owner and format known), its keyframe is the file as + // the track began (a new document's: empty), and its steps, applied in order to a + // document of their own, make the same text. (Playing it on the demo stage replaces the + // open files and waits for unsaved ones, which this run has.) + var check = app.automation.Input.check; + check.steps = fizzy.Editor.Demo.stepsCheck; + var owned = try app.automation.Tape.load(gpa, bytes, check); + defer owned.deinit(); + const kf = owned.tape.keyframes[0]; + try std.testing.expectEqual(@as(usize, 1), kf.files.len); + try std.testing.expectEqualStrings("text", kf.files[0].owner); + try std.testing.expectEqualStrings("", kf.files[0].text); + try host.runCommand("fizzy.newFile"); + for (0..200) |_| { + _ = try dvui.testing.step(headlessFrame); + const d = editor.activeDoc() orelse continue; + if (d.id != doc.id and d.id != was.id and std.mem.startsWith(u8, std.fs.path.basename(d.owner.documentPath(d)), "untitled")) break; + } + const replay = editor.activeDoc() orelse return error.TestUnexpectedResult; + try std.testing.expect(replay.id != doc.id); + var applied: usize = 0; + for (owned.tape.ops) |op| switch (op.do) { + .step => |st| { + try std.testing.expect(replay.owner.applyStep(replay, st.effect, st.motion, 0)); + applied += 1; + }, + else => {}, + }; + try std.testing.expectEqual(@as(usize, 6), applied); + try dvui.testing.settle(headlessFrame); + try std.testing.expectEqualStrings(want, try textOf(host, replay.owner.documentPath(replay))); + + try editor.rawCloseFileID(replay.id); + try editor.rawCloseFileID(doc.id); + + // A track whose start is gone from the history (undone past, and edited over) is + // refused, and the document is left as it was. + editor.setTracking(false); + try host.runCommand("fizzy.newFile"); + for (0..200) |_| { + _ = try dvui.testing.step(headlessFrame); + const d = editor.activeDoc() orelse continue; + if (d.id != doc.id and d.id != was.id and std.mem.startsWith(u8, std.fs.path.basename(d.owner.documentPath(d)), "untitled")) break; + } + const gone = editor.activeDoc() orelse return error.TestUnexpectedResult; + for ([_][]const u8{ "a", "b" }) |word| { + const insert = try std.fmt.allocPrint(host.arena(), ".{{ .text = \"{s}\\n\", .line = 1 }}", .{word}); + try std.testing.expect(host.callCommand("text.insert", insert, host.arena()) == .ok); + } + editor.setTracking(true); + try dvui.testing.settle(headlessFrame); + try editor.undo(); + try editor.undo(); + try std.testing.expect(host.callCommand("text.insert", ".{ .text = \"c\\n\", .line = 1 }", host.arena()) == .ok); + const gone_path = gone.owner.documentPath(gone); + const before_text = try gpa.dupe(u8, try textOf(host, gone_path)); + defer gpa.free(before_text); + const gone_args = try std.fmt.allocPrint(host.arena(), ".{{ .path = \"{s}\", .out = \"{s}\" }}", .{ gone_path, out }); + try std.testing.expect(host.callCommand("fizzy.tracks.export", gone_args, host.arena()) != .ok); + try std.testing.expectEqualStrings(before_text, try textOf(host, gone_path)); + try editor.rawCloseFileID(gone.id); + editor.workbench.setActiveDocIndex(editor.app.open_files.getIndex(was.id) orelse return error.TestUnexpectedResult); + try dvui.testing.settle(headlessFrame); + } + // Revealing a view (`Host.revealSurface`): with the bottom panel shut, revealing Output opens // the panel with Output selected, and a view no region shows is not revealed. { From 3f7b2ff9d1f8a92c7c0fe71cca22b8d13723d371 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:11:55 -0500 Subject: [PATCH 42/46] app: fizzy.undo and fizzy.redo take a path and the step expected (#487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of fizzyedit/plans#14 — `plans/SHARED_HISTORY_PLAN.md` step 5a, as amended for `plans/CONTROL_SERVER_PLAN.md` (fizzyedit/plans#24). ## What changes `fizzy.undo` and `fizzy.redo` take two optional arguments: - **`path`**: the open document to undo, which need not be the active one. It goes through the owner's `historyLine` and `historyGoTo` (text and pixi both have them), so nothing is focused and the document in front is untouched. An owner with no line undoes only the active document, as before. - **`expect`**: the step id the caller means, the last change for undo or the next one for redo. When another step is there instead, the call refuses and names it: *"step 41 is not the one to undo: that is step 43, Draw, by the person. The document changed since; fix it forward."* So a caller never takes back a step made since it looked. Its answer is `.{ .step, .title, .by }`, where `by` is whose the step was: "the person", or an author's label (`Host.stepAuthor`, #474). Keys, menus and the palette run Undo exactly as before (`run`, with its transport and key repeat). Only a call with arguments takes the new path. This is the refusal the shared-history plan wanted from the agent plugin (fizzyedit/agent#21's `undoesAnother`). As a command, it holds for any caller, and over the control server it becomes a 409 from `POST /commands/fizzy.undo` with no agent code. **One trade-off.** `fizzy.undo`/`redo` are now enabled while any document is open, as text's by-path commands are, because a call may name a document other than the active one. The command palette therefore shows Undo enabled when the active document has nothing to undo; running it then does nothing. The menus still grey it by the active document (`menu_model.canUndo`). An enabled check that sees the call's target is the control plan's open question ("Commands that take a document"), so it isn't decided here. ## SDK impact - [x] None. A doc comment on `Plugin.historyGoTo` notes that `fizzy.undo` reaches it; the fingerprint does not move (`test-sdk-version` passes). ## Verified - Integration (`headless: the whole editor comes up…`), on the open text documents: an author acting as "Claude" writes into `notes.txt` by path while `main.zig` is in front. - A stale `expect` is refused, naming Claude, and changes nothing. - The right `expect` undoes it, answering `.by = "Claude"`, and `main.zig` stays in front, untouched. - Redo and undo again by the same id work. - Gates on macOS: `check-fmt`, `zig build test` (573/574, 1 skipped), `zig build test-integration` (53/53 steps), `check-web`, `test-sdk-version`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- sdk/src/Plugin.zig | 2 ++ src/editor/Keybinds.zig | 68 ++++++++++++++++++++++++++++++++++++----- tests/integration.zig | 36 ++++++++++++++++++++++ 3 files changed, 98 insertions(+), 8 deletions(-) diff --git a/sdk/src/Plugin.zig b/sdk/src/Plugin.zig index e96f6b007..a0c87a807 100644 --- a/sdk/src/Plugin.zig +++ b/sdk/src/Plugin.zig @@ -236,6 +236,8 @@ pub const VTable = struct { historyLine: ?*const fn (state: *anyopaque, doc: DocHandle, arena: std.mem.Allocator) anyerror!HistoryLine = null, /// Undo or redo `doc` until step `id` (`HistoryLine.Entry.id`) is the last one applied; 0 /// goes back before the first. An id not on the line is an error, and nothing moves. + /// `fizzy.undo`/`fizzy.redo` with a `path` or an `expect` go through it too, so a document + /// can be undone without being the active one. historyGoTo: ?*const fn (state: *anyopaque, doc: DocHandle, id: u64) anyerror!void = null, /// Apply a step this owner recorded (`Step`, `Host.recordStep`) to `doc`, forward, as a new /// entry on its history: `effect` and `motion` in the owner's `step_format`. `ms` asks for it diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index 3573e608d..d24d35ae6 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -155,8 +155,8 @@ const fizzy_commands = [_]FizzyCommand{ // No dvui bind name: its default comes from the profile table (`close_defaults`). .{ .id = "fizzy.close", .title = "Close", .bind = null, .run = cmdClose, .isEnabled = anyDocOpen, .params = ClosePath.params, .runWith = ClosePath.bind(cmdClosePath), .icon = icons.tvg.lucide.x }, .{ .id = "fizzy.saveAll", .title = "Save All", .bind = "save_all", .run = cmdSaveAll, .icon = icons.tvg.lucide.@"save-all" }, - .{ .id = "fizzy.undo", .title = "Undo", .bind = "undo", .run = cmdUndo, .isEnabled = cmdUndoEnabled, .icon = icons.tvg.lucide.undo }, - .{ .id = "fizzy.redo", .title = "Redo", .bind = "redo", .run = cmdRedo, .isEnabled = cmdRedoEnabled, .icon = icons.tvg.lucide.redo }, + .{ .id = "fizzy.undo", .title = "Undo", .bind = "undo", .run = cmdUndo, .isEnabled = anyDocOpen, .params = HistoryStep.params, .runWith = HistoryStep.bind(cmdUndoWith), .icon = icons.tvg.lucide.undo }, + .{ .id = "fizzy.redo", .title = "Redo", .bind = "redo", .run = cmdRedo, .isEnabled = anyDocOpen, .params = HistoryStep.params, .runWith = HistoryStep.bind(cmdRedoWith), .icon = icons.tvg.lucide.redo }, .{ .id = "fizzy.copy", .title = "Copy", .bind = "copy", .run = cmdCopy, .isEnabled = cmdCopyEnabled, .icon = icons.tvg.lucide.copy }, .{ .id = "fizzy.paste", .title = "Paste", .bind = "paste", .run = cmdPaste, .isEnabled = cmdPasteEnabled, .icon = icons.tvg.lucide.@"clipboard-paste" }, .{ .id = "fizzy.toggleExplorer", .title = "Toggle Explorer", .bind = "explorer", .run = cmdToggleExplorer, .icon = icons.tvg.lucide.@"panel-left" }, @@ -413,6 +413,64 @@ fn cmdUndo(state: *anyopaque) anyerror!void { fn cmdRedo(state: *anyopaque) anyerror!void { try editorFromState(state).redo(); } + +const HistoryStep = sdk.Command.Params(struct { + path: sdk.Command.Arg([]const u8, .{ .description = "The open document, by its path. Empty: the active one." }) = .init(""), + expect: sdk.Command.Arg(u64, .{ .description = "The step you mean, by its id: for undo the last change, for redo the next one. Refused when it is not, so a step someone made since is never taken back. 0: whichever it is." }) = .init(0), +}); + +/// Enabled while any document is open, as text's by-path commands are: a call may name a document +/// that is not the active one, and says why when there is nothing to take. The menus grey Undo by +/// the active document's own (`menu_model.canUndo`). An enabled check that sees the call's target +/// is the control server's to decide (`plans/CONTROL_SERVER_PLAN.md`, "Commands that take a +/// document"). +fn cmdUndoWith(state: *anyopaque, args: HistoryStep.Args, call: *sdk.Command.Call) anyerror!void { + try historyStepWith(editorFromState(state), args, call, .undo); +} +fn cmdRedoWith(state: *anyopaque, args: HistoryStep.Args, call: *sdk.Command.Call) anyerror!void { + try historyStepWith(editorFromState(state), args, call, .redo); +} + +/// Undo or redo one step of a document named by path, or of the active one, as any caller: the +/// person's palette, a script, an agent. Over the owner's history line (`historyLine`, +/// `historyGoTo`), so it reaches a document that is not active. With `expect`, only when that +/// step is the one it would take, so a caller never takes back a step made since it looked +/// (`plans/SHARED_HISTORY_PLAN.md`, step 5). Answers the step and whose it was. +fn historyStepWith(editor: *Editor, args: HistoryStep.Args, call: *sdk.Command.Call, action: enum { undo, redo }) !void { + const doc = if (args.path.len == 0) + editor.activeDoc() orelse return call.fail("no document is active", .{}) + else + editor.docFromPath(args.path) orelse return call.fail("no open document at {s}", .{args.path}); + const line = doc.owner.historyLine(doc, call.arena) orelse { + // An owner with no line undoes the active document only, through its own command. + const active = editor.activeDoc(); + if (args.expect != 0 or active == null or active.?.id != doc.id) + return call.fail("{s} keeps no history line, so it can only undo the active document, with no expect", .{doc.owner.id}); + return switch (action) { + .undo => editor.undo(), + .redo => editor.redo(), + }; + }; + const host = &editor.app.host; + const step: sdk.Plugin.HistoryLine.Entry, const to: u64 = switch (action) { + .undo => blk: { + if (line.at == 0) return call.fail("nothing to undo", .{}); + break :blk .{ line.entries[line.at - 1], if (line.at >= 2) line.entries[line.at - 2].id else 0 }; + }, + .redo => blk: { + if (line.at >= line.entries.len) return call.fail("nothing to redo", .{}); + break :blk .{ line.entries[line.at], line.entries[line.at].id }; + }, + }; + const whose = host.stepAuthor(doc.id, step.id); + const by = if (whose.kind == .person) "the person" else if (whose.label.len > 0) whose.label else @tagName(whose.kind); + if (args.expect != 0 and args.expect != step.id) return call.fail( + "step {d} is not the one to {s}: that is step {d}, {s}, by {s}. The document changed since; fix it forward", + .{ args.expect, @tagName(action), step.id, step.title, by }, + ); + if (!doc.owner.historyGoTo(doc, to)) return call.fail("{s} could not {s} step {d}", .{ doc.owner.id, @tagName(action), step.id }); + try call.returns(.{ .step = step.id, .title = step.title, .by = by }); +} /// 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 @@ -495,12 +553,6 @@ fn cmdFitView(state: *anyopaque) anyerror!void { fn cmdFitViewEnabled(state: *anyopaque) bool { return editorFromState(state).activeDocCommandEnabled("fitView"); } -fn cmdUndoEnabled(state: *anyopaque) bool { - return editorFromState(state).activeDocCommandEnabled("undo"); -} -fn cmdRedoEnabled(state: *anyopaque) bool { - return editorFromState(state).activeDocCommandEnabled("redo"); -} /// 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 diff --git a/tests/integration.zig b/tests/integration.zig index 0bd16c83f..ee02bf58b 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7418,6 +7418,42 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try std.testing.expect(std.mem.indexOf(u8, host.callCommand("text.write", write_args, host.arena()).ok.?, ".changed = true") != null); try std.testing.expect(std.mem.indexOf(u8, host.callCommand("text.write", write_args, host.arena()).ok.?, ".changed = false") != null); + // `fizzy.undo`/`fizzy.redo` by path, on a document behind the one in front, as a script or + // an agent calls them: only the step `expect` names, and the answer says whose it was. + { + const notes_doc = editor.docFromPath(notes) orelse return error.TestUnexpectedResult; + const before = (try read(host, notes_args)).text; + const notes_write = try std.fmt.allocPrint(gpa, ".{{ .path = \"{s}\", .text = \"# Notes, by Claude\\n\" }}", .{notes}); + defer gpa.free(notes_write); + const outer = host.act(.{ .kind = .agent, .plugin = "test", .session = 1, .label = "Claude" }); + try std.testing.expect(host.callCommand("text.write", notes_write, host.arena()) == .ok); + host.endAct(outer); + const line = notes_doc.owner.historyLine(notes_doc, host.arena()) orelse return error.TestUnexpectedResult; + const top = line.entries[line.at - 1].id; + + const stale = try std.fmt.allocPrint(gpa, ".{{ .path = \"{s}\", .expect = {d} }}", .{ notes, top + 1000 }); + defer gpa.free(stale); + switch (host.callCommand("fizzy.undo", stale, host.arena())) { + .failed => |m| try std.testing.expect(std.mem.indexOf(u8, m, "by Claude") != null), + else => return error.TestUnexpectedResult, + } + try std.testing.expectEqualStrings("# Notes, by Claude\n", (try read(host, notes_args)).text); + + const named = try std.fmt.allocPrint(gpa, ".{{ .path = \"{s}\", .expect = {d} }}", .{ notes, top }); + defer gpa.free(named); + const undid = host.callCommand("fizzy.undo", named, host.arena()); + try std.testing.expect(undid == .ok); + try std.testing.expect(std.mem.indexOf(u8, undid.ok.?, ".by = \"Claude\"") != null); + try std.testing.expectEqualStrings(before, (try read(host, notes_args)).text); + // The document in front is untouched, and still in front. + try std.testing.expectEqualStrings(main_zig, (editor.activeDoc() orelse return error.TestUnexpectedResult).owner.documentPath(editor.activeDoc().?)); + + try std.testing.expect(host.callCommand("fizzy.redo", named, host.arena()) == .ok); + try std.testing.expectEqualStrings("# Notes, by Claude\n", (try read(host, notes_args)).text); + try std.testing.expect(host.callCommand("fizzy.undo", named, host.arena()) == .ok); + try std.testing.expectEqualStrings(before, (try read(host, notes_args)).text); + } + // Save and Close, the unsaved-changes dialog's: the same write Save makes, then the tab // closes once it lands. try std.testing.expect(doc.owner.isDirty(doc)); From e386e864bf8c1530ed1500cec5a6b4842c379590 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:26:18 -0500 Subject: [PATCH 43/46] app: a tape plays in a tab of its own, and the transport shares (#485) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 6, second half (first part): a tape plays in a tab of its own, and the transport shares. Stacked on #478. ## What changes **A tab player (`src/editor/TapeTabs.zig`).** A tape of one document's track opens beside the person's documents, never over them, and plays in at its own pace: - **How it opens:** the tape's keyframe file (the document as its track began) is mounted under a root of its own, `tape://<n>`, and opened like any file. Each step is then applied as its moment comes, through the owner's `applyStep`. - **History:** each step lands on the document's history, so the transport scrubs and steps back through what has played, as on any document. - **Unlike the demo stage,** nothing of the person's is set aside, and the player never waits on their unsaved work. - **Cost:** with nothing playing, a frame costs one check of an empty list. While a tape plays, a `dvui.timer` wakes the app for the next step and nothing else, so the app sleeps between steps. - **Played out,** the document is written into its own mount, so it is clean and closes without asking. An edit the person makes afterwards would make it ask. - **A step that no longer fits** (the person edited the document mid-play) stops the playback with a notice. The document is left theirs. - **A document that never comes** (its load failed) is given 30 s, then the playback lets its mount go. - **Closing the tab** ends the playback and unmounts. - **The root is one nothing holds:** not a mount, and no open document under it. A restart's hand-over or a crash's recovery can bring back an earlier run's `tape://1/…` document. **`Demo: Play a Tape…` routes by what the tape holds.** One document's track goes to a tab; anything else (a demo, a multi-document session, a `.zon`) goes to the demo stage as before. **The transport shares.** `HistoryTransport.draw` takes `DrawOptions{ .share }` and returns `Pressed{ .share }`: - With `.share` set, a fourth bubble (lucide `share-2`, anchor `history.share:<path>`) sits at the bar's end, inside its width. - What a press does is the app's. In fizzy it shows when the document has a track with steps. A press marks the document (`share_pending`), and at the top of the next frame, before anything draws, `Editor.shareDocument` runs. The export walks the document's history back and forth, which shouldn't happen in the middle of its draw. It: - exports the track (`Tracks.exportTape`); - keeps it in the config folder's `tapes/`; - opens it in a tab to play; - says "Saved <name>.tape" on the tape's tab, which the pane turns to. - On the web nothing is written; the tape just opens in its tab. - `Editor.keepTape` is the one writer, and `fizzy.tracks.export` uses it too. **Notices are glass, on the document.** `dvui.toast` draws dvui's opaque pill over the whole window. The share's notices go to the document's own activity feed instead (`Editor.noteOnDocument`, the feed's `push` made public): the same glass pills its commands show in, at its pane's bottom right. That covers the saved tape, a failed share, and a tape that stopped. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build check-web`, `zig build test-sdk-version` and `zig build check-tests-reached` pass. - `zig build test-integration`: 53/53 steps, 559/559 tests (rebased on `main`). - #478's export test now plays the exported tape through `TapeTabs` instead of applying its steps by hand. It checks: - the played document at `tape://1/<name>` makes the same text, and the person's document is unchanged; - the played document's history stands at 6; - played out, it is clean and closes with `closeFileID` without a prompt; - the playback and its mount are gone after the close; - the share itself (`share_pending`, as the button sets it) writes one more tape into the config folder's `tapes/` and plays it in `tape://2/<name>` to the same text. - **In a running fizzy** (Debug, its own profile, `FIZZY_TRACKS=1`, driven through the agent plugin's bridge and fizzy's own frame capture): - edited `notes.txt`, then undo and redo brought the bar up with the share bubble at its end; - pressing it wrote `tapes/notes-<time>.tape`, opened a third tab, and the tape played in; - the played text matched the original byte for byte, `.unsaved = false`, and the "Saved <name>.tape" notice showed in glass on the tape's tab; - with an earlier `tape://1` document brought back by crash recovery, the share took `tape://2`. - [ ] Windows: not run. - [ ] Linux: CI. - [ ] Web: `check-web` builds (the share opens the tape without writing it). Not run in a browser. ## Follow-ups - **Mounted documents in the session:** a `tape://` (or `demo://`) document is saved in `layout.zon` and kept by restart hand-over and crash recovery, though its mount is gone. That is generic, not this player's: the session should skip documents on mounts that don't outlive the run. The seam is a mount flag the session reads. - **The tab's name** is the file's (`notes.txt`). A tape tab should say it's a tape. That needs a title hook the workbench reads, or a name on the mount. - **The rest of step 6:** an export summary before writing, and a range of the track rather than all of it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- app/ActivityFeed.zig | 4 +- app/HistoryTransport.zig | 45 +++++++-- src/editor/Editor.zig | 68 +++++++++++++- src/editor/Keybinds.zig | 21 ++--- src/editor/TapeTabs.zig | 196 +++++++++++++++++++++++++++++++++++++++ tests/integration.zig | 73 +++++++++++---- 6 files changed, 365 insertions(+), 42 deletions(-) create mode 100644 src/editor/TapeTabs.zig diff --git a/app/ActivityFeed.zig b/app/ActivityFeed.zig index 657196536..c70ae3d57 100644 --- a/app/ActivityFeed.zig +++ b/app/ActivityFeed.zig @@ -44,7 +44,9 @@ pub fn clear(self: *ActivityFeed) void { self.len = 0; } -fn push(self: *ActivityFeed, title: []const u8, now: i128) void { +/// Show `title` as though a command of that name had just run: how the app says something +/// about the document itself (a share kept, a tape stopped) in the same glass as its commands. +pub fn push(self: *ActivityFeed, title: []const u8, now: i128) void { if (self.len == max) { std.mem.copyForwards(Item, self.items[0 .. max - 1], self.items[1..max]); self.len -= 1; diff --git a/app/HistoryTransport.zig b/app/HistoryTransport.zig index f00163b02..3b471d71a 100644 --- a/app/HistoryTransport.zig +++ b/app/HistoryTransport.zig @@ -62,26 +62,41 @@ fn stopId(line: sdk.Plugin.HistoryLine, i: usize) u64 { return if (i == 0) 0 else line.entries[i - 1].id; } +pub const DrawOptions = struct { + /// A share button at the bar's end: the app has a way to share how the document was made + /// (fizzy: its track as a tape). What a press does is the app's (`Pressed.share`). + share: bool = false, +}; + +/// What was pressed on the bar this frame that is the app's to act on. +pub const Pressed = struct { + share: bool = false, +}; + /// Draw the bar over `area` (the document's pane, physical) when it shows, and take its input. /// `id_extra` keeps one pane's bar apart from another's. -pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physical, id_extra: usize) void { +pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physical, id_extra: usize, opts: DrawOptions) Pressed { + var pressed: Pressed = .{}; const now = dvui.frameTimeNS(); // The pointer on it, or a drag in it, keeps it. if (self.drag != null) self.shown.stir(now) else if (self.place(area, now)) |b| if (b.contains(dvui.currentWindow().mouse_pt)) self.shown.stir(now); const p = self.shown.openness(now); - if (p <= 0) return; + if (p <= 0) return pressed; const arena = dvui.currentWindow().arena(); - const line = doc.owner.historyLine(doc, arena) orelse return; + const line = doc.owner.historyLine(doc, arena) orelse return pressed; const n = line.entries.len; const wrs = dvui.windowRectScale(); const s = wrs.s; - const whole = geometry(area.toNatural()) orelse return; + const whole = geometry(area.toNatural()) orelse return pressed; const w = whole.w; const center = whole.center(); + // Share, when there is a way to: the last bubble, inside the bar's width. + const end_w: f32 = if (opts.share) button_d + gap else 0; const back: dvui.Rect = .{ .x = whole.x, .y = center.y - button_d / 2, .w = button_d, .h = button_d }; - const fwd: dvui.Rect = .{ .x = whole.x + w - button_d, .y = back.y, .w = button_d, .h = button_d }; - const track: dvui.Rect = .{ .x = back.x + button_d + gap, .y = whole.y, .w = w - 2 * (button_d + gap), .h = bar_h }; + const fwd: dvui.Rect = .{ .x = whole.x + w - button_d - end_w, .y = back.y, .w = button_d, .h = button_d }; + const share: dvui.Rect = .{ .x = whole.x + w - button_d, .y = back.y, .w = if (opts.share) button_d else 0, .h = button_d }; + const track: dvui.Rect = .{ .x = back.x + button_d + gap, .y = whole.y, .w = w - 2 * (button_d + gap) - end_w, .h = bar_h }; const hit = wrs.rectToPhysical(whole.outsetAll(4)); var float: dvui.FloatingWidget = undefined; @@ -93,7 +108,7 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica // and went), it holds nothing, and the knob is the document's again. if (self.drag != null and !dvui.captured(fid)) self.drag = null; - const phys = [3]dvui.Rect.Physical{ wrs.rectToPhysical(back), wrs.rectToPhysical(track), wrs.rectToPhysical(fwd) }; + const phys = [4]dvui.Rect.Physical{ wrs.rectToPhysical(back), wrs.rectToPhysical(track), wrs.rectToPhysical(fwd), wrs.rectToPhysical(share) }; const pad = 14; const x0 = phys[1].x + pad * s; const x1 = phys[1].x + phys[1].w - pad * s; @@ -106,6 +121,7 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica core.anchor.markRect(fid, phys[0], true, "history.back:{s}", .{path}); core.anchor.markRect(fid, .{ .x = x0, .y = phys[1].y, .w = x1 - x0, .h = phys[1].h }, true, "history.line:{s}", .{path}); core.anchor.markRect(fid, phys[2], true, "history.forward:{s}", .{path}); + if (opts.share) core.anchor.markRect(fid, phys[3], true, "history.share:{s}", .{path}); // Input: the buttons step, the line goes where it is pressed and follows a drag. var go: ?usize = null; @@ -122,6 +138,8 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica go = line.at -| 1; } else if (phys[2].contains(me.p)) { go = @min(n, line.at + 1); + } else if (opts.share and phys[3].contains(me.p)) { + pressed.share = true; } else if (line_hit.contains(me.p)) { dvui.captureMouse(float.data(), e.num); self.drag = rules.stopAt(me.p.x, x0, x1, n); @@ -149,16 +167,22 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica const prev_alpha = dvui.alpha(p); defer dvui.alphaSet(prev_alpha); const pt = dvui.currentWindow().mouse_pt; - var bubbles: [3]core.dialogs.Bubble = undefined; + var bubbles: [4]core.dialogs.Bubble = undefined; for (phys, &bubbles, 0..) |r, *b, i| b.* = .{ .rect = r, - .light = if (i != 1 and r.contains(pt) and (if (i == 0) at > 0 else at < n)) core.dialogs.bubble_hover else 0, + .light = if (r.contains(pt) and switch (i) { + 0 => at > 0, + 2 => at < n, + 3 => true, + else => false, + }) core.dialogs.bubble_hover else 0, }; - core.dialogs.glassBubbles(fid, &bubbles, s, .{ .merge = 14, .shadow = 1 }); + core.dialogs.glassBubbles(fid, bubbles[0..if (opts.share) 4 else 3], s, .{ .merge = 14, .shadow = 1 }); const ink = dvui.themeGet().color(.window, .text); button(bounds, back, "history_back", icons.tvg.lucide.undo, ink.opacity(if (at > 0) 0.85 else 0.3)); button(bounds, fwd, "history_forward", icons.tvg.lucide.redo, ink.opacity(if (at < n) 0.85 else 0.3)); + if (opts.share) button(bounds, share, "history_share", icons.tvg.lucide.@"share-2", ink.opacity(0.85)); // What the document is at, over the line: the step's title, and how far along. const font = dvui.Font.theme(.body).larger(-2); @@ -178,6 +202,7 @@ pub fn draw(self: *HistoryTransport, doc: sdk.DocHandle, area: dvui.Rect.Physica // own (it starts to go, it is gone). if (p < 1) dvui.refresh(null, @src(), fid); if (self.shown.nextChange(now)) |t| dvui.timer(fid, @intCast(@min(@divTrunc(t - now, 1000) + 1, std.math.maxInt(i32)))); + return pressed; } /// A step button's icon, in the middle of its bubble `r` (window-natural; `bounds` the layer's). diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 19b47b377..6190115f6 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -130,6 +130,11 @@ command_palette: @import("CommandPalette.zig") = .{}, /// The demo player and fizzy's stage for it. Attached in `postInit`, at the Editor's final /// address (the stage's context is this field). demo: Demo = .detached, +/// Tapes playing in tabs of their own (`Demo: Play a Tape…`, the transport's share). +tape_tabs: @import("TapeTabs.zig") = .{}, +/// A document whose transport's share was pressed: shared at the top of the next frame, before +/// anything draws, since the export walks the document's history back and forth. +share_pending: ?u64 = null, /// Rebuilt plugins opening off the UI thread, swapped in once open (`PluginReloads`). plugin_reloads: @import("PluginReloads.zig") = .{}, @@ -2979,10 +2984,65 @@ fn drawDocSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result { } } // Its history, while undo or redo or the pointer keep it: not while someone else holds it. - if (claim) |c| drawClaim(editor, c, claim_pill.?, doc.owner.documentPath(doc)) else ds.transport.draw(doc, canvas.data().borderRectScale().r, @truncate(ds.doc_id)); + if (claim) |c| drawClaim(editor, c, claim_pill.?, doc.owner.documentPath(doc)) else { + const shareable = if (editor.app.tracks) |*t| if (t.trackOf(doc.id)) |tr| tr.steps.items.len > 0 else false else false; + const pressed = ds.transport.draw(doc, canvas.data().borderRectScale().r, @truncate(ds.doc_id), .{ .share = shareable }); + if (pressed.share) { + editor.share_pending = doc.id; + dvui.refresh(null, @src(), null); + } + } return .ok; } +/// How `doc` was made, as a tape: its track exported (`Tracks.exportTape`), kept in the config +/// folder's `tapes/` and opened in a tab of its own to play (`TapeTabs`). The transport's share. +pub fn shareDocument(editor: *Editor, doc: sdk.DocHandle) void { + const gpa = editor.app.gpa; + const t = &(editor.app.tracks orelse return); + const bytes = t.exportTape(&editor.app.host, doc, gpa, .{}) catch |err| { + dvui.log.warn("share: could not export {s}: {t}", .{ doc.owner.documentPath(doc), err }); + _ = editor.noteOnDocument(doc.id, "Could not share"); + return; + }; + defer gpa.free(bytes); + const arena = dvui.currentWindow().arena(); + const saved = if (comptime builtin.target.cpu.arch == .wasm32) null else editor.keepTape(arena, doc, bytes, "") catch |err| saved: { + dvui.log.warn("share: could not keep the tape: {t}", .{err}); + break :saved null; + }; + // Said on the tape's tab, which the pane turns to. + const note = if (saved) |path| std.fmt.allocPrint(arena, "Saved {s}", .{std.fs.path.basename(path)}) catch null else null; + editor.tape_tabs.open(editor, bytes, note) catch |err| { + dvui.log.warn("share: could not open the tape: {t}", .{err}); + _ = editor.noteOnDocument(doc.id, "Could not open the tape"); + }; +} + +/// Say `message` on the document's own panes, in the glass its commands show in +/// (`ActivityFeed`), rather than in a toast over the whole window. False while the document has +/// no panes yet (it is still opening). +pub fn noteOnDocument(editor: *Editor, doc_id: u64, message: []const u8) bool { + const ds = editor.doc_surfaces.get(doc_id) orelse return false; + ds.feed.push(message, dvui.frameTimeNS()); + dvui.refresh(null, @src(), null); + return true; +} + +/// Write a tape of `doc` to `out`, or (empty) to the config folder's `tapes/`, named for the +/// document and the time. Returns where it went. +pub fn keepTape(editor: *Editor, arena: std.mem.Allocator, doc: sdk.DocHandle, bytes: []const u8, out: []const u8) ![]const u8 { + const io = dvui.io; + const path = if (out.len > 0) out else path: { + const stem = std.fs.path.stem(doc.owner.documentPath(doc)); + const file = try std.fmt.allocPrint(arena, "{s}-{d}.tape", .{ stem, std.Io.Clock.real.now(io).toSeconds() }); + break :path try std.fs.path.join(arena, &.{ editor.app.config_folder, "tapes", file }); + }; + if (std.fs.path.dirname(path)) |dir| try std.Io.Dir.cwd().createDirPath(io, dir); + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = bytes }); + return path; +} + /// Record documents' tracks, or stop. Documents open now get one; those opened after too. pub fn setTracking(editor: *Editor, on: bool) void { const t = &(editor.app.tracks orelse return); @@ -3977,6 +4037,11 @@ pub fn tick(editor: *Editor) !dvui.App.Result { editor.doc_io.tick(); // The document states tracks asked for since last frame, before anything draws. if (editor.app.tracks) |*t| t.tick(); + if (editor.share_pending) |id| { + editor.share_pending = null; + if (editor.app.open_files.get(id)) |doc| editor.shareDocument(doc); + } + editor.tape_tabs.tick(editor); editor.promoteEditedPreviews(); editor.window_opacity = if (dvui.themeGet().dark) editor.app.settings.window_opacity_dark else editor.app.settings.window_opacity_light; @@ -6235,6 +6300,7 @@ pub fn deinit(editor: *Editor) !void { // nothing it changed is written below. const demo_was_active = editor.demo.active(); editor.demo.deinit(); + editor.tape_tabs.deinit(editor); editor.black_box.deinit(editor.app.gpa); editor.app.host.recorder = null; if (editor.app.tracks) |*t| t.deinit(); diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index d24d35ae6..2ed5cf260 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -207,8 +207,9 @@ fn playTapeCallback(paths: ?[][:0]const u8) void { playTapeFile(fizzy.editor(), list[0]) catch |err| dvui.log.err("tape {s}: {t}", .{ list[0], err }); } -/// Plays a tape file in the player, with its transport: seek, chapters, play and pause. A tape -/// that opens on a keyframe sets the person's session aside and gives it back when it ends. +/// Plays a tape file: one document's track in a tab of its own (`TapeTabs`), anything else in the +/// player, with its transport: seek, chapters, play and pause. A tape that opens on a keyframe +/// sets the person's session aside and gives it back when it ends. fn cmdPlayTapePath(state: *anyopaque, args: TapePath.Args, call: *sdk.Command.Call) anyerror!void { if (args.path.len == 0) return cmdPlayTape(state); if (comptime is_web) return call.fail("a page plays a tape from its address (?demo=<url>)", .{}); @@ -221,7 +222,12 @@ fn playTapeFile(editor: *Editor, path: []const u8) !void { const gpa = editor.app.gpa; const bytes = try std.Io.Dir.cwd().readFileAlloc(dvui.io, path, gpa, .limited(256 << 20)); defer gpa.free(bytes); - editor.demo.playTapeSoon(bytes); + // One document's track (a shared tape) plays in a tab of its own, beside the person's work; + // anything else on the demo stage. + editor.tape_tabs.open(editor, bytes, null) catch |err| switch (err) { + error.NotOneDocument, error.BadTape => editor.demo.playTapeSoon(bytes), + else => return err, + }; } const DemoSpeed = sdk.Command.Params(struct { @@ -682,14 +688,7 @@ fn cmdTracksExport(state: *anyopaque, args: TracksExport.Args, call: *sdk.Comman const gpa = editor.app.gpa; const bytes = t.exportTape(&editor.app.host, doc, gpa, .{ .real_time = args.real_time }) catch |err| return call.fail("could not export {s}: {t}", .{ doc.owner.documentPath(doc), err }); defer gpa.free(bytes); - const io = dvui.io; - const out = if (args.out.len > 0) args.out else out: { - const stem = std.fs.path.stem(doc.owner.documentPath(doc)); - const file = try std.fmt.allocPrint(call.arena, "{s}-{d}.tape", .{ stem, std.Io.Clock.real.now(io).toSeconds() }); - break :out try std.fs.path.join(call.arena, &.{ editor.app.config_folder, "tapes", file }); - }; - if (std.fs.path.dirname(out)) |dir| std.Io.Dir.cwd().createDirPath(io, dir) catch |err| return call.fail("could not make {s}: {t}", .{ dir, err }); - std.Io.Dir.cwd().writeFile(io, .{ .sub_path = out, .data = bytes }) catch |err| return call.fail("could not write {s}: {t}", .{ out, err }); + const out = editor.keepTape(call.arena, doc, bytes, args.out) catch |err| return call.fail("could not write the tape: {t}", .{err}); const track = t.trackOf(doc.id).?; try call.returns(.{ .path = out, .bytes = bytes.len, .steps = track.steps.items.len }); } diff --git a/src/editor/TapeTabs.zig b/src/editor/TapeTabs.zig new file mode 100644 index 000000000..0ba5f3a3e --- /dev/null +++ b/src/editor/TapeTabs.zig @@ -0,0 +1,196 @@ +//! Tapes played in a tab of their own: a document's track shared as a tape (`fizzy.tracks.export`) +//! opens beside the person's documents, never over them, and plays itself in. +//! +//! The tape's file (its keyframe's one document, as it was when the track began) is mounted +//! under a root of its own (`tape://<n>`) and opened like any file; then each step is applied as +//! its moment comes, by the document's owner (`Plugin.applyStep`, at pace). Each step lands as an +//! entry on the document's history, so the document's transport plays back over what has played: +//! scrub back through it, step through it, as through any document's history. +//! +//! Unlike the demo stage, nothing of the person's is set aside or waits on their unsaved work: the +//! tape is a document among theirs. Closing it ends the playback and the mount. While nothing is +//! playing a frame costs one check of an empty list; while one is, a timer wakes it for the next +//! step and nothing more. +const TapeTabs = @This(); + +const std = @import("std"); +const dvui = @import("dvui"); +const fizzy = @import("../fizzy.zig"); +const core = fizzy.core; +const sdk = fizzy.sdk; +const automation = @import("app").automation; +const Editor = @import("Editor.zig"); + +playing: std.ArrayListUnmanaged(*Playback) = .empty, +/// The next playback's number, for its root. +next: u32 = 1, + +const Playback = struct { + owned: automation.Tape.Owned, + mem: *core.vfs.Mem, + /// `tape://<n>`: the tape's mount. + root: []u8, + /// The document's path under it. + path: []u8, + /// The document once it opened; null while it loads. + doc_id: ?u64 = null, + /// The next op to apply. + op: usize = 0, + /// When the document opened: the tape's time 0. + start_ns: i128 = 0, + /// When the tape was opened: a document that has not come by `load_limit_ns` after never + /// will (its load failed), and the playback lets its mount go. + opened_ns: i128, + /// Said on the tape's own tab once it shows (`Editor.noteOnDocument`); owned. + note: ?[]u8 = null, +}; + +const load_limit_ns: i128 = 30 * std.time.ns_per_s; + +pub fn deinit(self: *TapeTabs, editor: *Editor) void { + for (self.playing.items) |p| free(editor, p); + self.playing.deinit(editor.app.gpa); +} + +pub const OpenError = error{ + /// Not a tape fizzy reads, or steps of an owner not loaded or of another format. + BadTape, + /// A tape of anything but one document's steps (a demo, a session of several): the demo stage + /// plays those (`fizzy.demo.playTape`). + NotOneDocument, + OutOfMemory, +}; + +/// Open the tape in `bytes` in a tab of its own and play it, saying `note` on that tab once it +/// shows. The bytes may be let go of at once. +pub fn open(self: *TapeTabs, editor: *Editor, bytes: []const u8, note: ?[]const u8) (OpenError || anyerror)!void { + const gpa = editor.app.gpa; + var check = automation.Input.check; + check.steps = @import("Demo.zig").stepsCheck; + var owned = automation.Tape.load(gpa, bytes, check) catch return error.BadTape; + errdefer owned.deinit(); + const tape = owned.tape; + if (tape.keyframes.len != 1 or tape.keyframes[0].files.len != 1) return error.NotOneDocument; + for (tape.ops[1..]) |op| switch (op.do) { + .step, .wait => {}, + else => return error.NotOneDocument, + }; + const file = tape.keyframes[0].files[0]; + + // A root nothing holds: not a mount, and no document open under it (one a crash's recovery + // or a restart brought back from an earlier run). + const root = root: while (true) : (self.next += 1) { + const r = try std.fmt.allocPrint(gpa, "tape://{d}", .{self.next}); + if (!editor.app.file_table.isMounted(r) and !openUnder(editor, r)) break :root r; + gpa.free(r); + }; + errdefer gpa.free(root); + const path = try core.paths.join(gpa, root, file.path); + errdefer gpa.free(path); + const mem = try gpa.create(core.vfs.Mem); + errdefer gpa.destroy(mem); + mem.* = try .init(gpa); + errdefer mem.deinit(); + const at = try std.fmt.allocPrint(gpa, "/{s}", .{file.path}); + defer gpa.free(at); + try mem.put(at, file.text); + try editor.app.host.mount(root, mem.fs()); + errdefer editor.app.host.unmount(root); + + const p = try gpa.create(Playback); + errdefer gpa.destroy(p); + p.* = .{ .owned = owned, .mem = mem, .root = root, .path = path, .opened_ns = dvui.frameTimeNS(), .note = if (note) |n| try gpa.dupe(u8, n) else null }; + errdefer if (p.note) |n| gpa.free(n); + _ = try editor.openFilePath(path, editor.workbench.currentGroupingID()); + // Last, once nothing above can fail: the list owns it from here. + try self.playing.append(gpa, p); + self.next += 1; + dvui.refresh(null, @src(), null); +} + +/// Once a frame, before anything draws: each tape's steps whose moment has come. Returns at once +/// with nothing playing. +pub fn tick(self: *TapeTabs, editor: *Editor) void { + if (self.playing.items.len == 0) return; + const now = dvui.frameTimeNS(); + var i: usize = 0; + while (i < self.playing.items.len) { + const p = self.playing.items[i]; + const doc = editor.docFromPath(p.path); + if (doc == null) { + // Closed after it opened, or never came: the playback is over. Still loading: wait. + if (p.doc_id != null or now - p.opened_ns > load_limit_ns) { + free(editor, p); + _ = self.playing.swapRemove(i); + continue; + } + i += 1; + continue; + } + if (p.doc_id == null) { + p.doc_id = doc.?.id; + p.start_ns = now; + } + // Its panes come with its first draw. + if (p.note) |n| if (editor.noteOnDocument(doc.?.id, n)) { + editor.app.gpa.free(n); + p.note = null; + }; + advance(editor, p, doc.?, now); + i += 1; + } +} + +/// Apply `p`'s steps due by `now`, and wake for the next. Played out, the document is written to +/// its own mount: it is as the tape left it, so closing it asks nothing (an edit of the person's +/// after that would). +fn advance(editor: *Editor, p: *Playback, doc: sdk.DocHandle, now: i128) void { + const ops = p.owned.tape.ops; + if (p.op == ops.len) return; + const elapsed_ms: u64 = @intCast(@max(0, @divTrunc(now - p.start_ns, std.time.ns_per_ms))); + while (p.op < ops.len and ops[p.op].at <= elapsed_ms) : (p.op += 1) { + const op = ops[p.op]; + switch (op.do) { + .step => |st| if (!doc.owner.applyStep(doc, st.effect, st.motion, op.ms)) { + // The document is not what the step was made on (the person edited it): the rest + // would miss too, so the playback stops here, its document theirs. + p.op = ops.len; + _ = editor.noteOnDocument(doc.id, "Tape stopped: the document changed"); + return; + }, + else => {}, + } + } + if (p.op == ops.len) { + editor.writeDocument(doc) catch |err| dvui.log.warn("tape: could not keep {s} as played: {t}", .{ p.path, err }); + return; + } + const wait_ms: u64 = ops[p.op].at - elapsed_ms; + dvui.timer(dvui.Id.extendId(null, @src(), @intFromPtr(p)), @intCast(@min(wait_ms * 1000 + 1, std.math.maxInt(i32)))); +} + +fn openUnder(editor: *Editor, root: []const u8) bool { + for (editor.app.open_files.values()) |d| { + const path = d.owner.documentPath(d); + if (std.mem.startsWith(u8, path, root) and path.len > root.len and path[root.len] == '/') return true; + } + return false; +} + +/// Whether the document at `path` is a tape still to play out in its tab (loading, or steps to go). +pub fn isPlaying(self: *const TapeTabs, path: []const u8) bool { + for (self.playing.items) |p| if (std.mem.eql(u8, p.path, path) and p.op < p.owned.tape.ops.len) return true; + return false; +} + +fn free(editor: *Editor, p: *Playback) void { + const gpa = editor.app.gpa; + editor.app.host.unmount(p.root); + p.mem.deinit(); + gpa.destroy(p.mem); + gpa.free(p.root); + gpa.free(p.path); + if (p.note) |n| gpa.free(n); + p.owned.deinit(); + gpa.destroy(p); +} diff --git a/tests/integration.zig b/tests/integration.zig index ee02bf58b..fb22c5e34 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7997,9 +7997,7 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes const bytes = try std.Io.Dir.cwd().readFileAlloc(dvui.io, out, gpa, .limited(1 << 20)); defer gpa.free(bytes); // What it holds: fizzy reads it (its owner and format known), its keyframe is the file as - // the track began (a new document's: empty), and its steps, applied in order to a - // document of their own, make the same text. (Playing it on the demo stage replaces the - // open files and waits for unsaved ones, which this run has.) + // the track began (a new document's: empty). var check = app.automation.Input.check; check.steps = fizzy.Editor.Demo.stepsCheck; var owned = try app.automation.Tape.load(gpa, bytes, check); @@ -8008,27 +8006,64 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try std.testing.expectEqual(@as(usize, 1), kf.files.len); try std.testing.expectEqualStrings("text", kf.files[0].owner); try std.testing.expectEqualStrings("", kf.files[0].text); - try host.runCommand("fizzy.newFile"); + // Played in a tab of its own (`TapeTabs`, the transport's share), each step at its moment: + // the same text, under the tape's own mount, with the person's documents untouched. + try editor.tape_tabs.open(editor, bytes, null); + const played_path = try std.fmt.allocPrint(gpa, "tape://1/{s}", .{std.fs.path.basename(path)}); + defer gpa.free(played_path); + for (0..2000) |_| { + _ = try dvui.testing.step(headlessFrame); + if (!editor.tape_tabs.isPlaying(played_path)) break; + std.Io.sleep(dvui.io, .fromMilliseconds(5), .awake) catch {}; + } + const replay = editor.docFromPath(played_path) orelse return error.TestUnexpectedResult; + try std.testing.expect(!editor.tape_tabs.isPlaying(played_path)); + try dvui.testing.settle(headlessFrame); + try std.testing.expectEqualStrings(want, try textOf(host, played_path)); + try std.testing.expectEqualStrings(want, try textOf(host, path)); + const played_line = replay.owner.historyLine(replay, host.arena()) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(usize, 6), played_line.at); + // Played out, it is kept as played in its own mount: closing it asks nothing. for (0..200) |_| { + if (!replay.owner.isDirty(replay)) break; _ = try dvui.testing.step(headlessFrame); - const d = editor.activeDoc() orelse continue; - if (d.id != doc.id and d.id != was.id and std.mem.startsWith(u8, std.fs.path.basename(d.owner.documentPath(d)), "untitled")) break; } - const replay = editor.activeDoc() orelse return error.TestUnexpectedResult; - try std.testing.expect(replay.id != doc.id); - var applied: usize = 0; - for (owned.tape.ops) |op| switch (op.do) { - .step => |st| { - try std.testing.expect(replay.owner.applyStep(replay, st.effect, st.motion, 0)); - applied += 1; - }, - else => {}, - }; - try std.testing.expectEqual(@as(usize, 6), applied); + try std.testing.expect(!replay.owner.isDirty(replay)); + try editor.closeFileID(replay.id); + try dvui.testing.settle(headlessFrame); + try std.testing.expect(editor.docFromPath(played_path) == null); + try std.testing.expectEqual(@as(usize, 0), editor.tape_tabs.playing.items.len); + + // The transport's share (pressed: `share_pending`), at the top of the next frame: the + // tape kept in the config folder's tapes/, and played in a tab of its own. + const tapes_dir = try std.fs.path.join(gpa, &.{ editor.app.config_folder, "tapes" }); + defer gpa.free(tapes_dir); + const count = struct { + fn count(dir_path: []const u8) !usize { + var dir = try std.Io.Dir.cwd().openDir(dvui.io, dir_path, .{ .iterate = true }); + defer dir.close(dvui.io); + var it = dir.iterate(); + var n: usize = 0; + while (try it.next(dvui.io)) |_| n += 1; + return n; + } + }.count; + const kept_before = try count(tapes_dir); + editor.share_pending = doc.id; + const shared_path = try std.fmt.allocPrint(gpa, "tape://2/{s}", .{std.fs.path.basename(path)}); + defer gpa.free(shared_path); + for (0..2000) |_| { + _ = try dvui.testing.step(headlessFrame); + if (editor.docFromPath(shared_path) != null and !editor.tape_tabs.isPlaying(shared_path)) break; + std.Io.sleep(dvui.io, .fromMilliseconds(5), .awake) catch {}; + } + try std.testing.expectEqual(kept_before + 1, try count(tapes_dir)); + try dvui.testing.settle(headlessFrame); + try std.testing.expectEqualStrings(want, try textOf(host, shared_path)); + const shared = editor.docFromPath(shared_path) orelse return error.TestUnexpectedResult; + try editor.closeFileID(shared.id); try dvui.testing.settle(headlessFrame); - try std.testing.expectEqualStrings(want, try textOf(host, replay.owner.documentPath(replay))); - try editor.rawCloseFileID(replay.id); try editor.rawCloseFileID(doc.id); // A track whose start is gone from the history (undone past, and edited over) is From 83001ec646d5da5ad0ac94129213f49c6417826b Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:45:03 -0500 Subject: [PATCH 44/46] app: share a range of a track, after a summary of what it holds (#493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 6, the rest: share a range of the track, after the export summary. ## What changes **The share asks first** (`plans/RECORDING_PLAN.md`, "Privacy": export shows what is in it before writing). The transport's share opens `Dialogs.ShareTape`: - what it would share, in one line: *notes.txt: 4 steps over 6 s, by you and Claude. The tape plays in 1 s.*; - the range, as two sliders over the track. Each end says where it falls ("where it began", "after step 12, 3 min 20 s in"); - "Keep every pause as long as it was" (real time); - when the range can't be shared as it stands, why, and no Share button; - Share hands the range to `Editor.shareDocument` at the next frame's top (`share_pending`, now holding the options), as before. **A range of the track** (`Tracks.ExportOptions.from`, `to`). The tape opens on the document as it was after the range's first `from` steps and holds the steps through `to`: - **Where the history stood after any step** (`Tracks.topAfter`) is worked out from the steps the track already keeps: added, joined, undone, redone. Owners don't need to report it. pixi doesn't fill `Step.top` after an undo or redo, so relying on owners would leave pixi out; this way it gets ranges for free. - **Refused, with the reason, when the start can't be reached:** - an undo or redo there reached an entry from before the track began; - a later step joined the entry the range starts on; - the entry was undone and edited over. The checks the track's start already had now apply to any start (`startOf`). `EmptyRange` is new. - **A range from a step** opens on that step: its first step plays at once rather than after the pause before it. - **Timing:** the play-time rule (a pause past 2 s plays as 0.6 s, each measured from where the previous step ended) is one `Timing` that both the export and the summary use. **`Tracks.summarize`** is what the dialog shows: - the range as it will be exported; - the step count; - recorded and played durations; - the distinct authors (`Author.same`); - the refusal, if there is one. It moves nothing. **`fizzy.tracks.export`** takes `from` and `to`, and returns the summary with the path: steps, range, durations, and authors by label (a label-less author by its kind). `history_transport.shareRange` turns the dialog's two slider fractions into a range: at least one step, inside the track. It lives with the transport's other pure rules and their tests, and `app` now exports that module. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build check-web`, `zig build test-sdk-version` and `zig build check-tests-reached` (914 tests, all reached) pass. - `zig build test-integration`: 53/53 steps, 560/560 tests. - **Unit tests:** - `topAfter` over added, joined, undone, redone, an overwrite, and an undo past the track's start (unknown); - `shareRange` at both ends and with the ends crossed. - **Integration:** the export test also exports `from = 2` of six steps. It checks: - the result says 4 steps, range 2–6 and one author; - the tape's keyframe file is the document after two steps (`"two\none\n"`) and it holds 4 step ops; - the person's document is unchanged. - **In a running fizzy** (Debug, its own profile, `FIZZY_TRACKS=1`, driven through the agent plugin's bridge): - share on `notes.txt` opened the dialog in its own window, captured through fizzy's own frame capture. It read "notes.txt: 4 steps over 6 s, by someone unknown. The tape plays in 1 s." The steps came from bridge commands with no author, so "someone unknown" is right. - Pressing Share closed the dialog, wrote a tape into `tapes/`, and opened it in `tape://2`. - I didn't drag the range sliders there (the dialog is an OS window of its own, which the bridge's clicks don't reach); the integration test covers the range. - [ ] Windows: not run. - [ ] Linux: CI. - [ ] Web: `check-web` builds. Not run in a browser. ## Follow-ups - **"Only the result's steps"** for text, where typed-then-deleted text matters, is step 12 (compacting): it needs `composeSteps`. - **The summary recomputes each frame** while the dialog is open. It walks the range's steps and the line, which is nothing at today's sizes. If 8-hour tracks make it show, the seam is to cache the summary on the dialog, keyed on the range and the track's step count. - **Fit to View** for a played pixi tape (fizzyedit/fizzy#444), from the plan's "Playing one". 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- app/Tracks.zig | 172 ++++++++++++++++++++++++++----- app/history_transport.zig | 16 +++ app/root.zig | 1 + src/editor/Editor.zig | 23 ++--- src/editor/Keybinds.zig | 11 +- src/editor/dialogs/Dialogs.zig | 1 + src/editor/dialogs/ShareTape.zig | 139 +++++++++++++++++++++++++ tests/integration.zig | 28 ++++- 8 files changed, 348 insertions(+), 43 deletions(-) create mode 100644 src/editor/dialogs/ShareTape.zig diff --git a/app/Tracks.zig b/app/Tracks.zig index 846d553cc..8272eaa14 100644 --- a/app/Tracks.zig +++ b/app/Tracks.zig @@ -441,6 +441,10 @@ pub fn replayInto(self: *Tracks, doc_id: u64, target: sdk.DocHandle, count: usiz pub const ExportOptions = struct { /// Keep every pause as long as it was. real_time: bool = false, + /// The range of the track's steps to share: from the document as it was after the first + /// `from`, through step `to` (exclusive; null: the last). + from: usize = 0, + to: ?usize = null, }; /// A pause longer than this plays as `short_gap_ms`. pub const gap_ms = 2000; @@ -453,9 +457,12 @@ pub const ExportError = error{ NoBase, /// The owner keeps no history to go back through. NoHistory, - /// Where the track began is no longer on the document's history (undone past and edited - /// over), or a step joined the entry it began in: the document cannot be put back there. + /// Where the range begins is no longer on the document's history (undone past and edited + /// over), was reached by undoing past the track's start, or a step joined the entry it began + /// in: the document cannot be put back there. StartNotInHistory, + /// The range holds no steps. + EmptyRange, /// The owner could not write the document as a file. NoBytes, /// The document went back to the track's start for its file and could not come forward @@ -464,12 +471,118 @@ pub const ExportError = error{ OutOfMemory, }; +/// Where `t`'s document's history stood after its first `count` steps: the top entry's id (0 for +/// none), worked out from the steps themselves, so it holds for any owner. Null where it cannot be +/// known: an undo or redo reached an entry from before the track began. +pub fn topAfter(t: *const Track, gpa: std.mem.Allocator, count: usize) error{OutOfMemory}!?u64 { + // The entries added since the track began, and how many of them are done. + var line: std.ArrayListUnmanaged(u64) = .empty; + defer line.deinit(gpa); + var at: usize = 0; + for (t.steps.items[0..@min(count, t.steps.items.len)]) |r| switch (r.kind) { + .added => { + line.shrinkRetainingCapacity(at); + try line.append(gpa, r.id); + at += 1; + }, + .joined => {}, + .undone => at = std.mem.indexOfScalar(u64, line.items[0..at], r.id) orelse return null, + .redone => at = 1 + (std.mem.indexOfScalar(u64, line.items, r.id) orelse return null), + .restarted => return null, + }; + return if (at == 0) t.base_top else line.items[at - 1]; +} + +/// What a range of a track holds, for the person to see before it is shared (`summarize`). +pub const Summary = struct { + /// The range, as it will be exported (`to` resolved and clamped). + from: usize, + to: usize, + steps: usize, + /// From the range's first step to its last's end, as it happened; and as the tape plays it, + /// its long pauses shortened unless `real_time`. + recorded_ms: u64, + played_ms: u64, + /// Who made its steps, each once, in the order they first came. + authors: []const sdk.Author, + /// Why the range cannot be shared as it stands, when it cannot (`exportTape` would refuse). + refused: ?ExportError = null, +}; + +/// The range `opts` names of `doc`'s track, summed up: how many steps, how long, by whom, and +/// whether the document can be put back where it begins. Moves nothing; `arena` holds the authors. +pub fn summarize(self: *Tracks, host: *const sdk.Host, doc: sdk.DocHandle, arena: std.mem.Allocator, opts: ExportOptions) ExportError!Summary { + const t = self.open.get(doc.id) orelse return error.NoTrack; + const base = t.base orelse return error.NoBase; + const to = @min(opts.to orelse t.steps.items.len, t.steps.items.len); + const first = @min(opts.from, to); + var out: Summary = .{ .from = first, .to = to, .steps = to - first, .recorded_ms = 0, .played_ms = 0, .authors = &.{} }; + if (first == to) { + out.refused = error.EmptyRange; + return out; + } + _ = self.startOf(doc, arena, t, first) catch |err| { + out.refused = err; + }; + + var timing: Timing = .init(t, base, first, opts.real_time); + var authors: std.ArrayListUnmanaged(sdk.Author) = .empty; + for (t.steps.items[first..to]) |r| { + out.played_ms = timing.place(r) + r.ms; + const who = host.stepAuthor(doc.id, r.id); + for (authors.items) |a| { + if (a.same(who)) break; + } else try authors.append(arena, who); + } + out.recorded_ms = timing.recorded_end -| (if (first == 0) base.at else t.steps.items[first].at); + out.authors = authors.items; + return out; +} + +/// The history entry a range starting after step `first` goes back to, checked against the +/// document's line as it is now. Errors as `exportTape` refuses. +fn startOf(self: *Tracks, doc: sdk.DocHandle, arena: std.mem.Allocator, t: *const Track, first: usize) ExportError!u64 { + _ = self; + const top = (try topAfter(t, arena, first)) orelse return error.StartNotInHistory; + // A step after it that joined that entry changed it: going back to the entry would keep it. + if (top != 0) for (t.steps.items[first..]) |r| if (r.kind == .joined and r.id == top) return error.StartNotInHistory; + const line = doc.owner.historyLine(doc, arena) orelse return error.NoHistory; + // Gone from the line (undone past, and edited over). + if (top != 0) for (line.entries) |e| { + if (e.id == top) break; + } else return error.StartNotInHistory; + return top; +} + +/// When each step plays, in ms from the tape's start: each pause runs from where the step before +/// it ended, recorded and played, so a stroke drawn over a second and a half is never cut short +/// by the next step arriving early; a pause past `gap_ms` plays as `short_gap_ms` unless real time. +const Timing = struct { + played_end: u32 = 0, + recorded_end: u32, + real_time: bool, + + /// From the track's start, its time opening counts; from a step, the range opens on it. + fn init(t: *const Track, base: Track.State, first: usize, real_time: bool) Timing { + return .{ .recorded_end = if (first == 0) base.at else t.steps.items[first].at, .real_time = real_time }; + } + + fn place(self: *Timing, r: Track.Rec) u32 { + const gap = r.at -| self.recorded_end; + const at = self.played_end + if (!self.real_time and gap > gap_ms) short_gap_ms else gap; + self.played_end = at + r.ms; + self.recorded_end = @max(self.recorded_end, r.at + r.ms); + return at; + } +}; + /// `doc`'s track as a tape, in the binary form, deflated (`tape.binary`): a keyframe holding -/// the document's file as it was when the track began, a wait for it to open, then every step -/// at the moment it came, a replay of how the document was made that plays anywhere. +/// the document's file as it was where the range begins (`ExportOptions.from`; the track's start +/// by default), a wait for it to open, then each step of the range at the moment it came, a +/// replay of how the document was made that plays anywhere. /// /// The file is the document's own, written by its owner (`Plugin.documentBytes`), as it stood at -/// the track's start: the document is taken back there through its history (`historyGoTo`), +/// the range's start (`topAfter`): the document is taken back there through its history (`historyGoTo`), /// written, and brought forward again in the same call, with nothing drawn between and neither /// the track nor the activity feed hearing of it. The person's history is as it was. Named /// `name` (the document's file name, with its owner's extension when it has none), under @@ -486,15 +599,13 @@ pub fn exportTape(self: *Tracks, host: *sdk.Host, doc: sdk.DocHandle, gpa: std.m var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); - // A step that joined the entry the track began in changed that entry after the base: going - // back to the entry would keep it. - for (t.steps.items) |r| if (r.kind == .joined and r.id == t.base_top and t.base_top != 0) return error.StartNotInHistory; + const to = @min(opts.to orelse t.steps.items.len, t.steps.items.len); + const first = @min(opts.from, to); + if (first == to) return error.EmptyRange; + // Refused before anything moves. + const start_top = try self.startOf(doc, arena, t, first); const line = doc.owner.historyLine(doc, arena) orelse return error.NoHistory; const now_top = if (line.at == 0) 0 else line.entries[line.at - 1].id; - // Gone from the line (undone past, and edited over): refused before anything moves. - if (t.base_top != 0) for (line.entries) |e| { - if (e.id == t.base_top) break; - } else return error.StartNotInHistory; const name = blk: { const base_name = std.fs.path.basename(doc.owner.documentPath(doc)); @@ -504,8 +615,8 @@ pub fn exportTape(self: *Tracks, host: *sdk.Host, doc: sdk.DocHandle, gpa: std.m break :blk try std.mem.concat(arena, u8, &.{ base_name, types[0] }); }; - // Back to where the track began, the file written there, and forward again: that last - // move is checked, since a failure would leave the person's document at the track's start. + // Back to where the range begins, the file written there, and forward again: that last + // move is checked, since a failure would leave the person's document at the range's start. const bytes = bytes: { const was_track_muted = self.muted; self.muted = doc.id; @@ -513,8 +624,8 @@ pub fn exportTape(self: *Tracks, host: *sdk.Host, doc: sdk.DocHandle, gpa: std.m const was_muted = host.activity_muted; host.activity_muted = true; defer host.activity_muted = was_muted; - const moved = now_top != t.base_top; - if (moved and !doc.owner.historyGoTo(doc, t.base_top)) return error.StartNotInHistory; + const moved = now_top != start_top; + if (moved and !doc.owner.historyGoTo(doc, start_top)) return error.StartNotInHistory; const written = doc.owner.documentBytes(doc, name, arena) catch null; if (moved and !doc.owner.historyGoTo(doc, now_top)) { std.log.err("tracks: {s} went back to its track's start and could not come forward; redo brings the rest back", .{doc.owner.documentPath(doc)}); @@ -527,19 +638,12 @@ pub fn exportTape(self: *Tracks, host: *sdk.Host, doc: sdk.DocHandle, gpa: std.m files[0] = .{ .path = name, .text = bytes, .owner = doc.owner.id, .format = t.format }; const keyframes = try arena.alloc(tape.Tape.Keyframe, 1); keyframes[0] = .{ .root = "demo://share", .files = files, .open = try arena.dupe([]const u8, &.{name}), .layout = .focused }; - const ops = try arena.alloc(tape.Tape.Op, t.steps.items.len + 2); + const ops = try arena.alloc(tape.Tape.Op, to - first + 2); ops[0] = .{ .at = 0, .do = .{ .keyframe = 0 } }; ops[1] = .{ .at = 0, .do = .{ .wait = .{ .until = .idle } } }; - // Each pause runs from where the step before it ended, recorded and played: a stroke drawn - // over a second and a half is never cut short by the next step arriving early. - var played_end: u32 = 0; - var recorded_end = base.at; - for (t.steps.items, ops[2..]) |r, *op| { - const gap = r.at -| recorded_end; - const at = played_end + if (!opts.real_time and gap > gap_ms) short_gap_ms else gap; - op.* = .{ .at = at, .ms = r.ms, .do = .{ .step = .{ .file = name, .effect = t.effect(r), .motion = t.motion(r) } } }; - played_end = at + r.ms; - recorded_end = @max(recorded_end, r.at + r.ms); + var timing: Timing = .init(t, base, first, opts.real_time); + for (t.steps.items[first..to], ops[2..]) |r, *op| { + op.* = .{ .at = timing.place(r), .ms = r.ms, .do = .{ .step = .{ .file = name, .effect = t.effect(r), .motion = t.motion(r) } } }; } const title = try std.fmt.allocPrint(arena, "How {s} was made", .{name}); return tape.binary.encode(gpa, .{ .name = "share", .title = title, .ops = ops, .keyframes = keyframes }, .{ .deflate = true }) catch return error.OutOfMemory; @@ -721,3 +825,17 @@ test "over the cap, ended tracks go first, then all but each open track's newest try testing.expectEqual(track.steps.items.len, track.checkpoints.items[0].step); try testing.expectEqual(t.held, track.size()); } + +test "where the history stood after each step, from the steps alone" { + var t: Track = .{ .doc_id = 1, .doc = Stand.doc(1), .owner = &Stand.plugin, .format = 1, .base_top = 7 }; + defer t.steps.deinit(testing.allocator); + const steps = [_]struct { sdk.Step.Kind, u64 }{ + .{ .added, 10 }, .{ .joined, 10 }, .{ .added, 11 }, .{ .undone, 11 }, .{ .undone, 10 }, + .{ .redone, 10 }, .{ .added, 12 }, .{ .undone, 7 }, + }; + for (steps) |st| try t.steps.append(testing.allocator, .{ .kind = st[0], .id = st[1], .at = 0, .ms = 0, .effect = .{ .start = 0, .len = 0 }, .motion = .{ .start = 0, .len = 0 } }); + // Before any step, where the track began; then each step's top. Adding 12 after undoing 11 + // overwrites it, and undoing 7 (from before the track) cannot be known. + const want = [_]?u64{ 7, 10, 10, 11, 10, 7, 10, 12, null }; + for (want, 0..) |w, count| try testing.expectEqual(w, try topAfter(&t, testing.allocator, count)); +} diff --git a/app/history_transport.zig b/app/history_transport.zig index 7aae14312..61b887b1e 100644 --- a/app/history_transport.zig +++ b/app/history_transport.zig @@ -76,6 +76,22 @@ pub const Repeat = struct { } }; +/// The range of a track's `n` steps a share's two slider fractions name (`Dialogs.ShareTape`): +/// from after `from` steps to after `to`, at least one step and inside the track. +pub fn shareRange(n: usize, from_f: f32, to_f: f32) struct { usize, usize } { + const nf: f32 = @floatFromInt(n); + const from = @min(n -| 1, @as(usize, @intFromFloat(@floor(from_f * nf)))); + const to = @max(from + 1, @min(n, @as(usize, @intFromFloat(@ceil(to_f * nf))))); + return .{ from, to }; +} + +test "a share's range is at least one step, and inside the track" { + try std.testing.expectEqual(.{ 0, 10 }, shareRange(10, 0, 1)); + try std.testing.expectEqual(.{ 9, 10 }, shareRange(10, 1, 1)); + try std.testing.expectEqual(.{ 5, 6 }, shareRange(10, 0.5, 0.1)); + try std.testing.expectEqual(.{ 2, 8 }, shareRange(10, 0.25, 0.75)); +} + /// The step a point `x` on a line from `x0` to `x1` means, in a history of `n` steps: how many /// of them are applied, 0 (before the first) to `n`. The line is split into `n + 1` equal stops, /// the point taking the nearest. diff --git a/app/root.zig b/app/root.zig index e87fbf614..c65eb1170 100644 --- a/app/root.zig +++ b/app/root.zig @@ -44,6 +44,7 @@ pub const ActivityFeed = @import("ActivityFeed.zig"); pub const Tracks = @import("Tracks.zig"); /// A document's transport: its history as a line at the bottom of its pane, undo and redo as seeks. pub const HistoryTransport = @import("HistoryTransport.zig"); +pub const history_transport = @import("history_transport.zig"); /// Key chords → command ids: the table, its ZON form, chord matching, and the dvui adapter. pub const keymap = struct { diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 6190115f6..f5cadbd5a 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -132,9 +132,9 @@ command_palette: @import("CommandPalette.zig") = .{}, demo: Demo = .detached, /// Tapes playing in tabs of their own (`Demo: Play a Tape…`, the transport's share). tape_tabs: @import("TapeTabs.zig") = .{}, -/// A document whose transport's share was pressed: shared at the top of the next frame, before +/// A share the person asked for (`Dialogs.ShareTape`): made at the top of the next frame, before /// anything draws, since the export walks the document's history back and forth. -share_pending: ?u64 = null, +share_pending: ?struct { doc_id: u64, opts: @import("app").Tracks.ExportOptions = .{} } = null, /// Rebuilt plugins opening off the UI thread, swapped in once open (`PluginReloads`). plugin_reloads: @import("PluginReloads.zig") = .{}, @@ -2987,20 +2987,19 @@ fn drawDocSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result { if (claim) |c| drawClaim(editor, c, claim_pill.?, doc.owner.documentPath(doc)) else { const shareable = if (editor.app.tracks) |*t| if (t.trackOf(doc.id)) |tr| tr.steps.items.len > 0 else false else false; const pressed = ds.transport.draw(doc, canvas.data().borderRectScale().r, @truncate(ds.doc_id), .{ .share = shareable }); - if (pressed.share) { - editor.share_pending = doc.id; - dvui.refresh(null, @src(), null); - } + // What it would share, first: the range, how long, whose steps. + if (pressed.share) Dialogs.ShareTape.request(doc.id); } return .ok; } -/// How `doc` was made, as a tape: its track exported (`Tracks.exportTape`), kept in the config -/// folder's `tapes/` and opened in a tab of its own to play (`TapeTabs`). The transport's share. -pub fn shareDocument(editor: *Editor, doc: sdk.DocHandle) void { +/// How `doc` was made, as a tape: the range `opts` names of its track exported +/// (`Tracks.exportTape`), kept in the config folder's `tapes/` and opened in a tab of its own to +/// play (`TapeTabs`). The transport's share, once its summary says go. +pub fn shareDocument(editor: *Editor, doc: sdk.DocHandle, opts: @import("app").Tracks.ExportOptions) void { const gpa = editor.app.gpa; const t = &(editor.app.tracks orelse return); - const bytes = t.exportTape(&editor.app.host, doc, gpa, .{}) catch |err| { + const bytes = t.exportTape(&editor.app.host, doc, gpa, opts) catch |err| { dvui.log.warn("share: could not export {s}: {t}", .{ doc.owner.documentPath(doc), err }); _ = editor.noteOnDocument(doc.id, "Could not share"); return; @@ -4037,9 +4036,9 @@ pub fn tick(editor: *Editor) !dvui.App.Result { editor.doc_io.tick(); // The document states tracks asked for since last frame, before anything draws. if (editor.app.tracks) |*t| t.tick(); - if (editor.share_pending) |id| { + if (editor.share_pending) |p| { editor.share_pending = null; - if (editor.app.open_files.get(id)) |doc| editor.shareDocument(doc); + if (editor.app.open_files.get(p.doc_id)) |doc| editor.shareDocument(doc, p.opts); } editor.tape_tabs.tick(editor); editor.promoteEditedPreviews(); diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index 2ed5cf260..d274f687d 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -675,6 +675,8 @@ const TracksExport = sdk.Command.Params(struct { path: sdk.Command.Arg([]const u8, .{ .description = "The open document whose track to export, by its path. Empty: the active one." }) = .init(""), out: sdk.Command.Arg([]const u8, .{ .description = "Where to write the tape. Empty: the config folder's tapes/, named for the document and the time." }) = .init(""), real_time: sdk.Command.Arg(bool, .{ .description = "Keep every pause as long as it was. Off: a pause past 2 s plays as 0.6 s." }) = .init(false), + from: sdk.Command.Arg(u32, .{ .description = "Start after this many of the track's steps: the tape opens on the document as it was then. 0: where the track began." }) = .init(0), + to: sdk.Command.Arg(u32, .{ .description = "End after this many of the track's steps. 0: the last." }) = .init(0), }); /// A document's track as a tape (`Tracks.exportTape`): how it was made, from where its track @@ -686,11 +688,14 @@ fn cmdTracksExport(state: *anyopaque, args: TracksExport.Args, call: *sdk.Comman const doc = (if (args.path.len == 0) editor.activeDoc() else editor.docFromPath(args.path)) orelse return call.fail("no open document at {s}", .{if (args.path.len == 0) "(active)" else args.path}); const gpa = editor.app.gpa; - const bytes = t.exportTape(&editor.app.host, doc, gpa, .{ .real_time = args.real_time }) catch |err| return call.fail("could not export {s}: {t}", .{ doc.owner.documentPath(doc), err }); + const opts: @import("app").Tracks.ExportOptions = .{ .real_time = args.real_time, .from = args.from, .to = if (args.to == 0) null else args.to }; + const sum = t.summarize(&editor.app.host, doc, call.arena, opts) catch |err| return call.fail("could not sum up {s}'s track: {t}", .{ doc.owner.documentPath(doc), err }); + const bytes = t.exportTape(&editor.app.host, doc, gpa, opts) catch |err| return call.fail("could not export {s}: {t}", .{ doc.owner.documentPath(doc), err }); defer gpa.free(bytes); const out = editor.keepTape(call.arena, doc, bytes, args.out) catch |err| return call.fail("could not write the tape: {t}", .{err}); - const track = t.trackOf(doc.id).?; - try call.returns(.{ .path = out, .bytes = bytes.len, .steps = track.steps.items.len }); + const authors = try call.arena.alloc([]const u8, sum.authors.len); + for (sum.authors, authors) |a, *l| l.* = if (a.kind == .person) "the person" else if (a.label.len > 0) a.label else @tagName(a.kind); + try call.returns(.{ .path = out, .bytes = bytes.len, .steps = sum.steps, .from = sum.from, .to = sum.to, .recorded_ms = sum.recorded_ms, .played_ms = sum.played_ms, .authors = authors }); } fn cmdTracksRecord(state: *anyopaque, args: TracksRecord.Args, call: *sdk.Command.Call) anyerror!void { diff --git a/src/editor/dialogs/Dialogs.zig b/src/editor/dialogs/Dialogs.zig index db30b2efd..7905f17ef 100644 --- a/src/editor/dialogs/Dialogs.zig +++ b/src/editor/dialogs/Dialogs.zig @@ -12,6 +12,7 @@ pub const AppQuitUnsaved = @import("AppQuitUnsaved.zig"); pub const AboutFizzy = @import("AboutFizzy.zig"); pub const PluginUpdates = @import("PluginUpdates.zig"); pub const FileTypeDefaults = @import("FileTypeDefaults.zig"); +pub const ShareTape = @import("ShareTape.zig"); pub const WebFolderUnavailable = if (builtin.target.cpu.arch == .wasm32) @import("WebFolderUnavailable.zig") else diff --git a/src/editor/dialogs/ShareTape.zig b/src/editor/dialogs/ShareTape.zig new file mode 100644 index 000000000..f579681a7 --- /dev/null +++ b/src/editor/dialogs/ShareTape.zig @@ -0,0 +1,139 @@ +//! What a share of a document's track holds, before anything is written (`plans/RECORDING_PLAN.md`, +//! "Privacy": export shows what is in it first): which document, how many steps over how long, +//! whose they are, and how long the tape plays. The person picks the range of the track to share +//! and whether pauses keep their length; Share hands it to `Editor.shareDocument` at the next +//! frame's top (`Editor.share_pending`). Opened by the transport's share. +const std = @import("std"); +const fizzy = @import("../../fizzy.zig"); +const dvui = @import("dvui"); +const sdk = fizzy.sdk; +const Tracks = @import("app").Tracks; + +pub fn request(doc_id: u64) void { + var mutex = fizzy.core.dialogs.dialog(@src(), .{ + .displayFn = dialog, + .callafterFn = callAfter, + .title = "Share how it was made", + .ok_label = "", + .cancel_label = "", + .resizeable = false, + .default = .cancel, + .hide_footer = true, + .max_size = .width(520), + }); + dvui.dataSet(null, mutex.id, "_doc", doc_id); + dvui.dataSet(null, mutex.id, "_from", @as(f32, 0)); + dvui.dataSet(null, mutex.id, "_to", @as(f32, 1)); + dvui.dataSet(null, mutex.id, "_real", false); + mutex.mutex.unlock(dvui.io); +} + +/// "48 s", "3 min 20 s", "1 h 5 min". +fn duration(arena: std.mem.Allocator, ms: u64) []const u8 { + const s = (ms + 500) / 1000; + return (if (s < 60) + std.fmt.allocPrint(arena, "{d} s", .{s}) + else if (s < 3600) + std.fmt.allocPrint(arena, "{d} min {d} s", .{ s / 60, s % 60 }) + else + std.fmt.allocPrint(arena, "{d} h {d} min", .{ s / 3600, s % 3600 / 60 })) catch ""; +} + +/// "you", "you and Claude", "you, Claude and the tour". +fn whose(arena: std.mem.Allocator, authors: []const sdk.Author) []const u8 { + var out: std.Io.Writer.Allocating = .init(arena); + for (authors, 0..) |a, i| { + if (i > 0) out.writer.writeAll(if (i == authors.len - 1) " and " else ", ") catch {}; + out.writer.writeAll(switch (a.kind) { + .person => "you", + .unattributed => "someone unknown", + else => if (a.label.len > 0) a.label else "an agent", + }) catch {}; + } + return out.written(); +} + +fn refusal(err: Tracks.ExportError) []const u8 { + return switch (err) { + error.StartNotInHistory => "The document can't be put back where this range begins: that point was undone and edited over. Start the range later.", + error.NoHistory => "This document keeps no history to share from.", + error.EmptyRange => "The range holds no steps.", + else => "This document's track can't be shared yet.", + }; +} + +pub fn dialog(id: dvui.Id) anyerror!bool { + const editor = fizzy.editor(); + const doc_id = dvui.dataGet(null, id, "_doc", u64) orelse return false; + const doc = editor.app.docById(doc_id) orelse return false; + const tracks = &(editor.app.tracks orelse return false); + const track = tracks.trackOf(doc_id) orelse return false; + const from_f = dvui.dataGetPtr(null, id, "_from", f32) orelse return false; + const to_f = dvui.dataGetPtr(null, id, "_to", f32) orelse return false; + const real = dvui.dataGetPtr(null, id, "_real", bool) orelse return false; + const arena = dvui.currentWindow().arena(); + const n = track.steps.items.len; + if (n == 0) return false; + + const from, const to = @import("app").history_transport.shareRange(n, from_f.*, to_f.*); + const opts: Tracks.ExportOptions = .{ .from = from, .to = to, .real_time = real.* }; + const sum = tracks.summarize(&editor.app.host, doc, arena, opts) catch return false; + const name = std.fs.path.basename(doc.owner.documentPath(doc)); + + var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .padding = .all(8) }); + defer outer.deinit(); + + const message = std.fmt.allocPrint(arena, "{s}: {d} step{s} over {s}, by {s}. The tape plays in {s}.", .{ + name, + sum.steps, + if (sum.steps == 1) "" else "s", + duration(arena, sum.recorded_ms), + whose(arena, sum.authors), + duration(arena, sum.played_ms), + }) catch name; + var tl = dvui.textLayout(@src(), .{}, .{ .expand = .horizontal, .background = false }); + tl.addText(message, .{ .font = dvui.Font.theme(.body) }); + tl.deinit(); + + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 8, .h = 12 } }); + + // The range, as two ends over the track: each says when its step came. + const base_at = if (track.base) |b| b.at else 0; + inline for (.{ .{ "From", from_f, from }, .{ "To", to_f, to } }, 0..) |end, i| { + var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .id_extra = i }); + defer row.deinit(); + dvui.labelNoFmt(@src(), end[0], .{}, .{ .min_size_content = .{ .w = 48 }, .gravity_y = 0.5 }); + _ = dvui.slider(@src(), .{ .fraction = end[1] }, .{ .expand = .horizontal, .gravity_y = 0.5 }); + if (end[2] == 0) { + dvui.labelNoFmt(@src(), "where it began", .{}, .{ .min_size_content = .{ .w = 170 }, .gravity_y = 0.5 }); + } else { + const when = duration(arena, track.steps.items[@min(end[2], n) - 1].at -| base_at); + dvui.label(@src(), "after step {d}, {s} in", .{ end[2], when }, .{ .min_size_content = .{ .w = 170 }, .gravity_y = 0.5 }); + } + } + + _ = dvui.checkbox(@src(), real, "Keep every pause as long as it was", .{}); + + if (sum.refused) |err| { + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 8, .h = 8 } }); + var why = dvui.textLayout(@src(), .{}, .{ .expand = .horizontal, .background = false }); + why.addText(refusal(err), .{ .font = dvui.Font.theme(.body), .color_text = .{ .color = dvui.themeGet().color(.err, .text) } }); + why.deinit(); + } + + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 8, .h = 16 } }); + var btn_row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .none, .gravity_x = 0.5 }); + defer btn_row.deinit(); + if (sum.refused == null and fizzy.core.dialogs.bodyButton(@src(), id, "Share", .highlight, 1, 0)) { + editor.share_pending = .{ .doc_id = doc_id, .opts = opts }; + dvui.refresh(null, @src(), null); + fizzy.core.dialogs.closeFloatingDialogAnchored(); + } + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 1 } }); + if (fizzy.core.dialogs.bodyButton(@src(), id, "Cancel", .control, 2, 1)) { + fizzy.core.dialogs.closeFloatingDialogAnchored(); + } + return true; +} + +pub fn callAfter(_: dvui.Id, _: dvui.enums.DialogResponse) !void {} diff --git a/tests/integration.zig b/tests/integration.zig index fb22c5e34..7e29cdd9a 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -7988,6 +7988,32 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes }, }; try std.testing.expect(std.mem.indexOf(u8, result, ".steps = 6") != null); + // A range (`from`): the tape opens on the document as it was after two steps, and holds + // the four after; its summary names whose they are. + { + const ranged = try std.fmt.allocPrint(gpa, "{s}/tapes/export-range.tape", .{editor.app.config_folder}); + defer gpa.free(ranged); + const range_args = try std.fmt.allocPrint(host.arena(), ".{{ .path = \"{s}\", .out = \"{s}\", .from = 2 }}", .{ path, ranged }); + const range_result = switch (host.callCommand("fizzy.tracks.export", range_args, host.arena())) { + .ok => |r| r orelse return error.TestUnexpectedResult, + else => return error.TestUnexpectedResult, + }; + try std.testing.expect(std.mem.indexOf(u8, range_result, ".steps = 4") != null); + // One author, named (a command run with no one acting is nobody known). + try std.testing.expect(std.mem.indexOf(u8, range_result, ".authors = .{\"unattributed\"}") != null); + const range_bytes = try std.Io.Dir.cwd().readFileAlloc(dvui.io, ranged, gpa, .limited(1 << 20)); + defer gpa.free(range_bytes); + var range_check = app.automation.Input.check; + range_check.steps = fizzy.Editor.Demo.stepsCheck; + var range_tape = try app.automation.Tape.load(gpa, range_bytes, range_check); + defer range_tape.deinit(); + try std.testing.expectEqualStrings("two\none\n", range_tape.tape.keyframes[0].files[0].text); + var range_steps: usize = 0; + for (range_tape.tape.ops) |op| range_steps += @intFromBool(op.do == .step); + try std.testing.expectEqual(@as(usize, 4), range_steps); + // The person's document is where it was. + try std.testing.expectEqualStrings(want, try textOf(host, path)); + } // The person's document is where it was, its history too. try std.testing.expectEqualStrings(want, try textOf(host, path)); const line_after = (doc.owner.historyLine(doc, host.arena()) orelse return error.TestUnexpectedResult); @@ -8049,7 +8075,7 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes } }.count; const kept_before = try count(tapes_dir); - editor.share_pending = doc.id; + editor.share_pending = .{ .doc_id = doc.id }; const shared_path = try std.fmt.allocPrint(gpa, "tape://2/{s}", .{std.fs.path.basename(path)}); defer gpa.free(shared_path); for (0..2000) |_| { From de7152fbedd3baf16d923cce50a1ea3999ed75a3 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:17:32 -0500 Subject: [PATCH 45/46] app: trim a shared tape in its tab, as macOS trims a recording (#497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of `plans/RECORDING_PLAN.md` (fizzyedit/plans#17), step 6: replaces #493's summary dialog with a trim in the tape's own tab, the way macOS's recorder trims a recording. ## Why The dialog asked for a range as two sliders labelled with step numbers and times. Nobody knows which step or second they want; they know what the document looked like. macOS trims in the player: the bar becomes a yellow bracket, and the picture shows wherever a handle is dragged. Fizzy already has the player (the tape tab, #485), so the trim happens there, over the document itself. ## What changes **Share opens the trim.** The transport's share exports the whole track in memory and opens it in a tape tab (`TapeTabs.open` with `.trim`). The tape is played in at once, so the whole of it is there to trim, and nothing is written yet. **The trim bar** (`app/TrimBar.zig`) takes the transport's place on that tab: - **The line:** the tab's history as a line under a yellow bracket over the part kept, with a handle at each end and a white playhead. - **The handles:** dragging one takes the document to that point (`historyGoTo`, one jump a frame at most, as the transport does), so the person sees exactly where the part begins and ends. A press away from the handles moves the playhead inside the part. - **The summary:** over the line, in words: *3 steps, 48 s, by you and Claude*. The authors come from the source track's steps (`Host.stepAuthor`), carried into the tab with the tape. - **Cancel (×):** closes the tab. Nothing was written. - **Play (▶/❚❚):** plays the part kept at the tape's pace, from the playhead (or the part's start), and stops at its end. A timer wakes for each step; nothing runs otherwise. - **Share (✓, yellow):** writes the part as a tape of its own (`TapeTabs.trimmed`), keeps it in `tapes/`, and says "Saved <name>.tape" in glass on the tab. The tab is then an ordinary tape tab: its transport is back, and it is clean, so closing it asks nothing. - The rules (what a press takes, how a drag clamps, at least one step kept) are `history_transport.Trim`, std-only and unit-tested beside the transport's own. **A trimmed tape** is made from the tab's document, not from the source track: - **The keyframe** is the document's file as it stood at the start handle. The tab is taken back there through its history, written by its owner (`documentBytes`) and brought forward again in the same call, unheard by tracks and the activity feed. - **The steps** are the part's, re-timed from its first. - **Any trim start is reachable.** The tab's history is the tape's steps one-to-one and nothing overwrites them, so the "start not in the history" refusals of a track range can't happen here. **Removed:** `Dialogs.ShareTape` and its slider helper (`history_transport.shareRange`). The track-range export from #493 stays: `fizzy.tracks.export { from, to }` and `Tracks.summarize`, for agents and tests. ## SDK impact - [x] None - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: - `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig build check-web`, `zig build test-sdk-version` and `zig build check-tests-reached` (915 tests, all reached) pass. - `zig build test-integration`: 53/53 steps, 560/560 tests. - **Unit tests:** `Trim.drag` keeps at least one step and stays inside the history, for both handles and the playhead; `Trim.grab` takes the nearer handle, the playhead away from them, and the press's side for a one-step part. - **Integration:** - share on a document of six steps opens `tape://2/<name>` in trim mode, the same text, with nothing written to `tapes/`, and a summary of "6 steps…"; - trimmed to start after two and shared, exactly one tape is kept. Its keyframe is the document after two steps (`"two\none\n"`) and it holds 4 steps; the tab and the person's document keep their text, and the trim ends; - a second share, cancelled, closes its tab and writes nothing. - **In a running fizzy** (Debug, its own profile, `FIZZY_TRACKS=1`, driven through the agent plugin's bridge, seen through fizzy's frame capture): - share opened the trim bar on the tape's tab; - dragging the start handle halfway took the document to step 3 of 6, and the summary read "3 steps"; - Play (by its `trim.play` anchor) played the document from the handle to the end; - Share wrote one tape and noted it in glass, and the tab was clean; - a second share, cancelled, closed its tab with no tape written. - [ ] Windows: not run. - [ ] Linux: CI. - [ ] Web: `check-web` builds. Not run in a browser. ## Follow-ups - **Mounted documents in the session** (from #485): a restart's hand-over brings `tape://` tabs back without their trim. The session should skip documents on mounts that don't outlive the run. - **Thumbnails along the line**, as macOS's filmstrip: a document preview hook drawn small. That's a seam for owners (pixi's art at a size), not needed to trim. - **Real-time pauses:** the share exports with long pauses shortened; the toggle the dialog had is gone. `fizzy.tracks.export { real_time }` still has it. - **A pane too narrow for the bar** (under about 200 px) shows no trim bar, so its Cancel can't be reached there; closing the tab still works. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- app/TrimBar.zig | 191 ++++++++++++++++++++ app/history_transport.zig | 72 ++++++-- app/root.zig | 2 + src/editor/Editor.zig | 61 ++++--- src/editor/Keybinds.zig | 2 +- src/editor/TapeTabs.zig | 290 ++++++++++++++++++++++++++++++- src/editor/dialogs/Dialogs.zig | 1 - src/editor/dialogs/ShareTape.zig | 139 --------------- tests/integration.zig | 93 ++++++++-- 9 files changed, 655 insertions(+), 196 deletions(-) create mode 100644 app/TrimBar.zig delete mode 100644 src/editor/dialogs/ShareTape.zig diff --git a/app/TrimBar.zig b/app/TrimBar.zig new file mode 100644 index 000000000..99b91322d --- /dev/null +++ b/app/TrimBar.zig @@ -0,0 +1,191 @@ +//! A trim of a document's history, as macOS trims a recording: the bar at the bottom of its pane +//! holds the history as a line with a yellow bracket over the part kept, and the document shows +//! wherever a handle is dragged, so the person sees where the part they keep begins and ends rather +//! than choosing a time. Cancel and share either side, play to see the part kept. +//! +//! What a trim keeps, and how a press takes a handle, are `history_transport.Trim`'s rules. The +//! bar seeks the document itself while a handle or the playhead is dragged (one `historyGoTo` a +//! frame at most, as the transport does); what cancel, play and share do is the app's (`Pressed`). +//! Shown for as long as the app draws it: a trim is something the person is in the middle of. +const TrimBar = @This(); + +const std = @import("std"); +const dvui = @import("dvui"); +const core = @import("core"); +const sdk = @import("fizzy_sdk"); +const icons = @import("icons"); +const rules = @import("history_transport.zig"); + +/// What the pointer holds while it drags on the line, until the drag ends. +grip: ?rules.Trim.Grip = null, + +const bar_h: f32 = 48; +const button_d: f32 = 34; +const gap: f32 = 10; +const max_w: f32 = 620; +/// macOS's trim yellow. +const yellow: dvui.Color = .{ .r = 0xF5, .g = 0xC0, .b = 0x2E }; + +/// What was pressed this frame, for the app to act on. +pub const Pressed = struct { + cancel: bool = false, + play: bool = false, + share: bool = false, + /// The playhead or a handle was taken: a playing preview stops. + grabbed: bool = false, +}; + +/// The id `historyGoTo` takes for stop `i`: 0 before the first step, else the step's. +fn stopId(line: sdk.Plugin.HistoryLine, i: usize) u64 { + return if (i == 0) 0 else line.entries[i - 1].id; +} + +/// Draw the bar over `area` (the document's pane, physical) and take its input. `trim` is the +/// part kept, over `doc`'s history; `summary` says what it holds, over the line; `playing` turns +/// play into pause. `id_extra` keeps one pane's bar apart from another's. +pub fn draw(self: *TrimBar, doc: sdk.DocHandle, area: dvui.Rect.Physical, id_extra: usize, trim: *rules.Trim, summary: []const u8, playing: bool) Pressed { + var pressed: Pressed = .{}; + const arena = dvui.currentWindow().arena(); + const line = doc.owner.historyLine(doc, arena) orelse return pressed; + const n = line.entries.len; + if (n != trim.n) return pressed; + + const wrs = dvui.windowRectScale(); + const s = wrs.s; + const nat = area.toNatural(); + const w = @min(max_w, nat.w - 24); + if (w < 6 * button_d) return pressed; + const whole: dvui.Rect = .{ .x = nat.x + (nat.w - w) / 2, .y = nat.y + nat.h - 14 - bar_h, .w = w, .h = bar_h }; + const cy = whole.y + bar_h / 2; + const cancel: dvui.Rect = .{ .x = whole.x, .y = cy - button_d / 2, .w = button_d, .h = button_d }; + const play: dvui.Rect = .{ .x = cancel.x + button_d + gap, .y = cancel.y, .w = button_d, .h = button_d }; + const share: dvui.Rect = .{ .x = whole.x + w - button_d, .y = cancel.y, .w = button_d, .h = button_d }; + const track: dvui.Rect = .{ .x = play.x + button_d + gap, .y = whole.y, .w = share.x - gap - (play.x + button_d + gap), .h = bar_h }; + + var float: dvui.FloatingWidget = undefined; + float.init(@src(), .{}, .{ .rect = whole.outsetAll(16), .id_extra = id_extra }); + defer float.deinit(); + const bounds = float.data().rect; + const fid = float.data().id; + if (self.grip != null and !dvui.captured(fid)) self.grip = null; + + const phys = [4]dvui.Rect.Physical{ wrs.rectToPhysical(cancel), wrs.rectToPhysical(play), wrs.rectToPhysical(track), wrs.rectToPhysical(share) }; + const pad = 16; + const x0 = phys[2].x + pad * s; + const x1 = phys[2].x + phys[2].w - pad * s; + const line_r: dvui.Rect.Physical = .{ .x = x0, .y = phys[2].y + phys[2].h - 24 * s, .w = @max(1, x1 - x0), .h = 16 * s }; + const line_hit = line_r.outsetAll(8 * s); + const path = doc.owner.documentPath(doc); + core.anchor.markRect(fid, phys[0], true, "trim.cancel:{s}", .{path}); + core.anchor.markRect(fid, phys[1], true, "trim.play:{s}", .{path}); + core.anchor.markRect(fid, .{ .x = x0, .y = line_r.y, .w = x1 - x0, .h = line_r.h }, true, "trim.line:{s}", .{path}); + core.anchor.markRect(fid, phys[3], true, "trim.share:{s}", .{path}); + + var go: ?usize = null; + const hit = wrs.rectToPhysical(whole.outsetAll(4)); + for (dvui.events()) |*e| { + if (!dvui.eventMatch(e, .{ .id = fid, .r = hit })) continue; + const me = switch (e.evt) { + .mouse => |m| m, + else => continue, + }; + switch (me.action) { + .press => if (me.button.pointer()) { + e.handle(@src(), float.data()); + if (phys[0].contains(me.p)) { + pressed.cancel = true; + } else if (phys[1].contains(me.p)) { + pressed.play = true; + } else if (phys[3].contains(me.p)) { + pressed.share = true; + } else if (line_hit.contains(me.p)) { + dvui.captureMouse(float.data(), e.num); + const grip = trim.grab(me.p.x, x0, x1, 10 * s); + self.grip = grip; + go = trim.drag(grip, rules.stopAt(me.p.x, x0, x1, n)); + pressed.grabbed = true; + } + }, + .motion => if (self.grip) |grip| if (dvui.captured(fid)) { + e.handle(@src(), float.data()); + go = trim.drag(grip, rules.stopAt(me.p.x, x0, x1, n)); + }, + .release => if (me.button.pointer() and dvui.captured(fid)) { + e.handle(@src(), float.data()); + dvui.captureMouse(null, e.num); + self.grip = null; + }, + else => {}, + } + } + var at = line.at; + if (go) |to| if (to != at) { + if (doc.owner.historyGoTo(doc, stopId(line, to))) at = to; + dvui.refresh(null, @src(), fid); + }; + + const pt = dvui.currentWindow().mouse_pt; + var bubbles: [4]core.dialogs.Bubble = undefined; + for (phys, &bubbles, 0..) |r, *b, i| b.* = .{ .rect = r, .light = if (i != 2 and r.contains(pt)) core.dialogs.bubble_hover else 0 }; + core.dialogs.glassBubbles(fid, &bubbles, s, .{ .merge = 14, .shadow = 1 }); + + const ink = dvui.themeGet().color(.window, .text); + button(bounds, cancel, "trim_cancel", icons.tvg.lucide.x, ink.opacity(0.85)); + button(bounds, play, "trim_play", if (playing) icons.tvg.lucide.pause else icons.tvg.lucide.play, ink.opacity(0.85)); + button(bounds, share, "trim_share", icons.tvg.lucide.check, yellow); + + const font = dvui.Font.theme(.body).larger(-2); + dvui.labelNoFmt(@src(), summary, .{}, .{ + .rect = .{ .x = track.x + pad - bounds.x, .y = track.y + 5 - bounds.y, .w = track.w - 2 * pad, .h = font.lineHeight() }, + .padding = .{}, + .margin = .{}, + .font = font, + .color_text = .{ .color = ink.opacity(0.85) }, + }); + paintLine(line_r, trim.*, at, ink, s); + return pressed; +} + +/// A button's icon, in the middle of its bubble `r` (window-natural; `bounds` the layer's). +fn button(bounds: dvui.Rect, r: dvui.Rect, name: []const u8, tvg: []const u8, color: dvui.Color) void { + const d = r.w * 0.44; + core.icon.icon(@src(), name, tvg, .{ + .stroke_color = .{ .color = color }, + .fill_color = .{ .color = color }, + }, .{ + .id_extra = @intFromPtr(name.ptr), + .rect = .{ .x = r.x + (r.w - d) / 2 - bounds.x, .y = r.y + (r.h - d) / 2 - bounds.y, .w = d, .h = d }, + }); +} + +/// The line `r` (physical): the whole history faint, the part kept brighter inside the yellow +/// bracket, its two handles, and the playhead at `at`. +fn paintLine(r: dvui.Rect.Physical, trim: rules.Trim, at: usize, ink: dvui.Color, s: f32) void { + const h = 6 * s; + const mid = r.y + r.h / 2; + const whole: dvui.Rect.Physical = .{ .x = r.x, .y = mid - h / 2, .w = r.w, .h = h }; + whole.fill(.all(h / 2), .{ .color = .{ .color = ink.opacity(0.12) } }); + + const xf = rules.stopX(trim.from, r.x, r.x + r.w, trim.n); + const xt = rules.stopX(trim.to, r.x, r.x + r.w, trim.n); + const kept: dvui.Rect.Physical = .{ .x = xf, .y = whole.y, .w = @max(1, xt - xf), .h = h }; + kept.fill(.all(h / 2), .{ .color = .{ .color = ink.opacity(0.4) } }); + + // The bracket: yellow over and under the part kept, a handle at each end. + const handle_w = 7 * s; + const bracket: dvui.Rect.Physical = .{ .x = xf - handle_w, .y = r.y, .w = xt - xf + 2 * handle_w, .h = r.h }; + const edge = 2 * s; + (dvui.Rect.Physical{ .x = xf, .y = bracket.y, .w = xt - xf, .h = edge }).fill(.{}, .{ .color = .{ .color = yellow } }); + (dvui.Rect.Physical{ .x = xf, .y = bracket.y + bracket.h - edge, .w = xt - xf, .h = edge }).fill(.{}, .{ .color = .{ .color = yellow } }); + for ([_]f32{ xf - handle_w, xt }) |x| { + const hr: dvui.Rect.Physical = .{ .x = x, .y = bracket.y, .w = handle_w, .h = bracket.h }; + hr.fill(.all(3 * s), .{ .color = .{ .color = yellow } }); + const notch: dvui.Rect.Physical = .{ .x = x + handle_w / 2 - 0.75 * s, .y = mid - 4 * s, .w = 1.5 * s, .h = 8 * s }; + notch.fill(.all(0.75 * s), .{ .color = .{ .color = .black } }); + } + + // The playhead: where the document is. + const xa = rules.stopX(at, r.x, r.x + r.w, trim.n); + const head: dvui.Rect.Physical = .{ .x = xa - 1 * s, .y = r.y - 3 * s, .w = 2 * s, .h = r.h + 6 * s }; + head.fill(.all(1 * s), .{ .color = .{ .color = .white } }); +} diff --git a/app/history_transport.zig b/app/history_transport.zig index 61b887b1e..7578f8b95 100644 --- a/app/history_transport.zig +++ b/app/history_transport.zig @@ -76,20 +76,68 @@ pub const Repeat = struct { } }; -/// The range of a track's `n` steps a share's two slider fractions name (`Dialogs.ShareTape`): -/// from after `from` steps to after `to`, at least one step and inside the track. -pub fn shareRange(n: usize, from_f: f32, to_f: f32) struct { usize, usize } { - const nf: f32 = @floatFromInt(n); - const from = @min(n -| 1, @as(usize, @intFromFloat(@floor(from_f * nf)))); - const to = @max(from + 1, @min(n, @as(usize, @intFromFloat(@ceil(to_f * nf))))); - return .{ from, to }; +/// A trim of a history of `n` steps (`TrimBar`): the steps from after `from` through `to` are +/// kept, at least one, as macOS's trim keeps the part of a recording between its two handles. +pub const Trim = struct { + from: usize, + to: usize, + n: usize, + + pub fn all(n: usize) Trim { + return .{ .from = 0, .to = n, .n = n }; + } + + /// What a press at `x` takes hold of on a line from `x0` to `x1`: the nearer handle within + /// `slop` of the press, else the playhead. + pub fn grab(self: Trim, x: f32, x0: f32, x1: f32, slop: f32) Grip { + const xf = stopX(self.from, x0, x1, self.n); + const xt = stopX(self.to, x0, x1, self.n); + const df = @abs(x - xf); + const dt = @abs(x - xt); + if (@min(df, dt) > slop) return .head; + // Both as close (a range of one step on a long line): the side the press is on. + if (df == dt) return if (x < xf) .from else .to; + return if (df < dt) .from else .to; + } + + /// Move what `grip` holds to `stop`, kept inside the history and at least one step apart. + /// Returns the stop the document shows: the handle's, or the playhead's inside the range. + pub fn drag(self: *Trim, grip: Grip, stop: usize) usize { + switch (grip) { + .from => { + self.from = @min(stop, self.to - 1); + return self.from; + }, + .to => { + self.to = @max(@min(stop, self.n), self.from + 1); + return self.to; + }, + .head => return std.math.clamp(stop, self.from, self.to), + } + } + + pub const Grip = enum { from, to, head }; +}; + +test "a trim keeps one step at least, its handles inside the history" { + var t: Trim = .all(10); + try std.testing.expectEqual(@as(usize, 9), t.drag(.from, 12)); + try std.testing.expectEqual(@as(usize, 10), t.drag(.to, 3)); + t = .all(10); + try std.testing.expectEqual(@as(usize, 4), t.drag(.to, 4)); + try std.testing.expectEqual(@as(usize, 3), t.drag(.from, 7)); + try std.testing.expectEqual(@as(usize, 4), t.drag(.head, 9)); + try std.testing.expectEqual(@as(usize, 3), t.drag(.head, 0)); } -test "a share's range is at least one step, and inside the track" { - try std.testing.expectEqual(.{ 0, 10 }, shareRange(10, 0, 1)); - try std.testing.expectEqual(.{ 9, 10 }, shareRange(10, 1, 1)); - try std.testing.expectEqual(.{ 5, 6 }, shareRange(10, 0.5, 0.1)); - try std.testing.expectEqual(.{ 2, 8 }, shareRange(10, 0.25, 0.75)); +test "a press takes the nearer handle, else the playhead" { + const t: Trim = .{ .from = 2, .to = 8, .n = 10 }; + // Stops 10 apart over 0..100. + try std.testing.expectEqual(Trim.Grip.from, t.grab(22, 0, 100, 5)); + try std.testing.expectEqual(Trim.Grip.to, t.grab(79, 0, 100, 5)); + try std.testing.expectEqual(Trim.Grip.head, t.grab(50, 0, 100, 5)); + const one: Trim = .{ .from = 5, .to = 6, .n = 1000 }; + try std.testing.expectEqual(Trim.Grip.from, one.grab(0.4, 0, 100, 5)); } /// The step a point `x` on a line from `x0` to `x1` means, in a history of `n` steps: how many diff --git a/app/root.zig b/app/root.zig index c65eb1170..7164e6974 100644 --- a/app/root.zig +++ b/app/root.zig @@ -44,6 +44,7 @@ pub const ActivityFeed = @import("ActivityFeed.zig"); pub const Tracks = @import("Tracks.zig"); /// A document's transport: its history as a line at the bottom of its pane, undo and redo as seeks. pub const HistoryTransport = @import("HistoryTransport.zig"); +pub const TrimBar = @import("TrimBar.zig"); pub const history_transport = @import("history_transport.zig"); /// Key chords → command ids: the table, its ZON form, chord matching, and the dvui adapter. @@ -172,4 +173,5 @@ test { _ = store.Loader; _ = Tracks; _ = HistoryTransport; + _ = TrimBar; } diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index f5cadbd5a..bbe6004e9 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -95,6 +95,7 @@ pub const FileLoadJob = workbench_mod.FileLoadJob; pub const sdk = fizzy.sdk; /// Demo automation: fizzy's stage for `app.automation`, and the demos it ships with. pub const Demo = @import("Demo.zig"); +const TapeTabs = @import("TapeTabs.zig"); /// A float taken out of the main window into an OS window of its own: on by default on macOS, /// `FIZZY_POPOUT=1` elsewhere (`plans/POPOUT_WINDOWS_PLAN.md`). pub const Popout = @import("Popout.zig"); @@ -131,10 +132,10 @@ command_palette: @import("CommandPalette.zig") = .{}, /// address (the stage's context is this field). demo: Demo = .detached, /// Tapes playing in tabs of their own (`Demo: Play a Tape…`, the transport's share). -tape_tabs: @import("TapeTabs.zig") = .{}, -/// A share the person asked for (`Dialogs.ShareTape`): made at the top of the next frame, before +tape_tabs: TapeTabs = .{}, +/// A document whose transport's share was pressed: exported at the top of the next frame, before /// anything draws, since the export walks the document's history back and forth. -share_pending: ?struct { doc_id: u64, opts: @import("app").Tracks.ExportOptions = .{} } = null, +share_pending: ?u64 = null, /// Rebuilt plugins opening off the UI thread, swapped in once open (`PluginReloads`). plugin_reloads: @import("PluginReloads.zig") = .{}, @@ -2984,35 +2985,55 @@ fn drawDocSurface(ctx: ?*anyopaque) anyerror!dvui.App.Result { } } // Its history, while undo or redo or the pointer keep it: not while someone else holds it. - if (claim) |c| drawClaim(editor, c, claim_pill.?, doc.owner.documentPath(doc)) else { + // A shared tape being trimmed has the trim bar instead. + if (claim) |c| drawClaim(editor, c, claim_pill.?, doc.owner.documentPath(doc)) else if (editor.tape_tabs.trimOf(doc.id, dvui.currentWindow().arena())) |trim| { + TapeTabs.drawTrim(doc, trim[0], trim[1], canvas.data().borderRectScale().r, @truncate(ds.doc_id)); + } else { const shareable = if (editor.app.tracks) |*t| if (t.trackOf(doc.id)) |tr| tr.steps.items.len > 0 else false else false; const pressed = ds.transport.draw(doc, canvas.data().borderRectScale().r, @truncate(ds.doc_id), .{ .share = shareable }); - // What it would share, first: the range, how long, whose steps. - if (pressed.share) Dialogs.ShareTape.request(doc.id); + if (pressed.share) { + editor.share_pending = doc.id; + dvui.refresh(null, @src(), null); + } } return .ok; } -/// How `doc` was made, as a tape: the range `opts` names of its track exported -/// (`Tracks.exportTape`), kept in the config folder's `tapes/` and opened in a tab of its own to -/// play (`TapeTabs`). The transport's share, once its summary says go. -pub fn shareDocument(editor: *Editor, doc: sdk.DocHandle, opts: @import("app").Tracks.ExportOptions) void { +/// How `doc` was made, as a tape to trim: its whole track exported (`Tracks.exportTape`) and +/// opened in a tab of its own, played in at once, for the person to trim and share +/// (`TapeTabs`, `TrimBar`). Nothing is written until they share. The transport's share. +pub fn shareDocument(editor: *Editor, doc: sdk.DocHandle) void { const gpa = editor.app.gpa; const t = &(editor.app.tracks orelse return); - const bytes = t.exportTape(&editor.app.host, doc, gpa, opts) catch |err| { + const bytes = t.exportTape(&editor.app.host, doc, gpa, .{}) catch |err| { dvui.log.warn("share: could not export {s}: {t}", .{ doc.owner.documentPath(doc), err }); _ = editor.noteOnDocument(doc.id, "Could not share"); return; }; defer gpa.free(bytes); + // Whose each step is, for the trim to say. const arena = dvui.currentWindow().arena(); - const saved = if (comptime builtin.target.cpu.arch == .wasm32) null else editor.keepTape(arena, doc, bytes, "") catch |err| saved: { - dvui.log.warn("share: could not keep the tape: {t}", .{err}); - break :saved null; - }; - // Said on the tape's tab, which the pane turns to. - const note = if (saved) |path| std.fmt.allocPrint(arena, "Saved {s}", .{std.fs.path.basename(path)}) catch null else null; - editor.tape_tabs.open(editor, bytes, note) catch |err| { + const steps = (t.trackOf(doc.id) orelse return).steps.items; + const by = arena.alloc(u8, steps.len) catch return; + var names: std.ArrayListUnmanaged([]const u8) = .empty; + var authors: std.ArrayListUnmanaged(sdk.Author) = .empty; + for (steps, by) |r, *b| { + const who = editor.app.host.stepAuthor(doc.id, r.id); + const i = for (authors.items, 0..) |a, i| { + if (a.same(who)) break i; + } else i: { + if (authors.items.len == 255) break :i 254; + authors.append(arena, who) catch return; + names.append(arena, switch (who.kind) { + .person => "you", + .unattributed => "someone unknown", + else => if (who.label.len > 0) who.label else @tagName(who.kind), + }) catch return; + break :i authors.items.len - 1; + }; + b.* = @intCast(i); + } + editor.tape_tabs.open(editor, bytes, .{ .trim = .{ .by = by, .names = names.items } }) catch |err| { dvui.log.warn("share: could not open the tape: {t}", .{err}); _ = editor.noteOnDocument(doc.id, "Could not open the tape"); }; @@ -4036,9 +4057,9 @@ pub fn tick(editor: *Editor) !dvui.App.Result { editor.doc_io.tick(); // The document states tracks asked for since last frame, before anything draws. if (editor.app.tracks) |*t| t.tick(); - if (editor.share_pending) |p| { + if (editor.share_pending) |id| { editor.share_pending = null; - if (editor.app.open_files.get(p.doc_id)) |doc| editor.shareDocument(doc, p.opts); + if (editor.app.open_files.get(id)) |doc| editor.shareDocument(doc); } editor.tape_tabs.tick(editor); editor.promoteEditedPreviews(); diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index d274f687d..846e1e3f0 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -224,7 +224,7 @@ fn playTapeFile(editor: *Editor, path: []const u8) !void { defer gpa.free(bytes); // One document's track (a shared tape) plays in a tab of its own, beside the person's work; // anything else on the demo stage. - editor.tape_tabs.open(editor, bytes, null) catch |err| switch (err) { + editor.tape_tabs.open(editor, bytes, .{}) catch |err| switch (err) { error.NotOneDocument, error.BadTape => editor.demo.playTapeSoon(bytes), else => return err, }; diff --git a/src/editor/TapeTabs.zig b/src/editor/TapeTabs.zig index 0ba5f3a3e..e9027c8c2 100644 --- a/src/editor/TapeTabs.zig +++ b/src/editor/TapeTabs.zig @@ -11,6 +11,13 @@ //! tape is a document among theirs. Closing it ends the playback and the mount. While nothing is //! playing a frame costs one check of an empty list; while one is, a timer wakes it for the next //! step and nothing more. +//! +//! **Trimming.** A tape opened to be shared (`OpenOptions.trim`, the transport's share) plays in +//! at once and stays in its tab for the person to trim, as macOS trims a recording: the pane's bar +//! is a `TrimBar`, the document showing wherever its handles are dragged, and play previews the +//! part kept. Share writes that part as a tape of its own (`trimmed`): the document's file where +//! the part begins, the steps from there, timed from its first. Nothing is written until then; +//! cancel closes the tab. const TapeTabs = @This(); const std = @import("std"); @@ -18,7 +25,8 @@ const dvui = @import("dvui"); const fizzy = @import("../fizzy.zig"); const core = fizzy.core; const sdk = fizzy.sdk; -const automation = @import("app").automation; +const app = @import("app"); +const automation = app.automation; const Editor = @import("Editor.zig"); playing: std.ArrayListUnmanaged(*Playback) = .empty, @@ -43,6 +51,45 @@ const Playback = struct { opened_ns: i128, /// Said on the tape's own tab once it shows (`Editor.noteOnDocument`); owned. note: ?[]u8 = null, + /// Being trimmed before it is shared; null for a tape that only plays. + trim: ?*Trimming = null, +}; + +/// A tape in its tab, being trimmed (`TrimBar`). +pub const Trimming = struct { + span: app.history_transport.Trim = .all(0), + bar: app.TrimBar = .{}, + /// Each step's time and length in the tape, ms, in order. + at: []u32, + ms: []u32, + /// Who made each step, as an index into `names`; empty when not known. + by: []u8, + names: [][]u8, + /// The preview playing the part kept: when its first step played, as frame time. + playing_since: ?i128 = null, + /// Pressed on the bar, done at the next frame's top: sharing walks the document's history. + action: enum { none, share, cancel } = .none, + + fn deinit(self: *Trimming, gpa: std.mem.Allocator) void { + gpa.free(self.at); + gpa.free(self.ms); + gpa.free(self.by); + for (self.names) |n| gpa.free(n); + gpa.free(self.names); + } +}; + +/// Who made a shared track's steps, for the trim's summary: an author name per step, by index. +pub const Authors = struct { + by: []const u8, + names: []const []const u8, +}; + +pub const OpenOptions = struct { + /// Said on the tape's tab once it shows. + note: ?[]const u8 = null, + /// Open it to be trimmed and shared (the transport's share): played in at once, then trimmed. + trim: ?Authors = null, }; const load_limit_ns: i128 = 30 * std.time.ns_per_s; @@ -61,9 +108,9 @@ pub const OpenError = error{ OutOfMemory, }; -/// Open the tape in `bytes` in a tab of its own and play it, saying `note` on that tab once it -/// shows. The bytes may be let go of at once. -pub fn open(self: *TapeTabs, editor: *Editor, bytes: []const u8, note: ?[]const u8) (OpenError || anyerror)!void { +/// Open the tape in `bytes` in a tab of its own and play it, or play it in at once and trim it +/// (`opts.trim`). The bytes may be let go of at once. +pub fn open(self: *TapeTabs, editor: *Editor, bytes: []const u8, opts: OpenOptions) (OpenError || anyerror)!void { const gpa = editor.app.gpa; var check = automation.Input.check; check.steps = @import("Demo.zig").stepsCheck; @@ -99,8 +146,13 @@ pub fn open(self: *TapeTabs, editor: *Editor, bytes: []const u8, note: ?[]const const p = try gpa.create(Playback); errdefer gpa.destroy(p); - p.* = .{ .owned = owned, .mem = mem, .root = root, .path = path, .opened_ns = dvui.frameTimeNS(), .note = if (note) |n| try gpa.dupe(u8, n) else null }; + p.* = .{ .owned = owned, .mem = mem, .root = root, .path = path, .opened_ns = dvui.frameTimeNS(), .note = if (opts.note) |n| try gpa.dupe(u8, n) else null }; errdefer if (p.note) |n| gpa.free(n); + if (opts.trim) |authors| p.trim = try trimming(gpa, tape, authors); + errdefer if (p.trim) |t| { + t.deinit(gpa); + gpa.destroy(t); + }; _ = try editor.openFilePath(path, editor.workbench.currentGroupingID()); // Last, once nothing above can fail: the list owns it from here. try self.playing.append(gpa, p); @@ -136,18 +188,58 @@ pub fn tick(self: *TapeTabs, editor: *Editor) void { editor.app.gpa.free(n); p.note = null; }; + if (p.trim) |t| switch (t.action) { + .none => preview(t, doc.?, now), + .cancel => { + // Nothing of it was written: the tab goes, and the playback with it next frame. + editor.rawCloseFileID(doc.?.id) catch {}; + t.action = .none; + }, + .share => { + t.action = .none; + share(editor, p, t, doc.?); + }, + }; advance(editor, p, doc.?, now); i += 1; } } +/// Write the part kept as a tape in the config folder's `tapes/`, and end the trim: the tab is the +/// tape's from here, its transport back. +fn share(editor: *Editor, p: *Playback, t: *Trimming, doc: sdk.DocHandle) void { + const gpa = editor.app.gpa; + const bytes = trimmed(editor, p, t, doc, gpa) catch |err| { + dvui.log.warn("share: could not trim {s}: {t}", .{ p.path, err }); + _ = editor.noteOnDocument(doc.id, "Could not share"); + return; + }; + defer gpa.free(bytes); + const arena = dvui.currentWindow().arena(); + if (comptime @import("builtin").target.cpu.arch != .wasm32) { + const saved = editor.keepTape(arena, doc, bytes, "") catch |err| { + dvui.log.warn("share: could not keep the tape: {t}", .{err}); + _ = editor.noteOnDocument(doc.id, "Could not save the tape"); + return; + }; + _ = editor.noteOnDocument(doc.id, std.fmt.allocPrint(arena, "Saved {s}", .{std.fs.path.basename(saved)}) catch "Saved the tape"); + } + t.deinit(gpa); + gpa.destroy(t); + p.trim = null; + // The handles moved the document about; as it stands now is the tab's, so closing it asks + // nothing. + editor.writeDocument(doc) catch |err| dvui.log.warn("tape: could not keep {s}: {t}", .{ p.path, err }); +} + /// Apply `p`'s steps due by `now`, and wake for the next. Played out, the document is written to /// its own mount: it is as the tape left it, so closing it asks nothing (an edit of the person's /// after that would). fn advance(editor: *Editor, p: *Playback, doc: sdk.DocHandle, now: i128) void { const ops = p.owned.tape.ops; if (p.op == ops.len) return; - const elapsed_ms: u64 = @intCast(@max(0, @divTrunc(now - p.start_ns, std.time.ns_per_ms))); + // To be trimmed, it plays in at once: the whole of it is there to trim. + const elapsed_ms: u64 = if (p.trim != null) std.math.maxInt(u64) else @intCast(@max(0, @divTrunc(now - p.start_ns, std.time.ns_per_ms))); while (p.op < ops.len and ops[p.op].at <= elapsed_ms) : (p.op += 1) { const op = ops[p.op]; switch (op.do) { @@ -169,6 +261,188 @@ fn advance(editor: *Editor, p: *Playback, doc: sdk.DocHandle, now: i128) void { dvui.timer(dvui.Id.extendId(null, @src(), @intFromPtr(p)), @intCast(@min(wait_ms * 1000 + 1, std.math.maxInt(i32)))); } +fn trimming(gpa: std.mem.Allocator, tape: automation.Tape, authors: Authors) !*Trimming { + var n: usize = 0; + for (tape.ops) |op| n += @intFromBool(op.do == .step); + const t = try gpa.create(Trimming); + errdefer gpa.destroy(t); + const at = try gpa.alloc(u32, n); + errdefer gpa.free(at); + const ms = try gpa.alloc(u32, n); + errdefer gpa.free(ms); + var i: usize = 0; + for (tape.ops) |op| if (op.do == .step) { + at[i] = op.at; + ms[i] = op.ms; + i += 1; + }; + const by = try gpa.dupe(u8, if (authors.by.len == n) authors.by else &.{}); + errdefer gpa.free(by); + const names = try gpa.alloc([]u8, authors.names.len); + var made: usize = 0; + errdefer { + for (names[0..made]) |nm| gpa.free(nm); + gpa.free(names); + } + for (authors.names, names) |a, *nm| { + nm.* = try gpa.dupe(u8, a); + made += 1; + } + t.* = .{ .span = .all(n), .at = at, .ms = ms, .by = by, .names = names }; + return t; +} + +/// The tape at `path` being trimmed, and the summary its bar shows: how many steps the part kept +/// holds, how long it plays, whose they are. Null for a tape that only plays, or one still +/// playing in. +pub fn trimOf(self: *const TapeTabs, doc_id: u64, arena: std.mem.Allocator) ?struct { *Trimming, []const u8 } { + for (self.playing.items) |p| { + if (p.doc_id != doc_id) continue; + const t = p.trim orelse return null; + if (p.op < p.owned.tape.ops.len) return null; + return .{ t, summary(arena, t) }; + } + return null; +} + +fn summary(arena: std.mem.Allocator, t: *const Trimming) []const u8 { + const from = t.span.from; + const to = t.span.to; + if (to <= from) return ""; + const ms: u64 = t.at[to - 1] + t.ms[to - 1] - t.at[from]; + var out: std.Io.Writer.Allocating = .init(arena); + const w = &out.writer; + w.print("{d} step{s}, ", .{ to - from, if (to - from == 1) "" else "s" }) catch {}; + const secs = (ms + 500) / 1000; + if (secs < 60) w.print("{d} s", .{secs}) catch {} else w.print("{d} min {d} s", .{ secs / 60, secs % 60 }) catch {}; + if (t.by.len == t.at.len and t.names.len > 0) { + // Whose, each once, in the order they first come. + var seen: [8]u8 = undefined; + var count: usize = 0; + for (t.by[from..to]) |b| { + if (std.mem.indexOfScalar(u8, seen[0..count], b) != null) continue; + if (count == seen.len) break; + seen[count] = b; + count += 1; + } + w.writeAll(", by ") catch {}; + for (seen[0..count], 0..) |b, i| { + if (i > 0) w.writeAll(if (i == count - 1) " and " else ", ") catch {}; + w.writeAll(t.names[b]) catch {}; + } + } + return out.written(); +} + +/// The bar on a trimmed tape's pane, drawn by the pane in place of its transport; what was +/// pressed is done at the next frame's top (`tick`). +pub fn drawTrim(doc: sdk.DocHandle, t: *Trimming, text: []const u8, area: dvui.Rect.Physical, id_extra: usize) void { + const pressed = t.bar.draw(doc, area, id_extra, &t.span, text, t.playing_since != null); + if (pressed.grabbed) t.playing_since = null; + if (pressed.play) { + if (t.playing_since != null) t.playing_since = null else play(doc, t); + dvui.refresh(null, @src(), null); + } + if (pressed.cancel) t.action = .cancel; + if (pressed.share) t.action = .share; + if (t.action != .none) dvui.refresh(null, @src(), null); +} + +/// Start the preview from the playhead, or from the part's start when the playhead is outside it +/// or at its end. +fn play(doc: sdk.DocHandle, t: *Trimming) void { + const line = doc.owner.historyLine(doc, dvui.currentWindow().arena()) orelse return; + var head = line.at; + if (head < t.span.from or head >= t.span.to) { + head = t.span.from; + _ = doc.owner.historyGoTo(doc, if (head == 0) 0 else line.entries[head - 1].id); + } + const into: i128 = t.at[head] -| t.at[t.span.from]; + t.playing_since = dvui.frameTimeNS() - into * std.time.ns_per_ms; +} + +/// The preview's next frame: the document goes to the last step of the part whose moment has +/// come, and a timer wakes it for the one after. Ends at the part's end. +fn preview(t: *Trimming, doc: sdk.DocHandle, now: i128) void { + const since = t.playing_since orelse return; + const elapsed: i128 = @divTrunc(now - since, std.time.ns_per_ms); + const first = t.at[t.span.from]; + var stop = t.span.from; + while (stop < t.span.to and @as(i128, t.at[stop] - first) <= elapsed) stop += 1; + const line = doc.owner.historyLine(doc, dvui.currentWindow().arena()) orelse return; + if (line.entries.len != t.span.n) { + t.playing_since = null; + return; + } + if (stop != line.at) _ = doc.owner.historyGoTo(doc, if (stop == 0) 0 else line.entries[stop - 1].id); + if (stop >= t.span.to) { + t.playing_since = null; + return; + } + const wait_ms: i128 = @as(i128, t.at[stop] - first) - elapsed; + dvui.timer(dvui.Id.extendId(null, @src(), @intFromPtr(t)), @intCast(@min(@max(wait_ms, 0) * 1000 + 1, std.math.maxInt(i32)))); +} + +pub const TrimError = error{ NotTheTape, NoBytes, NotReturned, OutOfMemory }; + +/// The part of `p`'s tape that `t` keeps, as a tape of its own (binary, deflated, owned by `gpa`): +/// its keyframe the document's file where the part begins, written by its owner with the document +/// taken back there and brought forward again in the same call, unheard by its track and the +/// activity feed; then the part's steps, timed from its first. +fn trimmed(editor: *Editor, p: *Playback, t: *const Trimming, doc: sdk.DocHandle, gpa: std.mem.Allocator) TrimError![]u8 { + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const tape = p.owned.tape; + const file = tape.keyframes[0].files[0]; + const line = doc.owner.historyLine(doc, arena) orelse return error.NotTheTape; + if (line.entries.len != t.span.n) return error.NotTheTape; + const stopId = struct { + fn of(l: sdk.Plugin.HistoryLine, i: usize) u64 { + return if (i == 0) 0 else l.entries[i - 1].id; + } + }.of; + + const bytes = bytes: { + const host = &editor.app.host; + const tracks = if (editor.app.tracks) |*tr| tr else null; + const was_track_muted = if (tracks) |tr| tr.muted else 0; + if (tracks) |tr| tr.muted = doc.id; + defer if (tracks) |tr| { + tr.muted = was_track_muted; + }; + const was_muted = host.activity_muted; + host.activity_muted = true; + defer host.activity_muted = was_muted; + const now = line.at; + const moved = now != t.span.from; + if (moved and !doc.owner.historyGoTo(doc, stopId(line, t.span.from))) return error.NotTheTape; + const written = doc.owner.documentBytes(doc, file.path, arena) catch null; + if (moved and !doc.owner.historyGoTo(doc, stopId(line, now))) return error.NotReturned; + break :bytes written orelse return error.NoBytes; + }; + + const files = try arena.dupe(automation.Tape.File, tape.keyframes[0].files); + files[0].text = bytes; + const keyframes = try arena.dupe(automation.Tape.Keyframe, tape.keyframes); + keyframes[0].files = files; + const ops = try arena.alloc(automation.Tape.Op, t.span.to - t.span.from + 2); + ops[0] = .{ .at = 0, .do = .{ .keyframe = 0 } }; + ops[1] = .{ .at = 0, .do = .{ .wait = .{ .until = .idle } } }; + var i: usize = 0; + var out: usize = 2; + const first = t.at[t.span.from]; + for (tape.ops) |op| if (op.do == .step) { + if (i >= t.span.from and i < t.span.to) { + ops[out] = op; + ops[out].at = op.at - first; + out += 1; + } + i += 1; + }; + return automation.binary.encode(gpa, .{ .name = tape.name, .title = tape.title, .ops = ops, .keyframes = keyframes }, .{ .deflate = true }) catch error.OutOfMemory; +} + fn openUnder(editor: *Editor, root: []const u8) bool { for (editor.app.open_files.values()) |d| { const path = d.owner.documentPath(d); @@ -191,6 +465,10 @@ fn free(editor: *Editor, p: *Playback) void { gpa.free(p.root); gpa.free(p.path); if (p.note) |n| gpa.free(n); + if (p.trim) |t| { + t.deinit(gpa); + gpa.destroy(t); + } p.owned.deinit(); gpa.destroy(p); } diff --git a/src/editor/dialogs/Dialogs.zig b/src/editor/dialogs/Dialogs.zig index 7905f17ef..db30b2efd 100644 --- a/src/editor/dialogs/Dialogs.zig +++ b/src/editor/dialogs/Dialogs.zig @@ -12,7 +12,6 @@ pub const AppQuitUnsaved = @import("AppQuitUnsaved.zig"); pub const AboutFizzy = @import("AboutFizzy.zig"); pub const PluginUpdates = @import("PluginUpdates.zig"); pub const FileTypeDefaults = @import("FileTypeDefaults.zig"); -pub const ShareTape = @import("ShareTape.zig"); pub const WebFolderUnavailable = if (builtin.target.cpu.arch == .wasm32) @import("WebFolderUnavailable.zig") else diff --git a/src/editor/dialogs/ShareTape.zig b/src/editor/dialogs/ShareTape.zig deleted file mode 100644 index f579681a7..000000000 --- a/src/editor/dialogs/ShareTape.zig +++ /dev/null @@ -1,139 +0,0 @@ -//! What a share of a document's track holds, before anything is written (`plans/RECORDING_PLAN.md`, -//! "Privacy": export shows what is in it first): which document, how many steps over how long, -//! whose they are, and how long the tape plays. The person picks the range of the track to share -//! and whether pauses keep their length; Share hands it to `Editor.shareDocument` at the next -//! frame's top (`Editor.share_pending`). Opened by the transport's share. -const std = @import("std"); -const fizzy = @import("../../fizzy.zig"); -const dvui = @import("dvui"); -const sdk = fizzy.sdk; -const Tracks = @import("app").Tracks; - -pub fn request(doc_id: u64) void { - var mutex = fizzy.core.dialogs.dialog(@src(), .{ - .displayFn = dialog, - .callafterFn = callAfter, - .title = "Share how it was made", - .ok_label = "", - .cancel_label = "", - .resizeable = false, - .default = .cancel, - .hide_footer = true, - .max_size = .width(520), - }); - dvui.dataSet(null, mutex.id, "_doc", doc_id); - dvui.dataSet(null, mutex.id, "_from", @as(f32, 0)); - dvui.dataSet(null, mutex.id, "_to", @as(f32, 1)); - dvui.dataSet(null, mutex.id, "_real", false); - mutex.mutex.unlock(dvui.io); -} - -/// "48 s", "3 min 20 s", "1 h 5 min". -fn duration(arena: std.mem.Allocator, ms: u64) []const u8 { - const s = (ms + 500) / 1000; - return (if (s < 60) - std.fmt.allocPrint(arena, "{d} s", .{s}) - else if (s < 3600) - std.fmt.allocPrint(arena, "{d} min {d} s", .{ s / 60, s % 60 }) - else - std.fmt.allocPrint(arena, "{d} h {d} min", .{ s / 3600, s % 3600 / 60 })) catch ""; -} - -/// "you", "you and Claude", "you, Claude and the tour". -fn whose(arena: std.mem.Allocator, authors: []const sdk.Author) []const u8 { - var out: std.Io.Writer.Allocating = .init(arena); - for (authors, 0..) |a, i| { - if (i > 0) out.writer.writeAll(if (i == authors.len - 1) " and " else ", ") catch {}; - out.writer.writeAll(switch (a.kind) { - .person => "you", - .unattributed => "someone unknown", - else => if (a.label.len > 0) a.label else "an agent", - }) catch {}; - } - return out.written(); -} - -fn refusal(err: Tracks.ExportError) []const u8 { - return switch (err) { - error.StartNotInHistory => "The document can't be put back where this range begins: that point was undone and edited over. Start the range later.", - error.NoHistory => "This document keeps no history to share from.", - error.EmptyRange => "The range holds no steps.", - else => "This document's track can't be shared yet.", - }; -} - -pub fn dialog(id: dvui.Id) anyerror!bool { - const editor = fizzy.editor(); - const doc_id = dvui.dataGet(null, id, "_doc", u64) orelse return false; - const doc = editor.app.docById(doc_id) orelse return false; - const tracks = &(editor.app.tracks orelse return false); - const track = tracks.trackOf(doc_id) orelse return false; - const from_f = dvui.dataGetPtr(null, id, "_from", f32) orelse return false; - const to_f = dvui.dataGetPtr(null, id, "_to", f32) orelse return false; - const real = dvui.dataGetPtr(null, id, "_real", bool) orelse return false; - const arena = dvui.currentWindow().arena(); - const n = track.steps.items.len; - if (n == 0) return false; - - const from, const to = @import("app").history_transport.shareRange(n, from_f.*, to_f.*); - const opts: Tracks.ExportOptions = .{ .from = from, .to = to, .real_time = real.* }; - const sum = tracks.summarize(&editor.app.host, doc, arena, opts) catch return false; - const name = std.fs.path.basename(doc.owner.documentPath(doc)); - - var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .padding = .all(8) }); - defer outer.deinit(); - - const message = std.fmt.allocPrint(arena, "{s}: {d} step{s} over {s}, by {s}. The tape plays in {s}.", .{ - name, - sum.steps, - if (sum.steps == 1) "" else "s", - duration(arena, sum.recorded_ms), - whose(arena, sum.authors), - duration(arena, sum.played_ms), - }) catch name; - var tl = dvui.textLayout(@src(), .{}, .{ .expand = .horizontal, .background = false }); - tl.addText(message, .{ .font = dvui.Font.theme(.body) }); - tl.deinit(); - - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 8, .h = 12 } }); - - // The range, as two ends over the track: each says when its step came. - const base_at = if (track.base) |b| b.at else 0; - inline for (.{ .{ "From", from_f, from }, .{ "To", to_f, to } }, 0..) |end, i| { - var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .id_extra = i }); - defer row.deinit(); - dvui.labelNoFmt(@src(), end[0], .{}, .{ .min_size_content = .{ .w = 48 }, .gravity_y = 0.5 }); - _ = dvui.slider(@src(), .{ .fraction = end[1] }, .{ .expand = .horizontal, .gravity_y = 0.5 }); - if (end[2] == 0) { - dvui.labelNoFmt(@src(), "where it began", .{}, .{ .min_size_content = .{ .w = 170 }, .gravity_y = 0.5 }); - } else { - const when = duration(arena, track.steps.items[@min(end[2], n) - 1].at -| base_at); - dvui.label(@src(), "after step {d}, {s} in", .{ end[2], when }, .{ .min_size_content = .{ .w = 170 }, .gravity_y = 0.5 }); - } - } - - _ = dvui.checkbox(@src(), real, "Keep every pause as long as it was", .{}); - - if (sum.refused) |err| { - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 8, .h = 8 } }); - var why = dvui.textLayout(@src(), .{}, .{ .expand = .horizontal, .background = false }); - why.addText(refusal(err), .{ .font = dvui.Font.theme(.body), .color_text = .{ .color = dvui.themeGet().color(.err, .text) } }); - why.deinit(); - } - - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 8, .h = 16 } }); - var btn_row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .none, .gravity_x = 0.5 }); - defer btn_row.deinit(); - if (sum.refused == null and fizzy.core.dialogs.bodyButton(@src(), id, "Share", .highlight, 1, 0)) { - editor.share_pending = .{ .doc_id = doc_id, .opts = opts }; - dvui.refresh(null, @src(), null); - fizzy.core.dialogs.closeFloatingDialogAnchored(); - } - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 1 } }); - if (fizzy.core.dialogs.bodyButton(@src(), id, "Cancel", .control, 2, 1)) { - fizzy.core.dialogs.closeFloatingDialogAnchored(); - } - return true; -} - -pub fn callAfter(_: dvui.Id, _: dvui.enums.DialogResponse) !void {} diff --git a/tests/integration.zig b/tests/integration.zig index 7e29cdd9a..a178963d8 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -8034,7 +8034,7 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try std.testing.expectEqualStrings("", kf.files[0].text); // Played in a tab of its own (`TapeTabs`, the transport's share), each step at its moment: // the same text, under the tape's own mount, with the person's documents untouched. - try editor.tape_tabs.open(editor, bytes, null); + try editor.tape_tabs.open(editor, bytes, .{}); const played_path = try std.fmt.allocPrint(gpa, "tape://1/{s}", .{std.fs.path.basename(path)}); defer gpa.free(played_path); for (0..2000) |_| { @@ -8061,34 +8061,93 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try std.testing.expectEqual(@as(usize, 0), editor.tape_tabs.playing.items.len); // The transport's share (pressed: `share_pending`), at the top of the next frame: the - // tape kept in the config folder's tapes/, and played in a tab of its own. + // whole track opened in a tab to trim, played in at once, nothing written yet. Trimmed + // to the part after two steps and shared, the tape kept holds that part: the document as + // it was after two steps, then the four after. Cancelled, nothing is written and the tab + // goes. const tapes_dir = try std.fs.path.join(gpa, &.{ editor.app.config_folder, "tapes" }); defer gpa.free(tapes_dir); - const count = struct { - fn count(dir_path: []const u8) !usize { + const newest = struct { + /// How many tapes there are, and the newest name not in `before` (owned by `a`). + fn newest(a: std.mem.Allocator, dir_path: []const u8, before: []const []const u8) !struct { usize, ?[]const u8 } { var dir = try std.Io.Dir.cwd().openDir(dvui.io, dir_path, .{ .iterate = true }); defer dir.close(dvui.io); var it = dir.iterate(); var n: usize = 0; - while (try it.next(dvui.io)) |_| n += 1; - return n; + var fresh: ?[]const u8 = null; + while (try it.next(dvui.io)) |e| { + n += 1; + for (before) |b| { + if (std.mem.eql(u8, b, e.name)) break; + } else fresh = try a.dupe(u8, e.name); + } + return .{ n, fresh }; + } + }.newest; + var keep_state: std.heap.ArenaAllocator = .init(gpa); + defer keep_state.deinit(); + const keep = keep_state.allocator(); + var names: std.ArrayListUnmanaged([]const u8) = .empty; + { + var dir = try std.Io.Dir.cwd().openDir(dvui.io, tapes_dir, .{ .iterate = true }); + defer dir.close(dvui.io); + var it = dir.iterate(); + while (try it.next(dvui.io)) |e| try names.append(keep, try keep.dupe(u8, e.name)); + } + const kept_before = names.items.len; + const openTrim = struct { + fn open(ed: *fizzy.Editor, id: u64, at_path: []const u8) !sdk.DocHandle { + ed.share_pending = id; + for (0..400) |_| { + _ = try dvui.testing.step(headlessFrame); + if (ed.docFromPath(at_path)) |d| if (ed.tape_tabs.trimOf(d.id, dvui.currentWindow().arena()) != null) return d; + } + return error.TestUnexpectedResult; } - }.count; - const kept_before = try count(tapes_dir); - editor.share_pending = .{ .doc_id = doc.id }; + }.open; const shared_path = try std.fmt.allocPrint(gpa, "tape://2/{s}", .{std.fs.path.basename(path)}); defer gpa.free(shared_path); - for (0..2000) |_| { - _ = try dvui.testing.step(headlessFrame); - if (editor.docFromPath(shared_path) != null and !editor.tape_tabs.isPlaying(shared_path)) break; - std.Io.sleep(dvui.io, .fromMilliseconds(5), .awake) catch {}; - } - try std.testing.expectEqual(kept_before + 1, try count(tapes_dir)); + const shared = try openTrim(editor, doc.id, shared_path); + try std.testing.expectEqual(kept_before, (try newest(keep, tapes_dir, names.items))[0]); try dvui.testing.settle(headlessFrame); try std.testing.expectEqualStrings(want, try textOf(host, shared_path)); - const shared = editor.docFromPath(shared_path) orelse return error.TestUnexpectedResult; - try editor.closeFileID(shared.id); + { + const trim, const said = editor.tape_tabs.trimOf(shared.id, host.arena()).?; + try std.testing.expectEqual(@as(usize, 6), trim.span.n); + try std.testing.expect(std.mem.startsWith(u8, said, "6 steps")); + trim.span.from = 2; + trim.action = .share; + } + for (0..20) |_| _ = try dvui.testing.step(headlessFrame); + try std.testing.expect(editor.tape_tabs.trimOf(shared.id, host.arena()) == null); + const kept_after, const fresh = try newest(keep, tapes_dir, names.items); + try std.testing.expectEqual(kept_before + 1, kept_after); + { + const kept_path = try std.fs.path.join(keep, &.{ tapes_dir, fresh.? }); + const kept = try std.Io.Dir.cwd().readFileAlloc(dvui.io, kept_path, gpa, .limited(1 << 20)); + defer gpa.free(kept); + var kept_check = app.automation.Input.check; + kept_check.steps = fizzy.Editor.Demo.stepsCheck; + var kept_tape = try app.automation.Tape.load(gpa, kept, kept_check); + defer kept_tape.deinit(); + try std.testing.expectEqualStrings("two\none\n", kept_tape.tape.keyframes[0].files[0].text); + var kept_steps: usize = 0; + for (kept_tape.tape.ops) |op| kept_steps += @intFromBool(op.do == .step); + try std.testing.expectEqual(@as(usize, 4), kept_steps); + } + // The tab and the person's document are where they were. + try std.testing.expectEqualStrings(want, try textOf(host, shared_path)); + try std.testing.expectEqualStrings(want, try textOf(host, path)); + try editor.rawCloseFileID(shared.id); + try dvui.testing.settle(headlessFrame); + + const cancelled_path = try std.fmt.allocPrint(gpa, "tape://3/{s}", .{std.fs.path.basename(path)}); + defer gpa.free(cancelled_path); + const cancelled = try openTrim(editor, doc.id, cancelled_path); + editor.tape_tabs.trimOf(cancelled.id, host.arena()).?[0].action = .cancel; try dvui.testing.settle(headlessFrame); + try std.testing.expect(editor.docFromPath(cancelled_path) == null); + try std.testing.expectEqual(kept_after, (try newest(keep, tapes_dir, names.items))[0]); try editor.rawCloseFileID(doc.id); From c8bbbe9f22a0686f88f564cbde369d7b085de724 Mon Sep 17 00:00:00 2001 From: Colton Franklin <49629865+foxnne@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:33:46 -0500 Subject: [PATCH 46/46] app: control server on a Unix socket, commands over HTTP (#490) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of `plans/CONTROL_SERVER_PLAN.md` (fizzyedit/plans#22), step 1. ## What changes fizzy answers HTTP/1.1 on a Unix socket, `<config>/control.sock` (`<profile>/control.sock` under `--profile`). The API is the app's commands, with the parameters each already declares. Nothing registers a route. So `curl` drives fizzy, with no agent or MCP tooling in between. Dark behind `FIZZY_CONTROL=1` until the plan's step 4. ``` GET / {"name":"fizzy","version":"0.2.0","sdk_version":"0.2.29"} GET /commands every command: id, title, owner, visibility, enabled, params (kind, required, default, min/max, choices) GET /commands/{id} one of them POST /commands/{id} run it: body = arguments, response = result ``` ```sh curl --unix-socket ~/fizzy-profile/control.sock localhost/commands/text.insert \ -d '{"text":"from curl\n","line":1,"path":"/path/hello.txt"}' {"line":2,"column":1} ``` - **JSON outside, ZON inside** (`app/control/json.zig`). Arguments arrive as JSON and reach `Host.callCommand` as ZON. A string becomes an enum literal where the parameter is an enumeration. Results go back as JSON. Send `Content-Type: application/zon` to send and receive ZON as is. - **HTTP edges:** a request still queued at quit gets a 503 "fizzy is closing" (its socket stays open for it). An oversize body, an `Expect` other than `100-continue`, or a GET or HEAD with a body is refused (413/417/400) before any `100 Continue` and before the app sees it. - **Errors are HTTP's**, each with `{"error": "…"}`: 404 for an unknown command, 400 for arguments that do not fit or a body that is not JSON, 409 for a command not enabled now, 500 for one that ran and failed. - **Transport** (`app/control/Server.zig`): lifted from the agent plugin's socket server (thread per connection, requests handed to the UI thread, inode-checked socket removal), with `std.http.Server` on each stream and keep-alive. `start` does everything that can fail (address, listen, chmod) on the calling thread, so a server that returns is one that listens. The accept loop is an `Io` task that `stop` cancels, with no wake pipe. std interrupts a blocked `accept` that way on every OS. Closing the listener under it is `unreachable` in std's Windows `accept`, and connecting to the path would wake whichever fizzy owns it after a restart's handover. The socket is `chmod 0600`, and that is the authorization: there is no token. Requests are answered at the top of the frame, just before plugins' `beginFrame` (`routes.answerPending`), so that frame sees what they ran. - **Windows target minimum raised to 10 1803** (`build.zig`). std offers AF_UNIX on Windows only to a target whose minimum is `win10_rs4` (`std.Io.net.has_unix_sockets`). Below that, listening fails with `AddressFamilyUnsupported` whatever the machine runs. That means the agent plugin's socket has never worked on Windows either. Every Windows Microsoft still supports is newer than 1803. On Windows the socket is a reparse point `statFile` cannot open, so the inode check is skipped there: a server leaves its socket behind, and the next deletes it before listening. Written down in `app/AGENTS.md`. Measured: a call round-trips in 2–13 ms (one wake, then the next frame). Idle with the server running, `devtools.stats` (called over the socket) reads 0 frames over 3 s. ## SDK impact - [x] None. The app calls `Host.callCommand` as it is. Fingerprint unchanged (`test-sdk-version` passes). - [ ] Core-only or additive: reaches plugins at the next SDK release - [ ] Fingerprint moved: recorded in `sdk/src/version.zig`, `sdk_version` untouched, PR labelled `sdk` - [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the last `sdk-v*` tag ## Verified - [x] macOS: unit tests for `json.zig` and `Server.zig` (a request over a real socket, and the socket's 0600 mode). An integration test covers `routes` over a `Host`: listing, describing, running with JSON and with ZON, and each error status. Live, I ran the built fizzy with `FIZZY_CONTROL=1 --profile` and drove it with curl: `/`, `/commands`, `fizzy.openFiles`, then `text.insert` and `text.read` (the text came back), `fizzy.saveAs` (the file on disk has the line), and the 404 and 400 paths. The socket is gone after quit. Gates: fmt, `zig build`, `test` (577/578, one skip is pre-existing LSP), `test-integration` (53/53 steps), `check-web`, `test-sdk-version`, `check-tests-reached`. - [x] Windows: `zig build check -Dtarget=aarch64-windows-gnu`, then fizzy's own `fizzy-control-server-tests.exe` (5 tests: round trip, 503 at quit, refusals, too-long path, handover) passed 5/5 runs in a Windows 11 ARM64 VM. It ran rather than skipped, so the 1803 minimum took effect. Before the `Io`-cancel change, the same binary panicked in std's `netAcceptWindows`. The x86_64 cross-compile links. Not run: fizzy itself on Windows with the server on. - [ ] Linux: CI. The first run failed to compile the unit test without libc (`std.c` calls), now gone. Cross-compiles for x86_64-linux without libc. - [x] Web: `check-web`. Nothing is added there: the field is `void` on wasm. ## Review An independent review pass found 12 issues. Fixed, each with a test where it can have one: - A request queued at quit got an empty reply instead of its 503. - On Windows, quitting after a restart handover hung, because the wake reached the new fizzy. - Infinite floats and text that is not UTF-8 produced invalid JSON with a 200. - An `Io` wait could be cancelled while its request was still queued. - `100 Continue` was sent before a 413. - JSON `-0.0` was refused. - "listening on" was logged when nothing listened. - `run` and `stop` raced on the wake pipe. - The wake pipe was inherited by children (gone with the pipe). - An explicit `-Dtarget` Windows range ending below 1803 was silently inverted. The build now refuses it with a message. - A GET body was read as the next request. Deferred: a group-writable umask leaves the socket group-connectable between `listen` and `chmod`. It's a microsecond window, only for that umask, and only in the user's own config folder. Closing it means listening inside a 0700 directory, which can come with step 4's `--control-path`. ## Follow-ups - Steps 2–8 of the plan: sessions and SSE events, `/openapi.json`, on by default with `fizzy --control-path` and `docs/CONTROL.md`, `/mcp`, loopback TCP, chat over the server, `single_instance` over this socket. - Claims: `callCommand` does not check `Claim`s (its callers are the holders today). Over HTTP, sessions in step 2 decide who holds what. - An app built on fizzy with its own Windows target needs the same 1803 minimum, or its server logs that it cannot start. `buildApp` could raise it alongside step 4. - There is no quit command, so curl cannot close fizzy. - `fizzyedit/agent`: its socket has the same Windows gap until it moves onto this server (step 5). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --- app/AGENTS.md | 9 + app/App.zig | 6 + app/control/Server.zig | 465 +++++++++++++++++++++++++++++++++++++++++ app/control/json.zig | 185 ++++++++++++++++ app/control/routes.zig | 195 +++++++++++++++++ app/root.zig | 12 ++ build.zig | 13 +- build/app.zig | 6 + src/editor/Editor.zig | 30 +++ tests/integration.zig | 79 +++++++ 10 files changed, 999 insertions(+), 1 deletion(-) create mode 100644 app/control/Server.zig create mode 100644 app/control/json.zig create mode 100644 app/control/routes.zig diff --git a/app/AGENTS.md b/app/AGENTS.md index 535c3dd90..14e9a6e8f 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -33,3 +33,12 @@ each directory is. springs, easing or trails driven by the pointer: merged Liquid Glass necks re-form visibly under tremor. A one-shot, time-driven curve started by a discrete event (the aimed bubble changing) is fine. +- **std offers AF_UNIX on Windows only to a target whose minimum is 10 1803** (`win10_rs4`, + `std.Io.net.has_unix_sockets`); below it, listening fails with `AddressFamilyUnsupported` + whatever the machine runs. Fizzy's `build.zig` raises the minimum for the control server + (`control/`); an app built on fizzy with its own target must too, or its server does not start. + On Windows the socket is a reparse point `statFile` cannot open, so its inode is not checked. +- **End a blocked `accept` by cancelling its `Io` task, never by closing the listener.** std's + Windows `accept` treats the cancelled wait that closing causes as `unreachable` (a panic), and + connecting to the socket's path to wake it reaches whoever listens there now. `Future.cancel` + interrupts it on every OS (`control/Server.zig`). diff --git a/app/App.zig b/app/App.zig index 12d316d5c..d2fa0c2cc 100644 --- a/app/App.zig +++ b/app/App.zig @@ -44,6 +44,7 @@ const Layout = @import("layout/Layout.zig"); const Host = sdk.Host; const PluginLoader = @import("root.zig").store.Loader; const SettingsWatcher = @import("watch/SettingsWatcher.zig"); +const ControlServer = @import("control/Server.zig"); const ExecutableWatcher = @import("watch/ExecutableWatcher.zig"); const FolderWatcher = @import("watch/FolderWatcher.zig"); const Keymap = @import("keymap/Keymap.zig"); @@ -260,6 +261,11 @@ settings_save_deadline_ns: i128 = 0, /// `SettingsWatcher.start`'s doc comment), torn down first in `deinit`. settings_watcher: ?SettingsWatcher = null, +/// The control server (`app.control`), while it runs: started in `postInit` when `FIZZY_CONTROL=1` +/// (plans/CONTROL_SERVER_PLAN.md, steps 1–3 land dark), answered each frame, stopped at quit. +/// Always void on wasm, which has no socket. +control: if (builtin.target.cpu.arch == .wasm32) void else ?ControlServer = if (builtin.target.cpu.arch == .wasm32) {} else null, + /// Watches the app's own executable while `settings.restart_on_rebuild` is on: a rebuild restarts /// the app into it, keeping every document. Null while the setting is off, and wherever /// `SettingsWatcher` would be. diff --git a/app/control/Server.zig b/app/control/Server.zig new file mode 100644 index 000000000..8638b41a3 --- /dev/null +++ b/app/control/Server.zig @@ -0,0 +1,465 @@ +//! The control server's transport: HTTP/1.1 on a Unix socket only this user can open, served by +//! threads of its own (plans/CONTROL_SERVER_PLAN.md). Nothing here touches the app: a request is +//! handed to the UI thread (`take`, once a frame), and the connection's thread waits until it is +//! answered (`Request.answer`), then writes the response. +//! +//! Each connection has a thread of its own, so a client holding a connection open never makes +//! another wait, and keep-alive works as curl and every HTTP library expect. The UI thread answers +//! requests in the order they arrived. Idle, the server costs a thread blocked in `accept`. +//! +//! Lifted from the agent plugin's socket server (`fizzyedit/agent` `src/Server.zig`), which kept a +//! private ZON-lines wire behind a token; here the socket's permissions are the authorization. +//! +//! std-only, with a unit-test root of its own (`build/app.zig`). +const Server = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const Io = std.Io; + +/// The most a request body may hold. A command's arguments can be a whole drawing, but not this. +pub const max_body = 64 * 1024 * 1024; + +gpa: std.mem.Allocator, +io: Io, +socket_path: []const u8, +/// Wakes the app so its next frame drains the queue. Safe from any thread. +wake: *const fn () void, +/// The accept loop (`run`), an `Io` task so `stop` can cancel its blocked `accept`: std interrupts +/// it on every OS (a signal on POSIX, a cancelled wait on Windows). Closing the listener under it +/// instead is `unreachable` in std's Windows `accept`, and connecting to the path to wake it +/// reaches whoever listens there now, which after a restart's handover is the next fizzy. +accept_task: ?Io.Future(void) = null, +/// Set by `start` before the accept task exists, so the task and `stop` only ever read it. +listener: ?Io.net.Server = null, +/// The connections being served, under `lock`, so `stop` can end their reads and wait for them. +conns: std.ArrayListUnmanaged(*Conn) = .empty, +stopping: std.atomic.Value(bool) = .init(false), +/// The socket file this server made, by inode: `stop` removes the path only while it is still +/// this one, not the socket a fizzy taking over has made there since. +socket_inode: ?Io.File.INode = null, +/// Requests waiting for the UI thread, under `lock`. +lock: Io.Mutex = .init, +pending: std.ArrayListUnmanaged(*Request) = .empty, + +const Conn = struct { + stream: Io.net.Stream, + thread: ?std.Thread = null, + done: std.atomic.Value(bool) = .init(false), + /// A request of this connection's is queued or being answered, under `lock`: `stop` leaves its + /// socket open so the answer ("fizzy is closing") can still be written. + waiting: bool = false, +}; + +/// One request on its way through the UI thread. Everything in it lives in the connection's +/// arena until it is answered. +pub const Request = struct { + method: std.http.Method, + /// The path and query, as sent: `/commands/text.insert?x=1`. + target: []const u8, + content_type: ?[]const u8, + body: []const u8, + /// Set by `answer`. `body` is `gpa`-owned and the connection's thread frees it. + response: Response = .{}, + done: Io.Event = .unset, + + /// The path without its query. + pub fn path(self: *const Request) []const u8 { + return self.target[0 .. std.mem.indexOfScalar(u8, self.target, '?') orelse self.target.len]; + } + + /// Answer, from the UI thread. `body` is copied with the server's `gpa`. + pub fn answer(self: *Request, server: *Server, status: std.http.Status, content_type: []const u8, body: []const u8) void { + self.response = .{ + .status = status, + .content_type = content_type, + .body = server.gpa.dupe(u8, body) catch null, + }; + if (self.response.body == null) self.response.status = .internal_server_error; + self.done.set(server.io); + } +}; + +pub const Response = struct { + status: std.http.Status = .internal_server_error, + /// Static: a string literal, never freed. + content_type: []const u8 = "text/plain", + body: ?[]u8 = null, +}; + +/// Listen, then serve on a thread of its own. Everything that can fail happens here, on the +/// caller's thread, so a server that returns is one that listens. +pub fn start(self: *Server) !void { + // Windows has AF_UNIX from 10 1803, and std offers it only to a target whose minimum is that + // (`std.Io.net.has_unix_sockets`); fizzy's build sets it. + if (!Io.net.has_unix_sockets) return error.UnixSocketsUnsupported; + const addr = try Io.net.UnixAddress.init(self.socket_path); + // A socket left by a fizzy that did not quit cleanly, or one a fizzy this one takes over from + // still holds: this run's now. The other leaves it alone at its stop (`socket_inode`). + Io.Dir.deleteFileAbsolute(self.io, self.socket_path) catch {}; + self.listener = try addr.listen(self.io, .{}); + errdefer { + self.listener.?.deinit(self.io); + self.listener = null; + } + if (comptime builtin.os.tag != .windows) { + // This user only. The umask usually says so already; a shared machine's may not. + try Io.Dir.cwd().setFilePermissions(self.io, self.socket_path, .fromMode(0o600), .{}); + // Not on Windows, where the socket is a reparse point `statFile` cannot open: there this + // server leaves its socket behind at `stop`, and the next one deletes it (above). + if (Io.Dir.cwd().statFile(self.io, self.socket_path, .{})) |st| self.socket_inode = st.inode else |_| {} + } + self.accept_task = try self.io.concurrent(run, .{self}); +} + +/// Requests the UI thread should answer now. On the UI thread, each frame; each must be answered. +pub fn take(self: *Server, out: *std.ArrayListUnmanaged(*Request)) void { + self.lock.lockUncancelable(self.io); + defer self.lock.unlock(self.io); + out.appendSlice(self.gpa, self.pending.items) catch return; + self.pending.clearRetainingCapacity(); +} + +pub fn stop(self: *Server) void { + self.stopping.store(true, .release); + { + self.lock.lockUncancelable(self.io); + defer self.lock.unlock(self.io); + // Whatever the UI thread will never answer now, answered: its connection's thread is + // waiting, and writes it before it sees `stopping` and ends. + for (self.pending.items) |r| r.answer(self, .service_unavailable, "text/plain", "fizzy is closing\n"); + self.pending.clearRetainingCapacity(); + // The rest are reading their next request: end those reads. Only the ones still open, + // since shutting down a closed descriptor is a panic in std. + for (self.conns.items) |c| if (!c.done.load(.acquire) and !c.waiting) c.stream.shutdown(self.io, .both) catch {}; + } + if (self.accept_task) |*t| t.cancel(self.io); + self.accept_task = null; + // No more are accepted: wait for each connection's thread, its read ended above. + for (self.conns.items) |c| { + if (c.thread) |t| t.join(); + self.gpa.destroy(c); + } + self.conns.deinit(self.gpa); + if (self.listener) |*l| l.deinit(self.io); + self.listener = null; + if (self.socket_inode) |ours| { + if (Io.Dir.cwd().statFile(self.io, self.socket_path, .{})) |st| { + if (st.inode == ours) Io.Dir.deleteFileAbsolute(self.io, self.socket_path) catch {}; + } else |_| {} + } + self.pending.deinit(self.gpa); +} + +fn run(self: *Server) void { + var listener = self.listener.?; + while (!self.stopping.load(.acquire)) { + const stream = listener.accept(self.io) catch |err| switch (err) { + error.Canceled => return, + // Out of descriptors, say: let it pass rather than spin. Cancelable, like `accept`. + else => { + self.io.sleep(.fromMilliseconds(50), .awake) catch return; + continue; + }, + }; + if (self.stopping.load(.acquire)) { + stream.close(self.io); + return; + } + const conn = self.gpa.create(Conn) catch { + stream.close(self.io); + continue; + }; + self.lock.lockUncancelable(self.io); + defer self.lock.unlock(self.io); + conn.* = .{ .stream = stream }; + self.reap(); + self.conns.append(self.gpa, conn) catch { + stream.close(self.io); + self.gpa.destroy(conn); + continue; + }; + conn.thread = std.Thread.spawn(.{}, serveConn, .{ self, conn }) catch { + _ = self.conns.pop(); + stream.close(self.io); + self.gpa.destroy(conn); + continue; + }; + } +} + +fn serveConn(self: *Server, conn: *Conn) void { + self.serve(conn); + // Closed under `lock`, with `done`, so `stop` never shuts down a descriptor already closed. + self.lock.lockUncancelable(self.io); + defer self.lock.unlock(self.io); + conn.stream.close(self.io); + conn.done.store(true, .release); +} + +/// Join and free connections whose clients have gone. Under `lock`. +fn reap(self: *Server) void { + var i: usize = 0; + while (i < self.conns.items.len) { + const c = self.conns.items[i]; + if (!c.done.load(.acquire)) { + i += 1; + continue; + } + if (c.thread) |t| t.join(); + self.gpa.destroy(c); + _ = self.conns.swapRemove(i); + } +} + +/// Answer without reading the body, then close: nothing after it on the stream can be trusted to +/// be the next request. +fn refuse(head: *std.http.Server.Request, status: std.http.Status, message: []const u8) void { + // Answered without a `100 Continue` first: the client is not told to send what is refused. + head.head.expect = null; + head.respond(message, .{ .status = status, .keep_alive = false }) catch {}; +} + +fn serve(self: *Server, conn: *Conn) void { + var read_buf: [64 * 1024]u8 = undefined; + var write_buf: [16 * 1024]u8 = undefined; + var reader = conn.stream.reader(self.io, &read_buf); + var writer = conn.stream.writer(self.io, &write_buf); + var http: std.http.Server = .init(&reader.interface, &writer.interface); + while (!self.stopping.load(.acquire)) { + var arena_state: std.heap.ArenaAllocator = .init(self.gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var head = http.receiveHead() catch return; + if (head.head.expect) |e| if (!std.ascii.eqlIgnoreCase(e, "100-continue")) + return refuse(&head, .expectation_failed, "only Expect: 100-continue is understood\n"); + if (head.head.content_length) |n| if (n > max_body) + return refuse(&head, .payload_too_large, "request body too large\n"); + // std reads no body for GET and HEAD, so one sent would be taken for the next request. + const sends_body = (head.head.content_length orelse 0) > 0 or head.head.transfer_encoding != .none; + if (sends_body and !head.head.method.requestHasBody()) + return refuse(&head, .bad_request, "a GET or HEAD takes no body\n"); + // Copied before the body is read: reading it invalidates the head's strings. + var req: Request = .{ + .method = head.head.method, + .target = arena.dupe(u8, head.head.target) catch return, + .content_type = if (head.head.content_type) |t| arena.dupe(u8, t) catch return else null, + .body = "", + }; + var body_buf: [4096]u8 = undefined; + const body_reader = head.readerExpectContinue(&body_buf) catch return; + req.body = body_reader.allocRemaining(arena, .limited(max_body)) catch |err| switch (err) { + error.StreamTooLong => return refuse(&head, .payload_too_large, "request body too large\n"), + else => return, + }; + { + self.lock.lockUncancelable(self.io); + defer self.lock.unlock(self.io); + // `stop` answers what is queued before it joins: nothing may be queued after. + if (self.stopping.load(.acquire)) return; + self.pending.append(self.gpa, &req) catch return; + conn.waiting = true; + } + self.wake(); + // Uncancelable: `&req` is queued, and an early return would leave the UI thread writing + // into a frame that is gone. + req.done.waitUncancelable(self.io); + const body = req.response.body orelse "out of memory\n"; + defer if (req.response.body) |b| self.gpa.free(b); + const sent = head.respond(body, .{ + .status = req.response.status, + .extra_headers = &.{.{ .name = "content-type", .value = req.response.content_type }}, + }); + { + self.lock.lockUncancelable(self.io); + defer self.lock.unlock(self.io); + conn.waiting = false; + } + sent catch return; + if (!head.head.keep_alive) return; + } +} + +// ---- tests --------------------------------------------------------------------------------- + +fn noWake() void {} + +const TestClient = struct { + io: Io, + path: []const u8, + request: []const u8, + reply: [8192]u8 = undefined, + reply_len: usize = 0, + + fn run(self: *TestClient) void { + const addr = Io.net.UnixAddress.init(self.path) catch return; + // `start` has returned, so the server listens. + const stream = addr.connect(self.io) catch return; + defer stream.close(self.io); + var wbuf: [1024]u8 = undefined; + var w = stream.writer(self.io, &wbuf); + w.interface.writeAll(self.request) catch return; + w.interface.flush() catch return; + var rbuf: [1024]u8 = undefined; + var r = stream.reader(self.io, &rbuf); + self.reply_len = r.interface.readSliceShort(&self.reply) catch 0; + } +}; + +test "a request reaches the UI thread and its answer goes back" { + if (!Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir = try tmp.dir.realPathFileAlloc(io, ".", gpa); + defer gpa.free(dir); + const path = try std.fs.path.join(gpa, &.{ dir, "c.sock" }); + defer gpa.free(path); + + var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; + try server.start(); + defer server.stop(); + + var client: TestClient = .{ + .io = io, + .path = path, + .request = "POST /commands/test.echo?x=1 HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\n" ++ + "Content-Length: 11\r\nConnection: close\r\n\r\n{\"line\":12}", + }; + const thread = try std.Thread.spawn(.{}, TestClient.run, .{&client}); + + // The UI thread's side: take what came, answer it. + var taken: std.ArrayListUnmanaged(*Request) = .empty; + defer taken.deinit(gpa); + var waited: usize = 0; + while (taken.items.len == 0 and waited < 2000) : (waited += 1) { + server.take(&taken); + if (taken.items.len == 0) io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + try std.testing.expectEqual(@as(usize, 1), taken.items.len); + const req = taken.items[0]; + try std.testing.expectEqual(std.http.Method.POST, req.method); + try std.testing.expectEqualStrings("/commands/test.echo", req.path()); + try std.testing.expectEqualStrings("application/json", req.content_type.?); + try std.testing.expectEqualStrings("{\"line\":12}", req.body); + req.answer(&server, .ok, "application/json", "{\"ok\":true}"); + thread.join(); + + const reply = client.reply[0..client.reply_len]; + try std.testing.expect(std.mem.startsWith(u8, reply, "HTTP/1.1 200 OK\r\n")); + try std.testing.expect(std.mem.indexOf(u8, reply, "content-type: application/json\r\n") != null); + try std.testing.expect(std.mem.endsWith(u8, reply, "\r\n\r\n{\"ok\":true}")); + + // Only this user may open it. + if (builtin.os.tag == .windows) return; + const st = try Io.Dir.cwd().statFile(io, path, .{}); + try std.testing.expectEqual(@as(std.posix.mode_t, 0o600), st.permissions.toMode() & 0o777); +} + +fn testSocket(gpa: std.mem.Allocator, tmp: *std.testing.TmpDir) ![]u8 { + const dir = try tmp.dir.realPathFileAlloc(std.testing.io, ".", gpa); + defer gpa.free(dir); + return std.fs.path.join(gpa, &.{ dir, "c.sock" }); +} + +test "a request still queued at quit is answered: fizzy is closing" { + if (!Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try testSocket(gpa, &tmp); + defer gpa.free(path); + + var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; + try server.start(); + var client: TestClient = .{ .io = io, .path = path, .request = "POST /commands/x HTTP/1.1\r\nHost: x\r\nContent-Length: 0\r\n\r\n" }; + const thread = try std.Thread.spawn(.{}, TestClient.run, .{&client}); + // Queued, and never taken: the app is quitting. + var waited: usize = 0; + while (waited < 2000) : (waited += 1) { + server.lock.lockUncancelable(io); + const n = server.pending.items.len; + server.lock.unlock(io); + if (n == 1) break; + io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + server.stop(); + thread.join(); + const reply = client.reply[0..client.reply_len]; + try std.testing.expect(std.mem.startsWith(u8, reply, "HTTP/1.1 503 Service Unavailable\r\n")); + try std.testing.expect(std.mem.endsWith(u8, reply, "fizzy is closing\n")); +} + +test "what cannot be served is refused before its body, and never reaches the app" { + if (!Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try testSocket(gpa, &tmp); + defer gpa.free(path); + + var server: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; + try server.start(); + defer server.stop(); + const cases = [_]struct { request: []const u8, status: []const u8 }{ + // Too large: refused without a `100 Continue` that would invite the bytes. + .{ .request = "POST /x HTTP/1.1\r\nHost: x\r\nExpect: 100-continue\r\nContent-Length: 999999999\r\n\r\n", .status = "HTTP/1.1 413 " }, + .{ .request = "POST /x HTTP/1.1\r\nHost: x\r\nExpect: something-else\r\nContent-Length: 2\r\n\r\n{}", .status = "HTTP/1.1 417 " }, + // A body on a GET would otherwise be read as the next request. + .{ .request = "GET / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\n\r\nhello", .status = "HTTP/1.1 400 " }, + }; + for (cases) |case| { + var client: TestClient = .{ .io = io, .path = path, .request = case.request }; + client.run(); + const reply = client.reply[0..client.reply_len]; + try std.testing.expect(std.mem.startsWith(u8, reply, case.status)); + try std.testing.expect(std.mem.indexOf(u8, reply, "100 Continue") == null); + } + var taken: std.ArrayListUnmanaged(*Request) = .empty; + defer taken.deinit(gpa); + server.take(&taken); + try std.testing.expectEqual(@as(usize, 0), taken.items.len); +} + +test "a socket path the OS cannot hold fails at start, not later" { + if (!Io.net.has_unix_sockets) return error.SkipZigTest; + const long = "/" ++ "d" ** (Io.net.UnixAddress.max_len + 8); + var server: Server = .{ .gpa = std.testing.allocator, .io = std.testing.io, .socket_path = long, .wake = noWake }; + try std.testing.expectError(error.NameTooLong, server.start()); +} + +test "a server that took over the path keeps it when the old one stops" { + if (!Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try testSocket(gpa, &tmp); + defer gpa.free(path); + + // A restart's handover: the new fizzy listens on the profile's socket while the old runs. + var old: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; + try old.start(); + var new: Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = noWake }; + try new.start(); + defer new.stop(); + // The old one stops without waking, or deleting, the new one's socket. + old.stop(); + + var client: TestClient = .{ .io = io, .path = path, .request = "GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" }; + const thread = try std.Thread.spawn(.{}, TestClient.run, .{&client}); + var taken: std.ArrayListUnmanaged(*Request) = .empty; + defer taken.deinit(gpa); + var waited: usize = 0; + while (taken.items.len == 0 and waited < 2000) : (waited += 1) { + new.take(&taken); + if (taken.items.len == 0) io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + try std.testing.expectEqual(@as(usize, 1), taken.items.len); + taken.items[0].answer(&new, .ok, "text/plain", "new"); + thread.join(); + try std.testing.expect(std.mem.endsWith(u8, client.reply[0..client.reply_len], "\r\n\r\nnew")); +} diff --git a/app/control/json.zig b/app/control/json.zig new file mode 100644 index 000000000..c0a58d974 --- /dev/null +++ b/app/control/json.zig @@ -0,0 +1,185 @@ +//! JSON at the control server's edge, ZON inside: a command's arguments arrive as JSON and go to +//! `Host.callCommand` as ZON, and its ZON result goes back out as JSON. The one place the two +//! meet, so the plugin boundary never sees JSON (plans/CONTROL_SERVER_PLAN.md). +//! +//! The mapping is the obvious one both ways — objects and structs, arrays and tuples, strings, +//! numbers, booleans, null — with one thing JSON cannot say: an enum. Going in, a string is an +//! enum literal where the command's parameter is an enumeration (`enum_keys`); everywhere else it +//! is a string. Going out, an enum literal is its name as a string. A caller who needs an enum +//! deeper than a top-level argument sends ZON instead (`Content-Type: application/zon`). +//! +//! std-only, with a unit-test root of its own (`build/app.zig`). +const std = @import("std"); + +pub const Error = error{ OutOfMemory, NotJson, NotAnObject, NotZon, NotUtf8 }; + +/// `json`, an object of arguments, as a ZON struct literal: `{"line": 12}` → `.{.line=12}`. A +/// top-level string whose key is in `enum_keys` becomes an enum literal. An empty body is no +/// arguments, `""`. +pub fn zonFromJson(arena: std.mem.Allocator, json: []const u8, enum_keys: []const []const u8) Error![]u8 { + if (std.mem.trim(u8, json, " \t\r\n").len == 0) return arena.dupe(u8, ""); + const value = std.json.parseFromSliceLeaky(std.json.Value, arena, json, .{}) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.NotJson, + }; + if (value != .object) return error.NotAnObject; + var out: std.Io.Writer.Allocating = .init(arena); + writeZonObject(&out.writer, value.object, enum_keys) catch return error.OutOfMemory; + return out.written(); +} + +fn writeZonObject(w: *std.Io.Writer, object: std.json.ObjectMap, enum_keys: []const []const u8) std.Io.Writer.Error!void { + try w.writeAll(".{"); + var it = object.iterator(); + var first = true; + while (it.next()) |entry| { + if (!first) try w.writeByte(','); + first = false; + try w.print(".{f}=", .{std.zig.fmtId(entry.key_ptr.*)}); + const as_enum = for (enum_keys) |k| { + if (std.mem.eql(u8, k, entry.key_ptr.*)) break true; + } else false; + if (as_enum and entry.value_ptr.* == .string) { + try w.print(".{f}", .{std.zig.fmtId(entry.value_ptr.string)}); + } else try writeZon(w, entry.value_ptr.*); + } + try w.writeByte('}'); +} + +fn writeZon(w: *std.Io.Writer, value: std.json.Value) std.Io.Writer.Error!void { + switch (value) { + .null => try w.writeAll("null"), + .bool => |b| try w.writeAll(if (b) "true" else "false"), + .integer => |n| try w.print("{d}", .{n}), + // ZON reads `-0` as an integer it refuses; `-0.0` it takes. + .float => |f| if (f == 0 and std.math.signbit(f)) try w.writeAll("-0.0") else try w.print("{d}", .{f}), + .number_string => |s| try w.writeAll(s), + .string => |s| try w.print("\"{f}\"", .{std.zig.fmtString(s)}), + .array => |a| { + try w.writeAll(".{"); + for (a.items, 0..) |item, i| { + if (i > 0) try w.writeByte(','); + try writeZon(w, item); + } + try w.writeByte('}'); + }, + .object => |o| try writeZonObject(w, o, &.{}), + } +} + +/// `zon`, a command's result, as JSON. Empty or absent is `null`. +pub fn jsonFromZon(arena: std.mem.Allocator, zon: ?[]const u8) Error![]u8 { + const text = zon orelse return arena.dupe(u8, "null"); + if (std.mem.trim(u8, text, " \t\r\n").len == 0) return arena.dupe(u8, "null"); + const source = try arena.dupeZ(u8, text); + const ast = try std.zig.Ast.parse(arena, source, .zon); + if (ast.errors.len > 0) return error.NotZon; + const zoir = try std.zig.ZonGen.generate(arena, ast, .{ .parse_str_lits = true }); + if (zoir.hasCompileErrors()) return error.NotZon; + var out: std.Io.Writer.Allocating = .init(arena); + writeJson(&out.writer, zoir, .root) catch |err| return switch (err) { + error.NotUtf8 => error.NotUtf8, + error.WriteFailed => error.OutOfMemory, + }; + return out.written(); +} + +/// JSON strings are UTF-8, and std copies other bytes through as they are: a result holding them +/// is refused rather than sent as a body no client can parse. +fn writeString(w: *std.Io.Writer, s: []const u8) (std.Io.Writer.Error || error{NotUtf8})!void { + if (!std.unicode.utf8ValidateSlice(s)) return error.NotUtf8; + try std.json.Stringify.encodeJsonString(s, .{}, w); +} + +fn writeJson(w: *std.Io.Writer, zoir: std.zig.Zoir, index: std.zig.Zoir.Node.Index) (std.Io.Writer.Error || error{NotUtf8})!void { + switch (index.get(zoir)) { + .true => try w.writeAll("true"), + .false => try w.writeAll("false"), + // JSON has no infinities or NaN. + .null, .pos_inf, .neg_inf, .nan => try w.writeAll("null"), + .int_literal => |n| switch (n) { + .small => |s| try w.print("{d}", .{s}), + .big => |b| try w.print("{f}", .{b}), + }, + // Beyond f64 (`1e400`) is an infinity, which JSON cannot say either. + .float_literal => |f| { + const d: f64 = @floatCast(f); + if (std.math.isFinite(d)) try w.print("{d}", .{d}) else try w.writeAll("null"); + }, + .char_literal => |c| try w.print("{d}", .{c}), + .enum_literal => |name| try writeString(w, name.get(zoir)), + .string_literal => |s| try writeString(w, s), + // `.{}` is an empty struct as much as an empty tuple; an object is the likelier reading + // of a command's result. + .empty_literal => try w.writeAll("{}"), + .array_literal => |range| { + try w.writeByte('['); + for (0..range.len) |i| { + if (i > 0) try w.writeByte(','); + try writeJson(w, zoir, range.at(@intCast(i))); + } + try w.writeByte(']'); + }, + .struct_literal => |s| { + try w.writeByte('{'); + for (s.names, 0..) |name, i| { + if (i > 0) try w.writeByte(','); + try writeString(w, name.get(zoir)); + try w.writeByte(':'); + try writeJson(w, zoir, s.vals.at(@intCast(i))); + } + try w.writeByte('}'); + }, + } +} + +test "arguments: JSON object to a ZON struct literal" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + try std.testing.expectEqualStrings("", try zonFromJson(a, " \n", &.{})); + try std.testing.expectEqualStrings(".{}", try zonFromJson(a, "{}", &.{})); + try std.testing.expectEqualStrings( + ".{.line=12,.path=\"a \\\"b\\\".zig\",.on=true,.none=null,.xs=.{1,2.5},.at=.{.x=1}}", + try zonFromJson(a, "{\"line\":12,\"path\":\"a \\\"b\\\".zig\",\"on\":true,\"none\":null,\"xs\":[1,2.5],\"at\":{\"x\":1}}", &.{}), + ); + // A parameter that is an enumeration takes the string as its tag; a key that needs quoting is. + try std.testing.expectEqualStrings( + ".{.mode=.careful,.label=\"careful\",.@\"two words\"=1}", + try zonFromJson(a, "{\"mode\":\"careful\",\"label\":\"careful\",\"two words\":1}", &.{"mode"}), + ); + try std.testing.expectError(error.NotJson, zonFromJson(a, "{line: 12}", &.{})); + // Negative zero as ZON reads it. + const neg = try zonFromJson(a, "{\"x\":-0.0}", &.{}); + try std.testing.expectEqualStrings(".{.x=-0.0}", neg); + _ = try std.zon.parse.fromSliceAlloc(struct { x: f64 }, a, try a.dupeZ(u8, neg), null, .{}); + try std.testing.expectError(error.NotAnObject, zonFromJson(a, "[1]", &.{})); +} + +test "results: ZON to JSON" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + try std.testing.expectEqualStrings("null", try jsonFromZon(a, null)); + try std.testing.expectEqualStrings("null", try jsonFromZon(a, "")); + try std.testing.expectEqualStrings("{}", try jsonFromZon(a, ".{}")); + try std.testing.expectEqualStrings( + "{\"path\":\"a\\nb\",\"line\":12,\"big\":123456789012345678901234567890,\"f\":0.5,\"mode\":\"careful\",\"xs\":[1,true,null],\"c\":97}", + try jsonFromZon(a, ".{ .path = \"a\\nb\", .line = 12, .big = 123456789012345678901234567890, .f = 0.5, .mode = .careful, .xs = .{ 1, true, null }, .c = 'a' }"), + ); + try std.testing.expectEqualStrings("\"text\"", try jsonFromZon(a, "\"text\"")); + try std.testing.expectError(error.NotZon, jsonFromZon(a, ".{ .x = }")); + // What JSON cannot say: an infinity is null, bytes that are not UTF-8 are refused. + try std.testing.expectEqualStrings("{\"f\":null,\"g\":-1.5}", try jsonFromZon(a, ".{ .f = 1e400, .g = -1.5 }")); + try std.testing.expectError(error.NotUtf8, jsonFromZon(a, ".{ .s = \"\\x00\\xff\" }")); +} + +test "round trip: what goes in comes back" { + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const json = "{\"s\":\"\\u00e9 \\\\ \\t\",\"n\":-3,\"o\":{\"xs\":[]}}"; + const zon = try zonFromJson(a, json, &.{}); + // An empty array comes back as `{}`: ZON's `.{}` does not say which it was. + try std.testing.expectEqualStrings("{\"s\":\"é \\\\ \\t\",\"n\":-3,\"o\":{\"xs\":{}}}", try jsonFromZon(a, zon)); +} diff --git a/app/control/routes.zig b/app/control/routes.zig new file mode 100644 index 000000000..b32d957ab --- /dev/null +++ b/app/control/routes.zig @@ -0,0 +1,195 @@ +//! What the control server answers, on the UI thread: fizzy's identity and its commands +//! (plans/CONTROL_SERVER_PLAN.md, "The API"). The API is the commands every loaded plugin +//! registered, with the parameters each declared; nothing registers a route. +//! +//! GET / the app, its version, the SDK it was built against +//! GET /commands every command: id, title, owner, whether it is enabled, its params +//! GET /commands/{id} one of them +//! POST /commands/{id} run it: the body is its arguments, the response its result +//! +//! Bodies are JSON, converted at this edge (`json.zig`). A request sent as `application/zon` is +//! passed through as ZON and answered in ZON. Errors are HTTP's: 404 for an unknown command, 400 +//! for arguments that do not fit, 409 for a command not enabled now, 500 for one that ran and +//! failed, each with `{"error": "…"}`. +const std = @import("std"); +const sdk = @import("fizzy_sdk"); +const AppInfo = @import("../AppInfo.zig"); +const json = @import("json.zig"); +const Server = @import("Server.zig"); + +pub const Answer = struct { + status: std.http.Status, + /// Static: a string literal. + content_type: []const u8 = "application/json", + body: []const u8, +}; + +const zon_type = "application/zon"; + +/// Answer what waits on `server`. On the UI thread, once a frame, before plugins' `beginFrame`, so +/// a command run here is seen by the frame that follows. +pub fn answerPending(server: *Server, host: *sdk.Host) void { + var taken: std.ArrayListUnmanaged(*Server.Request) = .empty; + defer taken.deinit(server.gpa); + server.take(&taken); + for (taken.items) |req| { + var arena: std.heap.ArenaAllocator = .init(server.gpa); + defer arena.deinit(); + const a = handle(host, req.method, req.path(), req.content_type, req.body, arena.allocator()); + req.answer(server, a.status, a.content_type, a.body); + } +} + +/// Answer one request. Everything returned lives in `arena`. +pub fn handle(host: *sdk.Host, method: std.http.Method, path: []const u8, content_type: ?[]const u8, body: []const u8, arena: std.mem.Allocator) Answer { + return route(host, method, path, content_type, body, arena) catch .{ + .status = .internal_server_error, + .content_type = "text/plain", + .body = "out of memory\n", + }; +} + +fn route(host: *sdk.Host, method: std.http.Method, path: []const u8, content_type: ?[]const u8, body: []const u8, arena: std.mem.Allocator) error{OutOfMemory}!Answer { + const trimmed = std.mem.trimEnd(u8, path, "/"); + if (trimmed.len == 0) { + if (method != .GET and method != .HEAD) return notAllowed(arena, "GET"); + return ok(arena, .{ + .name = AppInfo.current.name, + .version = AppInfo.current.version, + .sdk_version = try std.fmt.allocPrint(arena, "{f}", .{sdk.version.sdk_version}), + }); + } + if (std.mem.eql(u8, trimmed, "/commands")) { + if (method != .GET and method != .HEAD) return notAllowed(arena, "GET"); + var list: std.ArrayListUnmanaged(CommandInfo) = .empty; + for (host.commands.items) |*c| try list.append(arena, try describe(c, arena)); + return ok(arena, list.items); + } + const prefix = "/commands/"; + if (std.mem.startsWith(u8, trimmed, prefix)) { + const id = std.Uri.percentDecodeInPlace(try arena.dupe(u8, trimmed[prefix.len..])); + const c = host.command(id) orelse + return failure(arena, .not_found, try std.fmt.allocPrint(arena, "no command {s}", .{id})); + return switch (method) { + .GET, .HEAD => ok(arena, try describe(c, arena)), + .POST => call(host, c, content_type, body, arena), + else => notAllowed(arena, "GET, POST"), + }; + } + return failure(arena, .not_found, try std.fmt.allocPrint(arena, "nothing at {s}", .{path})); +} + +fn call(host: *sdk.Host, c: *const sdk.Command, content_type: ?[]const u8, body: []const u8, arena: std.mem.Allocator) error{OutOfMemory}!Answer { + const as_zon = isZon(content_type); + const args = if (as_zon) body else args: { + var enum_keys: std.ArrayListUnmanaged([]const u8) = .empty; + for (c.params) |p| if (p.kind == .enumeration) try enum_keys.append(arena, p.key); + break :args json.zonFromJson(arena, body, enum_keys.items) catch |err| return switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.NotAnObject => failure(arena, .bad_request, "the arguments are a JSON object keyed by the command's params"), + else => failure(arena, .bad_request, "the body is not JSON (send ZON as application/zon)"), + }; + }; + switch (host.callCommand(c.id, args, arena)) { + .ok => |result| { + if (as_zon) return .{ .status = .ok, .content_type = zon_type, .body = result orelse "null" }; + const out = json.jsonFromZon(arena, result) catch |err| return switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.NotUtf8 => failure(arena, .internal_server_error, "the command's result holds text that is not UTF-8 (ask for application/zon)"), + else => failure(arena, .internal_server_error, "the command's result is not ZON"), + }; + return .{ .status = .ok, .body = out }; + }, + .unknown => return failure(arena, .not_found, try std.fmt.allocPrint(arena, "no command {s}", .{c.id})), + .disabled => return failure(arena, .conflict, "the command is not enabled now"), + .bad_args => |msg| return failure(arena, .bad_request, msg), + .failed => |msg| return failure(arena, .internal_server_error, msg), + } +} + +fn isZon(content_type: ?[]const u8) bool { + const t = content_type orelse return false; + const bare = std.mem.trim(u8, t[0 .. std.mem.indexOfScalar(u8, t, ';') orelse t.len], " "); + return std.ascii.eqlIgnoreCase(bare, zon_type); +} + +const CommandInfo = struct { + id: []const u8, + title: []const u8, + owner: ?[]const u8, + /// `external`: a person finds it too; `internal`: called by id only. + visibility: []const u8, + enabled: bool, + params: []const ParamInfo, +}; + +const ParamInfo = struct { + key: []const u8, + label: []const u8, + description: []const u8, + /// `bool`, `int`, `float`, `string`, `enum`, `color` or `zon` (anything else ZON can say). + kind: []const u8, + required: bool, + /// The default; absent when required or not simple to write. + default: ?std.json.Value = null, + min: ?f64 = null, + max: ?f64 = null, + choices: ?[]const []const u8 = null, +}; + +fn describe(c: *const sdk.Command, arena: std.mem.Allocator) error{OutOfMemory}!CommandInfo { + const params = try arena.alloc(ParamInfo, c.params.len); + for (c.params, params) |p, *out| { + out.* = .{ + .key = p.key, + .label = p.label, + .description = p.description, + .kind = switch (p.kind) { + .bool => "bool", + .int => "int", + .float => "float", + .string => "string", + .enumeration => "enum", + .color => "color", + .other => "zon", + }, + .required = p.required, + }; + switch (p.kind) { + .int => |k| { + out.min = @floatFromInt(k.min); + out.max = @floatFromInt(k.max); + }, + .float => |k| { + out.min = k.min; + out.max = k.max; + }, + .enumeration => |k| out.choices = k.choices, + else => {}, + } + if (p.default) |d| if (json.jsonFromZon(arena, d)) |text| { + out.default = std.json.parseFromSliceLeaky(std.json.Value, arena, text, .{}) catch null; + } else |_| {}; + } + const enabled = if (c.isEnabled) |f| if (c.owner) |o| f(o.state) else false else true; + return .{ + .id = c.id, + .title = c.title, + .owner = if (c.owner) |o| o.id else null, + .visibility = @tagName(c.visibility), + .enabled = enabled, + .params = params, + }; +} + +fn ok(arena: std.mem.Allocator, value: anytype) error{OutOfMemory}!Answer { + return .{ .status = .ok, .body = try std.json.Stringify.valueAlloc(arena, value, .{ .emit_null_optional_fields = false }) }; +} + +fn failure(arena: std.mem.Allocator, status: std.http.Status, message: []const u8) error{OutOfMemory}!Answer { + return .{ .status = status, .body = try std.json.Stringify.valueAlloc(arena, .{ .@"error" = message }, .{}) }; +} + +fn notAllowed(arena: std.mem.Allocator, allowed: []const u8) error{OutOfMemory}!Answer { + return failure(arena, .method_not_allowed, try std.fmt.allocPrint(arena, "use {s}", .{allowed})); +} diff --git a/app/root.zig b/app/root.zig index 7164e6974..f37618e4f 100644 --- a/app/root.zig +++ b/app/root.zig @@ -105,6 +105,17 @@ pub const update = struct { pub const update_notify = @import("update/update_notify.zig"); }; +/// The control server: HTTP on a Unix socket in the config folder, the app's commands as its API, +/// so `curl --unix-socket` drives the app with no agent (plans/CONTROL_SERVER_PLAN.md). Native +/// only. The app starts a `Server`, calls `routes.answerPending` each frame and stops it at quit. +pub const control = struct { + pub const Server = @import("control/Server.zig"); + pub const routes = @import("control/routes.zig"); + pub const json = @import("control/json.zig"); + /// The socket's name in the config folder. + pub const socket_name = "control.sock"; +}; + /// Quitting the way the app always quits — unsaved documents asked about first — then starting /// again: for a setting the app takes only at launch, and for installing a downloaded `update`. /// The app asks with `request`, runs `tick` each frame and `relaunch` at the end of teardown. @@ -173,5 +184,6 @@ test { _ = store.Loader; _ = Tracks; _ = HistoryTransport; + _ = control.routes; _ = TrimBar; } diff --git a/build.zig b/build.zig index 12df859fc..3b1ffe42d 100644 --- a/build.zig +++ b/build.zig @@ -19,7 +19,18 @@ pub fn build(b: *std.Build) !void { const macos_notary_profile = b.option([]const u8, "macos-notary-profile", "notarytool keychain profile name (run `xcrun notarytool store-credentials <name>` first)") orelse b.graph.environ_map.get("FIZZY_MACOS_NOTARY_PROFILE"); - const target = b.standardTargetOptions(.{}); + // Windows 10 1803 at least: AF_UNIX arrived there, and std offers it only to a target whose + // minimum is that (`std.Io.net.has_unix_sockets`) — the control server's socket + // (`app/control/Server.zig`). Every Windows still supported by Microsoft is newer. + const target = target: { + const asked = b.standardTargetOptions(.{}); + if (asked.result.os.tag != .windows or asked.result.os.isAtLeast(.windows, .win10_rs4) == true) break :target asked; + if (asked.result.os.version_range.windows.max.isAtLeast(.win10_rs4) != true) + std.debug.panic("fizzy needs Windows 10 1803 (win10_rs4) or newer; -Dtarget asks for an older range", .{}); + var query = asked.query; + query.os_version_min = .{ .windows = .win10_rs4 }; + break :target b.resolveTargetQuery(query); + }; const optimize = b.standardOptimizeOption(.{}); const base: @import("build/app.zig").Options = .{ diff --git a/build/app.zig b/build/app.zig index 8a213a70c..4cbeea111 100644 --- a/build/app.zig +++ b/build/app.zig @@ -651,6 +651,12 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. // step a point on its line is. std-only (see history_transport.zig); `HistoryTransport` // draws it. .{ "fizzy-history-transport-tests", "app/history_transport.zig" }, + // The control server's JSON at its edge, ZON inside, and its transport: a request over + // the socket reaching the UI thread and its answer going back. std-only (see + // control/json.zig, control/Server.zig); `control/routes.zig` answers over the Host, + // covered by `tests/integration.zig`. + .{ "fizzy-control-json-tests", "app/control/json.zig" }, + .{ "fizzy-control-server-tests", "app/control/Server.zig" }, // One material on two sliders: what every glass is at the window's opacity and roughness, // the OS's form and the app's. std-only (see glass_look.zig); `LiquidField` and `Popout` // apply it. diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index bbe6004e9..7e72b22f2 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -2269,6 +2269,23 @@ pub fn postInit(editor: *Editor) !void { // `editor.demo` was attached above, at its final address. try editor.app.host.registerService(sdk.services.automation.Api, &editor.demo.service.api, null); + // The control server: the app's commands over HTTP on `<config>/control.sock`, while + // `FIZZY_CONTROL=1` asks for it (plans/CONTROL_SERVER_PLAN.md: dark until step 4). + if (comptime builtin.target.cpu.arch != .wasm32) control: { + const v = std.process.Environ.getAlloc(fizzy.core.platform.processEnviron(), editor.app.gpa, "FIZZY_CONTROL") catch break :control; + defer editor.app.gpa.free(v); + if (!std.mem.eql(u8, v, "1")) break :control; + const path = std.fs.path.join(editor.app.gpa, &.{ editor.app.config_folder, @import("app").control.socket_name }) catch break :control; + editor.app.control = .{ .gpa = editor.app.gpa, .io = dvui.io, .socket_path = path, .wake = wakeEventLoop }; + editor.app.control.?.start() catch |err| { + dvui.log.warn("control: could not listen on {s}: {s}", .{ path, @errorName(err) }); + editor.app.gpa.free(path); + editor.app.control = null; + break :control; + }; + dvui.log.info("control: listening on {s}", .{path}); + } + // Live external-edit reconciliation for settings.zon + dropped-in plugin discovery (see // R11/R12 in plans/PLUGIN_MANIFEST_PLAN.md). Must happen here, in `postInit`, not `init` — // nightwatch retains `&editor.app.settings_watcher.handler`, so `editor` has to already be at @@ -4213,6 +4230,10 @@ pub fn tick(editor: *Editor) !dvui.App.Result { defer t.end(); // What was announced since last frame first, so a plugin's frame sees what it heard. editor.app.host.deliverEvents(); + // Requests from the control server, so the frame that follows sees what they ran. + if (comptime builtin.target.cpu.arch != .wasm32) { + if (editor.app.control) |*c| @import("app").control.routes.answerPending(c, &editor.app.host); + } // A seek replays a tape's commands where nobody watches: not shown as activity. editor.app.host.activity_muted = editor.demo.player.state == .seeking; editor.showClaimed(); @@ -6339,6 +6360,15 @@ pub fn deinit(editor: *Editor) !void { // this function still runs code that would otherwise query it — `saveSettingsRaw` below // reaches `writeMergedSettings`' `notifyPathChanged` (only on a real write, which is why // this crashed rarely rather than always). + // The control server first: what it still holds is answered "closing", before anything a + // command would reach goes. + if (comptime builtin.target.cpu.arch != .wasm32) { + if (editor.app.control) |*c| { + c.stop(); + editor.app.gpa.free(c.socket_path); + editor.app.control = null; + } + } if (editor.document_watcher) |*w| { w.stop(); editor.document_watcher = null; diff --git a/tests/integration.zig b/tests/integration.zig index a178963d8..60e38840b 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -6780,6 +6780,85 @@ test "demo: fizzy's stage runs a command op's arguments through callCommand" { try std.testing.expectEqual(PaletteArgs.Side.right, got.side); } +// The control server's routes over a Host: the commands as the API, JSON in and out, errors as +// HTTP's (plans/CONTROL_SERVER_PLAN.md). The socket half is `app/control/Server.zig`'s own test. +const ControlFixture = struct { + fn place(_: *anyopaque, args: PaletteArgs.Params.Args, call: *sdk.Command.Call) anyerror!void { + if (args.line > 100) return call.fail("line {d} is past the end", .{args.line}); + try call.returns(.{ .line = args.line, .side = args.side, .column = args.column, .note = "placed" }); + } + fn never(_: *anyopaque) bool { + return false; + } + fn nothing(_: *anyopaque) anyerror!void {} +}; + +test "control: commands are listed, described and run over the routes" { + const gpa = std.testing.allocator; + const routes = @import("app").control.routes; + var host: sdk.Host = .init(gpa); + defer host.deinit(); + const vtable = sdk.Plugin.VTable{}; + var state: u8 = 0; + var plugin = sdk.Plugin{ .state = &state, .vtable = &vtable, .id = "t", .display_name = "T" }; + try host.registerCommand(.{ + .id = "t.place", + .owner = &plugin, + .title = "Place", + .params = PaletteArgs.Params.params, + .runWith = PaletteArgs.Params.bind(ControlFixture.place), + }); + try host.registerCommand(.{ .id = "t.off", .owner = &plugin, .title = "Off", .run = ControlFixture.nothing, .isEnabled = ControlFixture.never }); + + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + // Who is answering. + const root = routes.handle(&host, .GET, "/", null, "", arena); + try std.testing.expectEqual(std.http.Status.ok, root.status); + try std.testing.expect(std.mem.indexOf(u8, root.body, "\"sdk_version\":") != null); + + // The listing: each command with its params, an enum's choices and a default. + const list = routes.handle(&host, .GET, "/commands", null, "", arena); + try std.testing.expectEqual(std.http.Status.ok, list.status); + const parsed = try std.json.parseFromSliceLeaky(std.json.Value, arena, list.body, .{}); + try std.testing.expectEqual(@as(usize, 2), parsed.array.items.len); + const place = parsed.array.items[0].object; + try std.testing.expectEqualStrings("t.place", place.get("id").?.string); + try std.testing.expectEqualStrings("t", place.get("owner").?.string); + try std.testing.expect(place.get("enabled").?.bool); + const params = place.get("params").?.array.items; + try std.testing.expectEqualStrings("line", params[0].object.get("key").?.string); + try std.testing.expect(params[0].object.get("required").?.bool); + try std.testing.expectEqualStrings("enum", params[1].object.get("kind").?.string); + try std.testing.expectEqualStrings("right", params[1].object.get("choices").?.array.items[1].string); + try std.testing.expectEqual(@as(i64, 1), params[2].object.get("default").?.integer); + try std.testing.expect(!parsed.array.items[1].object.get("enabled").?.bool); + + // Run it: JSON in (an enum as its name), the result back as JSON. + const ran = routes.handle(&host, .POST, "/commands/t.place", "application/json", "{\"line\":4,\"side\":\"right\"}", arena); + try std.testing.expectEqual(std.http.Status.ok, ran.status); + try std.testing.expectEqualStrings("{\"line\":4,\"side\":\"right\",\"column\":1,\"note\":\"placed\"}", ran.body); + + // ZON in, ZON out. + const zon = routes.handle(&host, .POST, "/commands/t.place", "application/zon", ".{ .line = 5, .side = .left }", arena); + try std.testing.expectEqual(std.http.Status.ok, zon.status); + try std.testing.expectEqualStrings("application/zon", zon.content_type); + try std.testing.expect(std.mem.indexOf(u8, zon.body, ".side = .left") != null or std.mem.indexOf(u8, zon.body, ".side=.left") != null); + + // Errors as HTTP's. + try std.testing.expectEqual(std.http.Status.not_found, routes.handle(&host, .POST, "/commands/t.missing", null, "", arena).status); + try std.testing.expectEqual(std.http.Status.not_found, routes.handle(&host, .GET, "/nowhere", null, "", arena).status); + try std.testing.expectEqual(std.http.Status.bad_request, routes.handle(&host, .POST, "/commands/t.place", null, "{\"side\":\"right\"}", arena).status); + try std.testing.expectEqual(std.http.Status.bad_request, routes.handle(&host, .POST, "/commands/t.place", null, "not json", arena).status); + try std.testing.expectEqual(std.http.Status.conflict, routes.handle(&host, .POST, "/commands/t.off", null, "", arena).status); + try std.testing.expectEqual(std.http.Status.method_not_allowed, routes.handle(&host, .DELETE, "/commands/t.place", null, "", arena).status); + const failed = routes.handle(&host, .POST, "/commands/t.place", null, "{\"line\":200,\"side\":\"left\"}", arena); + try std.testing.expectEqual(std.http.Status.internal_server_error, failed.status); + try std.testing.expectEqualStrings("{\"error\":\"line 200 is past the end\"}", failed.body); +} + test "a failed open is not a load in flight: settled and live tapes go on" { const gpa = std.testing.allocator; const Openings = @FieldType(fizzy.Editor, "openings");