From 2a5f07b1015a1c7e6d8354a7080b332f41779444 Mon Sep 17 00:00:00 2001 From: "fizzy-linux[bot]" <346940526+fizzy-linux[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:17:36 -0500 Subject: [PATCH 1/4] linux: work-589 (codex) Agent-Team: linux Worker: codex --- app/settings/Settings.zig | 5 -- app/settings/SettingsMigration.zig | 33 +++++++++ build.zig | 1 + build/app.zig | 10 +++ core/core.zig | 2 + core/person.zig | 66 ++++++++++++++++++ src/editor/Editor.zig | 50 +++++++++++++- src/editor/explorer/settings.zig | 19 ++++-- tests/integration.zig | 103 +++++++++++++++++++++++++++++ web/fizzy-worker.js | 15 ++++- web/fizzy.js | 18 +++-- web/tests/embed.test.mjs | 38 ++++++++++- 12 files changed, 340 insertions(+), 20 deletions(-) create mode 100644 core/person.zig diff --git a/app/settings/Settings.zig b/app/settings/Settings.zig index 83ae05ac7..3d224fa29 100644 --- a/app/settings/Settings.zig +++ b/app/settings/Settings.zig @@ -77,11 +77,6 @@ modal_dim: f32 = 0.0, /// theme's own corner. All the way round by default, on every platform. corner_roundness: f32 = 1.0, -/// How the interface moves, 0 to 1: 0 is off (nothing animates), up to 0.5 (minimal) plain even -/// motion, toward 1 (playful) an overshoot past the target and back, arriving on time throughout. -/// Published each frame through `core.motion`, which every animation reads. Playful by default. -motion: f32 = 1, - /// How fast the interface moves, 0 (slow) to 1 (fast): half as fast to twice as fast as written, /// 0.5 as written. Never stops motion — that is `motion` at 0. motion_speed: f32 = core.motion.default_speed, diff --git a/app/settings/SettingsMigration.zig b/app/settings/SettingsMigration.zig index b2c37a286..9f98e8ed5 100644 --- a/app/settings/SettingsMigration.zig +++ b/app/settings/SettingsMigration.zig @@ -267,3 +267,36 @@ pub fn windowGlass(allocator: std.mem.Allocator, data: [:0]const u8, settings: * settings.window_opacity_light = std.math.clamp(o, 0, 1); }; } + +const LegacyMotion = struct { + motion: ?f32 = null, +}; + +/// Moves the app's saved level into the shared preferences without replacing a person's choice. +/// Persists the shared file before dropping the field, so a failed write can be retried. +pub fn personMotion(gpa: std.mem.Allocator, io: std.Io, settings_path: []const u8, person_dir: []const u8) !void { + const data = core.fs.readZ(gpa, io, settings_path) catch return; + defer gpa.free(data); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + var diag: std.zon.parse.Diagnostics = undefined; + const legacy = std.zon.parse.fromSlice(LegacyMotion, .{ + .gpa = gpa, + .arena = arena.allocator(), + .source = data, + .diagnostics = &diag, + .ignore_unknown_fields = true, + }) catch return; + const level = legacy.motion orelse return; + var person = core.person.load(io, person_dir); + if (person.motion == null) { + person.motion = level; + try core.person.save(io, person_dir, person); + } + const parsed = try Settings.parseOnly(gpa, arena.allocator(), data); + const own = try Settings.serialize(&parsed, gpa); + defer gpa.free(own); + const composed = try SettingsPluginsZon.composeMergedText(gpa, own, data, &.{}); + defer gpa.free(composed); + try core.fs.write(io, settings_path, composed); +} diff --git a/build.zig b/build.zig index 27fa52d9c..e239eeb17 100644 --- a/build.zig +++ b/build.zig @@ -81,6 +81,7 @@ pub fn build(b: *std.Build) !void { const optimize = b.standardOptimizeOption(.{}); const base: @import("build/app.zig").Options = .{ + .look = .{ .motion = b.option(f32, "look-motion", "Initial motion level until the person chooses one") }, .windows_msvc_libc_opt = windows_msvc_libc_opt, .fetch_msvc_opt = fetch_msvc_opt, .macos_sign_app_identity = macos_sign_app_identity, diff --git a/build/app.zig b/build/app.zig index a32d14856..0c73dd825 100644 --- a/build/app.zig +++ b/build/app.zig @@ -33,7 +33,14 @@ pub const WebPluginDir = struct { /// The type of `Chrome.sidebar_edge`, for the build option. const ChromeEdge = enum { left, right }; +/// Initial presentation preferences, used until the person makes a shared choice. +pub const Look = struct { + /// Motion level from 0 (off) to 1 (playful). The OS reduced-motion request wins. + motion: ?f32 = null, +}; + pub const Options = struct { + look: Look = .{}, /// Every plugin the application links, fizzy's own among them, each in the executables its /// `targets` names — see `build.zig`'s `buildApp`. app_plugins: []const @import("sdk.zig").BundledPlugin = &@import("plugins.zig").entries.all, @@ -490,6 +497,7 @@ pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options build_opts.addOption(bool, "app_repo_url_set", app_repo_url_set); build_opts.addOption([]const u8, "app_issues_url", app_issues_url); build_opts.addOption(bool, "velopack_enabled", velopack_enabled); + build_opts.addOption(?f32, "look_motion", opts.look.motion); // 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. @@ -807,6 +815,7 @@ fn native( pack_opts.addOption(bool, "app_repo_url_set", cfg.app_repo_url_set); pack_opts.addOption([]const u8, "app_issues_url", cfg.app_issues_url); pack_opts.addOption(bool, "velopack_enabled", true); + pack_opts.addOption(?f32, "look_motion", opts.look.motion); pack_opts.addOption(bool, "static_workbench", static_workbench); pack_opts.addOption(bool, "static_text", static_text); pack_opts.addOption(bool, "static_image", static_image); @@ -1098,6 +1107,7 @@ fn addTests( // Hold / slop / tap rules of touch selection. std-only (see TouchHold.zig) — the dvui // half is widgets/SelectableText.zig. .{ "fizzy-touch-hold-tests", "core/TouchHold.zig" }, + .{ "fizzy-person-tests", "core/person.zig" }, // A motion's clock stepped by its frames, a long one held to a step. std-only (see // FrameClock.zig); the floats' landing and ghost run on it. .{ "fizzy-frame-clock-tests", "backend/src/glass/FrameClock.zig" }, diff --git a/core/core.zig b/core/core.zig index e5d0e6e2d..3594b8994 100644 --- a/core/core.zig +++ b/core/core.zig @@ -139,3 +139,5 @@ pub const transport = struct { /// Fixed Fizzy accent colours — theme-independent. See `palette.zig`. pub const palette = @import("palette.zig"); + +pub const person = @import("person.zig"); diff --git a/core/person.zig b/core/person.zig new file mode 100644 index 000000000..0495ca80b --- /dev/null +++ b/core/person.zig @@ -0,0 +1,66 @@ +//! The person's preferences, shared by every application built on fizzy. +const std = @import("std"); +const builtin = @import("builtin"); +const fs = @import("fs.zig"); + +pub const Person = struct { + motion: ?f32 = null, +}; + +/// Missing or invalid preferences leave the application's defaults in effect. +pub fn load(io: std.Io, dir: []const u8) Person { + // Preferences contain one scalar; bounded scratch also bounds malformed input. + var scratch: [8192]u8 = undefined; + var fixed = std.heap.FixedBufferAllocator.init(&scratch); + const gpa = fixed.allocator(); + const path = if (builtin.target.cpu.arch == .wasm32) "fizzyedit.person" else std.fs.path.join(gpa, &.{ dir, "person.zon" }) catch return .{}; + const data = fs.readZ(gpa, io, path) catch return .{}; + var diag: std.zon.parse.Diagnostics = undefined; + const person = std.zon.parse.fromSlice(Person, .{ .gpa = gpa, .arena = gpa, .source = data, .diagnostics = &diag }) catch return .{}; + if (person.motion) |level| if (!std.math.isFinite(level) or level < 0 or level > 1) return .{}; + return person; +} + +/// Atomically replaces the preferences. Creates `dir` if needed on desktop. +pub fn save(io: std.Io, dir: []const u8, person: Person) !void { + var buffer: [128]u8 = undefined; + const data = if (person.motion) |level| + try std.mem.print(&buffer, ".{{ .motion = {d} }}", .{level}) + else + ".{}"; + if (builtin.target.cpu.arch == .wasm32) return fs.write(io, "fizzyedit.person", data); + const cwd = std.Io.Dir.cwd(); + try cwd.createDirPath(io, dir); + var folder = try cwd.openDir(io, dir, .{}); + defer folder.close(io); + var atomic = try folder.createFileAtomic(io, "person.zon", .{ .replace = true }); + defer atomic.deinit(io); + try atomic.file.writeStreamingAll(io, data); + try atomic.replace(io); +} + +/// The person's choice supersedes an application's initial look. +pub fn motionLevel(person: Person, app_default: ?f32) f32 { + return person.motion orelse app_default orelse 1; +} + +test "preferences round trip, invalid files, and atomic replacement" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + const dir = try tmp.dir.realPathFileAlloc(io, ".", std.testing.allocator); + defer std.testing.allocator.free(dir); + try std.testing.expectEqual(Person{}, load(io, dir)); + try tmp.dir.writeFile(io, .{ .sub_path = "person.zon", .data = "garbage" }); + try std.testing.expectEqual(Person{}, load(io, dir)); + try save(io, dir, .{ .motion = 0.4 }); + try std.testing.expectEqual(@as(?f32, 0.4), load(io, dir).motion); + try save(io, dir, .{ .motion = 0.8 }); + var iter = tmp.dir.iterate(); + const entry = (try iter.next(io)).?; + try std.testing.expectEqualStrings("person.zon", entry.name); + try std.testing.expectEqual(@as(?std.Io.Dir.Entry, null), try iter.next(io)); + try std.testing.expectEqual(@as(f32, 0.3), motionLevel(.{}, 0.3)); + try std.testing.expectEqual(@as(f32, 0.8), motionLevel(load(io, dir), 0.3)); + try std.testing.expectEqual(@as(f32, 0.8), motionLevel(load(io, dir), 1)); +} diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 83046ce1a..ebf27065e 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -210,6 +210,11 @@ chrome: Chrome = .configured, /// answer. That is fine for deciding who owns a clipboard verb: focus doesn't change between /// the keystroke and the frame that handles it. text_input_focused: bool = false, +person: core.person.Person = .{}, +person_dir: []const u8, +person_mtime: ?i128 = null, +person_app_active: bool = false, +person_focus_epoch: u32 = 0, /// Whether any plugin asked to keep painting this frame — one poll of every plugin's /// `needsContinuousRepaint`, sampled in `tick` and read by everything that needs the answer. @@ -456,6 +461,7 @@ pub fn init( .file_table = .init(app.allocator, dvui.io), .secrets = try .init(app.allocator, dvui.io, config_folder), }, + .person_dir = try std.fs.path.join(app.allocator, &.{ config_root, "fizzyedit" }), .explorer = try app.allocator.create(Explorer), .infobar = try .init(), .last_titlebar_color = dvui.themeGet().color(.control, .fill), @@ -478,6 +484,9 @@ pub fn init( { const settings_path = try std.fs.path.join(app.allocator, &.{ editor.app.config_folder, "settings.zon" }); defer app.allocator.free(settings_path); + try @import("app").settings.Migration.personMotion(app.allocator, dvui.io, settings_path, editor.person_dir); + editor.person = core.person.load(dvui.io, editor.person_dir); + editor.person_mtime = editor.personMtime(); editor.app.settings = try Settings.load(app.allocator, settings_path, plugins_dir); } @@ -3933,7 +3942,6 @@ pub fn reconcileExternalSettingsChange(editor: *Editor) void { editor.app.settings.content_opacity = parsed.content_opacity; editor.app.settings.modal_dim = parsed.modal_dim; editor.app.settings.corner_roundness = parsed.corner_roundness; - editor.app.settings.motion = parsed.motion; editor.app.settings.motion_speed = parsed.motion_speed; editor.app.settings.glass_shader = parsed.glass_shader; editor.app.settings.float_windows = parsed.float_windows; @@ -4375,7 +4383,44 @@ pub fn restartPending(_: *const Editor) bool { return Popout.restartPending(); } +fn personMtime(editor: *const Editor) ?i128 { + if (comptime builtin.target.cpu.arch == .wasm32) return null; + const path = std.fs.path.join(editor.app.gpa, &.{ editor.person_dir, "person.zon" }) catch return null; + defer editor.app.gpa.free(path); + const stat = std.Io.Dir.cwd().statFile(dvui.io, path, .{}) catch return null; + return stat.mtime.nanoseconds; +} + +/// Re-reads shared preferences on focus when another app has replaced the file. +pub fn reloadPerson(editor: *Editor) void { + const mtime = editor.personMtime(); + if (comptime builtin.target.cpu.arch == .wasm32) { + editor.person = core.person.load(dvui.io, editor.person_dir); + } else if (mtime != editor.person_mtime) { + editor.person = core.person.load(dvui.io, editor.person_dir); + } + editor.person_mtime = mtime; +} + +pub fn savePerson(editor: *Editor) void { + core.person.save(dvui.io, editor.person_dir, editor.person) catch |err| { + dvui.log.err("Could not save person preferences: {s}", .{@errorName(err)}); + return; + }; + editor.person_mtime = editor.personMtime(); +} + pub fn tick(editor: *Editor) !dvui.App.Result { + const web_focus = struct { + extern "fizzy" fn fizzy_web_focus_epoch() u32; + }; + const epoch = if (comptime builtin.target.cpu.arch == .wasm32) web_focus.fizzy_web_focus_epoch() else 0; + const active = if (comptime builtin.target.cpu.arch != .wasm32 and @hasDecl(@import("backend"), "appActive")) @import("backend").appActive() else true; + if ((active and !editor.person_app_active) or epoch != editor.person_focus_epoch) { + editor.reloadPerson(); + } + editor.person_app_active = active; + editor.person_focus_epoch = epoch; // (A playing demo has already had its turn: `Entry.frameOnce` runs `Demo.frame` first.) // A restart handing the window to the new instance: before anything reads this frame's input. if (comptime restart.supported) editor.driveHandover(); @@ -4434,7 +4479,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // A demo replaying to a seek wants every frame to land where it is going: no motion — but the // glass keeps its edge, the transport's among it, scrubbed or not. const playback: f32 = if (editor.demo.player.state == .playing) editor.demo.player.seq.speed else 1; - fizzy.core.motion.publish(editor.app.settings.motion, editor.app.settings.motion_speed, dvui.currentWindow().backend.prefersReducedMotion(), editor.demo.fast, playback); + fizzy.core.motion.publish(core.person.motionLevel(editor.person, build_opts.look_motion), editor.app.settings.motion_speed, dvui.currentWindow().backend.prefersReducedMotion(), editor.demo.fast, playback); 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`). @@ -7289,6 +7334,7 @@ pub fn rawCloseFileID(editor: *Editor, id: u64) !void { } pub fn deinit(editor: *Editor) !void { + defer editor.app.gpa.free(editor.person_dir); // Freed however this ends: the fallible steps below may return early. defer editor.app.deinit(); // A demo playing at quit leaves the user's layout and settings as `Demo.begin` flushed them: diff --git a/src/editor/explorer/settings.zig b/src/editor/explorer/settings.zig index 65dd8cb44..4f7fb0c6d 100644 --- a/src/editor/explorer/settings.zig +++ b/src/editor/explorer/settings.zig @@ -12,6 +12,7 @@ //! by `PluginSettingsPane` (see `SettingsTree`), so a plugin's controls look identical to these. const builtin = @import("builtin"); const std = @import("std"); +const build_opts = @import("build_opts"); const fizzy = @import("../../fizzy.zig"); const icons = @import("icons"); @@ -558,14 +559,24 @@ fn drawCornerRoundness() void { } fn drawMotion() void { - const settings = &fizzy.editor().app.settings; + const editor = fizzy.editor(); + var level = fizzy.core.person.motionLevel(editor.person, build_opts.look_motion); + if (editor.person.motion == null) dvui.label(@src(), "System default ({d:0.01})", .{level}, .{}); + dvui.labelNoFmt(@src(), "Applies to every fizzy app.", .{}, .{}); + if (dvui.button(@src(), "Reset to system default", .{}, .{})) { + editor.person.motion = null; + level = fizzy.core.person.motionLevel(editor.person, build_opts.look_motion); + editor.savePerson(); + dvui.refresh(null, @src(), null); + } if (dvui.sliderEntry(@src(), "{d:0.01}", .{ - .value = &settings.motion, + .value = &level, .interval = 0.01, .max = 1.0, .min = 0.0, }, .{ .expand = .horizontal })) { - fizzy.editor().markSettingsDirty(); + editor.person.motion = level; + editor.savePerson(); dvui.refresh(null, @src(), null); } // The three names along the slider's length, the one it is nearest lit. @@ -574,7 +585,7 @@ fn drawMotion() void { const names = [_][]const u8{ "Off", "Minimal", "Playful" }; const at = [_]f32{ 0, 0.5, 1 }; const system_off = dvui.currentWindow().backend.prefersReducedMotion(); - const v: f32 = if (system_off) 0 else settings.motion; + const v: f32 = if (system_off) 0 else level; var nearest: usize = 0; for (at, 0..) |a, i| { if (@abs(v - a) < @abs(v - at[nearest])) nearest = i; diff --git a/tests/integration.zig b/tests/integration.zig index ee771f165..6c6f172ed 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -13820,3 +13820,106 @@ test "mobile sheets: turning the window with a sheet open shuts it, in both dire try dvui.testing.settle(MobileFrame.frame); try std.testing.expectApproxEqAbs(1024 * 0.6 + 16, regionRect(editor, "Panel").?.h, 1); } + +test "motion: migration and focus share the person's choice across app configs" { + var ctx = try shim.init(std.testing.allocator); + defer ctx.deinit(std.testing.allocator); + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realPathFileAlloc(io, ".", gpa); + defer gpa.free(root); + const person_dir = try std.fs.path.join(gpa, &.{ root, "person" }); + defer gpa.free(person_dir); + const Migration = @import("app").settings.Migration; + try tmp.dir.createDirPath(io, "first"); + try tmp.dir.createDirPath(io, "second"); + const first = try std.fs.path.join(gpa, &.{ root, "first", "settings.zon" }); + defer gpa.free(first); + const second = try std.fs.path.join(gpa, &.{ root, "second", "settings.zon" }); + defer gpa.free(second); + try fizzy.core.fs.write(io, first, ".{ .motion = 0.6 }"); + try Migration.personMotion(gpa, io, first, person_dir); + try std.testing.expectEqual(@as(?f32, 0.6), fizzy.core.person.load(io, person_dir).motion); + const migrated = try fizzy.core.fs.read(gpa, io, first); + defer gpa.free(migrated); + try std.testing.expect(std.mem.find(u8, migrated, ".motion =") == null); + + try fizzy.core.person.save(io, person_dir, .{ .motion = 0.2 }); + try fizzy.core.fs.write(io, second, ".{ .motion = 0.6 }"); + try Migration.personMotion(gpa, io, second, person_dir); + try std.testing.expectEqual(@as(?f32, 0.2), fizzy.core.person.load(io, person_dir).motion); + + const editor = ctx.editor; + editor.app.gpa = gpa; + editor.person_dir = person_dir; + editor.person_mtime = null; + editor.reloadPerson(); + try std.testing.expectEqual(@as(f32, 0.2), fizzy.core.person.motionLevel(editor.person, 1)); + try fizzy.core.person.save(io, person_dir, .{ .motion = 0.8 }); + editor.reloadPerson(); + try std.testing.expectEqual(@as(f32, 0.8), fizzy.core.person.motionLevel(editor.person, 0.3)); + const M = fizzy.core.motion; + M.publish(fizzy.core.person.motionLevel(.{}, 0.3), M.default_speed, false, false, 1); + try std.testing.expectEqual(@as(f32, 0.3), M.level()); + for ([_]f32{ 0.3, 1 }) |app_level| { + M.publish(fizzy.core.person.motionLevel(editor.person, app_level), M.default_speed, false, false, 1); + try std.testing.expectEqual(@as(f32, 0.8), M.level()); + } + for ([_]?f32{ null, 0, 0.8, 1 }) |level| { + M.publish(fizzy.core.person.motionLevel(.{ .motion = level }, 1), M.default_speed, true, false, 1); + try std.testing.expect(M.off()); + } +} + +test "motion: settings reset saves null and draws the shared default" { + var ctx = try shim.init(std.testing.allocator); + defer ctx.deinit(std.testing.allocator); + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir = try tmp.dir.realPathFileAlloc(std.testing.io, ".", gpa); + defer gpa.free(dir); + ctx.editor.app.gpa = gpa; + ctx.editor.person_dir = dir; + ctx.editor.person.motion = 0.8; + const frame = struct { + fn draw() !dvui.App.Result { + var box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal }); + defer box.deinit(); + for (fizzy.Explorer.settings.groups) |group| for (group.items) |item| { + if (std.mem.eql(u8, item.key, "motion")) item.draw.?(); + }; + return .ok; + } + }.draw; + dvui.debug.captureFrame(); + defer dvui.debug.clearCaptures(dvui.currentWindow().gpa); + _ = try dvui.testing.step(frame); + _ = try dvui.testing.step(frame); + var reset: ?dvui.Point.Physical = null; + for (dvui.debug.lastCapture().?.widgets.items) |widget| { + if (widget.text) |text| if (std.mem.eql(u8, text, "Reset to system default")) { + reset = widget.rect_border.center(); + }; + } + _ = try dvui.currentWindow().addEventMouseMotion(.{ .pt = reset orelse return error.TestUnexpectedResult }); + try dvui.testing.click(.left); + _ = try dvui.testing.step(frame); + try std.testing.expectEqual(@as(?f32, null), ctx.editor.person.motion); + try std.testing.expectEqual(@as(?f32, null), fizzy.core.person.load(std.testing.io, dir).motion); + dvui.debug.captureFrame(); + _ = try dvui.testing.step(frame); + _ = try dvui.testing.step(frame); + var default_shown = false; + var shared_shown = false; + for (dvui.debug.lastCapture().?.widgets.items) |widget| { + if (widget.text) |text| { + if (std.mem.startsWith(u8, text, "System default (")) default_shown = true; + if (std.mem.eql(u8, text, "Applies to every fizzy app.")) shared_shown = true; + } + } + try std.testing.expect(default_shown); + try std.testing.expect(shared_shown); +} diff --git a/web/fizzy-worker.js b/web/fizzy-worker.js index 35979aab5..fa8d6b594 100644 --- a/web/fizzy-worker.js +++ b/web/fizzy-worker.js @@ -69,6 +69,8 @@ const functionTable = new WebAssembly.Table({ initial: 4096, element: "anyfunc" // // A worker has no `localStorage`, and the app reads its settings synchronously. The page sends // the entries filed under its `storageName` at startup; writes land here at once and go back to the page to keep. +let focusEpoch = 0; +const fileKey = (path) => path === "fizzyedit.person" ? path : env.storageName + ".file:" + path; function storageGet(key) { return Object.prototype.hasOwnProperty.call(env.storage, key) ? env.storage[key] : null; } @@ -222,18 +224,19 @@ const fizzyImports = (target) => ({ fizzy_web_now_real_ms() { return Date.now(); }, + fizzy_web_focus_epoch() { return focusEpoch; }, fizzy_web_storage_get(keyPtr, keyLen, bufPtr, bufLen) { - const value = storageGet(env.storageName + ".file:" + str(keyPtr, keyLen)); + const value = storageGet(fileKey(str(keyPtr, keyLen))); if (value === null) return 0xffffffff; const bytes = utf8encode(value); if (bytes.length <= bufLen) new Uint8Array(mem(), bufPtr, bytes.length).set(bytes); return bytes.length; }, fizzy_web_storage_set(keyPtr, keyLen, valPtr, valLen) { - storageSet(env.storageName + ".file:" + str(keyPtr, keyLen), str(valPtr, valLen)); + storageSet(fileKey(str(keyPtr, keyLen)), str(valPtr, valLen)); }, fizzy_web_storage_remove(keyPtr, keyLen) { - storageRemove(env.storageName + ".file:" + str(keyPtr, keyLen)); + storageRemove(fileKey(str(keyPtr, keyLen))); }, fizzy_web_plugin_fingerprint(fpPtr, fpLen) { pluginFingerprint = str(fpPtr, fpLen); @@ -713,6 +716,12 @@ onmessage = (e) => { case "init": start(m).catch((err) => postMessage({ type: "error", message: err instanceof Error ? err.message : String(err) })); break; + case "personFocus": + if (m.value === null) delete env.storage["fizzyedit.person"]; + else env.storage["fizzyedit.person"] = m.value; + focusEpoch++; + if (dvuiApp) dvuiApp.requestRender(); + break; case "size": env.size = m.size; applyForm(); diff --git a/web/fizzy.js b/web/fizzy.js index 7cc010c9f..d2b5f13cd 100644 --- a/web/fizzy.js +++ b/web/fizzy.js @@ -98,6 +98,12 @@ export class Fizzy { run() { const opts = this.opts; const storageName = this.storageName; + let focusEpoch = 0; + window.addEventListener("focus", () => { + focusEpoch++; + if (dvuiApp) dvuiApp.requestRender(); + }); + const fileKey = (path) => path === "fizzyedit.person" ? path : storageName + ".file:" + path; const persists = opts.persist !== false; const onReady = opts.onReady ?? (() => {}); const onError = opts.onError ?? (() => {}); @@ -310,8 +316,9 @@ export class Fizzy { }, // `core.fs` on the web: the app's own small files (settings.zon, recents.zon, // keybinds.zon) live in localStorage, keyed by the path the app would have used. + fizzy_web_focus_epoch() { return focusEpoch; }, fizzy_web_storage_get(keyPtr, keyLen, bufPtr, bufLen) { - const key = storageName + ".file:" + utf8.decode(new Uint8Array(wasmInstance.exports.memory.buffer, keyPtr, keyLen)); + const key = fileKey(utf8.decode(new Uint8Array(wasmInstance.exports.memory.buffer, keyPtr, keyLen))); let value = null; try { value = store.getItem(key); } catch (_) {} if (value === null) return 0xffffffff; @@ -321,12 +328,12 @@ export class Fizzy { }, fizzy_web_storage_set(keyPtr, keyLen, valPtr, valLen) { const mem = wasmInstance.exports.memory.buffer; - const key = storageName + ".file:" + utf8.decode(new Uint8Array(mem, keyPtr, keyLen)); + const key = fileKey(utf8.decode(new Uint8Array(mem, keyPtr, keyLen))); const value = utf8.decode(new Uint8Array(mem, valPtr, valLen)); try { store.setItem(key, value); } catch (err) { console.warn("fizzy: could not store", key, err); } }, fizzy_web_storage_remove(keyPtr, keyLen) { - const key = storageName + ".file:" + utf8.decode(new Uint8Array(wasmInstance.exports.memory.buffer, keyPtr, keyLen)); + const key = fileKey(utf8.decode(new Uint8Array(wasmInstance.exports.memory.buffer, keyPtr, keyLen))); try { store.removeItem(key); } catch (_) {} }, // What the page remembers for an id, so the host can load it again after a disable @@ -947,10 +954,13 @@ export class Fizzy { reduce: media("(prefers-reduced-motion: reduce)"), }); const storage = {}; + window.addEventListener("focus", () => { + try { post({ type: "personFocus", value: store.getItem("fizzyedit.person") }); } catch (_) {} + }); try { for (let i = 0; i < store.length; i++) { const key = store.key(i); - if (key && key.startsWith(storageName + ".")) storage[key] = store.getItem(key); + if (key && (key === "fizzyedit.person" || key.startsWith(storageName + "."))) storage[key] = store.getItem(key); } } catch (_) {} diff --git a/web/tests/embed.test.mjs b/web/tests/embed.test.mjs index 5246d9663..7d0aaaa2a 100644 --- a/web/tests/embed.test.mjs +++ b/web/tests/embed.test.mjs @@ -70,6 +70,7 @@ let fetches = 0; let compiles = 0; const bound = []; const toggles = []; +const storageApis = []; const memory = new WebAssembly.Memory({ initial: 1 }); globalThis.fetch = (url) => { fetches++; return Promise.resolve({ url }); }; WebAssembly.compileStreaming = async () => { compiles++; return { fake: true }; }; @@ -77,6 +78,7 @@ WebAssembly.instantiate = async (module, imports) => { const id = bound.length; bound.push([]); toggles.push(imports.fizzy.fizzy_web_toggle_fullscreen); + storageApis.push(imports.fizzy); return { exports: { memory: { buffer: memory.buffer }, @@ -89,11 +91,11 @@ WebAssembly.Table = class {}; const { Fizzy } = await import(pathToFileURL(path.join(dir, "fizzy.js")).href); -const mount = async (parent) => { +const mount = async (parent, options = {}) => { const canvas = document.createElement("canvas"); canvas.parentElement = parent; const ready = new Promise((resolve, reject) => { - new Fizzy({ canvas, worker: false, persist: false, onReady: resolve, onError: reject, onInstance: () => {} }).run(); + new Fizzy({ canvas, worker: false, persist: false, ...options, onReady: resolve, onError: reject, onInstance: () => {} }).run(); }); await ready; return canvas; @@ -133,4 +135,36 @@ toggles[1](); assert.equal(document.fullscreenElement, parentB, "the other copy takes full screen for itself"); toggles[1](); assert.equal(document.fullscreenElement, null, "and a second toggle leaves it"); +// Independent app namespaces share only the person's preferences. +const stored = new Map(); +window.localStorage = { + getItem: (key) => stored.get(key) ?? null, + setItem: (key, value) => stored.set(key, value), + removeItem: (key) => stored.delete(key), + key: (i) => [...stored.keys()][i] ?? null, + get length() { return stored.size; }, +}; +await mount(new Element(), { persist: true, storage: "left" }); +await mount(new Element(), { persist: true, storage: "right" }); +const encoder = new TextEncoder(); +const put = (api, key, value) => { + const k = encoder.encode(key), v = encoder.encode(value); + new Uint8Array(memory.buffer, 0, k.length).set(k); + new Uint8Array(memory.buffer, 256, v.length).set(v); + api.fizzy_web_storage_set(0, k.length, 256, v.length); +}; +const get = (api, key) => { + const k = encoder.encode(key); + new Uint8Array(memory.buffer, 0, k.length).set(k); + const n = api.fizzy_web_storage_get(0, k.length, 512, 256); + return n === 0xffffffff ? null : new TextDecoder().decode(new Uint8Array(memory.buffer, 512, n)); +}; +put(storageApis[2], "fizzyedit.person", ".{ .motion = 0.4 }"); +assert.equal(stored.get("fizzyedit.person"), ".{ .motion = 0.4 }"); +assert.equal(get(storageApis[3], "fizzyedit.person"), ".{ .motion = 0.4 }"); +put(storageApis[2], "settings.zon", ".{}"); +assert.equal(get(storageApis[3], "settings.zon"), null); +const epoch = storageApis[3].fizzy_web_focus_epoch(); +window.dispatch("focus", {}); +assert.equal(storageApis[3].fizzy_web_focus_epoch(), epoch + 1); console.log("embed.test: ok"); From ec12dda82ee7948557957abc8599d0d09449f3c3 Mon Sep 17 00:00:00 2001 From: "fizzy-linux[bot]" <346940526+fizzy-linux[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:15:11 -0500 Subject: [PATCH 2/4] linux: work-589 (codex) Agent-Team: linux Worker: codex --- core/core.zig | 1 + core/person.zig | 4 +- src/editor/Editor.zig | 37 ++++- src/editor/explorer/settings.zig | 4 +- tests/integration.zig | 262 +++++++++++++++++++------------ 5 files changed, 198 insertions(+), 110 deletions(-) diff --git a/core/core.zig b/core/core.zig index 3594b8994..a0f25d6c9 100644 --- a/core/core.zig +++ b/core/core.zig @@ -140,4 +140,5 @@ pub const transport = struct { /// Fixed Fizzy accent colours — theme-independent. See `palette.zig`. pub const palette = @import("palette.zig"); +/// Preferences shared by applications, independent of app-local settings. pub const person = @import("person.zig"); diff --git a/core/person.zig b/core/person.zig index 0495ca80b..5c3fdbd1e 100644 --- a/core/person.zig +++ b/core/person.zig @@ -16,7 +16,7 @@ pub fn load(io: std.Io, dir: []const u8) Person { const path = if (builtin.target.cpu.arch == .wasm32) "fizzyedit.person" else std.fs.path.join(gpa, &.{ dir, "person.zon" }) catch return .{}; const data = fs.readZ(gpa, io, path) catch return .{}; var diag: std.zon.parse.Diagnostics = undefined; - const person = std.zon.parse.fromSlice(Person, .{ .gpa = gpa, .arena = gpa, .source = data, .diagnostics = &diag }) catch return .{}; + const person = std.zon.parse.fromSlice(Person, .{ .gpa = gpa, .arena = gpa, .source = data, .diagnostics = &diag, .ignore_unknown_fields = true }) catch return .{}; if (person.motion) |level| if (!std.math.isFinite(level) or level < 0 or level > 1) return .{}; return person; } @@ -53,6 +53,8 @@ test "preferences round trip, invalid files, and atomic replacement" { try std.testing.expectEqual(Person{}, load(io, dir)); try tmp.dir.writeFile(io, .{ .sub_path = "person.zon", .data = "garbage" }); try std.testing.expectEqual(Person{}, load(io, dir)); + try tmp.dir.writeFile(io, .{ .sub_path = "person.zon", .data = ".{ .motion = 0.4, .future = true }" }); + try std.testing.expectEqual(@as(?f32, 0.4), load(io, dir).motion); try save(io, dir, .{ .motion = 0.4 }); try std.testing.expectEqual(@as(?f32, 0.4), load(io, dir).motion); try save(io, dir, .{ .motion = 0.8 }); diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index ebf27065e..be56dcea6 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -210,11 +210,18 @@ chrome: Chrome = .configured, /// answer. That is fine for deciding who owns a clipboard verb: focus doesn't change between /// the keystroke and the frame that handles it. text_input_focused: bool = false, +/// Shared preferences last loaded from disk, with any pending local choice. person: core.person.Person = .{}, +/// Shared config directory, owned by `app.gpa` until deinit. person_dir: []const u8, +/// Timestamp of the last loaded or saved shared file. person_mtime: ?i128 = null, +/// Native focus state from the preceding frame. person_app_active: bool = false, +/// Browser focus notification last handled by tick. person_focus_epoch: u32 = 0, +/// Pending shared preference save deadline; null when clean. +person_save_deadline_ns: ?i128 = null, /// Whether any plugin asked to keep painting this frame — one poll of every plugin's /// `needsContinuousRepaint`, sampled in `tick` and read by everything that needs the answer. @@ -484,9 +491,7 @@ pub fn init( { const settings_path = try std.fs.path.join(app.allocator, &.{ editor.app.config_folder, "settings.zon" }); defer app.allocator.free(settings_path); - try @import("app").settings.Migration.personMotion(app.allocator, dvui.io, settings_path, editor.person_dir); - editor.person = core.person.load(dvui.io, editor.person_dir); - editor.person_mtime = editor.personMtime(); + editor.loadPerson(settings_path); editor.app.settings = try Settings.load(app.allocator, settings_path, plugins_dir); } @@ -4383,6 +4388,15 @@ pub fn restartPending(_: *const Editor) bool { return Popout.restartPending(); } +/// Loads shared preferences after a best-effort migration; disk errors never prevent startup. +pub fn loadPerson(editor: *Editor, settings_path: []const u8) void { + @import("app").settings.Migration.personMotion(editor.app.gpa, dvui.io, settings_path, editor.person_dir) catch |err| { + std.log.warn("Could not migrate person preferences: {s}", .{@errorName(err)}); + }; + editor.person = core.person.load(dvui.io, editor.person_dir); + editor.person_mtime = editor.personMtime(); +} + fn personMtime(editor: *const Editor) ?i128 { if (comptime builtin.target.cpu.arch == .wasm32) return null; const path = std.fs.path.join(editor.app.gpa, &.{ editor.person_dir, "person.zon" }) catch return null; @@ -4393,6 +4407,7 @@ fn personMtime(editor: *const Editor) ?i128 { /// Re-reads shared preferences on focus when another app has replaced the file. pub fn reloadPerson(editor: *Editor) void { + if (editor.person_save_deadline_ns != null) return; const mtime = editor.personMtime(); if (comptime builtin.target.cpu.arch == .wasm32) { editor.person = core.person.load(dvui.io, editor.person_dir); @@ -4402,12 +4417,21 @@ pub fn reloadPerson(editor: *Editor) void { editor.person_mtime = mtime; } +/// Debounces shared preference writes while a slider is changing. +pub fn markPersonDirty(editor: *Editor) void { + editor.person_save_deadline_ns = core.perf.nanoTimestamp() + Settings.autosave_timeout_ns; + dvui.refresh(null, @src(), null); +} + +/// Flushes the local shared preference choice to disk. pub fn savePerson(editor: *Editor) void { core.person.save(dvui.io, editor.person_dir, editor.person) catch |err| { dvui.log.err("Could not save person preferences: {s}", .{@errorName(err)}); + editor.person_save_deadline_ns = core.perf.nanoTimestamp() + Settings.autosave_timeout_ns; return; }; editor.person_mtime = editor.personMtime(); + editor.person_save_deadline_ns = null; } pub fn tick(editor: *Editor) !dvui.App.Result { @@ -4415,7 +4439,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { extern "fizzy" fn fizzy_web_focus_epoch() u32; }; const epoch = if (comptime builtin.target.cpu.arch == .wasm32) web_focus.fizzy_web_focus_epoch() else 0; - const active = if (comptime builtin.target.cpu.arch != .wasm32 and @hasDecl(@import("backend"), "appActive")) @import("backend").appActive() else true; + const active = fizzy.backend.viewports.appActive(); if ((active and !editor.person_app_active) or epoch != editor.person_focus_epoch) { editor.reloadPerson(); } @@ -5038,6 +5062,10 @@ pub fn tick(editor: *Editor) !dvui.App.Result { editor.saveSettingsGuarded() catch |err| { dvui.log.err("Failed to autosave settings ({s})", .{@errorName(err)}); }; + if (editor.person_save_deadline_ns) |deadline| { + const remaining = deadline - core.perf.nanoTimestamp(); + if (remaining > 0) App.scheduleSaveWakeup(remaining, 2) else editor.savePerson(); + } editor.saveWindowRatiosGuarded(); _ = editor.app.arena.reset(.retain_capacity); @@ -7334,6 +7362,7 @@ pub fn rawCloseFileID(editor: *Editor, id: u64) !void { } pub fn deinit(editor: *Editor) !void { + if (editor.person_save_deadline_ns != null) editor.savePerson(); defer editor.app.gpa.free(editor.person_dir); // Freed however this ends: the fallible steps below may return early. defer editor.app.deinit(); diff --git a/src/editor/explorer/settings.zig b/src/editor/explorer/settings.zig index 4f7fb0c6d..afc858629 100644 --- a/src/editor/explorer/settings.zig +++ b/src/editor/explorer/settings.zig @@ -563,7 +563,7 @@ fn drawMotion() void { var level = fizzy.core.person.motionLevel(editor.person, build_opts.look_motion); if (editor.person.motion == null) dvui.label(@src(), "System default ({d:0.01})", .{level}, .{}); dvui.labelNoFmt(@src(), "Applies to every fizzy app.", .{}, .{}); - if (dvui.button(@src(), "Reset to system default", .{}, .{})) { + if (editor.person.motion != null and dvui.button(@src(), "Reset to system default", .{}, .{})) { editor.person.motion = null; level = fizzy.core.person.motionLevel(editor.person, build_opts.look_motion); editor.savePerson(); @@ -576,7 +576,7 @@ fn drawMotion() void { .min = 0.0, }, .{ .expand = .horizontal })) { editor.person.motion = level; - editor.savePerson(); + editor.markPersonDirty(); dvui.refresh(null, @src(), null); } // The three names along the slider's length, the one it is nearest lit. diff --git a/tests/integration.zig b/tests/integration.zig index 6c6f172ed..13fc21748 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -6261,6 +6261,165 @@ fn peakOf(comptime f: fn (f32, f32) f32, lv: f32) f32 { return peak; } +test "motion: failed migration leaves startup preferences usable and legacy data intact" { + var ctx = try shim.init(std.testing.allocator); + defer ctx.deinit(std.testing.allocator); + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realPathFileAlloc(io, ".", gpa); + defer gpa.free(root); + const settings = try std.fs.path.join(gpa, &.{ root, "settings.zon" }); + defer gpa.free(settings); + const blocked = try std.fs.path.join(gpa, &.{ root, "blocked" }); + defer gpa.free(blocked); + try tmp.dir.writeFile(io, .{ .sub_path = "settings.zon", .data = ".{ .motion = 0.6 }" }); + try tmp.dir.writeFile(io, .{ .sub_path = "blocked", .data = "a file cannot be a config directory" }); + ctx.editor.app.gpa = gpa; + ctx.editor.person_dir = blocked; + ctx.editor.loadPerson(settings); + try std.testing.expectEqual(@as(?f32, null), ctx.editor.person.motion); + const data = try fizzy.core.fs.read(gpa, io, settings); + defer gpa.free(data); + try std.testing.expectEqualStrings(".{ .motion = 0.6 }", data); + try tmp.dir.deleteFile(io, "blocked"); + ctx.editor.loadPerson(settings); + try std.testing.expectEqual(@as(?f32, 0.6), ctx.editor.person.motion); +} + +test "motion: migration and focus share the person's choice across app configs" { + var ctx = try shim.init(std.testing.allocator); + defer ctx.deinit(std.testing.allocator); + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realPathFileAlloc(io, ".", gpa); + defer gpa.free(root); + const person_dir = try std.fs.path.join(gpa, &.{ root, "person" }); + defer gpa.free(person_dir); + const Migration = @import("app").settings.Migration; + try tmp.dir.createDirPath(io, "first"); + try tmp.dir.createDirPath(io, "second"); + const first = try std.fs.path.join(gpa, &.{ root, "first", "settings.zon" }); + defer gpa.free(first); + const second = try std.fs.path.join(gpa, &.{ root, "second", "settings.zon" }); + defer gpa.free(second); + try fizzy.core.fs.write(io, first, ".{ .motion = 0.6 }"); + try Migration.personMotion(gpa, io, first, person_dir); + try std.testing.expectEqual(@as(?f32, 0.6), fizzy.core.person.load(io, person_dir).motion); + const migrated = try fizzy.core.fs.read(gpa, io, first); + defer gpa.free(migrated); + try std.testing.expect(std.mem.find(u8, migrated, ".motion =") == null); + + try fizzy.core.person.save(io, person_dir, .{ .motion = 0.2 }); + try fizzy.core.fs.write(io, second, ".{ .motion = 0.6 }"); + try Migration.personMotion(gpa, io, second, person_dir); + try std.testing.expectEqual(@as(?f32, 0.2), fizzy.core.person.load(io, person_dir).motion); + + const editor = ctx.editor; + editor.app.gpa = gpa; + editor.person_dir = person_dir; + editor.person_mtime = null; + editor.reloadPerson(); + try std.testing.expectEqual(@as(f32, 0.2), fizzy.core.person.motionLevel(editor.person, 1)); + try fizzy.core.person.save(io, person_dir, .{ .motion = 0.8 }); + // Coarse filesystem clocks can give consecutive replacements the same timestamp. + const shared = try tmp.dir.openFile(io, "person/person.zon", .{ .mode = .read_write }); + defer shared.close(io); + try shared.setTimestamps(io, .{ .modify_timestamp = .{ .new = .{ .nanoseconds = @intCast(editor.person_mtime.? + std.time.ns_per_s) } } }); + editor.reloadPerson(); + try std.testing.expectEqual(@as(f32, 0.8), fizzy.core.person.motionLevel(editor.person, 0.3)); + const M = fizzy.core.motion; + M.publish(fizzy.core.person.motionLevel(.{}, 0.3), M.default_speed, false, false, 1); + try std.testing.expectEqual(@as(f32, 0.3), M.level()); + for ([_]f32{ 0.3, 1 }) |app_level| { + M.publish(fizzy.core.person.motionLevel(editor.person, app_level), M.default_speed, false, false, 1); + try std.testing.expectEqual(@as(f32, 0.8), M.level()); + } + for ([_]?f32{ null, 0, 0.8, 1 }) |level| { + M.publish(fizzy.core.person.motionLevel(.{ .motion = level }, 1), M.default_speed, true, false, 1); + try std.testing.expect(M.off()); + } +} + +test "motion: pending shared choices stay in memory until flushed" { + var ctx = try shim.init(std.testing.allocator); + defer ctx.deinit(std.testing.allocator); + 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); + ctx.editor.app.gpa = gpa; + ctx.editor.person_dir = dir; + try fizzy.core.person.save(io, dir, .{ .motion = 0.2 }); + ctx.editor.person.motion = 0.8; + ctx.editor.markPersonDirty(); + try std.testing.expectEqual(@as(?f32, 0.2), fizzy.core.person.load(io, dir).motion); + ctx.editor.reloadPerson(); + try std.testing.expectEqual(@as(?f32, 0.8), ctx.editor.person.motion); + ctx.editor.savePerson(); + try std.testing.expectEqual(@as(?f32, 0.8), fizzy.core.person.load(io, dir).motion); + try std.testing.expectEqual(@as(?i128, null), ctx.editor.person_save_deadline_ns); +} + +test "motion: settings reset saves null and draws the shared default" { + var ctx = try shim.init(std.testing.allocator); + defer ctx.deinit(std.testing.allocator); + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir = try tmp.dir.realPathFileAlloc(std.testing.io, ".", gpa); + defer gpa.free(dir); + ctx.editor.app.gpa = gpa; + ctx.editor.person_dir = dir; + ctx.editor.person.motion = 0.8; + const frame = struct { + fn draw() !dvui.App.Result { + var box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal }); + defer box.deinit(); + for (fizzy.Explorer.settings.groups) |group| for (group.items) |item| { + if (std.mem.eql(u8, item.key, "motion")) item.draw.?(); + }; + return .ok; + } + }.draw; + dvui.debug.captureFrame(); + defer dvui.debug.clearCaptures(dvui.currentWindow().gpa); + _ = try dvui.testing.step(frame); + _ = try dvui.testing.step(frame); + var reset: ?dvui.Point.Physical = null; + for (dvui.debug.lastCapture().?.widgets.items) |widget| { + if (widget.text) |text| if (std.mem.eql(u8, text, "Reset to system default")) { + reset = widget.rect_border.center(); + }; + } + _ = try dvui.currentWindow().addEventMouseMotion(.{ .pt = reset orelse return error.TestUnexpectedResult }); + try dvui.testing.click(.left); + _ = try dvui.testing.step(frame); + try std.testing.expectEqual(@as(?f32, null), ctx.editor.person.motion); + try std.testing.expectEqual(@as(?f32, null), fizzy.core.person.load(std.testing.io, dir).motion); + dvui.debug.captureFrame(); + _ = try dvui.testing.step(frame); + _ = try dvui.testing.step(frame); + var default_shown = false; + var shared_shown = false; + var reset_shown = false; + for (dvui.debug.lastCapture().?.widgets.items) |widget| { + if (widget.text) |text| { + if (std.mem.eql(u8, text, "Reset to system default")) reset_shown = true; + if (std.mem.startsWith(u8, text, "System default (")) default_shown = true; + if (std.mem.eql(u8, text, "Applies to every fizzy app.")) shared_shown = true; + } + } + try std.testing.expect(default_shown); + try std.testing.expect(shared_shown); + try std.testing.expect(!reset_shown); +} + test "motion: every curve starts at 0 and lands on 1, at every level" { const M = fizzy.core.motion; var lv: f32 = 0; @@ -13820,106 +13979,3 @@ test "mobile sheets: turning the window with a sheet open shuts it, in both dire try dvui.testing.settle(MobileFrame.frame); try std.testing.expectApproxEqAbs(1024 * 0.6 + 16, regionRect(editor, "Panel").?.h, 1); } - -test "motion: migration and focus share the person's choice across app configs" { - var ctx = try shim.init(std.testing.allocator); - defer ctx.deinit(std.testing.allocator); - const gpa = std.testing.allocator; - const io = std.testing.io; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - const root = try tmp.dir.realPathFileAlloc(io, ".", gpa); - defer gpa.free(root); - const person_dir = try std.fs.path.join(gpa, &.{ root, "person" }); - defer gpa.free(person_dir); - const Migration = @import("app").settings.Migration; - try tmp.dir.createDirPath(io, "first"); - try tmp.dir.createDirPath(io, "second"); - const first = try std.fs.path.join(gpa, &.{ root, "first", "settings.zon" }); - defer gpa.free(first); - const second = try std.fs.path.join(gpa, &.{ root, "second", "settings.zon" }); - defer gpa.free(second); - try fizzy.core.fs.write(io, first, ".{ .motion = 0.6 }"); - try Migration.personMotion(gpa, io, first, person_dir); - try std.testing.expectEqual(@as(?f32, 0.6), fizzy.core.person.load(io, person_dir).motion); - const migrated = try fizzy.core.fs.read(gpa, io, first); - defer gpa.free(migrated); - try std.testing.expect(std.mem.find(u8, migrated, ".motion =") == null); - - try fizzy.core.person.save(io, person_dir, .{ .motion = 0.2 }); - try fizzy.core.fs.write(io, second, ".{ .motion = 0.6 }"); - try Migration.personMotion(gpa, io, second, person_dir); - try std.testing.expectEqual(@as(?f32, 0.2), fizzy.core.person.load(io, person_dir).motion); - - const editor = ctx.editor; - editor.app.gpa = gpa; - editor.person_dir = person_dir; - editor.person_mtime = null; - editor.reloadPerson(); - try std.testing.expectEqual(@as(f32, 0.2), fizzy.core.person.motionLevel(editor.person, 1)); - try fizzy.core.person.save(io, person_dir, .{ .motion = 0.8 }); - editor.reloadPerson(); - try std.testing.expectEqual(@as(f32, 0.8), fizzy.core.person.motionLevel(editor.person, 0.3)); - const M = fizzy.core.motion; - M.publish(fizzy.core.person.motionLevel(.{}, 0.3), M.default_speed, false, false, 1); - try std.testing.expectEqual(@as(f32, 0.3), M.level()); - for ([_]f32{ 0.3, 1 }) |app_level| { - M.publish(fizzy.core.person.motionLevel(editor.person, app_level), M.default_speed, false, false, 1); - try std.testing.expectEqual(@as(f32, 0.8), M.level()); - } - for ([_]?f32{ null, 0, 0.8, 1 }) |level| { - M.publish(fizzy.core.person.motionLevel(.{ .motion = level }, 1), M.default_speed, true, false, 1); - try std.testing.expect(M.off()); - } -} - -test "motion: settings reset saves null and draws the shared default" { - var ctx = try shim.init(std.testing.allocator); - defer ctx.deinit(std.testing.allocator); - const gpa = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - const dir = try tmp.dir.realPathFileAlloc(std.testing.io, ".", gpa); - defer gpa.free(dir); - ctx.editor.app.gpa = gpa; - ctx.editor.person_dir = dir; - ctx.editor.person.motion = 0.8; - const frame = struct { - fn draw() !dvui.App.Result { - var box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal }); - defer box.deinit(); - for (fizzy.Explorer.settings.groups) |group| for (group.items) |item| { - if (std.mem.eql(u8, item.key, "motion")) item.draw.?(); - }; - return .ok; - } - }.draw; - dvui.debug.captureFrame(); - defer dvui.debug.clearCaptures(dvui.currentWindow().gpa); - _ = try dvui.testing.step(frame); - _ = try dvui.testing.step(frame); - var reset: ?dvui.Point.Physical = null; - for (dvui.debug.lastCapture().?.widgets.items) |widget| { - if (widget.text) |text| if (std.mem.eql(u8, text, "Reset to system default")) { - reset = widget.rect_border.center(); - }; - } - _ = try dvui.currentWindow().addEventMouseMotion(.{ .pt = reset orelse return error.TestUnexpectedResult }); - try dvui.testing.click(.left); - _ = try dvui.testing.step(frame); - try std.testing.expectEqual(@as(?f32, null), ctx.editor.person.motion); - try std.testing.expectEqual(@as(?f32, null), fizzy.core.person.load(std.testing.io, dir).motion); - dvui.debug.captureFrame(); - _ = try dvui.testing.step(frame); - _ = try dvui.testing.step(frame); - var default_shown = false; - var shared_shown = false; - for (dvui.debug.lastCapture().?.widgets.items) |widget| { - if (widget.text) |text| { - if (std.mem.startsWith(u8, text, "System default (")) default_shown = true; - if (std.mem.eql(u8, text, "Applies to every fizzy app.")) shared_shown = true; - } - } - try std.testing.expect(default_shown); - try std.testing.expect(shared_shown); -} From 7d67a8eefa839ee73c1bee292d0269ab38f19e4f Mon Sep 17 00:00:00 2001 From: "fizzy-linux[bot]" <346940526+fizzy-linux[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:04:26 -0500 Subject: [PATCH 3/4] linux: work-589 (codex) Agent-Team: linux Worker: codex --- src/editor/Editor.zig | 10 +++++++++- tests/integration.zig | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 19be73fcc..9b0303c90 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -468,7 +468,7 @@ pub fn init( .file_table = .init(app.allocator, dvui.io), .secrets = try .init(app.allocator, dvui.io, config_folder), }, - .person_dir = try std.fs.path.join(app.allocator, &.{ config_root, "fizzyedit" }), + .person_dir = try personDir(app.allocator, profile_root, config_root), .explorer = try app.allocator.create(Explorer), .infobar = try .init(), .last_titlebar_color = dvui.themeGet().color(.control, .fill), @@ -4388,6 +4388,14 @@ pub fn restartPending(_: *const Editor) bool { return Popout.restartPending(); } +/// Where `person.zon` lives, owned by the caller. A profile keeps its own, so a sandbox instance +/// never writes the person's real shared preferences; otherwise it is `fizzyedit/` beside every +/// fizzy app's config folder. +pub fn personDir(gpa: std.mem.Allocator, profile_root: ?[]const u8, config_root: []const u8) std.mem.Allocator.Error![]const u8 { + if (profile_root) |root| return gpa.dupe(u8, root); + return std.fs.path.join(gpa, &.{ config_root, "fizzyedit" }); +} + /// Loads shared preferences after a best-effort migration; disk errors never prevent startup. pub fn loadPerson(editor: *Editor, settings_path: []const u8) void { @import("app").settings.Migration.personMotion(editor.app.gpa, dvui.io, settings_path, editor.person_dir) catch |err| { diff --git a/tests/integration.zig b/tests/integration.zig index e88604bb0..a338efd51 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -6612,6 +6612,25 @@ test "motion: failed migration leaves startup preferences usable and legacy data try std.testing.expectEqual(@as(?f32, 0.6), ctx.editor.person.motion); } +test "motion: a profiled instance keeps person.zon under its profile root" { + const gpa = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realPathFileAlloc(io, ".", gpa); + defer gpa.free(root); + + const profiled = try fizzy.Editor.personDir(gpa, root, "/the/persons/config"); + defer gpa.free(profiled); + try std.testing.expectEqualStrings(root, profiled); + try fizzy.core.person.save(io, profiled, .{ .motion = 0.2 }); + try tmp.dir.access(io, "person.zon", .{}); + + const shared = try fizzy.Editor.personDir(gpa, null, "/the/persons/config"); + defer gpa.free(shared); + try std.testing.expectEqualStrings("/the/persons/config/fizzyedit", shared); +} + test "motion: migration and focus share the person's choice across app configs" { var ctx = try shim.init(std.testing.allocator); defer ctx.deinit(std.testing.allocator); From c47faea81d9d70668ac16873ee584b87158d55fd Mon Sep 17 00:00:00 2001 From: "fizzy-linux[bot]" <346940526+fizzy-linux[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 05:15:36 -0500 Subject: [PATCH 4/4] tests: profiled person_dir test builds paths natively, asserts the editor The expected shared path is built with std.fs.path.join so the test holds on Windows, and the headless whole-editor test asserts editor.person_dir equals the profile root, so reverting the Editor.init wiring fails it. Agent-Team: linux Worker: claude Co-Authored-By: Claude Sonnet 5.5 --- tests/integration.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/integration.zig b/tests/integration.zig index a338efd51..c643f258f 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -6628,7 +6628,9 @@ test "motion: a profiled instance keeps person.zon under its profile root" { const shared = try fizzy.Editor.personDir(gpa, null, "/the/persons/config"); defer gpa.free(shared); - try std.testing.expectEqualStrings("/the/persons/config/fizzyedit", shared); + const want_shared = try std.fs.path.join(gpa, &.{ "/the/persons/config", "fizzyedit" }); + defer gpa.free(want_shared); + try std.testing.expectEqualStrings(want_shared, shared); } test "motion: migration and focus share the person's choice across app configs" { @@ -10931,6 +10933,8 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try editor.postInit(); try dvui.testing.settle(headlessFrame); try std.testing.expectEqualStrings(root, editor.app.config_folder); + // The person's choices stay in the profile too, not in the shared config folder. + try std.testing.expectEqualStrings(root, editor.person_dir); // A plugin's data folder (`Host.pluginDataFolder`): under the profile, made on the first ask, // the same borrowed path after, one per plugin. {