diff --git a/app/settings/Settings.zig b/app/settings/Settings.zig index a6eca6493..3aaa03ada 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 1dab60a02..a446a4ee6 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 05307456a..e9a69665e 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 bd4cc23ba..856d1a507 100644 --- a/core/core.zig +++ b/core/core.zig @@ -144,5 +144,8 @@ 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"); + /// Input timestamps shared through the dvui window, including plugin images. pub const input_time = @import("input_time.zig"); diff --git a/core/person.zig b/core/person.zig new file mode 100644 index 000000000..5c3fdbd1e --- /dev/null +++ b/core/person.zig @@ -0,0 +1,68 @@ +//! 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, .ignore_unknown_fields = true }) 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 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 }); + 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 3914f6ff8..1832d987d 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -210,6 +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. @@ -456,6 +468,7 @@ pub fn init( .file_table = .init(app.allocator, dvui.io), .secrets = try .init(app.allocator, dvui.io, config_folder), }, + .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), @@ -478,6 +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); + editor.loadPerson(settings_path); editor.app.settings = try Settings.load(app.allocator, settings_path, plugins_dir); } @@ -3933,7 +3947,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 +4388,71 @@ 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| { + 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; + 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 { + 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); + } else if (mtime != editor.person_mtime) { + editor.person = core.person.load(dvui.io, editor.person_dir); + } + 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 { + 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 = fizzy.backend.viewports.appActive(); + 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 +4511,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`). @@ -4994,6 +5071,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); @@ -7293,6 +7374,8 @@ 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(); // 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..afc858629 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 (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(); + 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.markPersonDirty(); 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 48214d971..5895c2b97 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -6602,6 +6602,186 @@ 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: 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); + 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" { + 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; @@ -10770,6 +10950,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. { diff --git a/web/fizzy-worker.js b/web/fizzy-worker.js index 931434647..11681821e 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 07d977fee..70f8da829 100644 --- a/web/fizzy.js +++ b/web/fizzy.js @@ -203,6 +203,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 ?? (() => {}); @@ -415,8 +421,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; @@ -426,12 +433,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 @@ -1052,10 +1059,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 a626bebe1..716335246 100644 --- a/web/tests/embed.test.mjs +++ b/web/tests/embed.test.mjs @@ -75,6 +75,7 @@ let fetches = 0; let compiles = 0; const bound = []; const toggles = []; +const storageApis = []; const insets = []; const memory = new WebAssembly.Memory({ initial: 1 }); globalThis.fetch = (url) => { fetches++; return Promise.resolve({ url }); }; @@ -84,6 +85,7 @@ WebAssembly.instantiate = async (module, imports) => { bound.push([]); insets.push([]); toggles.push(imports.fizzy.fizzy_web_toggle_fullscreen); + storageApis.push(imports.fizzy); return { exports: { memory: { buffer: memory.buffer }, @@ -98,11 +100,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; @@ -162,6 +164,38 @@ toggles[1](); assert.equal(document.fullscreenElement, parentA, "leaving the second embed reveals the first"); toggles[0](); assert.equal(document.fullscreenElement, null, "leaving the first embed exits full screen"); +// 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"); // Deliver text input as soon as the handler is installed, before setup completes.