Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions app/settings/Settings.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
33 changes: 33 additions & 0 deletions app/settings/SettingsMigration.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
1 change: 1 addition & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions build/app.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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" },
Expand Down
3 changes: 3 additions & 0 deletions core/core.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
68 changes: 68 additions & 0 deletions core/person.zig
Original file line number Diff line number Diff line change
@@ -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));
}
87 changes: 85 additions & 2 deletions src/editor/Editor.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Expand All @@ -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);
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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`).
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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:
Expand Down
19 changes: 15 additions & 4 deletions src/editor/explorer/settings.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand Down
Loading
Loading