diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43f80871f..a8b2ae8d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -211,6 +211,13 @@ jobs: shell: bash run: bash scripts/ci-retry.sh zig build check-web + # The tapes fizzyed.it serves beside the app: a script or `docs/demos/` tape that no longer + # builds or reads back fails here, not on the deploy after merge. + - name: Build the hosted demo tapes + if: matrix.name != 'Windows' + shell: bash + run: bash scripts/ci-retry.sh zig build demos + # Fizzy on dvui's own SDL3 backend (`-Dnative-backend=sdl3`), where any dvui app on the stock # backend stands: the platform pieces compile without fizzy's backend and lose what needs it # (floats' windows, the window skin). It rotted unnoticed while nothing built it. macOS diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index fde215db8..a74d33b3f 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -45,6 +45,9 @@ on: - "build.zig" - "build.zig.zon" - "web/**" + # The tapes served beside the app (`zig-out/web/demos/`). + - "docs/demos/**" + - "scripts/demos.zig" - ".github/workflows/web.yml" release: types: [published] diff --git a/app/App.zig b/app/App.zig index bed1a98af..8c4155d4d 100644 --- a/app/App.zig +++ b/app/App.zig @@ -45,7 +45,7 @@ const Host = sdk.Host; const PluginLoader = @import("root.zig").store.Loader; const SettingsWatcher = @import("watch/SettingsWatcher.zig"); const ControlServer = @import("control/Server.zig"); -const ControlRelay = @import("control/routes.zig").Relay; +const ControlState = @import("control/routes.zig").State; const ExecutableWatcher = @import("watch/ExecutableWatcher.zig"); const FolderWatcher = @import("watch/FolderWatcher.zig"); const Keymap = @import("keymap/Keymap.zig"); @@ -268,8 +268,9 @@ settings_watcher: ?SettingsWatcher = null, /// The control server (`app.control`), while the `control_server` setting has it run: followed /// once a frame, answered each frame, stopped at quit. Always void on wasm, which has no socket. control: if (builtin.target.cpu.arch == .wasm32) void else ?ControlServer = if (builtin.target.cpu.arch == .wasm32) {} else null, -/// What relays the app's events to the control server's streams (`control.routes.Relay`). -control_relay: if (builtin.target.cpu.arch == .wasm32) void else ControlRelay = if (builtin.target.cpu.arch == .wasm32) {} else .{}, +/// What the control server's routes keep between frames: the relay of the app's events to its +/// streams, and the requests whose commands answer later (`control.routes.State`). +control_state: if (builtin.target.cpu.arch == .wasm32) void else ControlState = if (builtin.target.cpu.arch == .wasm32) {} else .{}, /// Watches the app's own executable while `settings.restart_on_rebuild` is on: a rebuild restarts /// the app into it, keeping every document. Null while the setting is off, and wherever diff --git a/app/HistoryFrames.zig b/app/HistoryFrames.zig index 84e2b2e89..7571f1e4b 100644 --- a/app/HistoryFrames.zig +++ b/app/HistoryFrames.zig @@ -77,6 +77,8 @@ pub fn get(self: *const HistoryFrames, top: u64) ?dvui.Texture { } pub fn deinit(self: *HistoryFrames, gpa: std.mem.Allocator) void { - for (self.frames.values()) |t| dvui.textureDestroyLater(t); + // Destroyed with a frame, and only in one: let go of at teardown (the app quitting) there is + // no window, and the GPU goes with the process. + if (dvui.current_window != null) for (self.frames.values()) |t| dvui.textureDestroyLater(t); self.frames.deinit(gpa); } diff --git a/app/Tracks.zig b/app/Tracks.zig index 40d0c5e86..b69967d82 100644 --- a/app/Tracks.zig +++ b/app/Tracks.zig @@ -18,9 +18,9 @@ //! open track's newest checkpoint (`shed`); steps and bases stay, since undo will walk them. //! Taking and diffing a checkpoint happen on the frame that takes it, on every target (the web has //! no threads); were that ever too long, the diff would be spread over the frames after, a slice -//! each, the copy being immutable once taken. Until tracks are on by default they are behind `on` -//! (fizzy: `FIZZY_TRACKS=1`). `zig build bench-tracks` measures what a step, a checkpoint and a -//! replay cost. +//! each, the copy being immutable once taken. They record while `on`: in fizzy by default, as its +//! setting says (`record_history`, `FIZZY_TRACKS` over it). `zig build bench-tracks` measures what +//! a step, a checkpoint and a replay cost. const Tracks = @This(); const std = @import("std"); diff --git a/app/control/Server.zig b/app/control/Server.zig index a6cae9c03..2fe5ce823 100644 --- a/app/control/Server.zig +++ b/app/control/Server.zig @@ -68,6 +68,9 @@ socket_inode: ?Io.File.INode = null, /// Requests waiting for the UI thread, under `lock`. lock: Io.Mutex = .init, pending: std.ArrayListUnmanaged(*Request) = .empty, +/// Requests the UI thread took and has not answered yet (a command answering in a later frame), +/// under `lock`: `stop` answers them too. +outstanding: std.ArrayListUnmanaged(*Request) = .empty, /// Connections streaming events (`publish`), under `lock`. streams: std.ArrayListUnmanaged(*Stream) = .empty, @@ -135,14 +138,17 @@ pub const Request = struct { const topics = topicsOf(self.arena, self.target) catch return self.answer(server, .internal_server_error, "text/plain", "out of memory\n"); self.stream = .{ .topics = topics, .conn = self.conn }; - { + const added = added: { server.lock.lockUncancelable(server.io); defer server.lock.unlock(server.io); - server.streams.append(server.gpa, &self.stream) catch - return self.answer(server, .internal_server_error, "text/plain", "out of memory\n"); + server.streams.append(server.gpa, &self.stream) catch break :added false; self.conn.streaming = true; - } + break :added true; + }; + // Answered outside the lock, which `answer` takes. + if (!added) return self.answer(server, .internal_server_error, "text/plain", "out of memory\n"); self.response = .{ .stream = true, .status = .ok }; + server.settle(self); self.done.set(server.io); } @@ -154,6 +160,7 @@ pub const Request = struct { .body = server.gpa.dupe(u8, body) catch null, }; if (self.response.body == null) self.response.status = .internal_server_error; + server.settle(self); self.done.set(server.io); } }; @@ -212,11 +219,23 @@ pub fn take(self: *Server, out: *std.ArrayListUnmanaged(*Request)) usize { self.lock.lockUncancelable(self.io); defer self.lock.unlock(self.io); // Out of memory: left queued for the next frame, never dropped unanswered. + self.outstanding.ensureUnusedCapacity(self.gpa, self.pending.items.len) catch return self.streams.items.len; out.appendSlice(self.gpa, self.pending.items) catch return self.streams.items.len; + self.outstanding.appendSliceAssumeCapacity(self.pending.items); self.pending.clearRetainingCapacity(); return self.streams.items.len; } +/// `req` is answered: no longer `outstanding`. +fn settle(self: *Server, req: *Request) void { + self.lock.lockUncancelable(self.io); + defer self.lock.unlock(self.io); + for (self.outstanding.items, 0..) |r, i| if (r == req) { + _ = self.outstanding.swapRemove(i); + return; + }; +} + /// Whether any stream wants the event `name`: the caller skips building a message nobody reads. pub fn wants(self: *Server, name: []const u8) bool { self.lock.lockUncancelable(self.io); @@ -252,17 +271,25 @@ pub fn publish(self: *Server, name: []const u8, json: []const u8) void { pub fn stop(self: *Server) void { self.stopping.store(true, .release); // The connections whose requests this answers ("fizzy is closing"): each writes it and ends. + // Queued, or taken and still to be answered (a command answering later). var answered: std.ArrayListUnmanaged(*Conn) = .empty; defer answered.deinit(self.gpa); + // Moved out whole, not copied: running out of memory here must not drop one unanswered, whose + // task would then wait for good. + var queued: std.ArrayListUnmanaged(*Request) = .empty; + defer queued.deinit(self.gpa); + var taken: std.ArrayListUnmanaged(*Request) = .empty; + defer taken.deinit(self.gpa); { self.lock.lockUncancelable(self.io); defer self.lock.unlock(self.io); - for (self.pending.items) |r| { - answered.append(self.gpa, r.conn) catch {}; - r.answer(self, .service_unavailable, "text/plain", "the control server is stopping\n"); - } - self.pending.clearRetainingCapacity(); + std.mem.swap(std.ArrayListUnmanaged(*Request), &queued, &self.pending); + std.mem.swap(std.ArrayListUnmanaged(*Request), &taken, &self.outstanding); } + for ([_][]const *Request{ queued.items, taken.items }) |owed| for (owed) |r| { + answered.append(self.gpa, r.conn) catch {}; + r.answer(self, .service_unavailable, "text/plain", "the control server is stopping\n"); + }; if (self.accept_task) |*t| t.cancel(self.io); self.accept_task = null; // No more are accepted, so `conns` holds still. Every other task, reading its next request or @@ -282,6 +309,7 @@ pub fn stop(self: *Server) void { } else |_| {} } self.pending.deinit(self.gpa); + self.outstanding.deinit(self.gpa); self.streams.deinit(self.gpa); } diff --git a/app/control/mcp.zig b/app/control/mcp.zig new file mode 100644 index 000000000..229c255bf --- /dev/null +++ b/app/control/mcp.zig @@ -0,0 +1,186 @@ +//! MCP on the control socket: `POST /mcp` speaks the Model Context Protocol's Streamable HTTP +//! transport, a JSON-RPC message per request answered as JSON, so an agent reaches the same +//! commands curl does (plans/CONTROL_SERVER_PLAN.md, step 5). Its tools are the app's commands: +//! `tools/list` from the registry, each tool's input schema the one `/openapi.json` gives the +//! command's body (`openapi.bodySchema`), and `tools/call` through `Host.callCommandAwaiting`, so a +//! command that answers in a later frame answers its call then. +//! +//! What it does not do yet: server-sent notifications (no `GET /mcp` stream: the tool list is +//! read when asked), sessions (`Mcp-Session-Id`), resources and prompts. +const std = @import("std"); +const sdk = @import("fizzy_sdk"); +const AppInfo = @import("../AppInfo.zig"); +const json = @import("json.zig"); +const openapi = @import("openapi.zig"); + +const Stringify = std.json.Stringify; + +/// The protocol versions this answers, newest first: an `initialize` asking for another gets the +/// newest, as the protocol says. 2025-03-26 asked servers to take JSON-RPC batches, which 2025-06-18 +/// dropped: none are taken here (no client sends them), a deliberate gap. +const versions = [_][]const u8{ "2025-06-18", "2025-03-26" }; + +/// What to send back for one message. +pub const Reply = union(enum) { + /// A JSON-RPC response, as the body. + now: []const u8, + /// A notification: answered `202 Accepted`, no body. + none, + /// A tool whose command answers later: `ticket` names it, and `id` is the request's id as JSON + /// (in the arena), for `result` to answer with once it comes. + later: struct { ticket: u32, id: []const u8 }, +}; + +/// Answer one JSON-RPC message, `body`. Everything returned lives in `arena`. `sink` hears a +/// command that answers later; without one, such a call is answered as started. +pub fn handle(host: *sdk.Host, body: []const u8, arena: std.mem.Allocator, sink: ?sdk.Host.CallSink) error{OutOfMemory}!Reply { + // Numbers kept as written, so an id comes back exactly as it was sent. + const value = std.json.parseFromSliceLeaky(std.json.Value, arena, body, .{ .parse_numbers = false }) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return .{ .now = try failure(arena, "null", -32700, "the body is not JSON") }, + }; + if (value != .object) return .{ .now = try failure(arena, "null", -32600, "one JSON-RPC message per request: an object, not a batch") }; + const msg = value.object; + const method = if (msg.get("method")) |m| if (m == .string) m.string else null else null; + const id_value = msg.get("id"); + // No id: a notification. No method: a response to a request of ours. Neither is answered. + if (id_value == null or method == null) return .none; + const id = switch (id_value.?) { + .string, .integer, .number_string => try std.json.Stringify.valueAlloc(arena, id_value.?, .{}), + else => return .{ .now = try failure(arena, "null", -32600, "an id is a string or a number") }, + }; + const name = method.?; + const params: ?std.json.ObjectMap = if (msg.get("params")) |p| if (p == .object) p.object else null else null; + + if (std.mem.eql(u8, name, "initialize")) return .{ .now = try initialize(host, arena, id, params) }; + if (std.mem.eql(u8, name, "ping")) return .{ .now = try result(arena, id, "{}") }; + if (std.mem.eql(u8, name, "tools/list")) return .{ .now = try listTools(host, arena, id) }; + if (std.mem.eql(u8, name, "tools/call")) return callTool(host, arena, id, params, sink); + return .{ .now = try failure(arena, id, -32601, try std.fmt.allocPrint(arena, "no method {s}", .{name})) }; +} + +/// A tool call's answer once its command answers later (`Reply.later`). +pub fn outcomeReply(arena: std.mem.Allocator, id: []const u8, outcome: sdk.Host.CommandOutcome) error{OutOfMemory}![]const u8 { + return switch (outcome) { + .ok => |zon| { + // The result as JSON text, which reads as it is; ZON JSON cannot say goes as it is. + const text = json.jsonFromZon(arena, zon) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.NotUtf8 => return toolResult(arena, id, "the result holds text that is not UTF-8", true), + // Not ZON: as it is, which JSON can carry while it is UTF-8. + else => if (std.unicode.utf8ValidateSlice(zon orelse "")) zon orelse "null" else return toolResult(arena, id, "the result is not ZON", true), + }; + return toolResult(arena, id, text, false); + }, + .unknown => failure(arena, id, -32602, "no such tool"), + .disabled => toolResult(arena, id, "the command is not enabled now", true), + .bad_args => |m| toolResult(arena, id, m, true), + .failed => |m| toolResult(arena, id, m, true), + }; +} + +fn initialize(host: *sdk.Host, arena: std.mem.Allocator, id: []const u8, params: ?std.json.ObjectMap) error{OutOfMemory}![]const u8 { + const asked = if (params) |p| if (p.get("protocolVersion")) |v| if (v == .string) v.string else "" else "" else ""; + const version = for (versions) |v| { + if (std.mem.eql(u8, v, asked)) break v; + } else versions[0]; + const body = try Stringify.valueAlloc(arena, .{ + .protocolVersion = version, + .capabilities = .{ .tools = .{ .listChanged = false } }, + .serverInfo = .{ .name = AppInfo.current.name, .title = AppInfo.current.display_name, .version = AppInfo.current.version }, + .instructions = try instructions(host, arena), + }, .{}); + return result(arena, id, body); +} + +/// What a model is told on connecting: what the tools are, and the app's own screen and settled +/// commands when it has them (fizzy does; an app built on fizzy may not). +fn instructions(host: *sdk.Host, arena: std.mem.Allocator) error{OutOfMemory}![]const u8 { + var out: std.Io.Writer.Allocating = .init(arena); + const w = &out.writer; + w.writeAll("Each tool is one of " ++ AppInfo.current.display_name ++ "'s commands, the same ones its menus, " ++ + "keybindings and plugins run. A command acting on a document takes its path, or acts on the active one.") catch return error.OutOfMemory; + for ([_][2][]const u8{ + .{ "fizzy.screen", " fizzy.screen says what is on screen." }, + .{ "fizzy.settled", " fizzy.settled waits until nothing is loading or animating." }, + }) |t| if (host.command(t[0]) != null) w.writeAll(t[1]) catch return error.OutOfMemory; + return out.written(); +} + +fn listTools(host: *sdk.Host, arena: std.mem.Allocator, id: []const u8) error{OutOfMemory}![]const u8 { + var out: std.Io.Writer.Allocating = .init(arena); + var s: Stringify = .{ .writer = &out.writer }; + writeTools(&s, host, arena) catch return error.OutOfMemory; + return result(arena, id, out.written()); +} + +fn writeTools(s: *Stringify, host: *sdk.Host, arena: std.mem.Allocator) openapi.Error!void { + try s.beginObject(); + try s.objectField("tools"); + try s.beginArray(); + for (host.commands.items, 0..) |*c, i| { + // Runnable, and the first of its id: the one `callCommand` runs. + if (c.owner == null) continue; + const first = for (host.commands.items[0..i]) |*o| { + if (std.mem.eql(u8, o.id, c.id)) break false; + } else true; + if (!first) continue; + try s.beginObject(); + try s.objectField("name"); + try s.write(c.id); + try s.objectField("title"); + try s.write(c.title); + try s.objectField("description"); + try s.write(try std.fmt.allocPrint(arena, "{s} ({s}'s command{s})", .{ + c.title, + c.owner.?.id, + if (c.visibility == .internal) ", for programs" else "", + })); + try s.objectField("inputSchema"); + try openapi.bodySchema(s, c, arena); + try s.endObject(); + } + try s.endArray(); + try s.endObject(); +} + +fn callTool(host: *sdk.Host, arena: std.mem.Allocator, id: []const u8, params: ?std.json.ObjectMap, sink: ?sdk.Host.CallSink) error{OutOfMemory}!Reply { + const p = params orelse return .{ .now = try failure(arena, id, -32602, "tools/call takes .name and .arguments") }; + const name = if (p.get("name")) |n| if (n == .string) n.string else null else null; + const tool = name orelse return .{ .now = try failure(arena, id, -32602, "tools/call needs a name") }; + const c = host.command(tool) orelse return .{ .now = try failure(arena, id, -32602, try std.fmt.allocPrint(arena, "no tool {s}", .{tool})) }; + // The arguments, through the same JSON-to-ZON edge as a REST call (`json.zig`). + // Absent and null are none. + const arguments = if (p.get("arguments")) |a| if (a == .null) "{}" else try Stringify.valueAlloc(arena, a, .{}) else "{}"; + var enum_keys: std.ArrayListUnmanaged([]const u8) = .empty; + for (c.params) |param| if (param.kind == .enumeration) try enum_keys.append(arena, param.key); + const args = json.zonFromJson(arena, arguments, enum_keys.items) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return .{ .now = try toolResult(arena, id, "the arguments are an object keyed by the tool's input schema", true) }, + }; + return switch (host.callCommandAwaiting(c.id, args, arena, sink)) { + .now => |outcome| .{ .now = try outcomeReply(arena, id, outcome) }, + .later => |ticket| if (sink != null) + .{ .later = .{ .ticket = ticket, .id = id } } + else + .{ .now = try toolResult(arena, id, "started; its answer comes later", false) }, + }; +} + +fn toolResult(arena: std.mem.Allocator, id: []const u8, text: []const u8, is_error: bool) error{OutOfMemory}![]const u8 { + const body = try Stringify.valueAlloc(arena, .{ + .content = .{.{ .type = "text", .text = text }}, + .isError = is_error, + }, .{}); + return result(arena, id, body); +} + +/// `{"jsonrpc":"2.0","id":,"result":}`: `id` and `body` already JSON. +fn result(arena: std.mem.Allocator, id: []const u8, body: []const u8) error{OutOfMemory}![]const u8 { + return std.fmt.allocPrint(arena, "{{\"jsonrpc\":\"2.0\",\"id\":{s},\"result\":{s}}}", .{ id, body }); +} + +fn failure(arena: std.mem.Allocator, id: []const u8, code: i32, message: []const u8) error{OutOfMemory}![]const u8 { + const err = try Stringify.valueAlloc(arena, .{ .code = code, .message = message }, .{}); + return std.fmt.allocPrint(arena, "{{\"jsonrpc\":\"2.0\",\"id\":{s},\"error\":{s}}}", .{ id, err }); +} diff --git a/app/control/openapi.zig b/app/control/openapi.zig index c3548688a..d15e3a345 100644 --- a/app/control/openapi.zig +++ b/app/control/openapi.zig @@ -16,7 +16,7 @@ const AppInfo = @import("../AppInfo.zig"); const json = @import("json.zig"); const Stringify = std.json.Stringify; -const Error = Stringify.Error || error{OutOfMemory}; +pub const Error = Stringify.Error || error{OutOfMemory}; /// The whole description as JSON, in `arena`. pub fn generate(host: *sdk.Host, arena: std.mem.Allocator) error{OutOfMemory}![]u8 { @@ -62,6 +62,16 @@ fn write(s: *Stringify, host: *sdk.Host, arena: std.mem.Allocator) Error!void { .summary = "This description, generated from what is registered now.", .responses = .{ .@"200" = jsonResponse("The description.", true) }, } }); + try s.objectField("/mcp"); + try s.write(.{ .post = .{ + .operationId = "control.mcp", + .summary = "The Model Context Protocol (Streamable HTTP): a JSON-RPC message, its tools the commands.", + .requestBody = .{ .required = true, .content = .{ .@"application/json" = .{ .schema = .{ .type = "object" } } } }, + .responses = .{ + .@"200" = jsonResponse("The JSON-RPC response.", .{ .type = "object" }), + .@"202" = .{ .description = "A notification, taken." }, + }, + } }); try eventsPath(s, host, arena); // The first of an id is the one the server runs (`Host.command`): a later one is not a path // of its own, which would repeat a key and an `operationId`. A command no plugin owns cannot @@ -194,27 +204,7 @@ fn commandPath(s: *Stringify, c: *const sdk.Command, arena: std.mem.Allocator) E try s.objectField("application/json"); try s.beginObject(); try s.objectField("schema"); - try s.beginObject(); - try s.objectField("type"); - try s.write("object"); - // Parameters declared: exactly those (`Command.Params` refuses others). None declared, as a - // `runWith` written by hand: whatever it reads, which the description cannot know. - if (c.params.len > 0) { - try s.objectField("additionalProperties"); - try s.write(false); - try s.objectField("properties"); - try s.beginObject(); - for (c.params) |p| { - try s.objectField(p.key); - try paramSchema(s, arena, p); - } - try s.endObject(); - try s.objectField("required"); - try s.beginArray(); - for (c.params) |p| if (p.required) try s.write(p.key); - try s.endArray(); - } - try s.endObject(); + try bodySchema(s, c, arena); try s.endObject(); try s.endObject(); try s.endObject(); @@ -231,6 +221,32 @@ fn commandPath(s: *Stringify, c: *const sdk.Command, arena: std.mem.Allocator) E try s.endObject(); } +/// The JSON Schema of what `c` takes as arguments: exactly its declared parameters +/// (`Command.Params` refuses others); any object for a `runWith` written by hand, which declares +/// none and reads whatever it reads; nothing for a command with only `run`. Shared by this +/// description's request bodies and MCP's tool inputs (`mcp.zig`). +pub fn bodySchema(s: *Stringify, c: *const sdk.Command, arena: std.mem.Allocator) Error!void { + try s.beginObject(); + try s.objectField("type"); + try s.write("object"); + if (c.params.len > 0 or c.runWith == null) { + try s.objectField("additionalProperties"); + try s.write(false); + try s.objectField("properties"); + try s.beginObject(); + for (c.params) |p| { + try s.objectField(p.key); + try paramSchema(s, arena, p); + } + try s.endObject(); + try s.objectField("required"); + try s.beginArray(); + for (c.params) |p| if (p.required) try s.write(p.key); + try s.endArray(); + } + try s.endObject(); +} + fn paramSchema(s: *Stringify, arena: std.mem.Allocator, p: sdk.Command.Param) Error!void { try s.beginObject(); try s.objectField("title"); diff --git a/app/control/routes.zig b/app/control/routes.zig index 34c97ba6e..687f7f460 100644 --- a/app/control/routes.zig +++ b/app/control/routes.zig @@ -7,6 +7,7 @@ //! GET /commands/{id} one of them //! POST /commands/{id} run it: the body is its arguments, the response its result //! GET /openapi.json all of this as OpenAPI 3.1, generated from what is registered now +//! POST /mcp the Model Context Protocol, its tools the commands (`mcp.zig`) //! GET /events the app's events as Server-Sent Events, `?topics=a,b` to choose //! (a name, or a prefix ending in `*`); every event when absent //! @@ -19,11 +20,14 @@ const sdk = @import("fizzy_sdk"); const AppInfo = @import("../AppInfo.zig"); const json = @import("json.zig"); const openapi = @import("openapi.zig"); +const mcp = @import("mcp.zig"); const Server = @import("Server.zig"); pub const Answer = struct { /// Stream the app's events to this connection instead (`GET /events`). stream: bool = false, + /// The command answers in a later frame (`Command.Call.later`): its waiter answers then. + later: bool = false, status: std.http.Status, /// Static: a string literal. content_type: []const u8 = "application/json", @@ -34,35 +38,96 @@ const zon_type = "application/zon"; /// Answer what waits on `server`. On the UI thread, once a frame, before plugins' `beginFrame`, so /// a command run here is seen by the frame that follows. -pub fn answerPending(server: *Server, relay: *Relay, host: *sdk.Host) void { +pub fn answerPending(server: *Server, state: *State, host: *sdk.Host) void { var taken: std.ArrayListUnmanaged(*Server.Request) = .empty; defer taken.deinit(server.gpa); var streaming = server.take(&taken) > 0; for (taken.items) |req| { var arena: std.heap.ArenaAllocator = .init(server.gpa); defer arena.deinit(); - const a = handle(host, req.method, req.path(), req.content_type, req.body, arena.allocator()); + // Ready for a command that answers later; let go of when it answers now. + const waiter = server.gpa.create(Waiter) catch { + req.answer(server, .internal_server_error, "text/plain", "out of memory\n"); + continue; + }; + waiter.* = .{ .server = server, .state = state, .host = host, .req = req, .zon = isZon(req.content_type) }; + const a = route(host, req.method, req.path(), req.content_type, req.body, arena.allocator(), waiter) catch Answer{ + .status = .internal_server_error, + .content_type = "text/plain", + .body = "out of memory\n", + }; + if (a.later) { + state.waiters.append(server.gpa, waiter) catch { + host.abandonCall(waiter.ticket); + req.answer(server, .internal_server_error, "text/plain", "out of memory\n"); + waiter.destroy(); + }; + continue; + } + waiter.destroy(); if (a.stream) { req.answerStream(server); streaming = true; } else req.answer(server, a.status, a.content_type, a.body); } - relay.follow(host, server, streaming); + state.follow(host, server, streaming); } -/// Stop relaying and stop `server`. On the UI thread, at quit: after it, no event reaches the -/// stopped server. -pub fn stop(server: *Server, relay: *Relay, host: *sdk.Host) void { - relay.follow(host, server, false); +/// Stop relaying and stop `server`. On the UI thread, at quit or when the setting goes off: the +/// commands still to answer are let go of, their requests answered by `server.stop`, and no event +/// reaches the stopped server. +pub fn stop(server: *Server, state: *State, host: *sdk.Host) void { + for (state.waiters.items) |w| { + host.abandonCall(w.ticket); + w.destroy(); + } + state.waiters.clearAndFree(server.gpa); + state.follow(host, server, false); server.stop(); } -/// The subscription that relays the app's events to the server's streams, owned beside the server -/// (`App.control_relay`). Its plugin is a pseudo-plugin, as fizzy's own commands have +/// A request whose command answers in a later frame: answered by the host's call to `done` +/// (`Host.finishCall`), or by `server.stop` if that comes first (`stop`). +const Waiter = struct { + server: *Server, + state: *State, + host: *sdk.Host, + req: *Server.Request, + zon: bool, + ticket: u32 = 0, + /// An MCP tool call's JSON-RPC id, as JSON (`gpa`): answered as JSON-RPC, not REST. + mcp_id: ?[]u8 = null, + + fn destroy(self: *Waiter) void { + if (self.mcp_id) |id| self.server.gpa.free(id); + self.server.gpa.destroy(self); + } + + fn done(ctx: *anyopaque, outcome: sdk.Host.CommandOutcome) void { + const self: *Waiter = @ptrCast(@alignCast(ctx)); + var arena: std.heap.ArenaAllocator = .init(self.server.gpa); + defer arena.deinit(); + const oom: Answer = .{ .status = .internal_server_error, .content_type = "text/plain", .body = "out of memory\n" }; + const a: Answer = if (self.mcp_id) |id| + if (mcp.outcomeReply(arena.allocator(), id, outcome)) |body| .{ .status = .ok, .body = body } else |_| oom + else + outcomeAnswer(arena.allocator(), self.zon, "", outcome) catch oom; + self.req.answer(self.server, a.status, a.content_type, a.body); + for (self.state.waiters.items, 0..) |w, i| if (w == self) { + _ = self.state.waiters.swapRemove(i); + break; + }; + self.destroy(); + } +}; + +/// What the routes keep between frames, owned beside the server (`App.control_state`): the requests +/// whose commands answer later, and the subscription that relays the app's events to the server's +/// streams. Its plugin is a pseudo-plugin, as fizzy's own commands have /// (`fizzy_plugin` in fizzy's `Keybinds.zig`): never in `host.plugins`, so no hook fires on it. It /// listens only while a stream is open, so with none an emitted event costs the bus its one atomic /// load, as before. -pub const Relay = struct { +pub const State = struct { plugin: sdk.Plugin = .{ .state = undefined, // the server, set when it starts listening .vtable = &relay_vtable, @@ -70,10 +135,12 @@ pub const Relay = struct { .display_name = "Control server", }, on: bool = false, + /// Requests whose commands answer in a later frame (`Waiter`). UI thread. + waiters: std.ArrayListUnmanaged(*Waiter) = .empty, const relay_vtable: sdk.Plugin.VTable = .{}; - fn follow(self: *Relay, host: *sdk.Host, server: *Server, on: bool) void { + fn follow(self: *State, host: *sdk.Host, server: *Server, on: bool) void { if (on == self.on) return; if (on) { self.plugin.state = server; @@ -105,14 +172,14 @@ fn relayEvent(state: *anyopaque, event: sdk.events.Event) void { /// Answer one request. Everything returned lives in `arena`. pub fn handle(host: *sdk.Host, method: std.http.Method, path: []const u8, content_type: ?[]const u8, body: []const u8, arena: std.mem.Allocator) Answer { - return route(host, method, path, content_type, body, arena) catch .{ + return route(host, method, path, content_type, body, arena, null) catch .{ .status = .internal_server_error, .content_type = "text/plain", .body = "out of memory\n", }; } -fn route(host: *sdk.Host, method: std.http.Method, path: []const u8, content_type: ?[]const u8, body: []const u8, arena: std.mem.Allocator) error{OutOfMemory}!Answer { +fn route(host: *sdk.Host, method: std.http.Method, path: []const u8, content_type: ?[]const u8, body: []const u8, arena: std.mem.Allocator, waiter: ?*Waiter) error{OutOfMemory}!Answer { const trimmed = std.mem.trimEnd(u8, path, "/"); if (trimmed.len == 0) { if (method != .GET and method != .HEAD) return notAllowed(arena, "GET"); @@ -136,6 +203,23 @@ fn route(host: *sdk.Host, method: std.http.Method, path: []const u8, content_typ else => notAllowed(arena, "GET"), }; } + if (std.mem.eql(u8, trimmed, "/mcp")) { + if (method != .POST) return notAllowed(arena, "POST"); + const sink: ?sdk.Host.CallSink = if (waiter) |w| .{ .ctx = w, .done = Waiter.done } else null; + return switch (try mcp.handle(host, body, arena, sink)) { + .now => |reply| .{ .status = .ok, .body = reply }, + .none => .{ .status = .accepted, .body = "" }, + .later => |l| later: { + const w = waiter.?; + w.ticket = l.ticket; + w.mcp_id = w.server.gpa.dupe(u8, l.id) catch { + host.abandonCall(l.ticket); + return failure(arena, .internal_server_error, "out of memory"); + }; + break :later .{ .status = .ok, .later = true, .body = "" }; + }, + }; + } if (std.mem.eql(u8, trimmed, "/events")) { return switch (method) { .GET => .{ .status = .ok, .stream = true, .body = "" }, @@ -151,14 +235,14 @@ fn route(host: *sdk.Host, method: std.http.Method, path: []const u8, content_typ return failure(arena, .not_found, try std.fmt.allocPrint(arena, "no command {s}", .{id})); return switch (method) { .GET, .HEAD => ok(arena, try describe(c, arena)), - .POST => call(host, c, content_type, body, arena), + .POST => call(host, c, content_type, body, arena, waiter), else => notAllowed(arena, "GET, POST"), }; } return failure(arena, .not_found, try std.fmt.allocPrint(arena, "nothing at {s}", .{path})); } -fn call(host: *sdk.Host, c: *const sdk.Command, content_type: ?[]const u8, body: []const u8, arena: std.mem.Allocator) error{OutOfMemory}!Answer { +fn call(host: *sdk.Host, c: *const sdk.Command, content_type: ?[]const u8, body: []const u8, arena: std.mem.Allocator, waiter: ?*Waiter) error{OutOfMemory}!Answer { const as_zon = isZon(content_type); const args = if (as_zon) body else args: { var enum_keys: std.ArrayListUnmanaged([]const u8) = .empty; @@ -169,7 +253,20 @@ fn call(host: *sdk.Host, c: *const sdk.Command, content_type: ?[]const u8, body: else => failure(arena, .bad_request, "the body is not JSON (send ZON as application/zon)"), }; }; - switch (host.callCommand(c.id, args, arena)) { + const sink: ?sdk.Host.CallSink = if (waiter) |w| .{ .ctx = w, .done = Waiter.done } else null; + switch (host.callCommandAwaiting(c.id, args, arena, sink)) { + .now => |outcome| return outcomeAnswer(arena, as_zon, c.id, outcome), + .later => |ticket| { + // With nobody to wait (`handle`), it has started: say so rather than hold an answer. + const w = waiter orelse return .{ .status = .accepted, .body = "null" }; + w.ticket = ticket; + return .{ .status = .ok, .later = true, .body = "" }; + }, + } +} + +fn outcomeAnswer(arena: std.mem.Allocator, as_zon: bool, id: []const u8, outcome: sdk.Host.CommandOutcome) error{OutOfMemory}!Answer { + switch (outcome) { .ok => |result| { if (as_zon) return .{ .status = .ok, .content_type = zon_type, .body = result orelse "null" }; const out = json.jsonFromZon(arena, result) catch |err| return switch (err) { @@ -179,7 +276,7 @@ fn call(host: *sdk.Host, c: *const sdk.Command, content_type: ?[]const u8, body: }; return .{ .status = .ok, .body = out }; }, - .unknown => return failure(arena, .not_found, try std.fmt.allocPrint(arena, "no command {s}", .{c.id})), + .unknown => return failure(arena, .not_found, try std.fmt.allocPrint(arena, "no command {s}", .{id})), .disabled => return failure(arena, .conflict, "the command is not enabled now"), .bad_args => |msg| return failure(arena, .bad_request, msg), .failed => |msg| return failure(arena, .internal_server_error, msg), diff --git a/app/layout/Layout.zig b/app/layout/Layout.zig index 3235487fc..63319f616 100644 --- a/app/layout/Layout.zig +++ b/app/layout/Layout.zig @@ -240,11 +240,11 @@ pub const slot_keywords: []const []const u8 = &.{"slot"}; // timing, because a sidebar and a document pane travelling at different speeds reads as broken // without ever looking wrong in a screenshot. -/// A frame's layout. Made at the top of the frame, before any place draws — which is why a view -/// being carried asks for its cursor here: dvui gives the cursor to the first to ask, and the -/// text under the pointer asking for a caret comes later. +/// A frame's layout. Made at the top of the frame, before any place draws. A view being carried +/// asks for its cursor at `.drag`, above the caret of the text under the pointer, a tab's hand or +/// a split's arrow. pub fn init(host: *sdk.Host, state: *State, gpa: std.mem.Allocator, arena: std.mem.Allocator) Layout { - if (state.view_drag.active()) dvui.cursorSet(ViewDrag.cursor); + if (state.view_drag.active()) dvui.cursorSetPriority(ViewDrag.cursor, .drag); return .{ .host = host, .state = state, .gpa = gpa, .arena = arena }; } diff --git a/app/settings/Settings.zig b/app/settings/Settings.zig index e145fa04d..3019d1f5b 100644 --- a/app/settings/Settings.zig +++ b/app/settings/Settings.zig @@ -108,6 +108,12 @@ native_dialogs: bool = true, /// for. A copy of each frame's events: nanoseconds. black_box: bool = true, +/// Whether each open document's history is recorded as it is made (`app.Tracks`): what lets the +/// transport's strip show it and its export share it as a tape. Kept in memory with the +/// document; nothing is written unless the person exports. Off, documents keep only their undo +/// history, and what was recorded is forgotten. +record_history: bool = true, + /// Whether a rebuild of the app's own executable restarts the app into it, keeping every open /// document (`ExecutableWatcher`). On in a source build, which is what rebuilds; off in a release /// built to update itself (Velopack), whose binary only changes through its own updater. diff --git a/backend/build.zig b/backend/build.zig index 43bd5ac22..30817b4a4 100644 --- a/backend/build.zig +++ b/backend/build.zig @@ -72,6 +72,8 @@ pub const unit_tests = [_]struct { name: []const u8, root: []const u8 }{ // The backend's health counters: frame-time percentiles, per-frame counts, SDL's log counted // from any thread, a snapshot as ZON. .{ .name = "backend-health-tests", .root = "src/Health.zig" }, + // Which touch button each finger on the screen presses; a trackpad's fingers, none. + .{ .name = "backend-touch-slots-tests", .root = "src/touch_slots.zig" }, }; /// Where the macOS SDK is, for a macOS target that is not the host's own (`-Dtarget=aarch64-macos` diff --git a/backend/src/SDLBackend.zig b/backend/src/SDLBackend.zig index b3044fab5..3f188f979 100644 --- a/backend/src/SDLBackend.zig +++ b/backend/src/SDLBackend.zig @@ -16,6 +16,7 @@ const dvui = @import("dvui"); const GpuRenderer = @import("GpuRenderer.zig"); pub const viewport_map = @import("viewport_map.zig"); +const TouchSlots = @import("touch_slots.zig"); /// The OS windows besides the main one an app draws with (`Viewport`). pub const viewports = @import("viewports.zig"); /// dvui's floating windows as OS windows of their own from the one frame (`floatingWindowOs`). @@ -68,6 +69,8 @@ begin_hook: ?*const fn (*SDLBackend) void = null, present_hook: ?*const fn (*SDLBackend) void = null, touch_mouse_events: bool = false, +/// The touch button each finger on the screen presses. +touch_slots: TouchSlots = .{}, log_events: bool = false, last_pixel_size: dvui.Size.Physical = .{ .w = 800, .h = 600 }, last_window_size: dvui.Size.Natural = .{ .w = 800, .h = 600 }, @@ -2935,6 +2938,24 @@ fn trackGeometry(self: *SDLBackend) void { self.window_geometry = .{ .x = x, .y = y, .w = w, .h = h }; } +comptime { + std.debug.assert(TouchSlots.mouse_touch_id == c.SDL_MOUSE_TOUCHID); + std.debug.assert(TouchSlots.pen_touch_id == c.SDL_PEN_TOUCHID); +} + +fn touchButton(slot: u4) dvui.enums.Button { + return @fromBackingInt(@intCast(@backingInt(dvui.enums.Button.touch0) + @as(u8, slot))); +} + +/// Lift every finger the window will not hear lift: it lost focus, is going to the background or +/// is closing. A held slot would stay held for good, and dvui would keep a finger down. +fn releaseTouches(self: *SDLBackend, win: *dvui.Window) !void { + const freed = self.touch_slots.releaseAll(); + for (0..10) |i| if (freed & (@as(u10, 1) << @intCast(i)) != 0) { + _ = try win.addEventPointer(.{ .button = touchButton(@intCast(i)), .action = .release }); + }; +} + /// Send an SDL_Event to a dvui.Window /// /// Return true if the event is to be handled by a subwindow. @@ -3114,26 +3135,30 @@ pub fn addEvent(self: *SDLBackend, win: *dvui.Window, event: c.SDL_Event) !bool } return ret; }, - if (sdl3) c.SDL_EVENT_FINGER_DOWN else c.SDL_FINGERDOWN => { - if (self.log_events) { - log.debug("event FINGERDOWN {d} {d} {d}\n", .{ if (sdl3) event.tfinger.fingerID else event.tfinger.fingerId, event.tfinger.x, event.tfinger.y }); - } - - return try win.addEventPointer(.{ .button = .touch0, .action = .press, .xynorm = .{ .x = event.tfinger.x, .y = event.tfinger.y } }); + // A finger on the screen presses the touch button of its slot; a trackpad's, a pen's and + // SDL's mouse touches have none (`touch_slots.zig`). + c.SDL_EVENT_FINGER_DOWN => { + const f = event.tfinger; + if (self.log_events) log.debug("event FINGERDOWN {d} {d} {d} {d}\n", .{ f.touchID, f.fingerID, f.x, f.y }); + const device = TouchSlots.deviceOf(f.touchID, c.SDL_GetTouchDeviceType(f.touchID) == c.SDL_TOUCH_DEVICE_DIRECT); + const slot = self.touch_slots.down(device, f.touchID, f.fingerID) orelse return false; + return try win.addEventPointer(.{ .button = touchButton(slot), .action = .press, .xynorm = .{ .x = f.x, .y = f.y } }); }, - if (sdl3) c.SDL_EVENT_FINGER_UP else c.SDL_FINGERUP => { - if (self.log_events) { - log.debug("event FINGERUP {d} {d} {d}\n", .{ if (sdl3) event.tfinger.fingerID else event.tfinger.fingerId, event.tfinger.x, event.tfinger.y }); - } - - return try win.addEventPointer(.{ .button = .touch0, .action = .release, .xynorm = .{ .x = event.tfinger.x, .y = event.tfinger.y } }); + c.SDL_EVENT_FINGER_MOTION => { + const f = event.tfinger; + if (self.log_events) log.debug("event FINGERMOTION {d} {d} {d} {d} {d} {d}\n", .{ f.touchID, f.fingerID, f.x, f.y, f.dx, f.dy }); + const slot = self.touch_slots.find(f.touchID, f.fingerID) orelse return false; + return try win.addEventTouchMotion(touchButton(slot), f.x, f.y, f.dx, f.dy); }, - if (sdl3) c.SDL_EVENT_FINGER_MOTION else c.SDL_FINGERMOTION => { - if (self.log_events) { - log.debug("event FINGERMOTION {d} {d} {d} {d} {d}\n", .{ if (sdl3) event.tfinger.fingerID else event.tfinger.fingerId, event.tfinger.x, event.tfinger.y, event.tfinger.dx, event.tfinger.dy }); - } - - return try win.addEventTouchMotion(.touch0, event.tfinger.x, event.tfinger.y, event.tfinger.dx, event.tfinger.dy); + c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => { + const f = event.tfinger; + if (self.log_events) log.debug("event FINGERUP {d} {d} {d} {d}\n", .{ f.touchID, f.fingerID, f.x, f.y }); + const slot = self.touch_slots.up(f.touchID, f.fingerID) orelse return false; + return try win.addEventPointer(.{ .button = touchButton(slot), .action = .release, .xynorm = .{ .x = f.x, .y = f.y } }); + }, + c.SDL_EVENT_WILL_ENTER_BACKGROUND => { + try self.releaseTouches(win); + return false; }, if (sdl3) c.SDL_EVENT_WINDOW_FOCUS_GAINED else c.SDL_WINDOWEVENT_FOCUS_GAINED => { if (self.log_events) { @@ -3153,6 +3178,7 @@ pub fn addEvent(self: *SDLBackend, win: *dvui.Window, event: c.SDL_Event) !bool if (self.log_events) { log.debug("event FOCUS_LOST\n", .{}); } + try self.releaseTouches(win); if (dvui.accesskit_enabled and builtin.target.os.tag == .linux) { dvui.AccessKit.c.accesskit_unix_adapter_update_window_focus_state(win.accesskit.adapter, false); } else if (dvui.accesskit_enabled and builtin.target.os.tag == .macos) { @@ -3193,6 +3219,7 @@ pub fn addEvent(self: *SDLBackend, win: *dvui.Window, event: c.SDL_Event) !bool if (self.log_events) { log.debug("SDL event window close\n", .{}); } + try self.releaseTouches(win); try win.addEventWindow(.{ .action = .close }); return false; }, diff --git a/backend/src/touch_slots.zig b/backend/src/touch_slots.zig new file mode 100644 index 000000000..269744d5e --- /dev/null +++ b/backend/src/touch_slots.zig @@ -0,0 +1,123 @@ +//! Which of dvui's touch buttons (`touch0` to `touch9`) each finger on the screen presses. +//! +//! dvui reads a touch button as a finger on the screen at the event's point: it sets +//! `dvui.pointerKind()` to `.touch`, and a lifted finger hovers nothing. Only a direct touch is +//! that. SDL also reports a trackpad's fingers as touches (on macOS, with a device of type +//! `SDL_TOUCH_DEVICE_INDIRECT_*`), at a point on the pad rather than in the window; the trackpad +//! already moves the mouse and clicks, so those get no slot and reach dvui as nothing. Nothing in +//! fizzy or its plugins read them: they arrived as `touch0` presses at the pad's point, mapped +//! into the window. +//! +//! SDL also registers two touch devices of its own as direct: `SDL_MOUSE_TOUCHID`, for the touches +//! it synthesizes from a mouse, and `SDL_PEN_TOUCHID`, for a pen's (`SDL_HINT_PEN_TOUCH_EVENTS`, +//! on by default). A pen already reaches dvui as the mouse; read as a finger too, a tablet stroke +//! would flip `dvui.pointerKind()` with every press. Neither is a finger (`deviceOf`). +//! +//! Each finger keeps its slot from down to up, so two fingers are two buttons (a pinch), and one +//! lifting releases its own button, not another finger's. A lift the window never hears of (it +//! lost focus, went to the background or closed mid-touch) would hold its slot for good, and +//! dvui would think a finger is still down: the backend releases every held slot then +//! (`releaseAll`). +const std = @import("std"); + +pub const Device = enum { direct, indirect }; + +/// `SDL_MOUSE_TOUCHID` and `SDL_PEN_TOUCHID` (`(SDL_TouchID)-1` and `-2`). +pub const mouse_touch_id: u64 = std.math.maxInt(u64); +pub const pen_touch_id: u64 = std.math.maxInt(u64) - 1; + +/// A touch device as dvui is to see it: a finger on the screen (`direct`, as SDL reports its type) +/// unless it is SDL's own mouse or pen device. +pub fn deviceOf(touch: u64, sdl_direct: bool) Device { + if (touch == mouse_touch_id or touch == pen_touch_id) return .indirect; + return if (sdl_direct) .direct else .indirect; +} + +const Finger = struct { touch: u64, finger: u64 }; + +fingers: [10]?Finger = @splat(null), + +const TouchSlots = @This(); + +/// A finger landed: its slot, or null for a finger dvui is not to see (not on the screen, or an +/// eleventh). +pub fn down(self: *TouchSlots, device: Device, touch: u64, finger: u64) ?u4 { + if (device != .direct) return null; + if (self.find(touch, finger)) |i| return i; + for (&self.fingers, 0..) |*slot, i| if (slot.* == null) { + slot.* = .{ .touch = touch, .finger = finger }; + return @intCast(i); + }; + return null; +} + +/// A finger moved: the slot it landed in, if it was given one. +pub fn find(self: *const TouchSlots, touch: u64, finger: u64) ?u4 { + for (self.fingers, 0..) |slot, i| if (slot) |f| { + if (f.touch == touch and f.finger == finger) return @intCast(i); + }; + return null; +} + +/// A finger lifted (or was cancelled): the slot it frees, if it had one. +pub fn up(self: *TouchSlots, touch: u64, finger: u64) ?u4 { + const i = self.find(touch, finger) orelse return null; + self.fingers[i] = null; + return i; +} + +/// Free every held slot, for a window that will not hear their lifts: a bit per slot freed, from +/// slot 0. +pub fn releaseAll(self: *TouchSlots) u10 { + var freed: u10 = 0; + for (&self.fingers, 0..) |*slot, i| if (slot.* != null) { + slot.* = null; + freed |= @as(u10, 1) << @intCast(i); + }; + return freed; +} + +test "SDL's mouse and pen touch devices are not fingers" { + try std.testing.expectEqual(.indirect, deviceOf(mouse_touch_id, true)); + try std.testing.expectEqual(.indirect, deviceOf(pen_touch_id, true)); + try std.testing.expectEqual(.direct, deviceOf(42, true)); + try std.testing.expectEqual(.indirect, deviceOf(42, false)); +} + +test "lifts the window never hears of free every slot" { + var slots: TouchSlots = .{}; + for (0..10) |i| _ = slots.down(.direct, 1, i); + try std.testing.expectEqual(null, slots.down(.direct, 1, 10)); + _ = slots.up(1, 3); + try std.testing.expectEqual(@as(u10, 0b11_1111_0111), slots.releaseAll()); + try std.testing.expectEqual(@as(u10, 0), slots.releaseAll()); + // Touch works again after. + try std.testing.expectEqual(0, slots.down(.direct, 1, 99)); +} + +test "a trackpad's fingers get no slot" { + var slots: TouchSlots = .{}; + try std.testing.expectEqual(null, slots.down(.indirect, 1, 7)); + try std.testing.expectEqual(null, slots.find(1, 7)); + try std.testing.expectEqual(null, slots.up(1, 7)); +} + +test "each finger on the screen keeps its own slot until it lifts" { + var slots: TouchSlots = .{}; + try std.testing.expectEqual(0, slots.down(.direct, 1, 100)); + try std.testing.expectEqual(1, slots.down(.direct, 1, 200)); + try std.testing.expectEqual(1, slots.find(1, 200)); + // The first lifts: its own slot is freed, the second keeps its. + try std.testing.expectEqual(0, slots.up(1, 100)); + try std.testing.expectEqual(1, slots.find(1, 200)); + // A new finger takes the free slot. + try std.testing.expectEqual(0, slots.down(.direct, 1, 300)); + // The same finger id on another device is another finger. + try std.testing.expectEqual(2, slots.down(.direct, 2, 100)); +} + +test "an eleventh finger is not seen" { + var slots: TouchSlots = .{}; + for (0..10) |i| try std.testing.expect(slots.down(.direct, 1, i) != null); + try std.testing.expectEqual(null, slots.down(.direct, 1, 10)); +} diff --git a/build/app.zig b/build/app.zig index 3065ea672..10429f692 100644 --- a/build/app.zig +++ b/build/app.zig @@ -556,7 +556,7 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. // web.js, index.html, NotoSansKR-Regular.ttf}`, deployable as-is to a static host. // --------------------------------------------------------------- - web.addSteps(b, optimize, build_opts, workbench_opts, assets_module, app_icon_module, opts.app_plugins, opts.web_plugin_deps, opts.web_plugin_dirs); + web.addSteps(b, optimize, build_opts, workbench_opts, assets_module, app_icon_module, opts.app_plugins, opts.web_plugin_deps, opts.web_plugin_dirs, opts.own != null); const main_fizzy = try fizzy_exe.addFizzyExecutableForTarget(b, vz, target, optimize, accesskit, native_backend, build_opts, workbench_opts, assets_module, app_icon_module, cfg.branding.rc, macos_sdl_paths, velopack_enabled, app_name, app_layout_path, opts.app_plugins, dynamic_plugins); const exe = main_fizzy.exe; diff --git a/build/sdk.zig b/build/sdk.zig index 0a340532a..05c6aa212 100644 --- a/build/sdk.zig +++ b/build/sdk.zig @@ -115,7 +115,12 @@ pub fn wireAppModule( app_module.addImport("tape", core_module.import_table.get("tape").?); app_module.addImport("replay", core_module.import_table.get("replay").?); app_module.addImport("crash", crashModule(b, target, optimize)); - if (consumer) |c| c.addImport("app", app_module); + if (consumer) |c| { + c.addImport("app", app_module); + // The bundled demos' scripts (`src/editor/demos/`) need only `tape`, so they build on the + // host too (`zig build demos`). + c.addImport("tape", core_module.import_table.get("tape").?); + } return app_module; } diff --git a/build/web.zig b/build/web.zig index 36750ad65..126cd4bc1 100644 --- a/build/web.zig +++ b/build/web.zig @@ -16,6 +16,9 @@ pub fn addSteps( app_plugins: []const sdk.BundledPlugin, web_plugin_deps: []const []const u8, web_plugin_dirs: []const @import("app.zig").WebPluginDir, + /// Fizzy's own build (`Options.own`): the hosted demo tapes come from `docs/demos/` and + /// `scripts/`, which fizzy's package leaves out, so an app built on fizzy has none. + own: bool, ) void { const web_target = b.resolveTargetQuery(.{ .cpu_arch = .wasm32, @@ -296,6 +299,16 @@ pub fn addSteps( web_install_dir, "oauth-callback.html", ).step); + // The tapes the page can fetch (`?demo=demos/.tape`), built from this commit's scripts. + if (own) { + const install_demos = b.addInstallDirectory(.{ + .source_dir = demoTapes(b), + .install_dir = web_install_dir, + .install_subdir = "demos", + }); + web_step.dependOn(&install_demos.step); + b.step("demos", "Build every hosted demo tape into zig-out/web/demos/").dependOn(&install_demos.step); + } for (web_pages.items) |page| { const web_dir = b.pathJoin(&.{ page.dir, "web" }); var d = b.root.openDir(b.graph.io, web_dir, .{ .iterate = true }) catch { @@ -361,3 +374,39 @@ fn stampStorage(b: *std.Build) std.Build.LazyPath { const stamped = std.mem.replaceOwned(u8, b.allocator, html, token, name) catch @panic("OOM"); return b.addWriteFiles().add("index.html", stamped); } + +/// Every tape fizzy hosts, built on the host (`scripts/demos.zig`): the bundled demos from their +/// scripts, which need only `tape`, and each hand-written tape in `docs/demos/`. +fn demoTapes(b: *std.Build) std.Build.LazyPath { + const tape = b.createModule(.{ + .target = b.graph.host, + .root_source_file = b.path("sdk/tape/root.zig"), + }); + const demos = b.createModule(.{ + .target = b.graph.host, + .root_source_file = b.path("src/editor/demos/catalog.zig"), + }); + demos.addImport("tape", tape); + const tool = b.addExecutable(.{ + .name = "demos", + .root_module = b.createModule(.{ + .target = b.graph.host, + .root_source_file = b.path("scripts/demos.zig"), + }), + }); + tool.root_module.addImport("tape", tape); + tool.root_module.addImport("demos", demos); + const run = b.addRunArtifact(tool); + const out = run.addOutputDirectoryArg("demos"); + // Declared, so a demo added or removed reconfigures the build. + b.dependOnDirectoryContents(b.path("docs/demos")); + var dir = b.root.openDir(b.graph.io, "docs/demos", .{ .iterate = true }) catch |err| + std.debug.panic("docs/demos: {t}", .{err}); + defer dir.close(b.graph.io); + var it = dir.iterate(); + while (it.next(b.graph.io) catch null) |entry| { + if (entry.kind != .file or !std.mem.endsWith(u8, entry.name, ".zon")) continue; + run.addFileArg(b.path(b.pathJoin(&.{ "docs/demos", entry.name }))); + } + return out; +} diff --git a/core/AGENTS.md b/core/AGENTS.md index e9d0574f1..d81662b95 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -28,6 +28,18 @@ paths, fuzzy matching, the LSP client, the profiler. selects instead of expanding, until you type. Fizzy's backends set `win.modifiers` from the OS before each mouse event (`backend/src/SDLBackend.zig`, `src/backend/WebBackend.zig`); a new backend must too. +- **Touch or mouse: ask dvui, never infer it.** `dvui.pointerKind()` is the window's answer, + set as the backend adds events, the same in the app, a plugin dylib and a wasm side module. + State kept by scanning events (a global, a per-frame key) differs per module and per frame. + A lifted finger hovers nothing: dvui puts the frame's `.position` event off every window, so a + hover look needs no touch check of its own. The native backend gives only fingers on the screen + touch buttons (`backend/src/touch_slots.zig`); a trackpad's fingers move the mouse. +- **A cursor that must win asks with a priority.** `dvui.cursorSet` keeps the first asked in a + frame; `dvui.cursorSetPriority` the highest. Splits and a float's corner ask at `.resize`, a + carried view at `.drag`, so a hover cursor (a tab's hand) never takes their arrow. The cursor + only: which widget takes a press is still drawing order, so ask at `.resize` only where the + handle takes the press before what is under it. A float's edges, handled after its children, + stay at `.normal`. - **Very long lists:** dvui clamps every min size at `max_float_safe` (2e6, about 93k rows at 21.5 pt), so the scroll range silently ends early. A widget not drawn on a frame loses its min size, so a row scrolled back into view is zero-height for a frame. A virtualized list must diff --git a/core/sizing_tests.zig b/core/sizing_tests.zig index 99da0a345..c892336bb 100644 --- a/core/sizing_tests.zig +++ b/core/sizing_tests.zig @@ -12,6 +12,8 @@ test { _ = @import("widgets/Split.zig"); _ = @import("widgets/ContextWidget.zig"); + // A tab's hand beside a split's arrow, and a tapped tab's hover look. + _ = @import("widgets/Tabs.zig"); // The local copies of upstream's docking + blur (see `widgets.zig`): referenced here so they // are analysed — and their own tests run — even before anything in the app draws them. _ = @import("widgets/DockingWidget.zig"); diff --git a/core/widgets/FloatingWindowWidget.zig b/core/widgets/FloatingWindowWidget.zig index d72612aa5..2f088fcc8 100644 --- a/core/widgets/FloatingWindowWidget.zig +++ b/core/widgets/FloatingWindowWidget.zig @@ -813,7 +813,7 @@ pub fn processEventsBefore(self: *FloatingWindowWidget) void { } if (me.action == .position) { e.handle(@src(), self.data()); // don't want any widgets under this to see a hover - dvui.cursorSet(.arrow_nw_se); + dvui.cursorSetPriority(.arrow_nw_se, .resize); continue; } } @@ -890,6 +890,10 @@ pub fn processEventsAfter(self: *FloatingWindowWidget) void { } else { if (self.init_options.resize == .none) continue; } + // At `.normal`: this runs after the children, which take a press here + // first, so the edge's arrow must not outrank their cursors. The corner, + // which takes its press before them (`processEventsBefore`), asks at + // `.resize`. dvui.cursorSet(self.cursorFor(dp)); }, else => {}, diff --git a/core/widgets/Split.zig b/core/widgets/Split.zig index df8f7a58d..9d2a92fb6 100644 --- a/core/widgets/Split.zig +++ b/core/widgets/Split.zig @@ -435,7 +435,10 @@ pub fn grab(self: *Split, container: *dvui.WidgetData) Grab { }; } }, - .position => dvui.cursorSet(cursor), + // Over the edge of a neighbour asking for a cursor of its own (a tab's hand), the + // split's arrow says what a press beside the sash does: it outranks a hover cursor + // whichever was drawn first. The cursor only: which widget takes a press is unchanged. + .position => dvui.cursorSetPriority(cursor, .resize), else => {}, } } diff --git a/core/widgets/Tabs.zig b/core/widgets/Tabs.zig index 1c7d22ce3..56dc86666 100644 --- a/core/widgets/Tabs.zig +++ b/core/widgets/Tabs.zig @@ -204,10 +204,18 @@ pub const Tab = struct { pub fn deinit(self: *Tab) void { _ = self.clicked(); + handCursor(self.box.data()); self.box.deinit(); } }; +/// A tab's cursor, for every tab strip (this one's, and one that draws its own tabs, as the +/// workbench's document tabs): the hand under the pointer, and while the tab is held. A split +/// beside the strip outranks it where their reaches overlap (`Split.grab`). +pub fn handCursor(wd: *dvui.WidgetData) void { + if (dvui.captured(wd.id) or @import("../widgets.zig").hovered(wd)) dvui.cursorSet(.hand); +} + /// One tab, keyed by `key`. `name` is what it is called — the view's title — for a screen reader /// or a script, which a rail of icons does not otherwise show; it must outlive the frame. pub fn tab(self: *Tabs, src: std.builtin.SourceLocation, key: usize, selected: bool, name: []const u8) Tab { @@ -260,3 +268,128 @@ pub fn deinit(self: *Tabs) void { } self.outer.deinit(); } + +// ── Tests ─────────────────────────────────────────────────────────────────────────────────── +// +// Against dvui's testing backend: the cursor and the hover look are decided by dvui's event +// routing (`cursorSetPriority`, the `.position` event a lifted finger leaves off every window). + +const Split = @import("Split.zig"); + +var t_cursor: dvui.enums.Cursor = .arrow; +var t_hovered = false; +var t_tab_captured = false; +var t_split_captured = false; +var t_tab_rect: dvui.Rect.Physical = .{}; +var t_sep_x: f32 = 0; +var t_scale: f32 = 1; + +/// A rail beside a split: the sidebar's icons next to the explorer's edge, where a cell runs the +/// rail's full width up to the split. +fn railFrame() !dvui.App.Result { + var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both }); + defer row.deinit(); + { + var left = dvui.box(@src(), .{ .dir = .vertical }, .{ .min_size_content = .{ .w = 100 }, .expand = .vertical }); + defer left.deinit(); + var strip: Tabs = .init(@src(), .{ .dir = .vertical, .scroll = false, .outer = .{ .expand = .both } }); + defer strip.deinit(); + var t = strip.tab(@src(), 1, false, "one"); + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 20, .h = 40 } }); + _ = t.clicked(); + t_tab_rect = t.box.data().borderRectScale().r; + t_tab_captured = dvui.captured(t.box.data().id); + t_hovered = @import("../widgets.zig").hovered(t.box.data()); + t.deinit(); + } + var divider = Split.init(@src(), .horizontal, 0, null); + { + const srs = divider.box.data().borderRectScale(); + t_sep_x = srs.r.x + srs.r.w / 2; + t_scale = srs.s; + _ = divider.grab(row.data()); + t_split_captured = dvui.captured(divider.box.data().id); + } + divider.deinit(); + { + var right = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both }); + defer right.deinit(); + } + t_cursor = dvui.currentWindow().cursorRequested(); + return .ok; +} + +test "a tab shows the hand" { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 400, .h = 300 } }); + defer t.deinit(); + try dvui.testing.settle(railFrame); + + const cw = dvui.currentWindow(); + _ = try cw.addEventMouseMotion(.{ .pt = .{ .x = t_tab_rect.x + 10 * t_scale, .y = t_tab_rect.center().y } }); + _ = try dvui.testing.step(railFrame); + try std.testing.expectEqual(.hand, t_cursor); + try std.testing.expect(t_hovered); + + // Held, it keeps the hand (its hover look gives way to the press). + _ = try cw.addEventMouseButton(.left, .press); + _ = try dvui.testing.step(railFrame); + try std.testing.expect(t_tab_captured); + try std.testing.expectEqual(.hand, t_cursor); +} + +test "beside a split, the split's arrow wins over the hand, and a press goes where it did" { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 400, .h = 300 } }); + defer t.deinit(); + try dvui.testing.settle(railFrame); + + // Over the tab's last few points, inside the split's reach (`Split.handle_size` from its + // centre line). + const p: dvui.Point.Physical = .{ .x = t_sep_x - (Split.handle_size - 2) * t_scale, .y = t_tab_rect.center().y }; + try std.testing.expect(t_tab_rect.contains(p)); + + const cw = dvui.currentWindow(); + _ = try cw.addEventMouseMotion(.{ .pt = p }); + _ = try dvui.testing.step(railFrame); + try std.testing.expectEqual(.arrow_w_e, t_cursor); + + // The cursor only: the tab, drawn first, still takes the press there, as it did before it + // asked for a cursor at all. + _ = try cw.addEventMouseButton(.left, .press); + _ = try dvui.testing.step(railFrame); + try std.testing.expect(t_tab_captured); + try std.testing.expect(!t_split_captured); + _ = try cw.addEventMouseButton(.left, .release); + _ = try dvui.testing.step(railFrame); + + // On the sash itself, past the tab, the split takes it. + _ = try cw.addEventMouseMotion(.{ .pt = .{ .x = t_sep_x, .y = p.y } }); + _ = try dvui.testing.step(railFrame); + _ = try cw.addEventMouseButton(.left, .press); + _ = try dvui.testing.step(railFrame); + try std.testing.expect(t_split_captured); + try std.testing.expect(!t_tab_captured); +} + +test "a tapped tab is left without a hover look" { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 400, .h = 300 } }); + defer t.deinit(); + try dvui.testing.settle(railFrame); + + const cw = dvui.currentWindow(); + const p: dvui.Point.Physical = .{ .x = t_tab_rect.x + 10 * t_scale, .y = t_tab_rect.center().y }; + const xynorm: dvui.Point = .{ .x = p.x / cw.rect_pixels.w, .y = p.y / cw.rect_pixels.h }; + _ = try cw.addEventPointer(.{ .button = .touch0, .action = .press, .xynorm = xynorm }); + _ = try dvui.testing.step(railFrame); + _ = try cw.addEventPointer(.{ .button = .touch0, .action = .release, .xynorm = xynorm }); + _ = try dvui.testing.step(railFrame); + try std.testing.expectEqual(.touch, dvui.pointerKind()); + try std.testing.expect(!t_hovered); + _ = try dvui.testing.step(railFrame); + try std.testing.expect(!t_hovered); + + // A mouse over the same tab lights it. + _ = try cw.addEventMouseMotion(.{ .pt = p }); + _ = try dvui.testing.step(railFrame); + try std.testing.expectEqual(.mouse, dvui.pointerKind()); + try std.testing.expect(t_hovered); +} diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 6df89c98a..023f9e57b 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -30,6 +30,16 @@ a `Stage`; fizzy's glass overlay and its plugin service are host framework (`app | Desktop | `FIZZY_DEMO=tour fizzy` plays from launch (how a screen recording is made); `FIZZY_DEMO=.zon` (or `.tape`) plays a tape file, a live one through the live driver | | Web | `?demo=tour` plays a bundled demo; `?demo=.zon` (or `.tape`) fetches a tape and plays it. Add `&storage=` to an embed so it keeps its own settings and layout | +**Hosted tapes.** `zig build demos` (part of `zig build web`) writes every tape fizzy hosts into +`zig-out/web/demos/`: the bundled demos built from their scripts, and each hand-written tape in +`docs/demos/`, all in the deflated binary form, with an `index.json` naming each one's title, +length and whether it is live (`scripts/demos.zig`). Each copy of the app on the site serves the +tapes built from its own commit, so a page embeds `fizzyed.it/app/?demo=demos/hello.tape` and +the tape always matches the app that plays it. A new tape for the site is a `.zon` in +`docs/demos/`, and nothing else. A bundled demo plays without fetching anything (`?demo=tour`); +its tape is for a player that is not that build of fizzy. The scripts in `src/editor/demos/` need +only `tape`, so the tool builds them on the host; one that needs more breaks `zig build demos`. + While a demo plays: - **A click, tap, scroll or key pauses it.** A pointer event goes on to do what the person meant; @@ -310,7 +320,7 @@ would play a document that never changes. The app applies them through its stage Fizzy applies them: a tape's steps are checked against the plugins loaded (`Demo.stepsCheck`: the owner loaded, writing that step format) and played on the demo stage into the documents its keyframe opened. A document's own history becomes such a tape with `fizzy.tracks.export` (while -tracks record, `FIZZY_TRACKS=1` or `fizzy.tracks.record`): its file as the track began, then its +tracks record: by default, the History setting's "Record document history"): its file as the track began, then its steps, pauses past 2 s played as 0.6 s unless `real_time`. `fizzy.demo.playTape` plays it. A link plays one on the web: `fizzyed.it/app?tape=` fetches the tape, loads each plugin its @@ -490,4 +500,3 @@ to keyframes. `src/editor/Demo.zig` and `src/Entry.zig` are the worked example. so the recording is size-independent, and writes the tape in its binary form. - **Quick open over mounts** — the palette's index should come from `core.FileTable`, which already lists mounts; then a demo can show it. -- **A `demos` build step** that writes every bundled demo as a tape, for a site to host. diff --git a/docs/CONTROL.md b/docs/CONTROL.md index d39007aef..16f6304ec 100644 --- a/docs/CONTROL.md +++ b/docs/CONTROL.md @@ -47,6 +47,7 @@ fizzy with its own Windows target must set it too, or its server logs that it ca | `POST /commands/{id}` | runs it: the body is its arguments, the response its result | | `GET /events?topics=a,b` | the app's events as Server-Sent Events | | `GET /openapi.json` | all of this as OpenAPI 3.1, generated from what is registered now | +| `POST /mcp` | the Model Context Protocol, its tools the commands | **Bodies are JSON**, converted to the ZON commands take at the server's edge: an object of arguments keyed by the command's parameter names, a string where the parameter is an enumeration @@ -74,7 +75,14 @@ Those about the request itself, refused before the app sees it, are plain text: | 503 | the control server is stopping (fizzy is quitting, or the setting went off) | A command runs on the UI thread at the start of the next frame, as a menu item or a keybind would, -so the frame after it shows what it did. A call costs one wake and returns within a frame. +so the frame after it shows what it did. A call costs one wake and returns within a frame, unless +the command answers later (`Command.Call.later`: a build, a reply from a language server): then the +request stays open until it does. Two of fizzy's own are like that, for scripts and tests: + +```sh +curl --unix-socket "$SOCK" localhost/commands/fizzy.settled -d '{"timeout_ms":5000}' # true once nothing is in flight +curl --unix-socket "$SOCK" localhost/commands/fizzy.screen # what is on screen: windows, widgets, rects +``` ## Events @@ -91,6 +99,22 @@ data: {"path":"/abs/notes.md"} keep-alive comment every 15 seconds; one that falls more than 8 MB behind gets nothing more. With no stream open, events cost the bus nothing extra. +## MCP + +`POST /mcp` speaks the Model Context Protocol's Streamable HTTP transport: one JSON-RPC message +per request, answered as JSON. Its tools are the commands. `tools/list` reads the registry, each +tool's `inputSchema` is the body schema `/openapi.json` gives the command, and `tools/call` runs it +as `POST /commands/{id}` would. A command that answers later answers its call then. + +```sh +curl --unix-socket "$SOCK" localhost/mcp -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +curl --unix-socket "$SOCK" localhost/mcp -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"fizzy.screen"}}' +``` + +It answers `initialize`, `ping`, `tools/list` and `tools/call`, and notifications get a `202`. There +is no server-sent stream yet (`GET /mcp`), no `Mcp-Session-Id`, and no resources or prompts. An +MCP client that can only dial a URL or speak stdio reaches it through a proxy for now. + ## The description `GET /openapi.json` describes the server as it is now, so it changes as plugins load and reload. diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index d22714006..867ff54ea 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -110,6 +110,26 @@ backend and never link SDL, so an SDL bump is never an SDK release and never mov proposing. Test: dvui's `zig build test -Dbackend=testing`, "closed RenderFrontToBack blocks are drawn after draw-last strokes, before or after they closed". +11. **Window: the pointer kind, and a lifted finger hovers nothing** (`e04bccc`). + `dvui.pointerKind()` is `.touch` or `.mouse`: what the last pointer event came from, set as the + backend adds events (a touch button is `.touch`; mouse motion, a mouse button or the wheel is + `.mouse`). It is the window's, so the app and every plugin dylib or wasm side module read the + same answer. The window keeps which touch buttons are down; after a touch with none, the + frame's `.position` event is off every window and targets no widget, so a tapped button, menu + row or tab is not left lit. Fizzy's hover looks and the corner button's tap (#555) read it. + Upstream: worth proposing. Test: "a finger that lifts hovers nothing, and a mouse hovers again". +12. **cursorSet: a priority, so a resize handle's cursor wins** (`20e56f1`). + `dvui.cursorSetPriority(cursor, priority)` keeps the highest asked in a frame (between + equals, the first); `cursorSet` asks at `.normal`. Resize handles ask at `.resize` (dvui's + `PanedWidget`, `GridWidget` columns, a floating window's bottom-right corner; fizzy's + `Split`), a carried thing at `.drag` (fizzy's view drag), so a hover cursor such as a tab's + hand never takes a split's arrow. Which widget takes a press is unchanged. Upstream: worth + proposing. Test: "cursorSetPriority: the higher asked wins, whichever asked first". +13. **FloatingWindowWidget: edges ask for their arrow at normal priority** (`40e664c`). Its + edges are handled after the window's children, which take a press there first, so their arrow + stays at `.normal` and a child under the band keeps both its cursor and its press. Only the + bottom-right corner, handled before the children, asks at `.resize`. Upstream: with 12. + Each of 5 and 6 is also a branch of its own on upstream `main` (`window-draw-retained`, `screens-set`), from which the upstream PR is opened. @@ -137,7 +157,9 @@ moves, so the second pin of one version takes a suffix, as the SDL fork's tags d (never pinned on `main`: its counter was reworked before merge); `fizzy-sdk-0.2.30-1` → `e2c9a85` (item 9 alone, never pinned); `fizzy-sdk-0.2.30-2` → `fdc03d4` (pinned by #540); `fizzy-sdk-0.2.30-3` → `cc60612` (never -pinned); `fizzy-sdk-0.2.30-4` → `44010a3`, what `sdk/build.zig.zon` pins now. +pinned); `fizzy-sdk-0.2.30-4` → `44010a3`; `fizzy-sdk-0.2.30-5` → +`20e56f1` (never pinned on `main`); `fizzy-sdk-0.2.30-6` → `40e664c`, what `sdk/build.zig.zon` +pins now. Fizzy builds dvui in its `custom` backend mode on every target and links its own backends (`backend/`, `src/backend/WebBackend.zig` + `web/web.js`), so dvui's `sdl3` and `web` diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index c0b0bb437..6d1be2af9 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -959,6 +959,15 @@ fn goToLine(state: *anyopaque, args: GoToLine.Args, call: *sdk.Command.Call) any (a list for an enum or bool, checked text otherwise — `sdk.Command.answer`). - **Failing.** Return `call.fail("…", .{})`. Your error's *name* cannot be read on fizzy's side (errors are numbered per compilation), so the message is how the reason gets there. +- **Answering later.** A command whose work outlives the frame (a language server's reply, a + build, a picture taken at the end of the next frame) calls `const ticket = call.later();`, keeps + the ticket, and returns from `runWith` with no result. Once it is done, on the UI thread, it calls + `host.finishCall(ticket, .{ .ok = result_zon })` (or `.failed = "why"`). A caller that can wait + (`host.callCommandAwaiting`, the control server, which holds its HTTP request open) hears the + answer then. One that cannot (a menu, a keybind, a tape, plain `callCommand`) has only started it: + it sees `.ok` with no result and goes on at once, and the answer is dropped. You may answer before + `runWith` returns (a cache hit); the caller then gets it at once. `host.callWanted(ticket)` says whether anyone still waits, so work nobody wants + can stop. If your plugin goes away owing answers, its waiters hear that it failed. - **`run` and `runWith`.** A command needs at least one; `registerCommand` refuses one with neither. Keep `run` alongside `runWith` only if invoking with no arguments should do something of its own (open your own dialog, say) rather than ask. diff --git a/plugins/text/plugin.zig b/plugins/text/plugin.zig index 5b1ca1eee..3a9110339 100644 --- a/plugins/text/plugin.zig +++ b/plugins/text/plugin.zig @@ -403,12 +403,23 @@ fn historyLine(_: *anyopaque, handle: DocHandle, arena: std.mem.Allocator) anyer const doc = docFrom(handle) orelse return error.DocumentNotFound; const steps = try doc.history.line(arena); const entries = try arena.alloc(sdk.Plugin.HistoryLine.Entry, steps.len); - for (steps, entries) |st, *e| e.* = .{ .id = st.step, .title = switch (st.kind) { + for (steps, entries) |st, *e| e.* = .{ .id = st.step, .title = kindTitle(st.kind) }; + // A step of a jump back reads as the move it is part of: "Back to Typing". + for (steps, entries) |st, *e| { + const to = st.back_to orelse continue; + const what = for (steps) |o| { + if (o.step == to) break kindTitle(o.kind); + } else "the Start"; + e.title = try std.fmt.allocPrint(arena, "Back to {s}", .{what}); + } + return .{ .version = doc.history.version, .entries = entries, .at = doc.history.undo_stack.items.len }; +} +fn kindTitle(kind: @import("src/textcore/textcore.zig").History.EditKind) []const u8 { + return switch (kind) { .typing_other, .typing_first_space, .typing_consecutive_space => "Typing", .deleting_left, .deleting_right => "Delete", .other => "Edit", - } }; - return .{ .version = doc.history.version, .entries = entries, .at = doc.history.undo_stack.items.len }; + }; } fn historyGoTo(_: *anyopaque, handle: DocHandle, id: u64) anyerror!void { const doc = docFrom(handle) orelse return error.DocumentNotFound; diff --git a/plugins/text/src/Document.zig b/plugins/text/src/Document.zig index 40a20eca5..19190d3a4 100644 --- a/plugins/text/src/Document.zig +++ b/plugins/text/src/Document.zig @@ -168,6 +168,9 @@ preview_split_ratio_user: f32 = 0.5, /// Undo/redo history — see `textcore.History` for the capture + grouping strategy. history: tc.History = .{}, +/// Who last went back in the history (`undo`): the steps an edit after it adds for the jump are +/// theirs (`recordGoneBack`). +went_back_by: sdk.Host.Acting = .{}, /// `history.topOpId()` as of the last successful save; the document is dirty exactly when /// the two differ. Deliberately an id, not `undo.items.len` — a length can return to its /// saved value via undo-then-new-edit while content differs from disk, but an id can't: @@ -550,7 +553,9 @@ pub fn replaceRange(self: *Document, start: usize, end: usize, new: []const u8) pub fn undo(self: *Document) void { const gpa = sdk.allocator(); const sel = self.history.applyUndo(gpa, &self.text) orelse return; - if (self.history.lastUndone()) |s| self.recordEdits(.undone, s.step, s.edits, s.sel_before, true); + // Whoever goes back owns the jump, when an edit later makes it steps (`recordGoneBack`). + self.went_back_by = sdk.host().acted(); + if (self.history.lastUndone()) |s| self.recordEdits(.undone, s.step, s.edits, s.sel_before, true, null); self.refreshLineCount(); self.sel_start = @min(sel.start(), self.text.items.len); self.sel_end = @min(sel.end(), self.text.items.len); @@ -562,7 +567,7 @@ pub fn undo(self: *Document) void { pub fn redo(self: *Document) void { const gpa = sdk.allocator(); const sel = self.history.applyRedo(gpa, &self.text) orelse return; - if (self.history.lastApplied()) |s| self.recordEdits(.redone, s.step, s.edits, s.sel_after, false); + if (self.history.lastApplied()) |s| self.recordEdits(.redone, s.step, s.edits, s.sel_after, false, null); self.refreshLineCount(); self.sel_start = @min(sel.start(), self.text.items.len); self.sel_end = @min(sel.end(), self.text.items.len); @@ -579,16 +584,33 @@ fn recordStep(self: *const Document, step: sdk.Step) void { sdk.host().recordStep(self.id, step); } -/// What `history.end` or `pushComplete` did, reported as a step. +/// What `history.end` or `pushComplete` did, reported as a step: after the steps an edit after +/// going back put on the line (`recordGoneBack`). pub fn recordCommitted(self: *const Document, c: tc.History.Committed) void { - self.recordEdits(if (c.joined) .joined else .added, c.step, c.edits, c.sel_after, false); + if (c.back > 0) self.recordGoneBack(c.back); + self.recordEdits(if (c.joined) .joined else .added, c.step, c.edits, c.sel_after, false, null); +} + +/// The steps an edit after going back put on the line below it (`tc.History.keepGoneBack`): the +/// `n` gone back over, applied again, then their reverses. Reported as whoever went back, so an +/// agent's next edit never makes it the author of taking back the person's work. +fn recordGoneBack(self: *const Document, n: usize) void { + const host = sdk.host(); + const outer = host.actAgain(self.went_back_by); + defer host.endAct(outer); + // Below the commit: the reverses (1..n), then the steps gone back over (n+1..2n). Oldest first. + var i: usize = 2 * n; + while (i > 0) : (i -= 1) { + const s = self.history.appliedBelowTop(i) orelse continue; + if (i > n) self.recordEdits(.redone, s.step, s.edits, s.sel_after, false, s.step) else self.recordEdits(.added, s.step, s.edits, s.sel_after, false, s.step); + } } /// `edits` of history step `id` reported as a step, forward or (`reverse`) as their undo, `sel` /// the selection they leave. -fn recordEdits(self: *const Document, kind: sdk.Step.Kind, id: u64, edits: []const tc.Transaction.Edit, sel: tc.Range, reverse: bool) void { +fn recordEdits(self: *const Document, kind: sdk.Step.Kind, id: u64, edits: []const tc.Transaction.Edit, sel: tc.Range, reverse: bool, on_top: ?u64) void { // Every step reaches the host, which keeps who made it; its bytes only while it is recorded. - const top: u64 = if (self.history.lastApplied()) |r| r.step else 0; + const top: u64 = on_top orelse if (self.history.lastApplied()) |r| r.step else 0; if (!sdk.host().recording(self.id)) return self.recordStep(.{ .kind = kind, .id = id, .top = top }); const gpa = sdk.allocator(); var out: std.Io.Writer.Allocating = .init(gpa); diff --git a/plugins/text/src/textcore/History.zig b/plugins/text/src/textcore/History.zig index 5f00dbebb..18e6fffd4 100644 --- a/plugins/text/src/textcore/History.zig +++ b/plugins/text/src/textcore/History.zig @@ -113,6 +113,9 @@ const Entry = struct { /// The step's own id, given when it is pushed and kept through merges (unlike `id`): what a /// timeline names it by (`line`, `stepsTo`). step: u64, + /// A step that takes another back, appended when an edit landed after going back + /// (`keepGoneBack`): the step the jump went back to, 0 before the first. + back_to: ?u64 = null, fn deinit(self: *Entry, gpa: Allocator) void { self.txn.deinit(gpa); @@ -196,6 +199,9 @@ pub const Committed = struct { /// Valid until the history next changes. edits: []const Transaction.Edit, sel_after: Range, + /// How many steps had been gone back over (undone) when it landed: they are on the line + /// again, followed by their reverses, just below it (`keepGoneBack`, `goneBack`). + back: usize = 0, }; /// A step as the history holds it: its id, its edits, and the selection either side. Valid until @@ -261,9 +267,9 @@ pub fn end(self: *History, gpa: Allocator, sel_after: Range, may_join: bool) ?Co .other; const kind = classify(removed_total, inserted_total, pos, p.cursor_before, prev_kind); - self.clearRedo(gpa); + const back = self.keepGoneBack(gpa); - const can_merge = may_join and self.group_open and + const can_merge = back == 0 and may_join and self.group_open and self.undo_stack.items.len > 0 and !shouldBreakGroup(prev_kind, kind); @@ -301,7 +307,7 @@ pub fn end(self: *History, gpa: Allocator, sel_after: Range, may_join: bool) ?Co }; self.next_id += 1; const top = &self.undo_stack.items[self.undo_stack.items.len - 1]; - committed = .{ .step = top.step, .joined = false, .edits = top.txn.edits.items, .sel_after = sel_after }; + committed = .{ .step = top.step, .joined = false, .edits = top.txn.edits.items, .sel_after = sel_after, .back = back }; } self.group_open = true; self.version += 1; @@ -332,18 +338,60 @@ pub fn pushComplete( /// Take `txn`, already applied, as a step of its own: a recorded step played into the document /// (`Transaction.applyStepTo`). pub fn push(self: *History, gpa: Allocator, txn: Transaction) !Committed { - self.clearRedo(gpa); + const back = self.keepGoneBack(gpa); try self.undo_stack.append(gpa, .{ .txn = txn, .kind = .other, .id = self.next_id, .step = self.next_id }); self.next_id += 1; self.group_open = false; self.version += 1; const top = &self.undo_stack.items[self.undo_stack.items.len - 1]; - return .{ .step = top.step, .joined = false, .edits = top.txn.edits.items, .sel_after = top.txn.sel_after }; -} - -fn clearRedo(self: *History, gpa: Allocator) void { + return .{ .step = top.step, .joined = false, .edits = top.txn.edits.items, .sel_after = top.txn.sel_after, .back = back }; +} + +/// Going back is a step (`plans/HISTORY_TIMELINE_PLAN.md`): before an edit lands after going +/// back, the steps gone back over (the redo stack) go back on the line as they were, then each +/// one's reverse, newest first. The text is where it was gone back to, as before, but nothing on +/// the line is lost: dragging to a step gone back over brings it back like any other. Answers how +/// many steps were gone back over; the line grew by twice that. Out of memory, they are dropped, +/// as redo always was. +fn keepGoneBack(self: *History, gpa: Allocator) usize { + const gone = self.redo_stack.items; + if (gone.len == 0) return 0; + const back_to: u64 = if (self.lastApplied()) |s| s.step else 0; + kept: { + const reverses = gpa.alloc(Transaction, gone.len) catch break :kept; + defer gpa.free(reverses); + // The redo stack holds the furthest step gone back over first: its reverse comes first. + var made: usize = 0; + for (gone, reverses) |e, *r| { + r.* = e.txn.inverted(gpa) catch break; + made += 1; + } + const room = if (made < gone.len) false else if (self.undo_stack.ensureUnusedCapacity(gpa, gone.len * 2)) true else |_| false; + if (!room) { + for (reverses[0..made]) |*r| r.deinit(gpa); + break :kept; + } + const n = gone.len; + // As they were, the nearest first. + while (self.redo_stack.pop()) |e| self.undo_stack.appendAssumeCapacity(e); + for (reverses) |r| { + self.undo_stack.appendAssumeCapacity(.{ .txn = r, .kind = .other, .id = self.next_id, .step = self.next_id, .back_to = back_to }); + self.next_id += 1; + } + self.version += 1; + return n; + } + std.log.err("History: the steps gone back over were dropped: out of memory", .{}); for (self.redo_stack.items) |*e| e.deinit(gpa); self.redo_stack.clearRetainingCapacity(); + return 0; +} + +/// The `i`th step below the top of the line (0 is the top), as `lastApplied` gives the top: how +/// a caller reads the steps `keepGoneBack` added below a commit. +pub fn appliedBelowTop(self: *const History, i: usize) ?StepRef { + const items = self.undo_stack.items; + return if (i >= items.len) null else .of(&items[items.len - 1 - i]); } // -- replay --------------------------------------------------------------------------------------- @@ -393,15 +441,15 @@ pub fn applyRedo(self: *History, gpa: Allocator, text: *std.ArrayList(u8)) ?Rang // -- the line ------------------------------------------------------------------------------------- /// One step of `line`. -pub const Step = struct { step: u64, kind: EditKind }; +pub const Step = struct { step: u64, kind: EditKind, back_to: ?u64 = null }; /// Every step, oldest first: the undo stack, then the redo stack from the next redo on. The /// first `undo_stack.items.len` are applied. pub fn line(self: History, arena: Allocator) ![]Step { const out = try arena.alloc(Step, self.undo_stack.items.len + self.redo_stack.items.len); - for (self.undo_stack.items, 0..) |e, i| out[i] = .{ .step = e.step, .kind = e.kind }; + for (self.undo_stack.items, 0..) |e, i| out[i] = .{ .step = e.step, .kind = e.kind, .back_to = e.back_to }; const redo = self.redo_stack.items; - for (redo, 0..) |e, i| out[out.len - 1 - i] = .{ .step = e.step, .kind = e.kind }; + for (redo, 0..) |e, i| out[out.len - 1 - i] = .{ .step = e.step, .kind = e.kind, .back_to = e.back_to }; return out; } @@ -634,15 +682,68 @@ test "merging still bumps the id so dirty tracking stays correct" { try t.expect(h.hist.topOpId() != id_after_save); } -test "a new edit after undo clears the redo stack" { +test "an edit after going back keeps what was gone back over, then its reverse" { var h: Harness = .{ .gpa = t.allocator }; defer h.deinit(); + var arena: std.heap.ArenaAllocator = .init(t.allocator); + defer arena.deinit(); + + try h.type_("abc"); // "abc" + try h.backspace(1); // "ab" + try h.type_("\n"); // "ab\n" + const before = try h.hist.line(arena.allocator()); + try t.expectEqual(@as(usize, 3), before.len); - try h.type_("abc"); _ = h.undo(); - try t.expect(h.hist.canRedo()); - try h.type_("z"); + _ = h.undo(); + try t.expectEqualStrings("abc", h.text.items); + h.hist.begin(.collapsed(h.cursor)); + try h.text.insertSlice(h.gpa, h.cursor, "z"); + h.hist.note(h.gpa, h.cursor, "", "z"); + h.cursor += 1; + const c = h.hist.end(h.gpa, .collapsed(h.cursor), true).?; + try t.expectEqualStrings("abcz", h.text.items); + // Nothing to redo, and nothing lost: the two steps gone back over, their reverses, the edit. try t.expect(!h.hist.canRedo()); + try t.expectEqual(@as(usize, 2), c.back); + try t.expect(!c.joined); + const after = try h.hist.line(arena.allocator()); + try t.expectEqual(@as(usize, 6), after.len); + for (before, after[0..3]) |a, b| try t.expectEqual(a.step, b.step); + for (after[3..5]) |r| try t.expectEqual(@as(?u64, before[0].step), r.back_to); + try t.expectEqual(@as(?u64, null), after[5].back_to); + // The commit's reverses sit just below it, the step gone back to's reverse nearest. + try t.expectEqual(after[4].step, h.hist.appliedBelowTop(1).?.step); + + // Every point on the line is still there to go to. + const goTo = struct { + fn f(hh: *Harness, step: u64) void { + const n = hh.hist.stepsTo(step).?; + for (0..@abs(n)) |_| _ = if (n < 0) hh.undo() else hh.redo(); + } + }.f; + goTo(&h, before[2].step); + try t.expectEqualStrings("ab\n", h.text.items); + goTo(&h, after[3].step); + try t.expectEqualStrings("ab", h.text.items); + goTo(&h, before[0].step); + try t.expectEqualStrings("abc", h.text.items); + goTo(&h, after[5].step); + try t.expectEqualStrings("abcz", h.text.items); +} + +test "an edit with nothing gone back over adds only itself" { + var h: Harness = .{ .gpa = t.allocator }; + defer h.deinit(); + try h.type_("ab"); + h.hist.closeGroup(); + h.hist.begin(.collapsed(h.cursor)); + try h.text.insertSlice(h.gpa, h.cursor, "\n"); + h.hist.note(h.gpa, h.cursor, "", "\n"); + h.cursor += 1; + const c = h.hist.end(h.gpa, .collapsed(h.cursor), true).?; + try t.expectEqual(@as(usize, 0), c.back); + try t.expectEqual(@as(usize, 2), h.steps()); } test "undo/redo round-trips a long mixed session" { diff --git a/plugins/text/src/textcore/Transaction.zig b/plugins/text/src/textcore/Transaction.zig index f1e66870f..7d3ef8215 100644 --- a/plugins/text/src/textcore/Transaction.zig +++ b/plugins/text/src/textcore/Transaction.zig @@ -70,6 +70,22 @@ pub fn append( }); } +/// This transaction's undo as a transaction of its own (owned): its edits newest first, each +/// with `removed` and `inserted` swapped, and the selections swapped. Applying it forward is +/// `applyInverse`. +pub fn inverted(self: Transaction, gpa: Allocator) !Transaction { + var out: Transaction = .{ .sel_before = self.sel_after, .sel_after = self.sel_before }; + errdefer out.deinit(gpa); + try out.edits.ensureTotalCapacity(gpa, self.edits.items.len); + var i = self.edits.items.len; + while (i > 0) { + i -= 1; + const e = self.edits.items[i]; + try out.append(gpa, e.pos, e.inserted, e.removed); + } + return out; +} + /// Re-apply (redo): forward, in order. Either the whole transaction lands or the buffer is /// left unchanged — a mid-list failure used to leave a half-applied state that made every /// subsequent undo retry hit `EditOutOfRange`. diff --git a/plugins/workbench/src/Workspace.zig b/plugins/workbench/src/Workspace.zig index dd2e02c81..7aee8a5e9 100644 --- a/plugins/workbench/src/Workspace.zig +++ b/plugins/workbench/src/Workspace.zig @@ -311,6 +311,7 @@ fn drawTabs(self: *Workspace, region: sdk.Host.Region, tabs: []const *sdk.Surfac // Not under a carried tab: what is under it is where it would go in, not a tab to close. const tab_hovered = carried == null and core.widgets.hovered(hbox.data()); + core.widgets.Tabs.handCursor(hbox.data()); // A tab not on show lifts to the window's hover fill under the pointer. hbox.data().borderAndBackground(.{ .fill_color = if (tab_hovered and !is_selected) diff --git a/scripts/ci-retry.sh b/scripts/ci-retry.sh index 08287cb14..78c991c4a 100755 --- a/scripts/ci-retry.sh +++ b/scripts/ci-retry.sh @@ -23,7 +23,7 @@ while :; do status="${PIPESTATUS[0]}" [ "$status" -eq 0 ] && exit 0 - if ! grep -qE 'HttpConnectionClosing|invalid HTTP response|ConnectionResetByPeer|ConnectionTimedOut|TemporaryNameServerFailure|unable to fetch|TlsInitializationFailed|UnexpectedEndOfStream' "$log"; then + if ! grep -qE 'HttpConnectionClosing|invalid HTTP response|ConnectionResetByPeer|ConnectionTimedOut|NameServerFailure|unable to discover remote git server capabilities|unable to fetch|bad HTTP response code: .5|TlsInitializationFailed|UnexpectedEndOfStream' "$log"; then echo "::notice::not a fetch failure — not retrying" exit "$status" fi diff --git a/scripts/demos.zig b/scripts/demos.zig new file mode 100644 index 000000000..12000d9f4 --- /dev/null +++ b/scripts/demos.zig @@ -0,0 +1,104 @@ +//! `zig build demos`: every tape fizzy hosts, in the binary form, deflated, for a page to fetch. +//! The bundled demos (`src/editor/demos/catalog.zig`) are built from their scripts, and each +//! hand-written tape (`docs/demos/*.zon`) is read and written again. Beside them, `index.json` +//! says what each one is, for a page that lists them: +//! +//! ```json +//! [{"name":"tour","title":"A Tour of Fizzy","file":"tour.tape","ms":69449,"live":false}, …] +//! ``` +//! +//! `zig build web` puts them in `zig-out/web/demos/`, so each copy of the app on the site serves +//! the tapes built from its own commit (`?demo=demos/tour.tape`). A bundled demo plays without +//! fetching anything (`?demo=tour`); its tape is for a player that is not this build of fizzy. +//! +//! Each tape is read back from the bytes written and must be the tape it was, and is checked as +//! the app checks one (`Tape.Check`): its chords in the keymap's spelling. A tape with steps is +//! refused, since only the app knows which plugins' steps it applies (`Demo.stepsCheck`). +//! +//! Usage: demos ... +const std = @import("std"); +const tape = @import("tape"); +const catalog = @import("demos"); + +const Tape = tape.Tape; + +const check: Tape.Check = .{ .key = struct { + fn ok(spelled: []const u8) bool { + _ = tape.chord.parseKeys(spelled, .other) catch return false; + return true; + } +}.ok }; + +const Row = struct { name: []const u8, title: []const u8, file: []const u8, ms: u32, live: bool }; + +pub fn main(init: std.process.Init) !void { + const arena = init.arena.allocator(); + const gpa = init.gpa; + const io = init.io; + const args = try init.minimal.args.toSlice(arena); + if (args.len < 2) { + std.debug.print("usage: demos ...\n", .{}); + std.process.exit(2); + } + const cwd = std.Io.Dir.cwd(); + try cwd.createDirPath(io, args[1]); + var out = try cwd.openDir(io, args[1], .{}); + defer out.close(io); + + var rows: std.ArrayList(Row) = .empty; + for (&catalog.entries) |e| { + var script: tape.Script = .init(gpa, e.name, e.title); + script.check = check; + e.build(&script) catch |err| { + script.deinit(); + return fail("demo '{s}' did not build: {t}", .{ e.name, err }); + }; + var owned = try script.finish(); + defer owned.deinit(); + try rows.append(arena, try emit(arena, gpa, io, out, rows.items, e.name, owned.tape, false)); + } + for (args[2..]) |path| { + const bytes = try cwd.readFileAlloc(io, path, arena, .limited(64 << 20)); + var live = false; + var owned = Tape.load(gpa, bytes, check) catch |err| switch (err) { + // A live tape plays on the app as it is, as `Demo.playTape` takes one. + error.NoKeyframeAtStart => blk: { + live = true; + var c = check; + c.live = true; + break :blk Tape.load(gpa, bytes, c) catch |e| return fail("{s}: {t}", .{ path, e }); + }, + else => return fail("{s}: {t}", .{ path, err }), + }; + defer owned.deinit(); + const name = std.fs.path.stem(path); + try rows.append(arena, try emit(arena, gpa, io, out, rows.items, name, owned.tape, live)); + } + + var index: std.Io.Writer.Allocating = .init(arena); + try std.json.Stringify.value(rows.items, .{ .whitespace = .indent_2 }, &index.writer); + try index.writer.writeByte('\n'); + try out.writeFile(io, .{ .sub_path = "index.json", .data = index.written() }); +} + +/// Write `t` as `.tape` in `out`, having read it back from those bytes. The row is `arena`'s: +/// the tape goes before the index is written. +fn emit(arena: std.mem.Allocator, gpa: std.mem.Allocator, io: std.Io, out: std.Io.Dir, rows: []const Row, name: []const u8, t: Tape, live: bool) !Row { + for (rows) |r| if (std.mem.eql(u8, r.name, name)) return fail("two tapes are named '{s}'", .{name}); + var bytes: std.Io.Writer.Allocating = .init(arena); + try tape.binary.write(gpa, t, &bytes.writer, .{ .deflate = true }); + var c = check; + c.live = live; + var back = tape.binary.read(gpa, bytes.written(), c) catch |err| return fail("{s}: its tape does not read back: {t}", .{ name, err }); + defer back.deinit(); + if (back.tape.ops.len != t.ops.len or back.tape.duration() != t.duration()) + return fail("{s}: its tape reads back as another", .{name}); + const file = try std.fmt.allocPrint(arena, "{s}.tape", .{name}); + try out.writeFile(io, .{ .sub_path = file, .data = bytes.written() }); + return .{ .name = try arena.dupe(u8, name), .title = try arena.dupe(u8, t.title), .file = file, .ms = t.duration(), .live = live }; +} + +fn fail(comptime fmt: []const u8, args: anytype) error{DemoFailed} { + std.debug.print("demos: " ++ fmt ++ "\n", args); + return error.DemoFailed; +} diff --git a/sdk/build.zig.zon b/sdk/build.zig.zon index dc4e57fc6..26e235959 100644 --- a/sdk/build.zig.zon +++ b/sdk/build.zig.zon @@ -41,8 +41,8 @@ // working branch and can be rebased freely — the tag, never. .dvui = .{ // zig-0.17 branch (zig/0.17 pins a commit; tagged when it merges to main) - .url = "git+https://github.com/foxnne/dvui-dev#8fa9b9c95a0156f0432753a53aa4e6e364b757d3", - .hash = "dvui-0.6.0-dev-AQFJmTePBAGK0KDGkqXrunv-ITWyshn9EpeUIFRlzRNj", + .url = "git+https://github.com/foxnne/dvui-dev#1b29a16d0340005c0d5ed2b689a7ffab797b1c37", + .hash = "dvui-0.6.0-dev-AQFJmXGtBAE8dNSt4nGJ7ToMZ9HR7rdMVubvTlhzO-mk", //.path = "../../dvui-dev", }, .zf = .{ diff --git a/sdk/src/Command.zig b/sdk/src/Command.zig index 026a1dc39..f1069319a 100644 --- a/sdk/src/Command.zig +++ b/sdk/src/Command.zig @@ -111,6 +111,26 @@ pub const Call = struct { /// stroke by stroke): for how long, in ms as written (`core.motion`). A recorded session /// gives its step that long, so a replay goes from one to the next without a pause. showing_ms: u32 = 0, + /// This call's ticket, set by the host: what `later` hands back. + ticket: u32 = 0, + /// Set by `later`: the answer comes in a later frame, through `Host.finishCall`. + deferred: bool = false, + /// Set when a deferred call was answered as failed before `runWith` returned (`Host.finishCall` + /// on the running call): `message` says why. + failed: bool = false, + + /// Answer in a later frame instead of now: for a command whose work outlives the frame (a + /// language server's reply, a build, a picture taken at the end of the next frame). Keep the + /// ticket, return from `runWith` without a result, and call `Host.finishCall(ticket, …)` once + /// it is done, on the UI thread (a worker posts back to the UI thread first). Answering before + /// `runWith` returns (a cache hit) is fine: the caller gets it now. A caller that can wait + /// (`Host.callCommandAwaiting`, the control server) hears the answer then; one that cannot (a + /// menu, a keybind, a tape) has only started it, and the answer is dropped. + /// `Host.callWanted(ticket)` says whether anyone still waits: stop when nobody does. + pub fn later(self: *Call) u32 { + self.deferred = true; + return self.ticket; + } /// Return `value` (anything `std.zon.stringify` can write) as the command's result. pub fn returns(self: *Call, value: anytype) error{OutOfMemory}!void { diff --git a/sdk/src/Host.zig b/sdk/src/Host.zig index 0658b4f91..bc15cf853 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -314,6 +314,12 @@ step_authors: std.AutoHashMapUnmanaged(u64, DocAuthors) = .empty, /// tests assert it stays 0. unattributed_steps: u64 = 0, next_claim_id: u32 = 1, +/// Commands answering in a later frame (`Command.Call.later`), until `finishCall`. UI thread. +calls_pending: std.ArrayListUnmanaged(PendingCall) = .empty, +next_call_ticket: u32 = 1, +/// The `runWith` call running now, innermost (`callResolved`): `finishCall` on its ticket answers +/// it before it returns. +running_call: ?*Command.Call = null, /// Pluggable language/format support (syntax highlighting, preview panes). language_support: std.ArrayListUnmanaged(LanguageSupport) = .empty, /// Who listens for which events (`subscribe`), and what plugins say they announce @@ -389,6 +395,7 @@ pub fn deinit(self: *Host) void { self.command_slots.deinit(self.allocator); self.language_support.deinit(self.allocator); self.subscriptions.deinit(self.allocator); + self.calls_pending.deinit(self.allocator); self.event_declarations.deinit(self.allocator); for (self.event_queue.items) |e| freeQueued(self.allocator, e); self.event_queue.deinit(self.allocator); @@ -952,6 +959,7 @@ pub fn unregisterPlugin(self: *Host, plugin: *Plugin) void { removeOwned(accounts.Provider, &self.account_providers, plugin); removeOwned(NativeMenuItem, &self.native_menu_items, plugin); self.removeCommandsOf(plugin); + self.failCallsOf(plugin); { var i: usize = 0; while (i < self.claims.items.len) { @@ -1004,6 +1012,7 @@ pub fn unregisterPlugin(self: *Host, plugin: *Plugin) void { pub fn withdrawPlugin(self: *Host, plugin: *Plugin) void { removeOwned(ServiceEntry, &self.services, plugin); self.removeCommandsOf(plugin); + self.failCallsOf(plugin); removeOwned(LanguageSupport, &self.language_support, plugin); self.removeEventsOf(plugin); } @@ -1794,9 +1803,10 @@ pub fn actAgain(self: *Host, stored: Acting) Acting { return outer; } -/// Who is acting now, to store with work done later (`actAgain`). +/// Who is acting now, to store with work done later (`actAgain`): the innermost `act`, else the +/// frame's input author (the person at the keys), as `author` answers. pub fn acted(self: *const Host) Acting { - return self.acting; + return .{ .author = self.acting.author orelse self.input_author, .run = self.acting.run }; } pub fn endAct(self: *Host, outer: Acting) void { @@ -2021,6 +2031,31 @@ pub fn runCommand(self: *Host, id: []const u8) !void { } } +/// A command answering in a later frame, and who waits for it. +pub const PendingCall = struct { + ticket: u32, + owner: *Plugin, + /// Who waits. Kept only while someone does (a call nobody waits for keeps nothing). + sink: CallSink, +}; + +/// Who hears a later answer (`callCommandAwaiting`): `done` is called once, on the UI thread, with +/// what came of it. What `outcome` holds is borrowed for the call. +pub const CallSink = struct { + ctx: *anyopaque, + done: *const fn (ctx: *anyopaque, outcome: CommandOutcome) void, + /// The plugin whose image holds `done` and `ctx`, when a plugin waits: its sinks are let go of + /// when it goes, never called into an image that is gone. Null for the app. + owner: ?*Plugin = null, +}; + +/// What `callCommandAwaiting` answers: what came of the command now, or the ticket of its answer +/// to come, which the sink hears. +pub const AwaitedOutcome = union(enum) { + now: CommandOutcome, + later: u32, +}; + /// What came of `callCommand`. Results and messages live in the arena the caller passed. pub const CommandOutcome = union(enum) { /// It ran. Its result as ZON, or null when it returns nothing. @@ -2128,10 +2163,21 @@ fn freeQueued(allocator: std.mem.Allocator, e: events.Queued) void { } pub fn callCommand(self: *Host, id: []const u8, args: []const u8, results: std.mem.Allocator) CommandOutcome { + return switch (self.callCommandAwaiting(id, args, results, null)) { + .now => |out| out, + // Started; nobody here waits for the answer. + .later => .{ .ok = null }, + }; +} + +/// `callCommand` for a caller that can wait: a command answering in a later frame +/// (`Command.Call.later`) gives its ticket, and `sink` hears the answer once it comes +/// (`finishCall`). A null sink drops it, as `callCommand` does. +pub fn callCommandAwaiting(self: *Host, id: []const u8, args: []const u8, results: std.mem.Allocator, sink: ?CallSink) AwaitedOutcome { // A copy, not the registry's entry: the body may register, replace or unregister commands. - const c = (self.command(id) orelse return .unknown).*; - if (self.inProgress(id)) return .{ .failed = "it called itself; an override reaches what it replaced with callReplaced" }; - return self.callResolved(c, args, results, false); + const c = (self.command(id) orelse return .{ .now = .unknown }).*; + if (self.inProgress(id)) return .{ .now = .{ .failed = "it called itself; an override reaches what it replaced with callReplaced" } }; + return self.callResolved(c, args, results, false, sink); } /// From inside `caller`'s override of `id` (`Command.replaces`): call what it replaced, the next @@ -2142,7 +2188,10 @@ pub fn callReplaced(self: *Host, caller: *Plugin, id: []const u8, args: []const const slot = self.command_slots.get(id) orelse return .unknown; const i = slot.overrideOf(caller) orelse return .unknown; const below = if (i > 0) slot.overrides.items[i - 1] else if (slot.bases.items.len > 0) slot.bases.items[0] else return .unknown; - return self.callResolved(below, args, results, true); + return switch (self.callResolved(below, args, results, true, null)) { + .now => |out| out, + .later => .{ .ok = null }, + }; } /// Whether an override of `id` is running, or what one reached through `callReplaced`: run by id @@ -2153,31 +2202,105 @@ fn inProgress(self: *Host, id: []const u8) bool { } /// `replaced`: reached through `callReplaced`, so running inside an override (`inProgress`). -fn callResolved(self: *Host, c: Command, args: []const u8, results: std.mem.Allocator, replaced: bool) CommandOutcome { - const owner = c.owner orelse return .unknown; +fn callResolved(self: *Host, c: Command, args: []const u8, results: std.mem.Allocator, replaced: bool, sink: ?CallSink) AwaitedOutcome { + const owner = c.owner orelse return .{ .now = .unknown }; if (c.isEnabled) |enabled| { - if (!enabled(owner.state)) return .disabled; + if (!enabled(owner.state)) return .{ .now = .disabled }; } const prof = core.profile.begin(owner.id, c.id); defer prof.end(); const run_with = c.runWith orelse { const trimmed = std.mem.trim(u8, args, " \t\r\n"); - if (trimmed.len > 0 and !std.mem.eql(u8, trimmed, ".{}")) return .{ .bad_args = "this command takes no arguments" }; + if (trimmed.len > 0 and !std.mem.eql(u8, trimmed, ".{}")) return .{ .now = .{ .bad_args = "this command takes no arguments" } }; const outer = self.running(c, replaced); defer self.ran(c, outer, replaced); - c.run.?(owner.state) catch |err| return .{ .failed = owner.errorName(err) }; - return .{ .ok = null }; + c.run.?(owner.state) catch |err| return .{ .now = .{ .failed = owner.errorName(err) } }; + return .{ .now = .{ .ok = null } }; }; self.last_showing_ms = 0; - var call: Command.Call = .{ .args = args, .arena = results }; + // Its own ticket, taken before it runs: a command it calls that defers takes the next. + const ticket = self.next_call_ticket; + self.next_call_ticket +%= 1; + if (self.next_call_ticket == 0) self.next_call_ticket = 1; + var call: Command.Call = .{ .args = args, .arena = results, .ticket = ticket }; const outer = self.running(c, replaced); defer self.ran(c, outer, replaced); + const outer_call = self.running_call; + self.running_call = &call; + defer self.running_call = outer_call; run_with(owner.state, &call) catch |err| { const msg = call.message orelse owner.errorName(err); - return if (call.bad_args) .{ .bad_args = msg } else .{ .failed = msg }; + return .{ .now = if (call.bad_args) .{ .bad_args = msg } else .{ .failed = msg } }; }; self.last_showing_ms = call.showing_ms; - return .{ .ok = call.result }; + if (call.failed) return .{ .now = if (call.bad_args) .{ .bad_args = call.message orelse "bad arguments" } else .{ .failed = call.message orelse "failed" } }; + if (!call.deferred) return .{ .now = .{ .ok = call.result } }; + // Nobody waits: started, with nothing kept for an answer nobody hears (`callWanted` is false). + const s = sink orelse return .{ .later = ticket }; + // Out of memory: answered now as failed, rather than owed to a waiter that never hears. + self.calls_pending.append(self.allocator, .{ .ticket = ticket, .owner = owner, .sink = s }) catch + return .{ .now = .{ .failed = "out of memory" } }; + return .{ .later = ticket }; +} + +/// Answer the command whose ticket is `ticket` (`Command.Call.later`), on the UI thread: `.ok` +/// with its result as ZON (or null), `.failed` or `.bad_args` with why. Whoever waits hears it +/// now; nobody, and it is dropped. A ticket already answered or unknown is ignored. +pub fn finishCall(self: *Host, ticket: u32, outcome: CommandOutcome) void { + // The call still running (`runWith` answering what it deferred, before returning): its caller + // gets it now, copied, since `outcome` is the command's to free. + if (self.running_call) |call| if (call.ticket == ticket and call.deferred) { + call.deferred = false; + switch (outcome) { + .ok => |r| call.result = if (r) |text| call.arena.dupe(u8, text) catch null else null, + .bad_args, .failed => |m| { + call.failed = true; + call.bad_args = outcome == .bad_args; + call.message = call.arena.dupe(u8, m) catch "failed"; + }, + .unknown, .disabled => { + call.failed = true; + call.message = @tagName(outcome); + }, + } + return; + }; + for (self.calls_pending.items, 0..) |p, i| { + if (p.ticket != ticket) continue; + _ = self.calls_pending.swapRemove(i); + p.sink.done(p.sink.ctx, outcome); + return; + } +} + +/// Whether anyone still waits for `ticket`'s answer: a command can stop work nobody wants. +pub fn callWanted(self: *const Host, ticket: u32) bool { + for (self.calls_pending.items) |p| if (p.ticket == ticket) return true; + return false; +} + +/// Stop waiting for `ticket`'s answer (the caller went away): nothing is kept for it, and the +/// command, asking `callWanted`, can stop. +pub fn abandonCall(self: *Host, ticket: u32) void { + for (self.calls_pending.items, 0..) |p, i| if (p.ticket == ticket) { + _ = self.calls_pending.swapRemove(i); + return; + }; +} + +/// A plugin going away: the calls it still owes are answered as failed, so nobody waits for good, +/// and the waits it holds are let go of, so nothing calls into its image once it is gone. +fn failCallsOf(self: *Host, plugin: *Plugin) void { + var i: usize = 0; + while (i < self.calls_pending.items.len) { + const p = self.calls_pending.items[i]; + if (p.owner != plugin and p.sink.owner != plugin) { + i += 1; + continue; + } + _ = self.calls_pending.swapRemove(i); + if (p.sink.owner != plugin) p.sink.done(p.sink.ctx, .{ .failed = "the plugin that was answering went away" }); + } } // ---- language support ------------------------------------------------------ @@ -3574,3 +3697,148 @@ test "an agent's edit says whose in the activity; the person's stays plain" { host.endAct(outer); try std.testing.expectEqualStrings("Claude · Draw", host.activity.items[1].title); } + +const LaterFixture = struct { + ticket: u32 = 0, + fn start(state: *anyopaque, call: *Command.Call) anyerror!void { + const self: *LaterFixture = @ptrCast(@alignCast(state)); + self.ticket = call.later(); + } + const Heard = struct { + count: u32 = 0, + result: ?[]const u8 = null, + failed: ?[]const u8 = null, + fn done(ctx: *anyopaque, outcome: CommandOutcome) void { + const self: *Heard = @ptrCast(@alignCast(ctx)); + self.count += 1; + switch (outcome) { + .ok => |r| self.result = r, + .failed => |m| self.failed = m, + else => {}, + } + } + }; +}; + +test "a command answering later: its waiter hears it once, a caller that cannot wait drops it" { + var host = Host.init(testing.allocator); + defer host.deinit(); + const vtable = Plugin.VTable{}; + var fixture: LaterFixture = .{}; + var plugin = Plugin{ .state = &fixture, .vtable = &vtable, .id = "t", .display_name = "T" }; + try host.registerCommand(.{ .id = "t.slow", .owner = &plugin, .title = "Slow", .runWith = LaterFixture.start }); + var results: std.heap.ArenaAllocator = .init(testing.allocator); + defer results.deinit(); + + // A caller that waits gets the ticket, and hears the answer when it comes, once. + var heard: LaterFixture.Heard = .{}; + const out = host.callCommandAwaiting("t.slow", "", results.allocator(), .{ .ctx = &heard, .done = LaterFixture.Heard.done }); + try testing.expectEqual(fixture.ticket, out.later); + try testing.expect(host.callWanted(fixture.ticket)); + host.finishCall(fixture.ticket, .{ .ok = ".{ .n = 1 }" }); + try testing.expectEqual(@as(u32, 1), heard.count); + try testing.expectEqualStrings(".{ .n = 1 }", heard.result.?); + host.finishCall(fixture.ticket, .{ .ok = null }); + try testing.expectEqual(@as(u32, 1), heard.count); + try testing.expect(!host.callWanted(fixture.ticket)); + + // A caller that cannot wait has started it; its answer is dropped. + try testing.expectEqual(@as(?[]const u8, null), host.callCommand("t.slow", "", results.allocator()).ok); + try testing.expect(!host.callWanted(fixture.ticket)); + host.finishCall(fixture.ticket, .{ .ok = null }); + try testing.expectEqual(@as(usize, 0), host.calls_pending.items.len); + + // Abandoned: the answer goes nowhere. + var gone: LaterFixture.Heard = .{}; + _ = host.callCommandAwaiting("t.slow", "", results.allocator(), .{ .ctx = &gone, .done = LaterFixture.Heard.done }); + host.abandonCall(fixture.ticket); + try testing.expect(!host.callWanted(fixture.ticket)); + host.finishCall(fixture.ticket, .{ .ok = null }); + try testing.expectEqual(@as(u32, 0), gone.count); + + // The plugin goes away owing an answer: its waiter hears that it failed. + var owed: LaterFixture.Heard = .{}; + _ = host.callCommandAwaiting("t.slow", "", results.allocator(), .{ .ctx = &owed, .done = LaterFixture.Heard.done }); + host.withdrawPlugin(&plugin); + try testing.expectEqual(@as(u32, 1), owed.count); + try testing.expect(owed.failed != null); + try testing.expectEqual(@as(usize, 0), host.calls_pending.items.len); +} + +const NestedFixture = struct { + host: *Host, + inner_ticket: u32 = 0, + outer_ticket: u32 = 0, + fn inner(state: *anyopaque, call: *Command.Call) anyerror!void { + const self: *NestedFixture = @ptrCast(@alignCast(state)); + self.inner_ticket = call.later(); + } + fn outer(state: *anyopaque, call: *Command.Call) anyerror!void { + const self: *NestedFixture = @ptrCast(@alignCast(state)); + // A command that, while it runs, calls another that defers, then defers itself. + _ = self.host.callCommand("t.inner", "", call.arena); + self.outer_ticket = call.later(); + } + fn cached(state: *anyopaque, call: *Command.Call) anyerror!void { + const self: *NestedFixture = @ptrCast(@alignCast(state)); + // Deferred, then answered before returning: a cache hit. + const t = call.later(); + self.host.finishCall(t, .{ .ok = ".{ .cached = true }" }); + } +}; + +test "later answers: nested calls keep their own tickets, an early answer is the caller's now, a gone waiter is never called" { + var host = Host.init(testing.allocator); + defer host.deinit(); + const vtable = Plugin.VTable{}; + var fixture: NestedFixture = .{ .host = &host }; + var plugin = Plugin{ .state = &fixture, .vtable = &vtable, .id = "t", .display_name = "T" }; + try host.registerCommand(.{ .id = "t.inner", .owner = &plugin, .title = "Inner", .runWith = NestedFixture.inner }); + try host.registerCommand(.{ .id = "t.outer", .owner = &plugin, .title = "Outer", .runWith = NestedFixture.outer }); + try host.registerCommand(.{ .id = "t.cached", .owner = &plugin, .title = "Cached", .runWith = NestedFixture.cached }); + var results: std.heap.ArenaAllocator = .init(testing.allocator); + defer results.deinit(); + + var heard: LaterFixture.Heard = .{}; + const sink: CallSink = .{ .ctx = &heard, .done = LaterFixture.Heard.done }; + const out = host.callCommandAwaiting("t.outer", "", results.allocator(), sink); + try testing.expectEqual(fixture.outer_ticket, out.later); + try testing.expect(fixture.inner_ticket != fixture.outer_ticket); + // The inner one, nobody waiting, keeps nothing; the outer one is the waiter's alone. + try testing.expect(!host.callWanted(fixture.inner_ticket)); + host.finishCall(fixture.inner_ticket, .{ .ok = ".{ .inner = true }" }); + try testing.expectEqual(@as(u32, 0), heard.count); + host.finishCall(fixture.outer_ticket, .{ .ok = ".{ .outer = true }" }); + try testing.expectEqualStrings(".{ .outer = true }", heard.result.?); + + // Answered before it returned: the caller has it now, and nothing is left pending. + const now = host.callCommandAwaiting("t.cached", "", results.allocator(), sink); + try testing.expectEqualStrings(".{ .cached = true }", now.now.ok.?); + try testing.expectEqual(@as(usize, 0), host.calls_pending.items.len); + + // A plugin waiting on another's command goes away: its sink is let go of, never called. + var waiter_vtable = Plugin.VTable{}; + _ = &waiter_vtable; + var waiter_state: u8 = 0; + var waiter = Plugin{ .state = &waiter_state, .vtable = &waiter_vtable, .id = "w", .display_name = "W" }; + var unheard: LaterFixture.Heard = .{}; + _ = host.callCommandAwaiting("t.inner", "", results.allocator(), .{ .ctx = &unheard, .done = LaterFixture.Heard.done, .owner = &waiter }); + host.withdrawPlugin(&waiter); + try testing.expect(!host.callWanted(fixture.inner_ticket)); + host.finishCall(fixture.inner_ticket, .{ .ok = null }); + try testing.expectEqual(@as(u32, 0), unheard.count); +} + +test "historyGoToWhy answers the owner's reason by name, and nothing when it went" { + const S = struct { + fn goTo(_: *anyopaque, _: DocHandle, id: u64) anyerror!void { + if (id == 7) return error.PersonBusy; + } + }; + const vtable = Plugin.VTable{ .historyGoTo = S.goTo }; + var state: u8 = 0; + var plugin = Plugin{ .state = &state, .vtable = &vtable, .id = "t", .display_name = "T" }; + const doc: DocHandle = .{ .ptr = &state, .owner = &plugin, .id = 1 }; + try testing.expectEqualStrings("PersonBusy", plugin.historyGoToWhy(doc, 7).?); + try testing.expect(plugin.historyGoToWhy(doc, 3) == null); +} diff --git a/sdk/src/Plugin.zig b/sdk/src/Plugin.zig index b42b32a72..b9d0189da 100644 --- a/sdk/src/Plugin.zig +++ b/sdk/src/Plugin.zig @@ -237,7 +237,9 @@ pub const VTable = struct { /// Undo or redo `doc` until step `id` (`HistoryLine.Entry.id`) is the last one applied; 0 /// goes back before the first. An id not on the line is an error, and nothing moves. /// `fizzy.undo`/`fizzy.redo` with a `path` or an `expect` go through it too, so a document - /// can be undone without being the active one. + /// can be undone without being the active one. `error.PersonBusy` refuses while the person has + /// a gesture open (a stroke under their hand) and someone else is acting: the caller is told + /// to try again once it ends. historyGoTo: ?*const fn (state: *anyopaque, doc: DocHandle, id: u64) anyerror!void = null, /// Apply a step this owner recorded (`Step`, `Host.recordStep`) to `doc`, forward, as a new /// entry on its history: `effect` and `motion` in the owner's `step_format`. `ms` asks for it @@ -635,12 +637,17 @@ pub fn applyStep(self: Plugin, doc: DocHandle, effect: []const u8, motion: []con /// Go to step `id` of `doc`'s history (`historyGoTo`). Returns whether it went. pub fn historyGoTo(self: Plugin, doc: DocHandle, id: u64) bool { - const f = self.vtable.historyGoTo orelse return false; - f(self.state, doc, id) catch |err| { - std.log.err("{s}: historyGoTo failed: {s}", .{ self.id, self.errorName(err) }); - return false; - }; - return true; + const why = self.historyGoToWhy(doc, id) orelse return true; + std.log.err("{s}: historyGoTo failed: {s}", .{ self.id, why }); + return false; +} + +/// `historyGoTo`, answering why it did not go (the owner's error, by name: `PersonBusy`), or null +/// when it went. Logs nothing: the caller says it. +pub fn historyGoToWhy(self: Plugin, doc: DocHandle, id: u64) ?[:0]const u8 { + const f = self.vtable.historyGoTo orelse return "NoHistory"; + f(self.state, doc, id) catch |err| return self.errorName(err); + return null; } /// `doc`'s state for a demo's snapshot (`captureDocumentState`). Null when the owner has no diff --git a/sdk/src/version.zig b/sdk/src/version.zig index c3570e3cd..2d5d61eac 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -75,7 +75,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the /// next SDK release. -pub const recorded_sdk_shape_fingerprint: u64 = 0xf20b8afc2f374648; +pub const recorded_sdk_shape_fingerprint: u64 = 0x5f9c818a6972efd4; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index db1c95293..db6feeb36 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -133,6 +133,8 @@ command_palette: @import("CommandPalette.zig") = .{}, demo: Demo = .detached, /// Tapes playing in tabs of their own (`Demo: Play a Tape…`, the transport's share). tape_tabs: TapeTabs = .{}, +/// The recording setting as last applied (`tick`); null before the first frame. +record_history_applied: ?bool = null, /// A part of a document's history to export (the strip's export window): at the top of the next /// frame, before anything draws, since the export walks the document's history back and forth. share_pending: ?struct { doc_id: u64, from: usize, to: usize } = null, @@ -141,6 +143,10 @@ share_pending: ?struct { doc_id: u64, from: usize, to: usize } = null, plugin_reloads: @import("PluginReloads.zig") = .{}, /// The last minute of input, frame by frame, for a capture (`captures`, `fizzy.capture`). black_box: @import("app").automation.BlackBox = .{}, +/// `fizzy.screen` calls waiting for their snapshot (`answerLaterCommands`). +later_screens: std.ArrayListUnmanaged(struct { ticket: u32, snapshot: sdk.services.automation.Api.SnapshotTicket, frames: u32 = 0 }) = .empty, +/// `fizzy.settled` calls waiting for nothing to be in flight, or their deadline. +later_settles: std.ArrayListUnmanaged(struct { ticket: u32, deadline_ns: i96 }) = .empty, /// The control server would not start (`syncControlServer`): not retried until the setting goes /// off and on again. control_failed: bool = false, @@ -237,6 +243,11 @@ pending_native_menu_item_indices_len: u8 = 0, /// When set, next `tick` runs `warmupDrawingComposites` on the active file (after open or drawing-tool select). pending_composite_warmup: bool = false, +/// Set whenever who opens which extension may have changed (`rebuildExtensionOwnerCache`): the +/// next `tick` moves documents the fallback editor holds only as the fallback to the plugin that +/// owns them now (`rehomeFallbackDocuments`). +rehome_pending: bool = false, + /// When set, the next frame closes the explorer and the bottom panel, as their own close buttons /// would. Set by the page's `?show=`: an embed that opens on one file is about that file, and the /// tree and the panel only take room from it. Done in the frame (`layout.zig`), not from the @@ -1087,6 +1098,7 @@ pub fn rebuildExtensionOwnerCache(editor: *Editor) void { }; } } + editor.rehome_pending = true; } /// **The only function that writes `.extensions` to disk**, and it runs only in direct response @@ -2057,9 +2069,9 @@ fn storeUninstalled(arena: std.mem.Allocator) []const @import("app").layout.Stat } pub fn postInit(editor: *Editor) !void { - // Documents' tracks, while they are behind a switch (plans/RECORDING_PLAN.md, step 3). + // Documents' tracks (plans/RECORDING_PLAN.md): on as the setting says (`record_history`), + // applied each time it changes (`tick`). editor.app.tracks = .init(editor.app.gpa); - if (Popout.envSwitch("FIZZY_TRACKS") orelse false) editor.setTracking(true); if (comptime build_opts.has_app_layout) { dvui.log.info("layout: app-supplied", .{}); } else { @@ -3116,8 +3128,10 @@ pub fn keepTape(editor: *Editor, arena: std.mem.Allocator, doc: sdk.DocHandle, b } /// Record documents' tracks, or stop. Documents open now get one; those opened after too. +/// Off, what was recorded is forgotten: documents keep only their undo history. pub fn setTracking(editor: *Editor, on: bool) void { const t = &(editor.app.tracks orelse return); + if (!on and t.on) t.deinit(); t.on = on; editor.app.host.recorder = if (on) t.recorder() else null; if (on) for (editor.app.open_files.values()) |doc| t.opened(doc); @@ -3623,6 +3637,7 @@ pub fn reconcileExternalSettingsChange(editor: *Editor) void { editor.app.settings.native_menus = parsed.native_menus; editor.app.settings.native_dialogs = parsed.native_dialogs; editor.app.settings.black_box = parsed.black_box; + editor.app.settings.record_history = parsed.record_history; editor.app.settings.control_server = parsed.control_server; editor.app.settings.input_scheme = parsed.input_scheme; editor.app.settings.plugin_update_mode = parsed.plugin_update_mode; @@ -4109,6 +4124,15 @@ pub fn tick(editor: *Editor) !dvui.App.Result { editor.app.file_table.pump(); // Saves whose encode finished on their worker: on to their write, or done. editor.doc_io.tick(); + // Recording as the setting says, when it changed (`FIZZY_TRACKS` over it): a command that + // turned it on or off (`fizzy.tracks.record`) holds until the setting next changes. + { + const want = Popout.envSwitch("FIZZY_TRACKS") orelse editor.app.settings.record_history; + if (editor.record_history_applied != want) { + editor.record_history_applied = want; + editor.setTracking(want); + } + } // The document states tracks asked for since last frame, before anything draws. if (editor.app.tracks) |*t| t.tick(); if (editor.share_pending) |p| { @@ -4117,6 +4141,11 @@ pub fn tick(editor: *Editor) !dvui.App.Result { } editor.tape_tabs.tick(editor); editor.promoteEditedPreviews(); + // Once the file-type question, if one is up, has its answer: that answer is who they go to. + if (editor.rehome_pending and !Dialogs.FileTypeDefaults.active(dvui.currentWindow())) { + editor.rehome_pending = false; + editor.rehomeFallbackDocuments(); + } editor.window_opacity = if (dvui.themeGet().dark) editor.app.settings.window_opacity_dark else editor.app.settings.window_opacity_light; @@ -4263,8 +4292,10 @@ pub fn tick(editor: *Editor) !dvui.App.Result { editor.app.host.deliverEvents(); // Requests from the control server, so the frame that follows sees what they ran. if (comptime builtin.target.cpu.arch != .wasm32) { - if (editor.app.control) |*c| @import("app").control.routes.answerPending(c, &editor.app.control_relay, &editor.app.host); + if (editor.app.control) |*c| @import("app").control.routes.answerPending(c, &editor.app.control_state, &editor.app.host); } + // What fizzy's own commands answer a frame or more later (`fizzy.screen`, `fizzy.settled`). + editor.answerLaterCommands(); // A seek replays a tape's commands where nobody watches: not shown as activity. editor.app.host.activity_muted = editor.demo.player.state == .seeking; editor.showClaimed(); @@ -5474,8 +5505,62 @@ pub const Placement = struct { take_slot_of: ?[]const u8 = null, /// `OpenOptions.beside`. beside: bool = false, + /// Take `take_slot_of`'s place without bringing it forward: it shows only if that tab was + /// showing (`rehomeFallbackDocuments`). + in_place: bool = false, }; +/// Answer fizzy's own commands that answer a frame or more later (`fizzy.screen`, `fizzy.settled`), +/// once what they wait for has come; drop those nobody waits for any more. Once a frame. +fn answerLaterCommands(editor: *Editor) void { + const host = &editor.app.host; + const api = editor.demo.service.api; + var i: usize = 0; + while (i < editor.later_screens.items.len) { + const w = &editor.later_screens.items[i]; + if (!host.callWanted(w.ticket)) { + _ = editor.later_screens.swapRemove(i); + continue; + } + if (api.snapshotText(w.snapshot)) |text| { + const ticket = w.ticket; + _ = editor.later_screens.swapRemove(i); + host.finishCall(ticket, .{ .ok = text }); + continue; + } + // Not taken yet. Asked again, should the last asking have come to nothing; a snapshot that never comes is given up on, not waited + // for frame after frame. + w.frames += 1; + if (w.frames > 120) { + const ticket = w.ticket; + _ = editor.later_screens.swapRemove(i); + host.finishCall(ticket, .{ .failed = "no snapshot came in 120 frames" }); + continue; + } + w.snapshot = api.snapshot(); + // And a frame to take it in: asking again does not wake a sleeping app by itself. + host.refresh(); + i += 1; + } + if (editor.later_settles.items.len == 0) return; + const settled = api.settled(); + const now = std.Io.Clock.awake.now(dvui.io).nanoseconds; + i = 0; + while (i < editor.later_settles.items.len) { + const w = editor.later_settles.items[i]; + if (!host.callWanted(w.ticket)) { + _ = editor.later_settles.swapRemove(i); + continue; + } + if (!settled and now < w.deadline_ns) { + i += 1; + continue; + } + _ = editor.later_settles.swapRemove(i); + host.finishCall(w.ticket, .{ .ok = if (settled) "true" else "false" }); + } +} + /// Run the control server while `control_server` is on, and stop it when it goes off. Once a frame; /// a no-op unless the setting changed. Not in tests, whose editors share no config folder worth a /// socket. @@ -5488,7 +5573,7 @@ fn syncControlServer(editor: *Editor) void { if (want == (editor.app.control != null)) return; if (!want) { if (editor.app.control) |*c| { - control.routes.stop(c, &editor.app.control_relay, &editor.app.host); + control.routes.stop(c, &editor.app.control_state, &editor.app.host); editor.app.gpa.free(c.socket_path); } editor.app.control = null; @@ -5690,6 +5775,64 @@ fn openPath(editor: *Editor, path_in: []const u8, grouping: u64, placement_in: P return true; } +/// Hand each document the fallback editor holds only as the fallback (its extension is none of +/// the fallback's own) to the plugin that opens that extension now, in the same tab. A file +/// opened before its plugin was there — launched with it while the plugin was refused, before a +/// dropped-in build was loaded, before a store install — is otherwise stuck as text until it is +/// closed and opened again. One with unsaved changes, one someone is working on (`sdk.Claim`), and +/// one with nothing on disk to read again stay as they are; so does one whose extension the +/// fallback offers in its own `fileTypes` (bundled text offers none). +pub fn rehomeFallbackDocuments(editor: *Editor) void { + const fallback = editor.app.host.fallback_editor orelse return; + const gpa = editor.app.gpa; + // Collected first: each move takes a document out of `open_files`. + var ids: std.ArrayListUnmanaged(u64) = .empty; + defer ids.deinit(gpa); + for (editor.app.open_files.values()) |doc| { + if (doc.owner != fallback) continue; + const ext = std.fs.path.extension(doc.owner.documentPath(doc)); + const owner = editor.app.host.pluginForExtension(ext) orelse continue; + if (owner == fallback) continue; + const own = for (fallback.fileTypes()) |e| { + if (std.ascii.eqlIgnoreCase(e, ext)) break true; + } else false; + if (own) continue; + if (doc.owner.isDirty(doc) or editor.app.host.documentClaim(doc.id) != null) continue; + ids.append(gpa, doc.id) catch return; + } + for (ids.items) |id| editor.rehomeDocument(id) catch |err| + dvui.log.warn("could not move a document to the plugin that opens it now: {t}", .{err}); +} + +fn rehomeDocument(editor: *Editor, id: u64) !void { + const doc = editor.app.open_files.get(id) orelse return; + const gpa = editor.app.gpa; + const path = try gpa.dupe(u8, doc.owner.documentPath(doc)); + defer gpa.free(path); + // Read again from where it came from: a mount, or the disk (which the web has none of). + if (!editor.doc_io.owns(path)) { + if (comptime builtin.target.cpu.arch == .wasm32) return; + std.Io.Dir.cwd().access(dvui.io, path, .{}) catch return; + } + const ds = editor.doc_surfaces.get(id) orelse return; + const slot = try gpa.dupe(u8, ds.id); + defer gpa.free(slot); + const grouping = doc.owner.documentGrouping(doc); + const preview = editor.documentIsPreview(id); + dvui.log.info("{s}: moving from {s} to {s}, which opens it now", .{ + std.fs.path.basename(path), + doc.owner.display_name, + (editor.app.host.pluginForExtension(std.fs.path.extension(path)) orelse doc.owner).display_name, + }); + // Out of fizzy with its tab left where it is, for the new owner's placeholder to take. + editor.app.host.emitZon("fizzy.document.closed", .{ .path = path }); + editor.detachFileID(id); + // Whichever open the user asked for last is still the one focused when it lands. + const focus = editor.last_load_request_path; + defer editor.last_load_request_path = focus; + _ = try editor.openPath(path, grouping, .{ .preview = preview, .take_slot_of = slot, .in_place = true }); +} + /// Stop loading `path`, wherever it is loading from. Its document never arrives. pub fn cancelOpen(editor: *Editor, path: []const u8) void { if (editor.loading_jobs.get(path)) |job| job.cancelled.store(true, .monotonic); @@ -6537,11 +6680,13 @@ pub fn deinit(editor: *Editor) !void { // command would reach goes. if (comptime builtin.target.cpu.arch != .wasm32) { if (editor.app.control) |*c| { - @import("app").control.routes.stop(c, &editor.app.control_relay, &editor.app.host); + @import("app").control.routes.stop(c, &editor.app.control_state, &editor.app.host); editor.app.gpa.free(c.socket_path); editor.app.control = null; } } + editor.later_screens.deinit(editor.app.gpa); + editor.later_settles.deinit(editor.app.gpa); if (editor.document_watcher) |*w| { w.stop(); editor.document_watcher = null; @@ -6637,6 +6782,8 @@ pub fn deinit(editor: *Editor) !void { // The registry itself goes with `host.deinit` below; these are the app's own strings. var it = editor.doc_surfaces.valueIterator(); while (it.next()) |ds| { + // Its history's pictures, taken while it recorded. + ds.*.frames.deinit(editor.app.gpa); editor.app.gpa.free(ds.*.id); editor.app.gpa.destroy(ds.*); } diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index a091fe9d1..bf71337bc 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -129,8 +129,10 @@ const FizzyCommand = struct { title: []const u8, /// dvui keybind name this action's default key comes from, or null when it has no default. bind: ?[]const u8, - run: *const fn (state: *anyopaque) anyerror!void, + /// Null for a command only a program calls (`runWith`, `visibility = .internal`). + run: ?*const fn (state: *anyopaque) anyerror!void = null, isEnabled: ?*const fn (state: *anyopaque) bool = null, + visibility: sdk.Command.Visibility = .external, /// What `runWith` takes (`sdk.Command.Params`). `run` stays what a menu row or a key does — /// with no arguments, so Open Files still asks with its dialog — and `runWith` is the same /// action told exactly what to act on: by a keybind with arguments, a tape, an agent. @@ -182,6 +184,10 @@ const fizzy_commands = [_]FizzyCommand{ .{ .id = "fizzy.tracks.export", .title = "Tracks: Export as a Tape…", .bind = null, .run = cmdTracks, .params = TracksExport.params, .runWith = TracksExport.bind(cmdTracksExport), .icon = icons.tvg.lucide.gauge }, .{ .id = "fizzy.tracks", .title = "Tracks: Report", .bind = null, .run = cmdTracks, .params = TracksArgs.params, .runWith = TracksArgs.bind(cmdTracksWith), .icon = icons.tvg.lucide.gauge }, .{ .id = "fizzy.writeTrace", .title = "Write Trace", .bind = null, .run = cmdWriteTrace, .params = TraceArgs.params, .runWith = TraceArgs.bind(cmdWriteTraceTo), .icon = icons.tvg.lucide.gauge }, + // For programs, answered a frame or more later (`Command.Call.later`): a test or a script on the + // control server reads the screen as text, and waits for a load to land before it looks. + .{ .id = "fizzy.screen", .title = "What Is on Screen", .bind = null, .params = ScreenArgs.params, .runWith = ScreenArgs.bind(cmdScreen), .visibility = .internal }, + .{ .id = "fizzy.settled", .title = "Wait Until Settled", .bind = null, .params = SettledArgs.params, .runWith = SettledArgs.bind(cmdSettled), .visibility = .internal }, } ++ open_folder_commands ++ demo_commands ++ automation_commands; /// Demo automation (`Editor.Demo`): one command per bundled demo, and the transport for whichever @@ -466,7 +472,11 @@ fn historyStepWith(editor: *Editor, args: HistoryStep.Args, call: *sdk.Command.C "step {d} is not the one to {s}: that is step {d}, {s}, by {s}. The document changed since; fix it forward", .{ args.expect, @tagName(action), t.step.id, t.step.title, by }, ); - if (!doc.owner.historyGoTo(doc, t.to)) return call.fail("{s} could not {s} step {d}", .{ doc.owner.id, @tagName(action), t.step.id }); + if (doc.owner.historyGoToWhy(doc, t.to)) |why| { + if (std.mem.eql(u8, why, "PersonBusy")) + return call.fail("the person is drawing in {s}; try again when they stop", .{doc.owner.documentPath(doc)}); + return call.fail("{s} could not {s} step {d}: {s}", .{ doc.owner.id, @tagName(action), t.step.id, why }); + } try call.returns(.{ .step = t.step.id, .title = t.step.title, .by = by }); } /// Set while `tick` is running a command because of a live key event. That event is never @@ -638,6 +648,30 @@ fn cmdCaptureWith(state: *anyopaque, args: CaptureArgs.Args, call: *sdk.Command. try call.returns(written); } +const ScreenArgs = sdk.Command.Params(struct {}); + +/// What is on screen, as ZON (`automation` service `snapshot`): each window, then each widget's +/// role, name, tag, rect and parent. Taken over the next frame, so answered then +/// (`Editor.answerLaterCommands`). +fn cmdScreen(state: *anyopaque, _: ScreenArgs.Args, call: *sdk.Command.Call) anyerror!void { + const editor = editorFromState(state); + try editor.later_screens.append(editor.app.gpa, .{ .ticket = call.later(), .snapshot = editor.demo.service.api.snapshot() }); +} + +const SettledArgs = sdk.Command.Params(struct { + timeout_ms: sdk.Command.Arg(u32, .{ .description = "How long to wait for nothing to be in flight before answering false.", .min = 0, .max = 600_000 }) = .init(5000), +}); + +/// `true` once nothing is in flight (no tape playing, nothing loading, nothing animating, as the +/// `automation` service sees it), at once when that is so now; `false` when `timeout_ms` passes +/// first. It asks for no frames of its own, which would keep the app from ever going quiet. +fn cmdSettled(state: *anyopaque, args: SettledArgs.Args, call: *sdk.Command.Call) anyerror!void { + const editor = editorFromState(state); + if (editor.demo.service.api.settled()) return call.returns(true); + const now = std.Io.Clock.awake.now(dvui.io).nanoseconds; + try editor.later_settles.append(editor.app.gpa, .{ .ticket = call.later(), .deadline_ns = now + @as(i96, args.timeout_ms) * std.time.ns_per_ms }); +} + const TracksArgs = sdk.Command.Params(struct {}); const TracksRecord = sdk.Command.Params(struct { on: sdk.Command.Arg(bool, .{ .description = "Record documents' tracks, or stop. Open documents start a track now." }), @@ -789,6 +823,7 @@ pub fn registerCommands(editor: *Editor) !void { .icon = c.icon, .params = c.params, .runWith = c.runWith, + .visibility = c.visibility, }); } } diff --git a/src/editor/Openings.zig b/src/editor/Openings.zig index 52838a7e3..04991701e 100644 --- a/src/editor/Openings.zig +++ b/src/editor/Openings.zig @@ -124,7 +124,7 @@ pub fn begin(self: *Openings, editor: *Editor, path: []const u8, grouping: u64, if (wb.swapTabId(old, o.surface_id)) |ws| { // A replaced preview is replaced by what was just clicked, so it shows; a restored // slot shows only if it was showing. - if (take_slot_of != null or was_selected) self.show(editor, o, ws); + if ((take_slot_of != null and !placement.in_place) or was_selected) self.show(editor, o, ws); host.refresh(); return; } diff --git a/src/editor/demos/catalog.zig b/src/editor/demos/catalog.zig index ceb39296c..361ff244b 100644 --- a/src/editor/demos/catalog.zig +++ b/src/editor/demos/catalog.zig @@ -4,8 +4,11 @@ //! Adding one: a file beside this with `pub fn build(s: *Script) !void`, and a row below. It is //! then a command ("Demo: ", `fizzy.demo.<name>`), `FIZZY_DEMO=<name>` natively and //! `?demo=<name>` on the web. What a script can aim at is in `docs/AUTOMATION.md`. +//! +//! They need nothing but `tape`, so `zig build demos` builds them on the host too, into the tapes +//! the site's copies of the app serve beside themselves (`scripts/demos.zig`). const std = @import("std"); -const Script = @import("app").automation.Script; +const Script = @import("tape").Script; pub const Entry = struct { /// Stable id: the command's suffix, the web's `?demo=`, the mount `demo://<name>`. @@ -76,8 +79,10 @@ pub const documents = "region:Main"; pub const palette = "fizzy.palette"; /// The rail's Files icon. A click opens the explorer when it is put away, and puts it away when it -/// is open — on a narrow window, where the explorer is folded rather than closed, too. -pub const files_icon = "fizzy.rail:" ++ @import("workbench").view_files; +/// is open — on a narrow window, where the explorer is folded rather than closed, too. Spelled out +/// rather than built from `workbench.view_files`, which would bring dvui into `zig build demos`; +/// `tests/demos.zig` holds them equal. +pub const files_icon = "fizzy.rail:workbench.files"; /// Open `rel` the way a person who keeps the explorer put away would: open the explorer from the /// rail, click the file, and put the explorer away again so the editor has the room. A demo's diff --git a/src/editor/demos/markdown.zig b/src/editor/demos/markdown.zig index 2be22236a..a449f3e49 100644 --- a/src/editor/demos/markdown.zig +++ b/src/editor/demos/markdown.zig @@ -1,5 +1,5 @@ //! Markdown, previewed as you type: a page written from nothing, its preview following along. -const Script = @import("app").automation.Script; +const Script = @import("tape").Script; const catalog = @import("catalog.zig"); const start = diff --git a/src/editor/demos/tour.zig b/src/editor/demos/tour.zig index faf6e5387..57e76820b 100644 --- a/src/editor/demos/tour.zig +++ b/src/editor/demos/tour.zig @@ -1,6 +1,6 @@ //! A tour of fizzy: the explorer, editing, the command palette and a live markdown preview — a //! minute and a half, in chapters a viewer can jump between. -const Script = @import("app").automation.Script; +const Script = @import("tape").Script; const catalog = @import("catalog.zig"); const readme = diff --git a/src/editor/dialogs/FileTypeDefaults.zig b/src/editor/dialogs/FileTypeDefaults.zig index 7e7e043cd..1469f015b 100644 --- a/src/editor/dialogs/FileTypeDefaults.zig +++ b/src/editor/dialogs/FileTypeDefaults.zig @@ -224,7 +224,8 @@ pub fn dialog(id: dvui.Id) anyerror!bool { var tl = dvui.textLayout(@src(), .{}, .{ .expand = .horizontal, .background = false }); defer tl.deinit(); tl.addText( - "Whatever you choose is remembered, and files already open stay where they are. " ++ + "Whatever you choose is remembered. Files open as plain text only because nothing " ++ + "else could open them move to your choice; any other open file stays where it is. " ++ "You can change any of this later in Settings › File Types.", .{ .font = body.larger(-1.0) }, ); diff --git a/src/editor/explorer/settings.zig b/src/editor/explorer/settings.zig index 1ca930b12..7a672410b 100644 --- a/src/editor/explorer/settings.zig +++ b/src/editor/explorer/settings.zig @@ -234,6 +234,23 @@ pub const groups = [_]Group{ }, }, }, + .{ + .title = "History", + .icon = icons.tvg.lucide.history, + .items = &.{ + .{ + .label = "Record document history", + .key = "record_history", + .description = "Keep how each open document is made, step by step, in memory: " ++ + "what lets undo scrub through pictures of it and export share it as a tape. " ++ + "Nothing is written unless you export. Off, documents keep only their undo " ++ + "history, and what was recorded is forgotten.", + .keywords = "history record recording track tape undo scrub export share privacy", + .draw = drawRecordHistory, + .inline_control = true, + }, + }, + }, .{ .title = "Debugging", .icon = icons.tvg.lucide.bug, @@ -668,6 +685,10 @@ fn drawFloatWindows() void { } } +fn drawRecordHistory() void { + drawToggle(&fizzy.editor().app.settings.record_history, "Record document history"); +} + fn drawNativeGlass() void { drawToggle(&fizzy.editor().app.settings.native_glass, "Native glass"); } diff --git a/tests/demos.zig b/tests/demos.zig index fd82225ad..973bfcb3b 100644 --- a/tests/demos.zig +++ b/tests/demos.zig @@ -64,6 +64,10 @@ const DemoMoment = struct { now: f64, cursor: usize, print: u64 }; const bundled_seek_seed: u64 = 0xf122_de40; const bundled_random_seeks = 16; +test "demo: the rail's Files anchor the scripts spell out is the one fizzy publishes" { + try std.testing.expectEqualStrings("fizzy.rail:" ++ workbench.view_files, fizzy.Editor.Demo.catalog.files_icon); +} + test "demo: every bundled demo plays to the end through fizzy's stage, and a seek lands on what playing reached" { var app: BundledApp = undefined; try app.up(); diff --git a/tests/integration.zig b/tests/integration.zig index 018fa380b..a4e6c312b 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -6966,6 +6966,186 @@ test "control: /openapi.json describes each command's body and the declared even try std.testing.expectEqualStrings("", routes.handle(&host, .HEAD, "/openapi.json", null, "", arena).body); } +/// Sends one request and reads the answer by its `Content-Length`, as an HTTP client does. +const ControlOneShot = struct { + path: []const u8, + request: []const u8, + reply: [2048]u8 = undefined, + len: usize = 0, + fn run(self: *ControlOneShot) void { + const io = std.testing.io; + const addr = std.Io.net.UnixAddress.init(self.path) catch return; + const stream = addr.connect(io) catch return; + defer stream.close(io); + var wbuf: [256]u8 = undefined; + var w = stream.writer(io, &wbuf); + w.interface.writeAll(self.request) catch return; + w.interface.flush() catch return; + var rbuf: [256]u8 = undefined; + var r = stream.reader(io, &rbuf); + while (self.len < self.reply.len) { + const n = r.interface.readSliceShort(self.reply[self.len..][0..1]) catch return; + if (n == 0) return; + self.len += 1; + if (std.mem.endsWith(u8, self.reply[0..self.len], "\r\n\r\n")) break; + } + const key = "content-length: "; + const i = std.ascii.findIgnoreCase(self.reply[0..self.len], key) orelse return; + const end = std.mem.indexOfPos(u8, self.reply[0..self.len], i, "\r\n") orelse return; + const want = std.fmt.parseInt(usize, self.reply[i + key.len .. end], 10) catch return; + r.interface.readSliceAll(self.reply[self.len..][0..want]) catch return; + self.len += want; + } +}; + +const SlowCommand = struct { + ticket: u32 = 0, + fn start(state: *anyopaque, call: *sdk.Command.Call) anyerror!void { + const self: *SlowCommand = @ptrCast(@alignCast(state)); + self.ticket = call.later(); + } +}; + +test "control: a command that answers in a later frame holds its request open until it does" { + if (!std.Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + const control = @import("app").control; + var host: sdk.Host = .init(gpa); + defer host.deinit(); + const vtable = sdk.Plugin.VTable{}; + var slow: SlowCommand = .{}; + var plugin = sdk.Plugin{ .state = &slow, .vtable = &vtable, .id = "t", .display_name = "T" }; + try host.registerCommand(.{ .id = "t.slow", .owner = &plugin, .title = "Slow", .runWith = SlowCommand.start }); + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir = try tmp.dir.realPathFileAlloc(io, ".", gpa); + defer gpa.free(dir); + const path = try std.fs.path.join(gpa, &.{ dir, "c.sock" }); + defer gpa.free(path); + const Wake = struct { + fn none() void {} + }; + var server: control.Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = Wake.none }; + try server.start(); + var state: control.routes.State = .{}; + errdefer control.routes.stop(&server, &state, &host); + + const post = "POST /commands/t.slow HTTP/1.1\r\nHost: x\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + var client: ControlOneShot = .{ .path = path, .request = post }; + const thread = try std.Thread.spawn(.{}, ControlOneShot.run, .{&client}); + // Frames, until the command has started and its request waits on it. + var frames: usize = 0; + while (state.waiters.items.len == 0 and frames < 2000) : (frames += 1) { + control.routes.answerPending(&server, &state, &host); + io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + try std.testing.expectEqual(@as(usize, 1), state.waiters.items.len); + // More frames: still nothing to answer. + for (0..5) |_| control.routes.answerPending(&server, &state, &host); + try std.testing.expectEqual(@as(usize, 0), client.len); + // Done, in a later frame: the request is answered with what it returned. + host.finishCall(slow.ticket, .{ .ok = ".{ .done = true }" }); + thread.join(); + try std.testing.expectEqual(@as(usize, 0), state.waiters.items.len); + const reply = client.reply[0..client.len]; + try std.testing.expect(std.mem.startsWith(u8, reply, "HTTP/1.1 200 OK\r\n")); + try std.testing.expect(std.mem.endsWith(u8, reply, "\r\n\r\n{\"done\":true}")); + + // One still waiting when the server stops: answered as stopping, its call let go of. + var late: ControlOneShot = .{ .path = path, .request = post }; + const late_thread = try std.Thread.spawn(.{}, ControlOneShot.run, .{&late}); + frames = 0; + while (state.waiters.items.len == 0 and frames < 2000) : (frames += 1) { + control.routes.answerPending(&server, &state, &host); + io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + try std.testing.expectEqual(@as(usize, 1), state.waiters.items.len); + const ticket = slow.ticket; + control.routes.stop(&server, &state, &host); + late_thread.join(); + try std.testing.expect(std.mem.startsWith(u8, late.reply[0..late.len], "HTTP/1.1 503 ")); + try std.testing.expect(!host.callWanted(ticket)); + // Its answer, when it comes, goes nowhere. + host.finishCall(ticket, .{ .ok = null }); +} + +test "control: MCP on the socket lists the commands as tools and calls them, now or later" { + if (!std.Io.net.has_unix_sockets) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + const control = @import("app").control; + var host: sdk.Host = .init(gpa); + defer host.deinit(); + const vtable = sdk.Plugin.VTable{}; + var slow: SlowCommand = .{}; + var plugin = sdk.Plugin{ .state = &slow, .vtable = &vtable, .id = "t", .display_name = "T" }; + try host.registerCommand(.{ .id = "t.slow", .owner = &plugin, .title = "Slow", .runWith = SlowCommand.start }); + try host.registerCommand(.{ + .id = "t.place", + .owner = &plugin, + .title = "Place", + .params = PaletteArgs.Params.params, + .runWith = PaletteArgs.Params.bind(ControlFixture.place), + }); + + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const routes = control.routes; + // Without a socket (`handle`): the replies a client reads. + const init = routes.handle(&host, .POST, "/mcp", null, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-03-26\"}}", arena); + try std.testing.expect(std.mem.indexOf(u8, init.body, "\"protocolVersion\":\"2025-03-26\"") != null); + try std.testing.expectEqual(std.http.Status.accepted, routes.handle(&host, .POST, "/mcp", null, "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}", arena).status); + const list = try std.json.parseFromSliceLeaky(std.json.Value, arena, routes.handle(&host, .POST, "/mcp", null, "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}", arena).body, .{}); + const tools = list.object.get("result").?.object.get("tools").?.array.items; + try std.testing.expectEqual(@as(usize, 2), tools.len); + const place = tools[1].object; + try std.testing.expectEqualStrings("t.place", place.get("name").?.string); + try std.testing.expectEqualStrings("integer", place.get("inputSchema").?.object.get("properties").?.object.get("line").?.object.get("type").?.string); + const called = routes.handle(&host, .POST, "/mcp", null, "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"t.place\",\"arguments\":{\"line\":4,\"side\":\"right\"}}}", arena); + try std.testing.expect(std.mem.indexOf(u8, called.body, "\"isError\":false") != null); + try std.testing.expect(std.mem.indexOf(u8, called.body, "\\\"note\\\":\\\"placed\\\"") != null); + const refused = routes.handle(&host, .POST, "/mcp", null, "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"t.place\",\"arguments\":{\"side\":\"right\"}}}", arena); + try std.testing.expect(std.mem.indexOf(u8, refused.body, "\"isError\":true") != null); + try std.testing.expect(std.mem.indexOf(u8, routes.handle(&host, .POST, "/mcp", null, "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"nope\"}", arena).body, "-32601") != null); + // A response from the client, as a notification is, is taken (202), not refused. + try std.testing.expectEqual(std.http.Status.accepted, routes.handle(&host, .POST, "/mcp", null, "{\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{}}", arena).status); + // An id comes back as it was sent; one that is neither a string nor a number is refused. + try std.testing.expect(std.mem.indexOf(u8, routes.handle(&host, .POST, "/mcp", null, "{\"jsonrpc\":\"2.0\",\"id\":12345678901234567890,\"method\":\"ping\"}", arena).body, "\"id\":12345678901234567890,") != null); + try std.testing.expect(std.mem.indexOf(u8, routes.handle(&host, .POST, "/mcp", null, "{\"jsonrpc\":\"2.0\",\"id\":true,\"method\":\"ping\"}", arena).body, "-32600") != null); + // Told only of commands the app has: no fizzy.screen here. + try std.testing.expect(std.mem.indexOf(u8, init.body, "fizzy.screen") == null); + + // Over the socket, a tool whose command answers later answers its call then. + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const dir = try tmp.dir.realPathFileAlloc(io, ".", gpa); + defer gpa.free(dir); + const path = try std.fs.path.join(gpa, &.{ dir, "c.sock" }); + defer gpa.free(path); + const Wake = struct { + fn none() void {} + }; + var server: control.Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = Wake.none }; + try server.start(); + var state: control.routes.State = .{}; + defer control.routes.stop(&server, &state, &host); + const body = "{\"jsonrpc\":\"2.0\",\"id\":\"later\",\"method\":\"tools/call\",\"params\":{\"name\":\"t.slow\"}}"; + const request = try std.fmt.allocPrint(arena, "POST /mcp HTTP/1.1\r\nHost: x\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n{s}", .{ body.len, body }); + var client: ControlOneShot = .{ .path = path, .request = request }; + const thread = try std.Thread.spawn(.{}, ControlOneShot.run, .{&client}); + var frames: usize = 0; + while (state.waiters.items.len == 0 and frames < 2000) : (frames += 1) { + control.routes.answerPending(&server, &state, &host); + io.sleep(.fromMilliseconds(1), .awake) catch {}; + } + try std.testing.expectEqual(@as(usize, 1), state.waiters.items.len); + host.finishCall(slow.ticket, .{ .ok = ".{ .done = true }" }); + thread.join(); + try std.testing.expect(std.mem.endsWith(u8, client.reply[0..client.len], "{\"jsonrpc\":\"2.0\",\"id\":\"later\",\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"done\\\":true}\"}],\"isError\":false}}")); +} + const ControlStreamClient = struct { path: []const u8, reply: [4096]u8 = undefined, @@ -7004,7 +7184,7 @@ test "control: the app's events reach a stream, and the bus listens only while o }; var server: control.Server = .{ .gpa = gpa, .io = io, .socket_path = path, .wake = Wake.none }; try server.start(); - var relay: control.routes.Relay = .{}; + var relay: control.routes.State = .{}; try std.testing.expect(!host.listening.load(.acquire)); var client: ControlStreamClient = .{ .path = path }; @@ -7739,6 +7919,46 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try std.testing.expectEqualStrings(before, (try read(host, "")).text); } + // Going back is a step, and the jump is whoever went back's: Claude writes, the person + // takes it back, Claude writes again. Claude's first write is still on the line, its + // reverse is the person's, and Claude's new write is Claude's. + { + const front = editor.activeDoc() orelse return error.TestUnexpectedResult; + const claude: sdk.Author = .{ .kind = .agent, .plugin = "test", .session = 2, .label = "Claude" }; + const first = try std.fmt.allocPrint(gpa, ".{{ .path = \"{s}\", .text = \"// first\\n\" }}", .{main_zig}); + defer gpa.free(first); + const second = try std.fmt.allocPrint(gpa, ".{{ .path = \"{s}\", .text = \"// second\\n\" }}", .{main_zig}); + defer gpa.free(second); + const before = try gpa.dupe(u8, (try read(host, "")).text); + defer gpa.free(before); + + var outer = host.act(claude); + try std.testing.expect(host.callCommand("text.write", first, host.arena()) == .ok); + host.endAct(outer); + const claudes = (try editor.stepToTake(front, .undo, host.arena())).step.id; + outer = host.act(.person); + try editor.undo(); + host.endAct(outer); + try std.testing.expectEqualStrings(before, (try read(host, "")).text); + outer = host.act(claude); + try std.testing.expect(host.callCommand("text.write", second, host.arena()) == .ok); + host.endAct(outer); + + const hist = front.owner.historyLine(front, host.arena()) orelse return error.TestUnexpectedResult; + const top = hist.entries[hist.at - 1]; + const reverse = hist.entries[hist.at - 2]; + try std.testing.expectEqual(claudes, hist.entries[hist.at - 3].id); + try std.testing.expect(std.mem.startsWith(u8, reverse.title, "Back to ")); + try std.testing.expectEqual(sdk.Author.Kind.person, host.stepAuthor(front.id, reverse.id).kind); + try std.testing.expectEqualStrings("Claude", host.stepAuthor(front.id, top.id).label); + try std.testing.expectEqualStrings("Claude", host.stepAuthor(front.id, claudes).label); + // Claude's first write is still there to go to, and before it the text as it was. + try std.testing.expect(front.owner.historyGoTo(front, claudes)); + try std.testing.expectEqualStrings("// first\n", (try read(host, "")).text); + try std.testing.expect(front.owner.historyGoTo(front, hist.entries[hist.at - 4].id)); + try std.testing.expectEqualStrings(before, (try read(host, "")).text); + } + // Save and Close, the unsaved-changes dialog's: the same write Save makes, then the tab // closes once it lands. try std.testing.expect(doc.owner.isDirty(doc)); @@ -8008,11 +8228,12 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try editor.undo(); try editor.redo(); try editor.undo(); - // An edit after going back: the step gone back over stays undone. + // An edit after going back: the step gone back over comes back on the line, then its + // reverse, then the edit, so the text is as it was gone back to and nothing is lost. try std.testing.expect(host.callCommand("text.insert", ".{ .text = \"Y\", .line = 1 }", host.arena()) == .ok); try dvui.testing.settle(headlessFrame); try std.testing.expectEqualStrings("Yab", try textOf(host, a)); - try std.testing.expectEqualSlices(sdk.Step.Kind, &.{ .added, .joined, .added, .undone, .redone, .undone, .added }, rec.kinds.items); + try std.testing.expectEqualSlices(sdk.Step.Kind, &.{ .added, .joined, .added, .undone, .redone, .undone, .redone, .added, .added }, rec.kinds.items); // Played into a document of its own, forward, in order: the same text. const b = try newDoc(editor, host, a.id); @@ -8022,7 +8243,7 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try std.testing.expectEqualStrings("Yab", try textOf(host, b)); // Unrecorded, B reported nothing; and a step that does not fit is refused, B untouched: // the undo takes back an `X` that is not there. - try std.testing.expectEqual(@as(usize, 7), rec.kinds.items.len); + try std.testing.expectEqual(@as(usize, 9), rec.kinds.items.len); // (Through the hook itself: the wrapper logs the refusal as an error, which fails a run.) try std.testing.expectError(error.StepDoesNotApply, b.owner.vtable.applyStep.?(b.owner.state, b, rec.effects.items[3], "", 0)); try std.testing.expectEqualStrings("Yab", try textOf(host, b)); @@ -8423,8 +8644,8 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try editor.rawCloseFileID(doc.id); - // A track whose start is gone from the history (undone past, and edited over) is - // refused, and the document is left as it was. + // A track whose start was undone past and edited over still exports: going back is a + // step, so its start is still on the line. The document is left as it was. editor.setTracking(false); try host.runCommand("fizzy.newFile"); for (0..200) |_| { @@ -8446,13 +8667,32 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes const before_text = try gpa.dupe(u8, try textOf(host, gone_path)); defer gpa.free(before_text); const gone_args = try std.fmt.allocPrint(host.arena(), ".{{ .path = \"{s}\", .out = \"{s}\" }}", .{ gone_path, out }); - try std.testing.expect(host.callCommand("fizzy.tracks.export", gone_args, host.arena()) != .ok); + try std.testing.expect(host.callCommand("fizzy.tracks.export", gone_args, host.arena()) == .ok); try std.testing.expectEqualStrings(before_text, try textOf(host, gone_path)); try editor.rawCloseFileID(gone.id); editor.workbench.setActiveDocIndex(editor.app.open_files.getIndex(was.id) orelse return error.TestUnexpectedResult); try dvui.testing.settle(headlessFrame); } + // Recording follows its setting (`record_history`, on by default) each time it changes, a + // command's say (`setTracking`, as the tests above used) holding until then: turned off, + // nothing is recorded and what was is forgotten; turned on, every open document gets a track. + { + const tracks = &editor.app.tracks.?; + try std.testing.expect(editor.app.settings.record_history); + editor.setTracking(true); + try dvui.testing.settle(headlessFrame); + try std.testing.expect(tracks.open.count() > 0); + editor.app.settings.record_history = false; + try dvui.testing.settle(headlessFrame); + try std.testing.expect(!tracks.on and editor.app.host.recorder == null); + try std.testing.expectEqual(@as(usize, 0), tracks.open.count()); + editor.app.settings.record_history = true; + try dvui.testing.settle(headlessFrame); + try std.testing.expect(tracks.on and editor.app.host.recorder != null); + try std.testing.expectEqual(editor.app.open_files.count(), tracks.open.count()); + } + // Revealing a view (`Host.revealSurface`): with the bottom panel shut, revealing Output opens // the panel with Output selected, and a view no region shows is not revealed. { @@ -8775,6 +9015,52 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try std.testing.expect(editor.workbench.pending_new_file_path == null); } + // A file opened before its plugin is there (launched with it while the plugin was refused, + // before a dropped-in build was loaded) is the fallback's, as text, until the plugin arrives; + // then it moves to it, in the same tab (`Editor.rehomeFallbackDocuments`). + { + const host = &editor.app.host; + const image = host.pluginById("image") orelse return error.TestUnexpectedResult; + host.unregisterPlugin(image); + editor.rebuildExtensionOwnerCache(); + // One white pixel, uncompressed true-colour TGA. + const tga = [_]u8{ 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 32, 8, 255, 255, 255, 255 }; + try tmp.dir.writeFile(dvui.io, .{ .sub_path = "project/late.tga", .data = &tga }); + const late = try std.fs.path.join(gpa, &.{ project, "late.tga" }); + defer gpa.free(late); + const ownerOf = struct { + fn ownerOf(e: *fizzy.Editor, path: []const u8) ?*sdk.Plugin { + const d = e.docFromPath(path) orelse return null; + return d.owner; + } + }.ownerOf; + _ = try editor.openFilePath(late, editor.workbench.currentGroupingID()); + for (0..200) |_| { + if (ownerOf(editor, late) != null) break; + _ = try dvui.testing.step(headlessFrame); + } + try dvui.testing.settle(headlessFrame); + try std.testing.expectEqual(host.fallback_editor, ownerOf(editor, late)); + const docs_before = editor.app.open_files.count(); + + try host.registerPlugin(image); + editor.rebuildExtensionOwnerCache(); + for (0..200) |_| { + if (ownerOf(editor, late) == image) break; + _ = try dvui.testing.step(headlessFrame); + } + try dvui.testing.settle(headlessFrame); + try std.testing.expectEqual(image, ownerOf(editor, late).?); + try std.testing.expectEqual(docs_before, editor.app.open_files.count()); + // Its tab was taken over, not added beside it: one tab for the file, the image's. + const surface = try sdk.document.surfaceId(host.arena(), image.id, late); + try std.testing.expect(editor.workbench.hasTabAnywhere(surface)); + const text_surface = try sdk.document.surfaceId(host.arena(), host.fallback_editor.?.id, late); + try std.testing.expect(!editor.workbench.hasTabAnywhere(text_surface)); + try editor.closeFileID(editor.docFromPath(late).?.id); + try dvui.testing.settle(headlessFrame); + } + // A quit straight after a pane slid shut (`snapshotClosingPane`): the pane still holds its // picture, and the quit comes after the last frame, when nothing can be freed at a frame's end. {