╭──────────╮ │← previous│ ╰──────────╯ ╭ changed ─╮ │src/ui.zig│ ╰──────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────╮ │54 data: Data = .{}, // serializable data sent down to web client │ │55 arena: *std.heap.ArenaAllocator, │ │56 haxy_moment: ?DB.HashMap(.read_only) = null, // db cursor (null on the wasm side)│ │57 + pending: std.ArrayList(Action) = .empty, // actions queued by widgets this frame │ │58 │ │59 pub const Data = struct { │ │60 user_id: ?[]const u8 = null, │ ╰─────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ 71 repo: *rp.Repo(.xit, repo_opts), │ │ 72 data: Data, │ │ 73 ) !Session { │ │ 74 - const RepoDB = rp.Repo(.xit, repo_opts).DB; │ │ 74 + var session = Session{ │ │ 75 + .data = data, │ │ 76 + .arena = arena, │ │ 77 + .haxy_moment = try evt.currentMoment(repo_opts, repo), │ │ 78 + }; │ │ 79 + try session.loadUserPrefs(); │ │ 80 + return session; │ │ 81 + } │ │ 82 │ │ 76 - const history = try RepoDB.ArrayList(.read_only).init(repo.core.db.rootCursor().readOnly()); │ │ 83 + // load the logged-in user's persisted preferences from the db │ │ 84 + pub fn loadUserPrefs(self: *Session) !void { │ │ 85 + const user_id = self.data.user_id orelse return; │ │ 86 + const moment = self.haxy_moment orelse return; │ │ 87 + if (try evt.User.readById(DB, canonical_repo_opts.hash, moment, self.arena, user_id)) |user| { │ │ 88 + self.data.enable_ansi = user.enable_ansi; │ │ 89 + } │ │ 90 + } │ │ 91 │ │ 78 - const moment_cursor = try history.getCursor(-1) orelse return error.NotFound; │ │ 79 - const moment = try RepoDB.HashMap(.read_only).init(moment_cursor); │ │ 92 + // queue an action for the host to drain this frame. │ │ 93 + pub fn push(self: *Session, action: Action) !void { │ │ 94 + try self.pending.append(self.arena.allocator(), action); │ │ 95 + } │ │ 96 │ │ 81 - const last_object_id_cursor = try moment.getCursor(hash.hashInt(repo_opts.hash, "haxy-last-object-id")) orelse return error.NotFound;│ │ 82 - var last_object_id: [hash.byteLen(repo_opts.hash)]u8 = undefined; │ │ 83 - _ = try last_object_id_cursor.readBytes(&last_object_id); │ │ 97 + // apply a single action's in-memory effect only (no persistence). │ │ 98 + pub fn apply(self: *Session, action: Action) void { │ │ 99 + switch (action) { │ │100 + .toggle_ansi => self.data.enable_ansi = !self.data.enable_ansi, │ │101 + } │ │102 + } │ │103 │ │ 85 - const haxy_cursor = try moment.getCursor(hash.hashInt(repo_opts.hash, "haxy")) orelse return error.NotFound; │ │ 86 - const haxy = try RepoDB.ArrayList(.read_only).init(haxy_cursor); │ │104 + // drain queued actions, applying only their in-memory effect. used on the │ │105 + // wasm path, which has no repo to persist to (and only anonymous toggles │ │106 + // reach it anyway). │ │107 + pub fn applyPending(self: *Session) void { │ │108 + for (self.pending.items) |action| self.apply(action); │ │109 + self.pending.clearRetainingCapacity(); │ │110 + } │ │111 +}; │ │112 │ │ 88 - const haxy_moments_cursor = try haxy.getCursor(-1) orelse return error.NotFound; │ │ 89 - const haxy_moments = try RepoDB.HashMap(.read_only).init(haxy_moments_cursor); │ │113 +// a user-initiated state change. widgets enqueue these on the Session during │ │114 +// input instead of mutating state or touching the DB themselves; the host │ │115 +// drains them each frame (see dispatchPending / applyPending). this keeps DB │ │116 +// side effects out of the widget tree and gives every render path one place to │ │117 +// turn a UI action into a state change + event. │ │118 +pub const Action = union(enum) { │ │119 + toggle_ansi, │ │120 │ │ 91 - const haxy_moment_cursor = try haxy_moments.getCursor(hash.bytesToInt(repo_opts.hash, &last_object_id)) orelse return error.NotFound;│ │ 92 - const haxy_moment = try RepoDB.HashMap(.read_only).init(haxy_moment_cursor); │ │121 + // apply this action and, when it maps to persistent state and the session │ │122 + // is logged in, emit the corresponding event. the single place that knows │ │123 + // how a UI action becomes a DB write. │ │124 + pub fn dispatch( │ │125 + self: Action, │ │126 + comptime repo_opts: rp.RepoOpts(.xit), │ │127 + io: std.Io, │ │128 + allocator: std.mem.Allocator, │ │129 + repo: *rp.Repo(.xit, repo_opts), │ │130 + session: *Session, │ │131 + ) !void { │ │132 + session.apply(self); │ │133 + switch (self) { │ │134 + .toggle_ansi => if (session.data.user_id) |user_id| { │ │135 + try evt.User.toggleAnsi(repo_opts, io, allocator, repo, user_id); │ │136 + }, │ │137 + } │ │138 + } │ │139 │ │ 94 - return .{ │ │ 95 - .data = data, │ │ 96 - .arena = arena, │ │ 97 - .haxy_moment = haxy_moment, │ │ 98 - }; │ │140 + // drain the session's queued actions through dispatch. always clears the │ │141 + // queue, even if a dispatch fails, so a single bad write isn't retried │ │142 + // every frame. │ │143 + pub fn dispatchPending( │ │144 + comptime repo_opts: rp.RepoOpts(.xit), │ │145 + io: std.Io, │ │146 + allocator: std.mem.Allocator, │ │147 + repo: *rp.Repo(.xit, repo_opts), │ │148 + session: *Session, │ │149 + ) !void { │ │150 + defer session.pending.clearRetainingCapacity(); │ │151 + for (session.pending.items) |action| { │ │152 + try action.dispatch(repo_opts, io, allocator, repo, session); │ │153 + } │ │154 } │ │155 }; │ │156 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────╮ │191 blocking = false; │ │192 } │ │193 │ │194 + // local run has no repo to persist to, so just apply in-memory │ │195 + session.applyPending(); │ │196 + │ │197 try root.build(allocator, .{ │ │198 .min_size = .{ .width = null, .height = null }, │ │199 .max_size = .{ .width = last_size.width, .height = last_size.height },│ ╰───────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ───────────╮ │src/ui/Home/Ansi.zig│ ╰────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │26 const button_index: usize = 0; │ │27 │ │28 pub fn init(allocator: std.mem.Allocator, data: *const Self, session: *ui.Session) !View { │ │29 - // only logged-in users get a real HTML form to POST to the server │ │29 const logged_in = session.data.user_id != null; │ │30 │ │31 var box = wgt.Box(ui.Widget).init(.{ .border_style = null, .rounded_corners = true, .direction = .vert });│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────╮ │32 errdefer box.deinit(allocator); │ │34 - // marks this subtree as an HTML form scope for the web overlay │ │35 - if (logged_in) box.getFocus().kind = .{ .custom = "form:/ansi" }; │ │33 + box.getFocus().kind = .{ .custom = if (logged_in) "form:/ansi" else "form:" };│ │34 │ │35 var button_id: usize = undefined; │ │36 { │ ╰──────────────────────────────────────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────────────────────────────╮ │39 button.getFocus().focusable = true; │ │40 // the renderer distinguishes plain clickables from buttons that│ │41 // should POST to a server route by this kind. │ │44 - if (logged_in) button.getFocus().kind = .{ .custom = "submit" };│ │42 + button.getFocus().kind = .{ .custom = "submit" }; │ │43 button_id = button.getFocus().id; │ │44 try box.children.put(allocator, button.getFocus().id, .{ │ │45 .widget = .{ .text_box = button }, │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │74 pub fn input(self: *View, allocator: std.mem.Allocator, key: inp.Key, root_focus: *Focus) !void {│ │75 _ = allocator; │ │76 switch (key) { │ │79 - .enter => self.toggle(), │ │77 + .enter => try self.toggle(), │ │78 .mouse => |mouse| { │ │79 if (mouse.action == .press and mouse.action.press == .left) { │ │80 if (root_focus.children.get(self.button_id)) |entry| { │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭───────────────────────────────────────────────────────────────────────────────────────────────╮ │82 if (mouse.x >= r.x and mouse.y >= r.y and │ │83 mouse.x < r.x + r.size.width and mouse.y < r.y + r.size.height)│ │84 { │ │87 - self.toggle(); │ │85 + try self.toggle(); │ │86 } │ │87 } │ │88 } │ ╰───────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────╮ │91 } │ │92 } │ │93 │ │96 - fn toggle(self: *View) void { │ │97 - self.session.data.enable_ansi = !self.session.data.enable_ansi; │ │94 + // enqueue the toggle; the host drains it (applying + persisting) this frame.│ │95 + fn toggle(self: *View) !void { │ │96 + try self.session.push(.toggle_ansi); │ │97 } │ │98 │ │99 pub fn clearGrid(self: *View) void { │ ╰─────────────────────────────────────────────────────────────────────────────────────╯ ╭ changed ─────────────────╮ │src/ui/Home/Auth/Login.zig│ ╰──────────────────────────╯ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │249 const user_id_stable = try self.session.arena.allocator().dupe(u8, &user_id);│ │250 self.session.data.user_id = user_id_stable; │ │251 self.session.data.login_failure = null; │ │252 + │ │253 + // adopt the user's persisted prefs │ │254 + try self.session.loadUserPrefs(); │ │255 │ │256 // wipe the entered credentials so they don't linger if the │ │254 - // user returns to this page after logging out. │ │257 + // user returns to this page after logging out │ │258 username_input.clear(allocator); │ │259 password_input.clear(allocator); │ │260 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭──────╮ │next →│ ╰──────╯