diff --git a/README.md b/README.md index c4dac28..8617675 100644 --- a/README.md +++ b/README.md @@ -127,8 +127,9 @@ Run `inkfinite --help` for examples and the complete command reference. ### Live desktop control With the desktop app running, use `app status`, `app context`, `app inspect`, and -`app query` to read current state. Status and context report the current agent -access mode alongside the active page, selection, viewport, actor, and heads. +`app query` to read current state. Context reports the active page and layer, +selection, camera, visible world bounds, floating-UI occlusions, actor, access +mode, and heads. The desktop starts each document in **Review changes** mode. Add `--app` to a structured mutation and the app opens a ghost preview for the user to accept or @@ -147,12 +148,21 @@ inkfinite shape patch --app --role architecture.service \ --patch '@service-patch.json' --json inkfinite app propose --transaction transaction.json --json inkfinite app proposal wait --proposal-id proposal:1 --json +inkfinite app proposal renew --proposal-id proposal:1 --json +inkfinite app render --output current.svg --transaction transaction.json \ + --proposed-output proposed.svg --json +inkfinite app ui --page page:1 --layer layer:1 --select shape:service \ + --camera 640,360,1.25 --json inkfinite app apply --transaction transaction.json --json ``` `app propose` always opens a review, even in Direct mode, and remains available for operations that the structured commands do not cover. `app apply` works -only while Direct mode is enabled. +only while Direct mode is enabled. `shape create` accepts semantic relative +placement through `--relative-id`, `--relative-name`, or `--relative-role` with +`--placement`. Live SVG rendering previews a transaction without changing the +document. UI control changes only transient editor navigation; it does not edit +the document or change Agent access. The desktop publishes a per-user Unix-domain socket on Unix-like systems or a per-user named pipe on Windows. A protected discovery file carries a random diff --git a/TODO.md b/TODO.md index df6cd06..31942a6 100644 --- a/TODO.md +++ b/TODO.md @@ -256,34 +256,4 @@ Acceptance criteria: - How should bundling/packaging work? -### Agent drawing workflow - -- [ ] Add a concise live context command for drawing sessions. It should return the - current heads, page, active layer, selection, camera, visible world bounds, and - canvas regions covered by floating UI. Agents should not have to infer the user's - viewport from document coordinates. -- [ ] Let structured shape, stencil, connection, and layout commands target a - live session and create a review proposal. - The CLI should fill the session actor, current heads, timestamps, and provenance - instead of requiring agents to hand-write a complete `TransactionDraft`. -- [ ] Support placement relative to semantic targets, such as inside, below, - right of, aligned with, or spaced from a shape selected by role, name, or ID. - Return the resolved bounds in the preview so the agent can explain the placement - before review. -- [ ] Render the current live document and a proposed result to deterministic - SVG without applying the transaction. Include created, changed, and deleted - geometry so an agent can check composition before asking the user to review the - desktop ghost. -- [ ] Expose proposal state through a status or wait command. Report accepted, - partially accepted, rejected, expired, or refreshed outcomes along with committed - heads and affected IDs; agents should not have to infer the decision by comparing - full snapshots. -- [ ] Give proposal operations stable human-readable labels and per-operation geometry. - Partial acceptance should identify the shape or semantic role, not only a - zero-based operation position or one broad affected region. -- [ ] Allow an unchanged proposal to be refreshed or given more review time. Human - discussion during a drawing session should not require rebuilding the same - transaction solely because its review timer expired. -- [ ] We need a mechanism whereby the agent can freely control the UI - ### QA diff --git a/apps/desktop/src-tauri/src/ipc.rs b/apps/desktop/src-tauri/src/ipc.rs index 36d32dc..795d7e7 100644 --- a/apps/desktop/src-tauri/src/ipc.rs +++ b/apps/desktop/src-tauri/src/ipc.rs @@ -19,6 +19,8 @@ pub const PROPOSAL_EVENT: &str = "inkfinite-proposal"; pub const PROPOSAL_CLEARED_EVENT: &str = "inkfinite-proposal-cleared"; /// Tauri event carrying a commit made by a live client. pub const COMMIT_EVENT: &str = "inkfinite-live-commit"; +/// Tauri event carrying typed page, layer, selection, and camera control. +pub const UI_CONTROL_EVENT: &str = "inkfinite-ui-control"; /// Tauri event carrying a remote synchronization result. pub const SYNC_EVENT: &str = "inkfinite-sync"; @@ -223,6 +225,10 @@ async fn handle_connection( fn dispatch_request( app: &AppHandle, service: &Arc>, request: AppRequest, ) -> Result { + let ui_control = match &request { + AppRequest::Ui { control, .. } => Some(control.clone()), + _ => None, + }; let requested_session = match &request { AppRequest::Context { session_id } | AppRequest::Inspect { session_id } @@ -230,6 +236,9 @@ fn dispatch_request( | AppRequest::Propose { session_id, .. } | AppRequest::Mutate { session_id, .. } | AppRequest::ProposalStatus { session_id, .. } + | AppRequest::RenewProposal { session_id, .. } + | AppRequest::Render { session_id, .. } + | AppRequest::Ui { session_id, .. } | AppRequest::Apply { session_id, .. } => session_id.clone(), AppRequest::Status | AppRequest::Focus => None, }; @@ -260,8 +269,22 @@ fn dispatch_request( ) })?; } + if matches!(&response, AppResponse::UiControlled) { + if let Some(control) = ui_control { + app.emit( + UI_CONTROL_EVENT, + json!({ "session_id": requested_session, "control": control }), + ) + .map_err(|error| { + protocol_error( + "ui_control_notification_failed", + format!("could not notify the desktop frontend: {error}"), + ) + })?; + } + } match &response { - AppResponse::Proposal(proposal) => app + AppResponse::Proposal(proposal) | AppResponse::RenewedProposal(proposal) => app .emit( PROPOSAL_EVENT, json!({ "session_id": requested_session, "proposal": proposal }), @@ -288,7 +311,9 @@ fn dispatch_request( | AppResponse::Snapshot(_) | AppResponse::QueryResult(_) | AppResponse::ProposalStatus(_) - | AppResponse::Focused => {} + | AppResponse::Rendered(_) + | AppResponse::Focused + | AppResponse::UiControlled => {} } Ok(response) } diff --git a/apps/desktop/src-tauri/src/session.rs b/apps/desktop/src-tauri/src/session.rs index 3acba74..e7eb967 100644 --- a/apps/desktop/src-tauri/src/session.rs +++ b/apps/desktop/src-tauri/src/session.rs @@ -5,14 +5,14 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, MutexGuard}; use inkfinite_core::proto::{ - AgentAccessMode, Bounds, DocumentPath, Proposal, ProposalId, ProtocolError, Query, QueryResult, SessionId, - TransactionDraft, + AgentAccessMode, DocumentPath, Proposal, ProposalId, ProtocolError, Query, QueryResult, SessionId, TransactionDraft, }; use inkfinite_core::session::{ - SessionCommit, SessionError, SessionOpened, SessionSaved, SessionService, SessionStatus, SessionSync, + EditorContextUpdate, SessionCommit, SessionError, SessionOpened, SessionSaved, SessionService, SessionStatus, + SessionSync, }; use inkfinite_core::sync::SyncMessage; -use inkfinite_core::{ActorId, ChangeHash, DocumentId, PageId, ShapeId}; +use inkfinite_core::{ActorId, ChangeHash, DocumentId}; use serde_json::json; use tauri::{AppHandle, Emitter, Manager, State}; @@ -171,17 +171,9 @@ pub fn snapshot(state: State<'_, DesktopState>, session_id: String) -> Result, session_id: String, page_id: Option, selection_ids: Vec, - viewport: Option, -) -> Result<()> { +pub fn update_context(state: State<'_, DesktopState>, session_id: String, context: EditorContextUpdate) -> Result<()> { lock_service(&state)? - .update_context( - &SessionId(session_id), - page_id.map(PageId::from), - selection_ids.into_iter().map(ShapeId::from).collect(), - viewport, - ) + .update_context(&SessionId(session_id), context) .map_err(to_protocol_error) } diff --git a/apps/desktop/src/lib/persistence/desktop-session.invoke.test.ts b/apps/desktop/src/lib/persistence/desktop-session.invoke.test.ts index af0d8dc..6cbc94e 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.invoke.test.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.invoke.test.ts @@ -270,6 +270,7 @@ describe('Tauri desktop session command boundary', () => { timestamp: 1_000 }, preview: { created: [], changed: [], deleted: [] }, + operation_previews: [], affected_regions: [], warnings: [], expires_at: 2_000 diff --git a/apps/desktop/src/lib/persistence/desktop-session.test.ts b/apps/desktop/src/lib/persistence/desktop-session.test.ts index 41a54bd..03eb671 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.test.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.test.ts @@ -433,18 +433,34 @@ describe('Rust-backed desktop session repository', () => { it('publishes editor context only after a desktop session is open', async () => { const repo = createDesktopSessionRepo(fileOps.ops, { api: session.api }); - await repo.updateAgentContext({ pageId: 'page:none', selectionIds: [], viewport: null }); + await repo.updateAgentContext({ + pageId: 'page:none', + activeLayerId: null, + selectionIds: [], + viewport: null, + camera: null, + occludedRegions: [] + }); expect(session.agentContexts).toEqual([]); const opened = await repo.openDraft(); const pageId = opened.doc.order.pageIds[0]; await repo.updateAgentContext({ pageId, + activeLayerId: 'layer:active', selectionIds: [], - viewport: { x: -100, y: -50, width: 200, height: 100 } + viewport: { x: -100, y: -50, width: 200, height: 100 }, + camera: { x: 0, y: 0, zoom: 2 }, + occludedRegions: [{ x: -100, y: -50, width: 20, height: 100 }] }); - expect(session.agentContexts.at(-1)).toMatchObject({ page_id: pageId, viewport: { width: 200, height: 100 } }); + expect(session.agentContexts.at(-1)).toMatchObject({ + page_id: pageId, + active_layer_id: 'layer:active', + viewport: { width: 200, height: 100 }, + camera: { zoom: 2 }, + occluded_regions: [{ width: 20 }] + }); }); it('lists native document paths without reading document bytes in the frontend', async () => { diff --git a/apps/desktop/src/lib/persistence/desktop-session.ts b/apps/desktop/src/lib/persistence/desktop-session.ts index c036a3e..7cc5613 100644 --- a/apps/desktop/src/lib/persistence/desktop-session.ts +++ b/apps/desktop/src/lib/persistence/desktop-session.ts @@ -105,6 +105,14 @@ export type SessionSync = { sync: SyncApplyResult; status: SessionStatus }; /** State update emitted when a live proposal is created, refreshed, or cleared. */ export type ProposalUpdate = { proposal: Proposal | null; message?: string }; +/** Typed editor navigation emitted by the authenticated live CLI. */ +export type AgentUiControl = { + page_id?: string | null; + active_layer_id?: string | null; + selection_ids?: string[] | null; + camera?: { x: number; y: number; zoom: number } | null; +}; + /** Typed command boundary used by the desktop adapter and its tests. */ export interface SessionApi { createDocument(args: { @@ -119,8 +127,11 @@ export interface SessionApi { updateContext(args: { session_id: string; page_id: string | null; + active_layer_id: string | null; selection_ids: string[]; viewport: { x: number; y: number; width: number; height: number } | null; + camera: { x: number; y: number; zoom: number } | null; + occluded_regions: Array<{ x: number; y: number; width: number; height: number }>; }): Promise; commit(args: { session_id: string; transaction: TransactionDraft }): Promise; propose(args: { session_id: string; transaction: TransactionDraft }): Promise; @@ -165,9 +176,14 @@ function createSessionApi(): SessionApi { updateContext: (args) => invokeSession('update_context', { sessionId: args.session_id, - pageId: args.page_id, - selectionIds: args.selection_ids, - viewport: args.viewport + context: { + page_id: args.page_id, + active_layer_id: args.active_layer_id, + selection_ids: args.selection_ids, + viewport: args.viewport, + camera: args.camera, + occluded_regions: args.occluded_regions + } }), commit: (args) => invokeSession('commit', { sessionId: args.session_id, transaction: args.transaction }), @@ -261,14 +277,18 @@ export type DesktopSessionRepo = PersistentDocRepo & { getProposal(): Proposal | null; subscribeProposal(listener: (update: ProposalUpdate) => void): () => void; subscribeLiveDocument(listener: (doc: LoadedDoc) => void): () => void; + subscribeAgentUi(listener: (control: AgentUiControl) => void): () => void; acceptProposal(proposalId: string, operationPositions?: number[]): Promise; rejectProposal(proposalId: string): Promise; setAgentAccess(agentAccess: 'review' | 'direct'): Promise; /** Publishes editor-only context for read-only agent queries. */ updateAgentContext(context: { pageId: string | null; + activeLayerId: string | null; selectionIds: string[]; viewport: { x: number; y: number; width: number; height: number } | null; + camera: { x: number; y: number; zoom: number } | null; + occludedRegions: Array<{ x: number; y: number; width: number; height: number }>; }): Promise; syncConnect(peerId: string): Promise; syncDisconnect(peerId: string): Promise; @@ -293,6 +313,7 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: let proposalExpiryTimer: ReturnType | null = null; const proposalListeners = new Set<(update: ProposalUpdate) => void>(); const liveDocumentListeners = new Set<(doc: LoadedDoc) => void>(); + const agentUiListeners = new Set<(control: AgentUiControl) => void>(); const liveUnlisteners: Array<() => void> = []; const boardFiles = new Map(); @@ -300,6 +321,7 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: type ProposalClearedEvent = { message?: string }; type LiveCommitEvent = { session_id?: string | null; commit: SessionCommit }; type LiveSyncEvent = { session_id?: string | null; sync: SessionSync }; + type AgentUiEvent = { session_id?: string | null; control: AgentUiControl }; function notifyProposal(update: ProposalUpdate) { if (proposalExpiryTimer) clearTimeout(proposalExpiryTimer); @@ -366,6 +388,12 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: }) .then((stop) => liveUnlisteners.push(stop)) .catch(() => undefined); + void listen('inkfinite-ui-control', (event) => { + if (!eventBelongsToCurrentSession(event.payload.session_id)) return; + for (const listener of agentUiListeners) listener(event.payload.control); + }) + .then((stop) => liveUnlisteners.push(stop)) + .catch(() => undefined); } startLiveListeners(); @@ -698,6 +726,11 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: return () => liveDocumentListeners.delete(listener); } + function subscribeAgentUi(listener: (control: AgentUiControl) => void): () => void { + agentUiListeners.add(listener); + return () => agentUiListeners.delete(listener); + } + async function acceptProposal(proposalId: string, operationPositions?: number[]): Promise { if (!currentStatus) throw new Error('No board loaded'); if (clearExpiredProposal(proposalId)) { @@ -732,15 +765,21 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: async function updateAgentContext(context: { pageId: string | null; + activeLayerId: string | null; selectionIds: string[]; viewport: { x: number; y: number; width: number; height: number } | null; + camera: { x: number; y: number; zoom: number } | null; + occludedRegions: Array<{ x: number; y: number; width: number; height: number }>; }): Promise { if (!currentStatus) return; await api.updateContext({ session_id: currentStatus.session_id, page_id: context.pageId, + active_layer_id: context.activeLayerId, selection_ids: context.selectionIds, - viewport: context.viewport + viewport: context.viewport, + camera: context.camera, + occluded_regions: context.occludedRegions }); } @@ -798,6 +837,7 @@ export function createDesktopSessionRepo(fileOps: DesktopFileOps, opts: { api?: getProposal, subscribeProposal, subscribeLiveDocument, + subscribeAgentUi, acceptProposal, rejectProposal, setAgentAccess, diff --git a/apps/web/src/lib/tests/Canvas.history.test.ts b/apps/web/src/lib/tests/Canvas.history.test.ts index 6f7db80..d3488cb 100644 --- a/apps/web/src/lib/tests/Canvas.history.test.ts +++ b/apps/web/src/lib/tests/Canvas.history.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import type { Action, Store } from '@inkfinite/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; diff --git a/crates/inkfinite-cli/src/bin/generate-bindings.rs b/crates/inkfinite-cli/src/bin/generate-bindings.rs index 7953c46..2967d87 100644 --- a/crates/inkfinite-cli/src/bin/generate-bindings.rs +++ b/crates/inkfinite-cli/src/bin/generate-bindings.rs @@ -6,10 +6,10 @@ use std::fs; use std::path::{Path, PathBuf}; use inkfinite_core::proto::{ - AffectedRegion, AgentAccessMode, AssetPatch, Bounds, CommitResult, DocumentPatch, DocumentPath, InverseMetadata, - LayerContentsDisposition, LayerPatch, LayoutAxis, Operation, Proposal, ProposalId, ProtocolError, Query, - QueryRecord, QueryResult, RecordId, Request, Response, SaveResult, SessionId, ShapeAlignment, ShapePatch, - TransactionDraft, TransactionId, Warning, + AffectedRegion, AgentAccessMode, AssetPatch, Bounds, CameraState, CommitResult, DocumentPatch, DocumentPath, + InverseMetadata, LayerContentsDisposition, LayerPatch, LayoutAxis, Operation, Proposal, ProposalId, + ProposalOperationPreview, ProtocolError, Query, QueryRecord, QueryResult, RecordId, Request, Response, SaveResult, + SessionId, ShapeAlignment, ShapePatch, TransactionDraft, TransactionId, Warning, }; use inkfinite_core::{ ActorId, AssetId, AssetRecord, AssetSource, BindingAnchor, BindingId, BindingKind, BindingRecord, ChangeHash, @@ -188,6 +188,7 @@ fn transaction_bindings() -> String { append_clean_declaration::(&mut output, &config); append_declaration::(&mut output, &config); append_declaration::(&mut output, &config); + append_clean_declaration::(&mut output, &config); append_declaration::(&mut output, &config); append_declaration::(&mut output, &config); append_declaration::(&mut output, &config); @@ -196,7 +197,8 @@ fn transaction_bindings() -> String { append_clean_declaration::(&mut output, &config); append_declaration::(&mut output, &config); append_declaration::(&mut output, &config); - append_declaration::(&mut output, &config); + append_clean_declaration::(&mut output, &config); + append_clean_declaration::(&mut output, &config); output } diff --git a/crates/inkfinite-cli/src/cli/app.rs b/crates/inkfinite-cli/src/cli/app.rs index 22166cf..c39ef83 100644 --- a/crates/inkfinite-cli/src/cli/app.rs +++ b/crates/inkfinite-cli/src/cli/app.rs @@ -2,17 +2,18 @@ use std::time::{Duration, Instant}; -use inkfinite_core::ipc::{self, AppRequest, AppResponse, IpcError}; +use inkfinite_core::ipc::{self, AppRequest, AppResponse, IpcError, UiControl}; use inkfinite_core::proto::{ProposalId, Query, RecordId, SessionId}; use inkfinite_core::session::{ProposalReviewState, ProposalStatus}; -use inkfinite_core::{LayerId, PageId}; +use inkfinite_core::{LayerId, PageId, ShapeId}; use super::apply::read_transaction; use super::args::{ AppApplyArgs, AppCommand, AppInspectArgs, AppProposalCommand, AppProposalWaitArgs, AppProposeArgs, AppQueryArgs, + AppRenderArgs, AppUiArgs, }; -use super::support::{map_output_error, write_heads, write_json}; -use super::{CliError, EXIT_CONFLICT, EXIT_INPUT, EXIT_INVALID, Result, Write, anyhow, json}; +use super::support::{map_output_error, portable_path, write_heads, write_json}; +use super::{CliError, EXIT_CONFLICT, EXIT_INPUT, EXIT_INVALID, Result, Write, anyhow, fs, json}; /// Runs one authenticated desktop command. pub fn run_app_command(command: AppCommand, json_output: bool, stdout: &mut dyn Write) -> Result<()> { @@ -23,6 +24,8 @@ pub fn run_app_command(command: AppCommand, json_output: bool, stdout: &mut dyn AppCommand::Query(args) => query(args, json_output, stdout), AppCommand::Propose(args) => propose(args, json_output, stdout), AppCommand::Proposal(command) => proposal(command, json_output, stdout), + AppCommand::Render(args) => render(args, json_output, stdout), + AppCommand::Ui(args) => control_ui(args, json_output, stdout), AppCommand::Apply(args) => apply(args, json_output, stdout), AppCommand::Focus => focus(json_output, stdout), } @@ -44,6 +47,12 @@ fn context(args: AppInspectArgs, json_output: bool, stdout: &mut dyn Write) -> R ) .map_err(map_output_error)?; writeln!(stdout, "Agent access: {:?}", context.agent_access).map_err(map_output_error)?; + writeln!( + stdout, + "Active layer: {}", + context.active_layer_id.as_ref().map_or("none", LayerId::as_str) + ) + .map_err(map_output_error)?; writeln!(stdout, "Selection: {}", context.selection_ids.len()).map_err(map_output_error)?; if let Some(viewport) = context.viewport { writeln!( @@ -53,6 +62,10 @@ fn context(args: AppInspectArgs, json_output: bool, stdout: &mut dyn Write) -> R ) .map_err(map_output_error)?; } + if let Some(camera) = context.camera { + writeln!(stdout, "Camera: {},{},{}", camera.x, camera.y, camera.zoom).map_err(map_output_error)?; + } + writeln!(stdout, "Occluded regions: {}", context.occluded_regions.len()).map_err(map_output_error)?; write_heads(stdout, &context.heads) } @@ -63,6 +76,104 @@ fn proposal(command: AppProposalCommand, json_output: bool, stdout: &mut dyn Wri write_proposal_status(&status, json_output, stdout) } AppProposalCommand::Wait(args) => wait_for_proposal(&args, json_output, stdout), + AppProposalCommand::Renew(args) => renew_proposal(args, json_output, stdout), + } +} + +fn renew_proposal(args: super::args::AppProposalStatusArgs, json_output: bool, stdout: &mut dyn Write) -> Result<()> { + let response = send(AppRequest::RenewProposal { + session_id: args.session_id.map(SessionId), + proposal_id: ProposalId(args.proposal_id), + })?; + let AppResponse::RenewedProposal(proposal) = response else { + return unexpected_response("proposal renew"); + }; + if json_output { + write_json(stdout, &proposal) + } else { + writeln!(stdout, "Renewed: {}", proposal.id.0).map_err(map_output_error)?; + writeln!(stdout, "Expires: {}", proposal.expires_at.0).map_err(map_output_error) + } +} + +fn render(args: AppRenderArgs, json_output: bool, stdout: &mut dyn Write) -> Result<()> { + let transaction = args.transaction.as_deref().map(read_transaction).transpose()?; + let response = send(AppRequest::Render { + session_id: args.session_id.map(SessionId), + transaction, + page_id: args.page.map(PageId::from), + region: args.region, + })?; + let AppResponse::Rendered(rendered) = response else { + return unexpected_response("render"); + }; + write_svg_output(&args.output, &rendered.current_svg)?; + if let (Some(path), Some(svg)) = (args.proposed_output.as_deref(), rendered.proposed_svg.as_deref()) { + write_svg_output(path, svg)?; + } + if json_output { + write_json( + stdout, + &json!({ + "heads": rendered.heads, + "current_output": portable_path(&args.output), + "proposed_output": args.proposed_output.as_deref().map(portable_path), + "preview": rendered.preview, + "affected_regions": rendered.affected_regions, + "warnings": rendered.warnings, + }), + ) + } else { + writeln!(stdout, "Rendered {}", portable_path(&args.output)).map_err(map_output_error)?; + if let Some(path) = args.proposed_output { + writeln!(stdout, "Rendered proposed {}", portable_path(&path)).map_err(map_output_error)?; + } + Ok(()) + } +} + +fn write_svg_output(path: &std::path::Path, svg: &str) -> Result<()> { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|error| { + CliError::new( + if error.kind() == std::io::ErrorKind::AlreadyExists { EXIT_CONFLICT } else { EXIT_INPUT }, + error, + ) + .with_code("render_output_error") + .context(format!("could not create {}", portable_path(path))) + })?; + file.write_all(svg.as_bytes()).map_err(|error| { + CliError::new(EXIT_INPUT, error) + .with_code("render_output_error") + .context(format!("could not write {}", portable_path(path))) + }) +} + +fn control_ui(args: AppUiArgs, json_output: bool, stdout: &mut dyn Write) -> Result<()> { + let selection_ids = if args.clear_selection { + Some(Vec::new()) + } else if args.selection.is_empty() { + None + } else { + Some(args.selection.into_iter().map(ShapeId::from).collect()) + }; + let control = UiControl { + page_id: args.page.map(PageId::from), + active_layer_id: args.layer.map(LayerId::from), + selection_ids, + camera: args.camera, + }; + let response = send(AppRequest::Ui { session_id: args.session_id.map(SessionId), control })?; + if !matches!(response, AppResponse::UiControlled) { + return unexpected_response("ui"); + } + if json_output { + write_json(stdout, &json!({ "controlled": true })) + } else { + writeln!(stdout, "Desktop UI updated").map_err(map_output_error) } } @@ -84,7 +195,13 @@ fn wait_for_proposal(args: &AppProposalWaitArgs, json_output: bool, stdout: &mut let deadline = Instant::now() + Duration::from_secs(args.timeout_seconds); loop { let status = fetch_proposal_status(args.session_id.clone(), args.proposal_id.clone())?; - if status.state != ProposalReviewState::Pending { + if matches!( + status.state, + ProposalReviewState::Accepted + | ProposalReviewState::PartiallyAccepted + | ProposalReviewState::Rejected + | ProposalReviewState::Expired + ) { return write_proposal_status(&status, json_output, stdout); } if Instant::now() >= deadline { @@ -104,6 +221,7 @@ fn write_proposal_status(status: &ProposalStatus, json_output: bool, stdout: &mu } writeln!(stdout, "Proposal: {}", status.proposal_id.0).map_err(map_output_error)?; writeln!(stdout, "State: {:?}", status.state).map_err(map_output_error)?; + writeln!(stdout, "Affected: {}", status.affected_ids.len()).map_err(map_output_error)?; write_heads(stdout, &status.heads) } diff --git a/crates/inkfinite-cli/src/cli/args.rs b/crates/inkfinite-cli/src/cli/args.rs index 7d6b406..ea67801 100644 --- a/crates/inkfinite-cli/src/cli/args.rs +++ b/crates/inkfinite-cli/src/cli/args.rs @@ -1,4 +1,4 @@ -use super::{ArgGroup, Args, Bounds, Parser, PathBuf, Subcommand, ValueEnum, parse_bounds}; +use super::{ArgGroup, Args, Bounds, CameraState, Parser, PathBuf, Subcommand, ValueEnum, parse_bounds}; #[derive(Debug, Parser)] #[command( @@ -218,7 +218,8 @@ pub struct MutationOptions { } #[derive(Debug, Args)] -#[command(group(ArgGroup::new("parent").required(true).args(["layer", "parent_shape"])))] +#[command(group(ArgGroup::new("parent").args(["layer", "parent_shape"])))] +#[command(group(ArgGroup::new("relative_target").args(["relative_id", "relative_name", "relative_role"])))] pub struct ShapeCreateArgs { /// Canonical document to change. Omit when using --app. #[arg(value_name = "FILE")] @@ -235,6 +236,21 @@ pub struct ShapeCreateArgs { /// Parent container shape ID. #[arg(long, value_name = "SHAPE_ID")] pub parent_shape: Option, + /// Place relative to this exact shape ID. + #[arg(long, value_name = "SHAPE_ID")] + pub relative_id: Option, + /// Place relative to the uniquely named shape. + #[arg(long)] + pub relative_name: Option, + /// Place relative to the shape with this unique semantic role. + #[arg(long)] + pub relative_role: Option, + /// Spatial relationship to the selected target. + #[arg(long, value_enum, requires = "relative_target")] + pub placement: Option, + /// Space between the new shape and its semantic target. + #[arg(long, default_value_t = 24.0, allow_hyphen_values = true)] + pub gap: f64, /// Horizontal position in parent coordinates. #[arg(long, default_value_t = 0.0, allow_hyphen_values = true)] pub x: f64, @@ -411,6 +427,19 @@ pub enum AxisArg { Vertical, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub enum PlacementArg { + Inside, + Below, + RightOf, + AlignLeft, + AlignCenter, + AlignRight, + AlignTop, + AlignMiddle, + AlignBottom, +} + #[derive(Debug, Args)] pub struct RenderArgs { /// Canonical .inkfinite document to render. @@ -512,6 +541,10 @@ pub enum AppCommand { /// Observe a proposal without accepting or rejecting it. #[command(subcommand)] Proposal(AppProposalCommand), + /// Render the current live document and an optional proposed result without applying it. + Render(AppRenderArgs), + /// Change the desktop page, active layer, selection, or camera. + Ui(AppUiArgs), /// Apply an agent transaction when Direct access is enabled in the desktop UI. #[command(after_help = "Example: @@ -532,6 +565,52 @@ pub enum AppProposalCommand { Status(AppProposalStatusArgs), /// Wait until desktop review accepts, rejects, or expires the proposal. Wait(AppProposalWaitArgs), + /// Revalidate the proposal and start a fresh review window. + Renew(AppProposalStatusArgs), +} + +#[derive(Debug, Args)] +pub struct AppRenderArgs { + /// Write the current live document SVG here. + #[arg(long, value_name = "SVG_FILE")] + pub output: PathBuf, + /// Write the proposed result SVG here. Requires --transaction. + #[arg(long, value_name = "SVG_FILE", requires = "transaction")] + pub proposed_output: Option, + /// Transaction JSON file, or - for standard input, to preview without applying. + #[arg(long, value_name = "TRANSACTION", requires = "proposed_output")] + pub transaction: Option, + /// Render this page instead of the first page. + #[arg(long, value_name = "PAGE_ID")] + pub page: Option, + /// Use this exact world-space SVG view box. + #[arg(long, value_name = "X,Y,WIDTH,HEIGHT", value_parser = parse_bounds)] + pub region: Option, + /// Render this session, or the only open session when omitted. + #[arg(long, value_name = "SESSION_ID")] + pub session_id: Option, +} + +#[derive(Debug, Args)] +pub struct AppUiArgs { + /// Show this page. + #[arg(long, value_name = "PAGE_ID")] + pub page: Option, + /// Activate this layer. + #[arg(long, value_name = "LAYER_ID")] + pub layer: Option, + /// Replace the selection with this shape. May be repeated; pass no --select to preserve it. + #[arg(long = "select", value_name = "SHAPE_ID")] + pub selection: Vec, + /// Clear the current selection. + #[arg(long, conflicts_with = "selection")] + pub clear_selection: bool, + /// Set camera center and zoom as x,y,zoom. + #[arg(long, value_name = "X,Y,ZOOM", value_parser = parse_camera)] + pub camera: Option, + /// Control this session, or the only open session when omitted. + #[arg(long, value_name = "SESSION_ID")] + pub session_id: Option, } #[derive(Debug, Args)] @@ -557,6 +636,22 @@ pub struct AppProposalWaitArgs { pub timeout_seconds: u64, } +fn parse_camera(value: &str) -> std::result::Result { + let values = value + .split(',') + .map(str::trim) + .map(str::parse::) + .collect::, _>>() + .map_err(|error| format!("invalid camera: {error}"))?; + let [x, y, zoom] = values.as_slice() else { + return Err("camera must contain x,y,zoom".into()); + }; + if !x.is_finite() || !y.is_finite() || !zoom.is_finite() || *zoom <= 0.0 { + return Err("camera coordinates must be finite and zoom must be positive".into()); + } + Ok(CameraState { x: *x, y: *y, zoom: *zoom }) +} + #[derive(Debug, Args)] pub struct AppInspectArgs { /// Inspect this session, or the only open session when omitted. diff --git a/crates/inkfinite-cli/src/cli/contract.rs b/crates/inkfinite-cli/src/cli/contract.rs index 5a2412e..e6c5f9a 100644 --- a/crates/inkfinite-cli/src/cli/contract.rs +++ b/crates/inkfinite-cli/src/cli/contract.rs @@ -53,7 +53,7 @@ pub fn print_capabilities(json_output: bool, stdout: &mut dyn Write) -> Result<( "stderr_only": true }, "live_mode": { - "commands": ["status", "context", "inspect", "query", "focus", "propose", "proposal status", "proposal wait", "apply"], + "commands": ["status", "context", "inspect", "query", "focus", "propose", "proposal status", "proposal wait", "proposal renew", "render", "ui", "apply"], "proposal_review": true, "proposal_decisions": "desktop_ui_only", "agent_access_modes": ["review", "direct"], @@ -67,6 +67,7 @@ pub fn print_capabilities(json_output: bool, stdout: &mut dyn Write) -> Result<( "connect": ["--binding-id", "--source", "--source-role", "--target", "--target-role", "--dry-run", "--transaction-out", "--app"], "layout": ["align", "distribute"], "shape": ["create", "patch", "delete", "kinds", "describe"], + "semantic_placement": ["inside", "below", "right-of", "align-left", "align-center", "align-right", "align-top", "align-middle", "align-bottom"], "structured_targets": ["file", "app_current_access_mode"] }, "path_format": "forward_slashes", @@ -94,7 +95,7 @@ pub fn print_capabilities(json_output: bool, stdout: &mut dyn Write) -> Result<( .map_err(map_output_error)?; writeln!( stdout, - "Live mode: app status, app context, app inspect, app query, app focus, app propose, app proposal status/wait, app apply" + "Live mode: app status, app context, app inspect, app query, app focus, app propose, app proposal status/wait/renew, app render, app ui, app apply" ) .map_err(map_output_error)?; writeln!(stdout, "Global options: --json, --non-interactive").map_err(map_output_error)?; diff --git a/crates/inkfinite-cli/src/cli/mod.rs b/crates/inkfinite-cli/src/cli/mod.rs index a637755..fa48f10 100644 --- a/crates/inkfinite-cli/src/cli/mod.rs +++ b/crates/inkfinite-cli/src/cli/mod.rs @@ -10,8 +10,8 @@ use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; use inkfinite_core::engine::{EngineError, validate_document}; use inkfinite_core::file::{DocumentFile, FileError}; use inkfinite_core::proto::{ - Bounds, LayoutAxis, Operation, PROTOCOL_ID, PROTOCOL_VERSION, Query, RecordId, ShapeAlignment, ShapePatch, - TransactionDraft, TransactionId, + Bounds, CameraState, LayoutAxis, Operation, PROTOCOL_ID, PROTOCOL_VERSION, Query, RecordId, ShapeAlignment, + ShapePatch, TransactionDraft, TransactionId, }; use inkfinite_core::render::{SvgRenderOptions, render_svg}; use inkfinite_core::{ @@ -116,8 +116,8 @@ mod support; use args::{ AlignmentArg, ApplyArgs, AxisArg, ConnectArgs, FileOutputArgs, InspectArgs, LayoutCommand, LayoutSelectionArgs, - MutationOptions, NewArgs, QueryArgs, RenderArgs, SchemaKind, ShapeCommand, ShapeCreateArgs, ShapeDeleteArgs, - ShapeDescribeArgs, ShapePatchArgs, + MutationOptions, NewArgs, PlacementArg, QueryArgs, RenderArgs, SchemaKind, ShapeCommand, ShapeCreateArgs, + ShapeDeleteArgs, ShapeDescribeArgs, ShapePatchArgs, }; use support::parse_bounds; diff --git a/crates/inkfinite-cli/src/cli/shape.rs b/crates/inkfinite-cli/src/cli/shape.rs index 7f416f9..00cae98 100644 --- a/crates/inkfinite-cli/src/cli/shape.rs +++ b/crates/inkfinite-cli/src/cli/shape.rs @@ -1,10 +1,12 @@ use super::mutation::{StructuredMutationTarget, read_json_argument, select_unique_shape}; use super::{ - BTreeMap, BuiltinShapeKind, CliError, EXIT_INVALID, LayerId, Opacity, Operation, Origin, Provenance, RecordVersion, - Result, SemanticMetadata, Serialize, ShapeCommand, ShapeCreateArgs, ShapeDeleteArgs, ShapeDescribeArgs, ShapeId, - ShapeKind, ShapeParent, ShapePatch, ShapePatchArgs, ShapeRecord, ShapeStyle, SiblingAnchor, Timestamp, Transform, - Value, Vec2, Write, anyhow, builtin_shape_kinds, + BTreeMap, BuiltinShapeKind, CliError, EXIT_INVALID, LayerId, Opacity, Operation, Origin, PlacementArg, Provenance, + RecordVersion, Result, SemanticMetadata, Serialize, ShapeCommand, ShapeCreateArgs, ShapeDeleteArgs, + ShapeDescribeArgs, ShapeId, ShapeKind, ShapeParent, ShapePatch, ShapePatchArgs, ShapeRecord, ShapeStyle, + SiblingAnchor, Timestamp, Transform, Value, Vec2, Write, anyhow, builtin_shape_kinds, }; +use inkfinite_core::engine::geometry::{Affine, bottom, right, world_shape_bounds, world_transform}; +use inkfinite_core::proto::Bounds; #[derive(Serialize)] struct ShapeKindDescription { @@ -93,7 +95,12 @@ fn shape_kind_description(kind: BuiltinShapeKind) -> ShapeKindDescription { } fn create_shape(args: ShapeCreateArgs, json_output: bool, stdout: &mut dyn Write) -> Result<()> { - if !args.x.is_finite() || !args.y.is_finite() || !args.rotation.is_finite() { + if !args.x.is_finite() + || !args.y.is_finite() + || !args.rotation.is_finite() + || !args.gap.is_finite() + || args.gap < 0.0 + { return Err(CliError::new( EXIT_INVALID, anyhow!("shape transform values must be finite"), @@ -109,14 +116,54 @@ fn create_shape(args: ShapeCreateArgs, json_output: bool, stdout: &mut dyn Write ), )); } - let properties: BTreeMap = serde_json::from_str(&args.properties) + let mut properties: BTreeMap = serde_json::from_str(&args.properties) .map_err(|error| CliError::new(EXIT_INVALID, error).context("could not parse --properties JSON object"))?; - let parent = match (args.layer, args.parent_shape) { - (Some(layer), None) => ShapeParent::Layer(LayerId::from(layer)), - (None, Some(shape)) => ShapeParent::Shape(ShapeId::from(shape)), - _ => return Err(CliError::new(EXIT_INVALID, anyhow!("select exactly one shape parent"))), - }; + properties.entry("width".into()).or_insert_with(|| Value::from(0.0)); + properties.entry("height".into()).or_insert_with(|| Value::from(0.0)); let mut target = StructuredMutationTarget::open(args.path.as_deref(), &args.mutation)?; + let snapshot = target.snapshot()?; + let relative_id = if args.relative_id.is_some() || args.relative_name.is_some() || args.relative_role.is_some() { + Some(select_unique_shape( + &snapshot.document, + args.relative_id.as_deref(), + args.relative_name.as_deref(), + args.relative_role.as_deref(), + )?) + } else { + None + }; + if relative_id.is_some() != args.placement.is_some() { + return Err(CliError::new( + EXIT_INVALID, + anyhow!("semantic placement requires both a relative target and --placement"), + )); + } + let explicit_parent = match (args.layer, args.parent_shape) { + (Some(layer), None) => Some(ShapeParent::Layer(LayerId::from(layer))), + (None, Some(shape)) => Some(ShapeParent::Shape(ShapeId::from(shape))), + (None, None) => None, + _ => return Err(CliError::new(EXIT_INVALID, anyhow!("select at most one shape parent"))), + }; + let parent = match (explicit_parent, relative_id.as_ref(), args.placement) { + (Some(parent), _, _) => parent, + (None, Some(target_id), Some(PlacementArg::Inside)) => ShapeParent::Shape(target_id.clone()), + (None, Some(target_id), Some(_)) => snapshot.document.shapes[target_id].parent.clone(), + (None, None, None) => { + return Err(CliError::new( + EXIT_INVALID, + anyhow!("select a shape parent or semantic placement target"), + )); + } + _ => return Err(CliError::new(EXIT_INVALID, anyhow!("invalid semantic placement"))), + }; + if let (Some(target_id), Some(PlacementArg::Inside)) = (relative_id.as_ref(), args.placement) + && parent != ShapeParent::Shape(target_id.clone()) + { + return Err(CliError::new( + EXIT_INVALID, + anyhow!("inside placement must use the target container as its parent"), + )); + } let provenance_actor = match &target { StructuredMutationTarget::File(file) => file.actor_id().clone(), StructuredMutationTarget::App { status, .. } => status.actor_id.clone(), @@ -124,7 +171,6 @@ fn create_shape(args: ShapeCreateArgs, json_output: bool, stdout: &mut dyn Write let shape_id = match args.shape_id { Some(shape_id) => ShapeId::from(shape_id), None => { - let snapshot = target.snapshot()?; let mut suffix = 1_u64; loop { let candidate = ShapeId::from(format!("shape:{}:{suffix}", args.kind)); @@ -135,16 +181,24 @@ fn create_shape(args: ShapeCreateArgs, json_output: bool, stdout: &mut dyn Write } } }; + let mut transform = + Transform { translation: Vec2 { x: args.x, y: args.y }, rotation: args.rotation, scale_x: 1.0, scale_y: 1.0 }; + if let (Some(target_id), Some(placement)) = (relative_id.as_ref(), args.placement) { + transform.translation = resolve_placement( + &snapshot.document, + &parent, + target_id, + placement, + args.gap, + &properties, + transform, + )?; + } let shape = ShapeRecord { id: shape_id.clone(), kind: ShapeKind::from(args.kind), parent, - transform: Transform { - translation: Vec2 { x: args.x, y: args.y }, - rotation: args.rotation, - scale_x: 1.0, - scale_y: 1.0, - }, + transform, child_ids: Vec::new(), layout: None, properties, @@ -174,6 +228,66 @@ fn create_shape(args: ShapeCreateArgs, json_output: bool, stdout: &mut dyn Write target.finish(transaction, &args.mutation, json_output, stdout) } +fn resolve_placement( + document: &inkfinite_core::Document, parent: &ShapeParent, target_id: &ShapeId, placement: PlacementArg, gap: f64, + properties: &BTreeMap, transform: Transform, +) -> Result { + let target = document + .shapes + .get(target_id) + .ok_or_else(|| CliError::new(EXIT_INVALID, anyhow!("shape {target_id} does not exist")))?; + if placement == PlacementArg::Inside && target.kind.as_str() != "container" { + return Err(CliError::new( + EXIT_INVALID, + anyhow!("inside placement requires a container target"), + )); + } + let width = properties.get("width").and_then(Value::as_f64).unwrap_or(0.0).abs(); + let height = properties.get("height").and_then(Value::as_f64).unwrap_or(0.0).abs(); + if placement == PlacementArg::Inside { + let local = Affine::from_transform(Transform { translation: Vec2 { x: 0.0, y: 0.0 }, ..transform }) + .transform_bounds(Bounds { x: 0.0, y: 0.0, width, height }); + return Ok(Vec2 { x: gap - local.x, y: gap - local.y }); + } + let parent_world = match parent { + ShapeParent::Layer(_) => Affine::IDENTITY, + ShapeParent::Shape(parent_id) => { + let parent_shape = document + .shapes + .get(parent_id) + .ok_or_else(|| CliError::new(EXIT_INVALID, anyhow!("parent shape {parent_id} does not exist")))?; + world_transform(document, parent_shape) + } + }; + let base_world = parent_world + .then(Affine::from_transform(Transform { + translation: Vec2 { x: 0.0, y: 0.0 }, + ..transform + })) + .transform_bounds(Bounds { x: 0.0, y: 0.0, width, height }); + let target_bounds = world_shape_bounds(document, target_id); + let desired = match placement { + PlacementArg::Below => Vec2 { x: target_bounds.x, y: bottom(&target_bounds) + gap }, + PlacementArg::RightOf => Vec2 { x: right(&target_bounds) + gap, y: target_bounds.y }, + PlacementArg::AlignLeft | PlacementArg::AlignTop => Vec2 { x: target_bounds.x, y: target_bounds.y }, + PlacementArg::AlignCenter => { + Vec2 { x: target_bounds.x + (target_bounds.width - base_world.width) / 2.0, y: target_bounds.y } + } + PlacementArg::AlignRight => Vec2 { x: right(&target_bounds) - base_world.width, y: target_bounds.y }, + PlacementArg::AlignMiddle => { + Vec2 { x: target_bounds.x, y: target_bounds.y + (target_bounds.height - base_world.height) / 2.0 } + } + PlacementArg::AlignBottom => Vec2 { x: target_bounds.x, y: bottom(&target_bounds) - base_world.height }, + PlacementArg::Inside => unreachable!(), + }; + let inverse = parent_world + .inverse() + .ok_or_else(|| CliError::new(EXIT_INVALID, anyhow!("semantic placement parent transform is singular")))?; + let origin = inverse.point(Vec2 { x: 0.0, y: 0.0 }); + let delta = inverse.point(Vec2 { x: desired.x - base_world.x, y: desired.y - base_world.y }); + Ok(Vec2 { x: delta.x - origin.x, y: delta.y - origin.y }) +} + fn patch_shape(args: &ShapePatchArgs, json_output: bool, stdout: &mut dyn Write) -> Result<()> { let patch_json = read_json_argument(&args.patch, "shape patch")?; let patch: ShapePatch = serde_json::from_str(&patch_json) diff --git a/crates/inkfinite-cli/tests/cli.rs b/crates/inkfinite-cli/tests/cli.rs index b1290c0..4094d14 100644 --- a/crates/inkfinite-cli/tests/cli.rs +++ b/crates/inkfinite-cli/tests/cli.rs @@ -15,6 +15,8 @@ use inkfinite_core::ipc::{ self, AppRequest, AppResponse, DiscoveryRecord, RequestEnvelope, RequestGuard, ResponseEnvelope, }; use inkfinite_core::proto::{Operation, TransactionDraft, TransactionId}; +#[cfg(unix)] +use inkfinite_core::session::EditorContextUpdate; use inkfinite_core::{ ActorId, DocumentId, Opacity, Origin, Provenance, RecordVersion, SemanticMetadata, ShapeId, ShapeKind, ShapeParent, ShapeRecord, ShapeStyle, SiblingAnchor, Timestamp, Transform, Vec2, blank_document, @@ -406,6 +408,62 @@ fn apply_dry_run_then_save_validate_reopen_and_render_is_atomic() { assert_eq!(fs::read(&document_path).unwrap(), saved); } +#[test] +fn shape_create_places_new_shapes_relative_to_semantic_targets() { + let temporary = TestDirectory::new("semantic-placement"); + let document_path = temporary.path.join("placement.inkfinite"); + assert_success(&run(["new", path(&document_path), "--json"])); + assert_success(&run([ + "shape", + "create", + path(&document_path), + "--shape-id", + "shape:target", + "--kind", + "rect", + "--layer", + "layer:document:placement:1", + "--x", + "10", + "--y", + "20", + "--properties", + r#"{"width":40,"height":20}"#, + "--role", + "layout.target", + "--json", + ])); + let placed = run([ + "shape", + "create", + path(&document_path), + "--shape-id", + "shape:placed", + "--kind", + "rect", + "--properties", + r#"{"width":20,"height":10}"#, + "--relative-role", + "layout.target", + "--placement", + "below", + "--gap", + "12", + "--json", + ]); + assert_success(&placed); + + let snapshot = parse_stdout(&run(["inspect", path(&document_path), "--json"])); + assert_eq!( + snapshot["document"]["shapes"]["shape:placed"]["transform"]["translation"], + json!({ "x": 10.0, "y": 52.0 }) + ); + assert_eq!( + snapshot["document"]["shapes"]["shape:placed"]["parent"], + json!({ "kind": "layer", "id": "layer:document:placement:1" }) + ); +} + #[test] fn structured_create_can_be_validated_reopened_and_rendered() { let temporary = TestDirectory::new("render"); @@ -713,6 +771,9 @@ fn live_commands_read_shared_records_from_the_authenticated_local_server() { | AppRequest::Propose { .. } | AppRequest::Mutate { .. } | AppRequest::ProposalStatus { .. } + | AppRequest::RenewProposal { .. } + | AppRequest::Render { .. } + | AppRequest::Ui { .. } | AppRequest::Apply { .. } => { panic!("proposal requests are outside this IPC fixture") } @@ -784,6 +845,7 @@ fn live_structured_edits_follow_desktop_agent_access_mode() { let temporary = TestDirectory::new("live-proposals"); let document_path = temporary.path.join("live.inkfinite"); let transaction_path = temporary.path.join("transaction.json"); + let live_svg_path = temporary.path.join("live.svg"); let document_id = DocumentId::from("document:live-proposals"); let actor = ActorId::from("actor:live-proposals"); let document = blank_document(&document_id, Some("Live proposals")); @@ -800,9 +862,14 @@ fn live_structured_edits_follow_desktop_agent_access_mode() { service .update_context( &session_id, - Some(page_id.clone()), - Vec::new(), - Some(inkfinite_core::proto::Bounds { x: -50.0, y: -25.0, width: 100.0, height: 50.0 }), + EditorContextUpdate { + page_id: Some(page_id.clone()), + active_layer_id: Some(layer_id.clone()), + selection_ids: Vec::new(), + viewport: Some(inkfinite_core::proto::Bounds { x: -50.0, y: -25.0, width: 100.0, height: 50.0 }), + camera: None, + occluded_regions: Vec::new(), + }, ) .unwrap(); @@ -824,7 +891,7 @@ fn live_structured_edits_follow_desktop_agent_access_mode() { runtime.block_on(async move { let listener = tokio::net::UnixListener::from_std(listener).unwrap(); let mut guard = RequestGuard::new(server_token); - for _ in 0..9 { + for _ in 0..11 { let (mut stream, _) = listener.accept().await.unwrap(); let request = ipc::read_frame::(&mut stream).await.unwrap(); guard.validate(&request).unwrap(); @@ -896,9 +963,39 @@ fn live_structured_edits_follow_desktop_agent_access_mode() { assert_success(&context); let context_json = parse_stdout(&context); assert_eq!(context_json["page_id"], page_id.as_str()); + assert_eq!(context_json["active_layer_id"], layer_id.as_str()); assert_eq!(context_json["agent_access"], "review"); assert_eq!(context_json["viewport"]["width"], 100.0); + let rendered = run([ + "app", + "render", + "--session-id", + "session:1", + "--output", + path(&live_svg_path), + "--json", + ]); + assert_success(&rendered); + assert!(fs::read_to_string(&live_svg_path).unwrap().starts_with(", + /// Layer to activate, when the active layer should change. + pub active_layer_id: Option, + /// Replacement selection, when the selection should change. + pub selection_ids: Option>, + /// Replacement camera, when the viewport should move or zoom. + pub camera: Option, +} + /// Agent-facing operation accepted by the desktop server. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case", tag = "type")] @@ -97,6 +115,31 @@ pub enum AppRequest { /// Proposal whose review state should be returned. proposal_id: crate::proto::ProposalId, }, + /// Revalidate a pending or recently expired proposal and renew its review window. + RenewProposal { + /// Session owning the proposal, or the only open session when omitted. + session_id: Option, + /// Proposal to renew. + proposal_id: crate::proto::ProposalId, + }, + /// Render the current live document and an optional proposed result. + Render { + /// Session to render, or the only open session when omitted. + session_id: Option, + /// Transaction to preview without applying. + transaction: Option, + /// Page to render, or the first page when omitted. + page_id: Option, + /// Exact world-space render bounds. + region: Option, + }, + /// Ask the desktop editor to change its page, layer, selection, or camera. + Ui { + /// Session to control, or the only open session when omitted. + session_id: Option, + /// Typed editor state change. + control: UiControl, + }, /// Apply an agent transaction when direct access is enabled. Apply { /// Session to change, or the only open session when omitted. @@ -140,10 +183,16 @@ pub enum AppResponse { Proposal(Proposal), /// Current or retained proposal review state. ProposalStatus(ProposalStatus), + /// A proposal was revalidated and returned to review. + RenewedProposal(Proposal), + /// Current and proposed deterministic SVG projections. + Rendered(Box), /// A directly authorized transaction was committed. Committed(Box), /// The focus notification was emitted. Focused, + /// The desktop accepted a typed UI navigation request. + UiControlled, } /// Response frame correlated with one request. @@ -471,6 +520,37 @@ pub fn dispatch(service: &mut SessionService, request: AppRequest) -> Result { + let session_id = service + .resolve_session_id(session_id.as_ref()) + .map_err(|error| session_protocol_error(&error))?; + service + .renew_proposal(&session_id, &proposal_id) + .map(AppResponse::RenewedProposal) + .map_err(|error| session_protocol_error_with_heads(service, &session_id, &error)) + } + AppRequest::Render { session_id, transaction, page_id, region } => { + let session_id = service + .resolve_session_id(session_id.as_ref()) + .map_err(|error| session_protocol_error(&error))?; + service + .render_live(&session_id, transaction.as_ref(), page_id, region) + .map(|preview| AppResponse::Rendered(Box::new(preview))) + .map_err(|error| session_protocol_error_with_heads(service, &session_id, &error)) + } + AppRequest::Ui { session_id, control } => { + let session_id = service + .resolve_session_id(session_id.as_ref()) + .map_err(|error| session_protocol_error(&error))?; + let status = service + .status(&session_id) + .map_err(|error| session_protocol_error(&error))?; + let context = service + .context(&session_id) + .map_err(|error| session_protocol_error(&error))?; + validate_ui_control(&status.snapshot, &context, &control)?; + Ok(AppResponse::UiControlled) + } AppRequest::Apply { session_id, transaction } => { let session_id = service .resolve_session_id(session_id.as_ref()) @@ -484,6 +564,84 @@ pub fn dispatch(service: &mut SessionService, request: AppRequest) -> Result Result<(), ProtocolError> { + if control.page_id.is_none() + && control.active_layer_id.is_none() + && control.selection_ids.is_none() + && control.camera.is_none() + { + return Err(protocol_error( + "invalid_ui_control", + "at least one UI field is required", + )); + } + if let Some(page_id) = &control.page_id + && !snapshot.document.pages.contains_key(page_id) + { + return Err(protocol_error( + "invalid_ui_control", + format!("page {page_id} does not exist"), + )); + } + if let Some(layer_id) = &control.active_layer_id + && !snapshot.document.layers.contains_key(layer_id) + { + return Err(protocol_error( + "invalid_ui_control", + format!("layer {layer_id} does not exist"), + )); + } + let effective_page = control.page_id.as_ref().or(context.page_id.as_ref()); + let effective_layer = control.active_layer_id.as_ref().or(context.active_layer_id.as_ref()); + if let (Some(page_id), Some(layer_id)) = (effective_page, effective_layer) + && snapshot.document.layers[layer_id].page_id != *page_id + { + return Err(protocol_error( + "invalid_ui_control", + format!("layer {layer_id} is not on page {page_id}"), + )); + } + if let Some(selection_ids) = &control.selection_ids + && let Some(shape_id) = selection_ids + .iter() + .find(|shape_id| !snapshot.document.shapes.contains_key(*shape_id)) + { + return Err(protocol_error( + "invalid_ui_control", + format!("shape {shape_id} does not exist"), + )); + } + if let (Some(page_id), Some(selection_ids)) = (effective_page, &control.selection_ids) { + for selected_shape_id in selection_ids { + let mut shape_id = selected_shape_id; + let layer_id = loop { + let shape = &snapshot.document.shapes[shape_id]; + match &shape.parent { + crate::ShapeParent::Layer(layer_id) => break layer_id, + crate::ShapeParent::Shape(parent_id) => shape_id = parent_id, + } + }; + if snapshot.document.layers[layer_id].page_id != *page_id { + return Err(protocol_error( + "invalid_ui_control", + format!("shape {selected_shape_id} is not on page {page_id}"), + )); + } + } + } + if control.camera.is_some_and(|camera| { + !camera.x.is_finite() || !camera.y.is_finite() || !camera.zoom.is_finite() || camera.zoom <= 0.0 + }) { + return Err(protocol_error( + "invalid_ui_control", + "camera coordinates must be finite and zoom must be positive", + )); + } + Ok(()) +} + fn session_protocol_error_with_heads( service: &mut SessionService, session_id: &SessionId, error: &SessionError, ) -> ProtocolError { @@ -505,6 +663,7 @@ pub fn session_protocol_error(error: &SessionError) -> ProtocolError { SessionError::SessionSelectionRequired { open_sessions: 0 } => "app_session_unavailable", SessionError::SessionSelectionRequired { .. } => "session_selection_required", SessionError::InvalidContext(_) => "invalid_context", + SessionError::Render(_) => "render_error", SessionError::ActorMismatch { .. } => "actor_mismatch", SessionError::StaleHeads => "stale_heads", SessionError::ProposalLimit(_) @@ -697,7 +856,8 @@ mod tests { use tokio::io::AsyncWriteExt as _; use super::*; - use crate::proto::Query; + use crate::proto::{Operation, Query, TransactionId}; + use crate::{Origin, Timestamp}; static TEST_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -824,6 +984,50 @@ mod tests { ) .unwrap(); assert!(matches!(query, AppResponse::QueryResult(result) if result.heads == opened.status.snapshot.heads)); + let render_transaction = TransactionDraft { + id: TransactionId("transaction:render-preview".into()), + actor_id: opened.status.actor_id.clone(), + origin: Origin::Agent, + base_heads: opened.status.snapshot.heads.clone(), + description: "preview page rename".into(), + operations: vec![Operation::RenamePage { + page_id: opened.status.snapshot.document.page_ids[0].clone(), + name: "Proposed".into(), + expected_version: None, + }], + timestamp: Timestamp(1), + }; + let rendered = dispatch( + &mut service, + AppRequest::Render { session_id: None, transaction: Some(render_transaction), page_id: None, region: None }, + ) + .unwrap(); + assert!(matches!( + rendered, + AppResponse::Rendered(rendered) + if rendered.current_svg.starts_with(", + /// Human-readable, independently selectable operation previews. + pub operation_previews: Vec, /// Validation or repair warnings shown before acceptance. pub warnings: Vec, /// Wall-clock expiry retained for clients and diagnostics. pub expires_at: Timestamp, } +/// Review metadata for one operation in a proposal. +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize, TS)] +pub struct ProposalOperationPreview { + /// Zero-based position used by partial acceptance. + pub position: u32, + /// Stable human-readable operation label. + pub label: String, + /// Records named directly by this operation. + pub record_ids: Vec, + /// Geometry attributable to this operation before or after the proposal. + pub bounds: Vec, +} + /// Transport-independent request accepted by desktop commands, IPC, or CLI adapters. #[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize, TS)] #[serde(rename_all = "snake_case", tag = "type")] diff --git a/crates/inkfinite-core/src/session.rs b/crates/inkfinite-core/src/session.rs index 0b053ff..b25cc0d 100644 --- a/crates/inkfinite-core/src/session.rs +++ b/crates/inkfinite-core/src/session.rs @@ -13,14 +13,20 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use thiserror::Error; +use crate::engine::geometry::world_shape_bounds; use crate::engine::{EngineError, SyncApplyResult, validate_document}; use crate::file::{DocumentFile, FileError}; use crate::proto::{ - AgentAccessMode, Bounds, CommitResult, DocumentPath, Proposal, ProposalId, Query, QueryResult, SaveResult, - SessionId, TransactionDraft, TransactionId, Warning, + AgentAccessMode, Bounds, CameraState, CommitResult, DocumentPath, Operation, Proposal, ProposalId, + ProposalOperationPreview, Query, QueryResult, RecordId, SaveResult, SessionId, TransactionDraft, TransactionId, + Warning, }; +use crate::render::{SvgRenderError, SvgRenderOptions, render_svg}; use crate::sync::{PeerSyncStatus, SyncMessage}; -use crate::{ActorId, ChangeHash, DocumentId, DocumentSnapshot, Origin, PageId, ShapeId, Timestamp, blank_document}; +use crate::{ + ActorId, ChangeHash, Document, DocumentId, DocumentSnapshot, LayerId, Origin, PageId, ShapeId, Timestamp, + blank_document, +}; /// Maximum number of pending proposals held by one live session. pub const MAX_PROPOSALS_PER_SESSION: usize = 32; @@ -99,8 +105,12 @@ pub struct SessionCommit { pub enum ProposalReviewState { /// The proposal is waiting for a desktop review decision. Pending, + /// The proposal was revalidated against current heads or given more review time. + Refreshed, /// A human accepted the proposal in the desktop UI. Accepted, + /// A human accepted a strict subset of the proposal operations. + PartiallyAccepted, /// A human rejected the proposal in the desktop UI. Rejected, /// The proposal was not reviewed before its bounded lifetime elapsed. @@ -116,6 +126,8 @@ pub struct ProposalStatus { pub state: ProposalReviewState, /// Current document heads when this status was observed or recorded. pub heads: Vec, + /// Records affected by an accepted proposal, empty for non-committing outcomes. + pub affected_ids: Vec, /// Full proposal while review is still pending. pub proposal: Option, } @@ -135,14 +147,37 @@ pub struct SessionContext { pub heads: Vec, /// Page currently visible in the editor. pub page_id: Option, + /// Layer currently receiving new editor shapes. + pub active_layer_id: Option, /// Shapes selected by the user in stable UI order. pub selection_ids: Vec, /// Visible world-space rectangle, when the renderer has reported one. pub viewport: Option, + /// Exact camera center and zoom reported by the renderer. + pub camera: Option, + /// World-space regions hidden by floating editor UI. + pub occluded_regions: Vec, /// Wall-clock time of the latest editor context update. pub updated_at: Timestamp, } +/// Editor-only drawing state published by the mounted desktop canvas. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct EditorContextUpdate { + /// Page currently visible in the editor. + pub page_id: Option, + /// Layer currently receiving new editor shapes. + pub active_layer_id: Option, + /// Shapes selected by the user in stable UI order. + pub selection_ids: Vec, + /// Visible world-space rectangle. + pub viewport: Option, + /// Exact camera center and zoom. + pub camera: Option, + /// World-space regions hidden by floating editor UI. + pub occluded_regions: Vec, +} + /// Result returned after a successful save or save-as. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct SessionSaved { @@ -161,6 +196,23 @@ pub struct SessionSync { pub status: SessionStatus, } +/// Deterministic current and proposed SVG projections for a live session. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct LiveSvgPreview { + /// Current causal heads used for both projections. + pub heads: Vec, + /// SVG for the current live document. + pub current_svg: String, + /// SVG after the proposed transaction, when one was supplied. + pub proposed_svg: Option, + /// Created, changed, and deleted records in the proposed result. + pub preview: Option, + /// Geometry invalidated by the proposed transaction. + pub affected_regions: Vec, + /// Renderer fallback warnings from either projection. + pub warnings: Vec, +} + /// Recoverable failure from session lookup, authorization, or document I/O. #[derive(Debug, Error)] pub enum SessionError { @@ -176,6 +228,9 @@ pub enum SessionError { /// The frontend reported editor context that does not belong to the session document. #[error("invalid editor context: {0}")] InvalidContext(String), + /// Deterministic SVG rendering rejected the requested live projection. + #[error(transparent)] + Render(#[from] SvgRenderError), /// The actor does not own the session's local mutation stream. #[error("actor {actual} does not own session actor {expected}")] ActorMismatch { @@ -254,12 +309,15 @@ pub enum SessionError { struct DocumentSession { file: DocumentFile, proposals: BTreeMap, - expired_proposals: BTreeSet, + expired_proposals: BTreeMap, proposal_outcomes: BTreeMap, proposal_outcome_order: VecDeque, page_id: Option, + active_layer_id: Option, selection_ids: Vec, viewport: Option, + camera: Option, + occluded_regions: Vec, context_updated_at: Timestamp, agent_access: AgentAccessMode, } @@ -268,6 +326,7 @@ struct DocumentSession { struct PendingProposal { proposal: Proposal, created_at: Instant, + state: ProposalReviewState, } /// In-process owner of all open desktop document sessions. @@ -338,7 +397,7 @@ impl SessionService { .collect() } - /// Returns the latest page, selection, viewport, actor, and heads reported by the desktop editor. + /// Returns the latest drawing context reported by the desktop editor. /// /// # Errors /// @@ -353,42 +412,58 @@ impl SessionService { agent_access: session.agent_access, heads: snapshot.heads, page_id: session.page_id.clone(), + active_layer_id: session.active_layer_id.clone(), selection_ids: session.selection_ids.clone(), viewport: session.viewport, + camera: session.camera, + occluded_regions: session.occluded_regions.clone(), updated_at: session.context_updated_at, }) } - /// Records the current frontend page, selection, and world-space viewport. + /// Records the current frontend drawing context. /// /// # Errors /// /// Returns a session, snapshot, or context validation error. - pub fn update_context( - &mut self, session_id: &SessionId, page_id: Option, selection_ids: Vec, - viewport: Option, - ) -> Result<(), SessionError> { + pub fn update_context(&mut self, session_id: &SessionId, update: EditorContextUpdate) -> Result<(), SessionError> { let session = self.session_mut(session_id)?; - if page_id != session.page_id || selection_ids != session.selection_ids { + if update.page_id != session.page_id + || update.active_layer_id != session.active_layer_id + || update.selection_ids != session.selection_ids + { let snapshot = session.file.snapshot()?; - if let Some(page_id) = &page_id + if let Some(page_id) = &update.page_id && !snapshot.document.pages.contains_key(page_id) { return Err(SessionError::InvalidContext(format!("page {page_id} does not exist"))); } - if let Some(shape_id) = selection_ids + if let Some(shape_id) = update + .selection_ids .iter() .find(|shape_id| !snapshot.document.shapes.contains_key(*shape_id)) { return Err(SessionError::InvalidContext(format!("shape {shape_id} does not exist"))); } - if page_id.is_none() && !selection_ids.is_empty() { + if update.page_id.is_none() && !update.selection_ids.is_empty() { return Err(SessionError::InvalidContext( "a selection requires an active page".into(), )); } - if let Some(page_id) = &page_id { - for selected_shape_id in &selection_ids { + if let Some(layer_id) = &update.active_layer_id { + let layer = snapshot + .document + .layers + .get(layer_id) + .ok_or_else(|| SessionError::InvalidContext(format!("layer {layer_id} does not exist")))?; + if Some(&layer.page_id) != update.page_id.as_ref() { + return Err(SessionError::InvalidContext(format!( + "layer {layer_id} is not on the active page" + ))); + } + } + if let Some(page_id) = &update.page_id { + for selected_shape_id in &update.selection_ids { let mut shape_id = selected_shape_id; let layer_id = loop { let shape = &snapshot.document.shapes[shape_id]; @@ -405,21 +480,30 @@ impl SessionService { } } } - if let Some(bounds) = viewport - && (!bounds.x.is_finite() - || !bounds.y.is_finite() - || !bounds.width.is_finite() - || !bounds.height.is_finite() - || bounds.width < 0.0 - || bounds.height < 0.0) + if update.viewport.is_some_and(|bounds| !valid_bounds(bounds)) + || update + .occluded_regions + .iter() + .copied() + .any(|bounds| !valid_bounds(bounds)) { return Err(SessionError::InvalidContext( - "viewport must be finite with non-negative width and height".into(), + "viewport and occluded regions must be finite with non-negative dimensions".into(), )); } - session.page_id = page_id; - session.selection_ids = selection_ids; - session.viewport = viewport; + if update.camera.is_some_and(|camera| { + !camera.x.is_finite() || !camera.y.is_finite() || !camera.zoom.is_finite() || camera.zoom <= 0.0 + }) { + return Err(SessionError::InvalidContext( + "camera coordinates must be finite and zoom must be positive".into(), + )); + } + session.page_id = update.page_id; + session.active_layer_id = update.active_layer_id; + session.selection_ids = update.selection_ids; + session.viewport = update.viewport; + session.camera = update.camera; + session.occluded_regions = update.occluded_regions; session.context_updated_at = timestamp_now(); Ok(()) } @@ -483,7 +567,11 @@ impl SessionService { let proposal = create_proposal(&mut session.file, proposal_id, transaction, None)?; session.proposals.insert( proposal.id.clone(), - PendingProposal { proposal: proposal.clone(), created_at: Instant::now() }, + PendingProposal { + proposal: proposal.clone(), + created_at: Instant::now(), + state: ProposalReviewState::Pending, + }, ); Ok(proposal) } @@ -503,7 +591,7 @@ impl SessionService { let session = self.session_mut(session_id)?; session.expire_state(); let pending = session.proposals.get(proposal_id).cloned().ok_or_else(|| { - if session.expired_proposals.remove(proposal_id) { + if session.expired_proposals.contains_key(proposal_id) { SessionError::ProposalExpired(proposal_id.clone()) } else { SessionError::ProposalNotFound(proposal_id.clone()) @@ -529,7 +617,11 @@ impl SessionService { }; session.proposals.insert( proposal_id.clone(), - PendingProposal { proposal: refreshed.clone(), created_at: pending.created_at }, + PendingProposal { + proposal: refreshed.clone(), + created_at: pending.created_at, + state: ProposalReviewState::Refreshed, + }, ); return Err(SessionError::ProposalStale { proposal_id: proposal_id.clone(), @@ -538,7 +630,8 @@ impl SessionService { } let operations = select_operations(&pending.proposal.transaction.operations, operation_positions)?; - let transaction = if operation_positions.is_none() { + let partial = operations.len() < pending.proposal.transaction.operations.len(); + let transaction = if !partial { pending.proposal.transaction } else { let mut transaction = pending.proposal.transaction.clone(); @@ -551,8 +644,9 @@ impl SessionService { session.proposals.remove(proposal_id); session.record_proposal_outcome(ProposalStatus { proposal_id: proposal_id.clone(), - state: ProposalReviewState::Accepted, + state: if partial { ProposalReviewState::PartiallyAccepted } else { ProposalReviewState::Accepted }, heads: commit.heads.clone(), + affected_ids: commit.affected_ids.clone(), proposal: None, }); let status = session.status(session_id)?; @@ -573,10 +667,11 @@ impl SessionService { proposal_id: proposal_id.clone(), state: ProposalReviewState::Rejected, heads, + affected_ids: Vec::new(), proposal: None, }); Ok(()) - } else if session.expired_proposals.remove(proposal_id) { + } else if session.expired_proposals.contains_key(proposal_id) { Err(SessionError::ProposalExpired(proposal_id.clone())) } else { Err(SessionError::ProposalNotFound(proposal_id.clone())) @@ -596,8 +691,9 @@ impl SessionService { if let Some(pending) = session.proposals.get(proposal_id) { return Ok(ProposalStatus { proposal_id: proposal_id.clone(), - state: ProposalReviewState::Pending, + state: pending.state, heads: session.file.snapshot()?.heads, + affected_ids: Vec::new(), proposal: Some(pending.proposal.clone()), }); } @@ -608,6 +704,38 @@ impl SessionService { .ok_or_else(|| SessionError::ProposalNotFound(proposal_id.clone())) } + /// Revalidates a pending or recently expired proposal and starts a new review window. + /// + /// # Errors + /// + /// Returns a session, proposal lookup, authorization, or transaction preview error. + pub fn renew_proposal( + &mut self, session_id: &SessionId, proposal_id: &ProposalId, + ) -> Result { + let session = self.session_mut(session_id)?; + session.expire_state(); + let old = session + .proposals + .remove(proposal_id) + .map(|pending| pending.proposal) + .or_else(|| session.expired_proposals.remove(proposal_id)) + .ok_or_else(|| SessionError::ProposalNotFound(proposal_id.clone()))?; + let mut transaction = old.transaction; + transaction.base_heads = session.file.snapshot()?.heads; + let refreshed = create_proposal(&mut session.file, proposal_id.clone(), transaction, None)?; + session.proposals.insert( + proposal_id.clone(), + PendingProposal { + proposal: refreshed.clone(), + created_at: Instant::now(), + state: ProposalReviewState::Refreshed, + }, + ); + session.proposal_outcomes.remove(proposal_id); + session.proposal_outcome_order.retain(|id| id != proposal_id); + Ok(refreshed) + } + /// Changes the agent mutation policy for this desktop session. /// /// # Errors @@ -716,6 +844,56 @@ impl SessionService { Ok(session.file.engine_mut().query(query)?) } + /// Renders the current live document and, optionally, a validated proposed result. + /// + /// The supplied transaction is previewed without changing CRDT heads, history, or bytes. + /// + /// # Errors + /// + /// Returns a session, transaction preview, or deterministic rendering error. + pub fn render_live( + &mut self, session_id: &SessionId, transaction: Option<&TransactionDraft>, page_id: Option, + region: Option, + ) -> Result { + let session = self.session_mut(session_id)?; + let snapshot = session.file.snapshot()?; + let options = SvgRenderOptions { page_id, region, ..SvgRenderOptions::default() }; + let current = render_svg(&snapshot, &options)?; + let mut warnings = current.warnings.iter().map(ToString::to_string).collect::>(); + let Some(transaction) = transaction else { + return Ok(LiveSvgPreview { + heads: snapshot.heads, + current_svg: current.svg, + proposed_svg: None, + preview: None, + affected_regions: Vec::new(), + warnings, + }); + }; + ensure_actor(session.file.actor_id(), &transaction.actor_id)?; + validate_live_transaction(transaction)?; + let preview = session.file.engine_mut().preview(transaction)?; + let proposed_snapshot = DocumentSnapshot { + format: snapshot.format, + format_version: snapshot.format_version, + document_id: snapshot.document_id, + heads: snapshot.heads.clone(), + document: preview.document, + }; + let proposed = render_svg(&proposed_snapshot, &options)?; + warnings.extend(proposed.warnings.iter().map(ToString::to_string)); + warnings.sort(); + warnings.dedup(); + Ok(LiveSvgPreview { + heads: snapshot.heads, + current_svg: current.svg, + proposed_svg: Some(proposed.svg), + preview: Some(preview.patch), + affected_regions: preview.affected_regions, + warnings, + }) + } + /// Validates the current materialized snapshot without changing it. /// /// # Errors @@ -821,16 +999,25 @@ impl SessionService { self.next_session_number = self.next_session_number.saturating_add(1); let session_id = SessionId(format!("session:{}", self.next_session_number)); - let page_id = file.snapshot()?.document.page_ids.first().cloned(); + let snapshot = file.snapshot()?; + let page_id = snapshot.document.page_ids.first().cloned(); + let active_layer_id = page_id + .as_ref() + .and_then(|page_id| snapshot.document.pages.get(page_id)) + .and_then(|page| page.layer_ids.first()) + .cloned(); let mut session = DocumentSession { file, proposals: BTreeMap::new(), - expired_proposals: BTreeSet::new(), + expired_proposals: BTreeMap::new(), proposal_outcomes: BTreeMap::new(), proposal_outcome_order: VecDeque::new(), page_id, + active_layer_id, selection_ids: Vec::new(), viewport: None, + camera: None, + occluded_regions: Vec::new(), context_updated_at: timestamp_now(), agent_access: AgentAccessMode::Review, }; @@ -856,19 +1043,20 @@ impl DocumentSession { .map(|(proposal_id, _)| proposal_id.clone()) .collect::>(); for proposal_id in expired { - self.proposals.remove(&proposal_id); - self.expired_proposals.insert(proposal_id.clone()); + let Some(pending) = self.proposals.remove(&proposal_id) else { continue }; + self.expired_proposals.insert(proposal_id.clone(), pending.proposal); if let Ok(snapshot) = self.file.snapshot() { self.record_proposal_outcome(ProposalStatus { proposal_id, state: ProposalReviewState::Expired, heads: snapshot.heads, + affected_ids: Vec::new(), proposal: None, }); } } while self.expired_proposals.len() > MAX_PROPOSALS_PER_SESSION { - let Some(oldest) = self.expired_proposals.iter().next().cloned() else { break }; + let Some(oldest) = self.expired_proposals.keys().next().cloned() else { break }; self.expired_proposals.remove(&oldest); } } @@ -915,17 +1103,153 @@ impl DocumentSession { fn create_proposal( file: &mut DocumentFile, id: ProposalId, transaction: TransactionDraft, expires_at: Option, ) -> Result { + let before = file.snapshot()?.document; let preview = file.engine_mut().preview(&transaction)?; + let operation_previews = transaction + .operations + .iter() + .enumerate() + .map(|(position, operation)| operation_preview(position, operation, &before, &preview.document)) + .collect(); Ok(Proposal { id, transaction, preview: preview.patch, affected_regions: preview.affected_regions, + operation_previews, warnings: Vec::::new(), expires_at: expires_at.unwrap_or_else(|| timestamp_after(PROPOSAL_TTL)), }) } +fn operation_preview( + position: usize, operation: &Operation, before: &Document, after: &Document, +) -> ProposalOperationPreview { + let (label, record_ids, shape_ids) = match operation { + Operation::CreatePage { page, .. } => ( + format!("Create page ‘{}’", page.name), + vec![RecordId::Page(page.id.clone())], + Vec::new(), + ), + Operation::RenamePage { page_id, .. } => ( + format!("Rename page {page_id}"), + vec![RecordId::Page(page_id.clone())], + Vec::new(), + ), + Operation::DeletePage { page_id, .. } => ( + format!("Delete page {page_id}"), + vec![RecordId::Page(page_id.clone())], + Vec::new(), + ), + Operation::CreateLayer { layer, .. } => ( + format!("Create layer ‘{}’", layer.name), + vec![RecordId::Layer(layer.id.clone())], + Vec::new(), + ), + Operation::PatchLayer { layer_id, .. } => ( + format!("Update layer {layer_id}"), + vec![RecordId::Layer(layer_id.clone())], + Vec::new(), + ), + Operation::ReorderLayer { layer_id, .. } => ( + format!("Reorder layer {layer_id}"), + vec![RecordId::Layer(layer_id.clone())], + Vec::new(), + ), + Operation::DeleteLayer { layer_id, .. } => ( + format!("Delete layer {layer_id}"), + vec![RecordId::Layer(layer_id.clone())], + Vec::new(), + ), + Operation::CreateShape { shape, .. } => ( + format!("Create {}", shape_description(shape)), + vec![RecordId::Shape(shape.id.clone())], + vec![shape.id.clone()], + ), + Operation::PatchShape { shape_id, .. } => ( + format!("Update {}", shape_description_from_documents(shape_id, before, after)), + vec![RecordId::Shape(shape_id.clone())], + vec![shape_id.clone()], + ), + Operation::ReparentShape { shape_id, .. } => ( + format!("Move {}", shape_description_from_documents(shape_id, before, after)), + vec![RecordId::Shape(shape_id.clone())], + vec![shape_id.clone()], + ), + Operation::DeleteShape { shape_id, .. } => ( + format!("Delete {}", shape_description_from_documents(shape_id, before, after)), + vec![RecordId::Shape(shape_id.clone())], + vec![shape_id.clone()], + ), + Operation::CreateBinding { binding } => ( + format!("Connect {} to {}", binding.source_shape_id, binding.target_shape_id), + vec![RecordId::Binding(binding.id.clone())], + vec![binding.source_shape_id.clone(), binding.target_shape_id.clone()], + ), + Operation::DeleteBinding { binding_id, .. } => ( + format!("Delete connection {binding_id}"), + vec![RecordId::Binding(binding_id.clone())], + Vec::new(), + ), + Operation::CreateAsset { asset } => ( + format!("Create asset ‘{}’", asset.name), + vec![RecordId::Asset(asset.id.clone())], + Vec::new(), + ), + Operation::PatchAsset { asset_id, .. } => ( + format!("Update asset {asset_id}"), + vec![RecordId::Asset(asset_id.clone())], + Vec::new(), + ), + Operation::DeleteAsset { asset_id, .. } => ( + format!("Delete asset {asset_id}"), + vec![RecordId::Asset(asset_id.clone())], + Vec::new(), + ), + Operation::AlignShapes { shape_ids, .. } => ( + format!("Align {} shapes", shape_ids.len()), + shape_ids.iter().cloned().map(RecordId::Shape).collect(), + shape_ids.clone(), + ), + Operation::DistributeShapes { shape_ids, .. } => ( + format!("Distribute {} shapes", shape_ids.len()), + shape_ids.iter().cloned().map(RecordId::Shape).collect(), + shape_ids.clone(), + ), + }; + let mut bounds = Vec::new(); + for shape_id in shape_ids { + if before.shapes.contains_key(&shape_id) { + bounds.push(world_shape_bounds(before, &shape_id)); + } + if after.shapes.contains_key(&shape_id) { + let candidate = world_shape_bounds(after, &shape_id); + if !bounds.contains(&candidate) { + bounds.push(candidate); + } + } + } + ProposalOperationPreview { position: u32::try_from(position).unwrap_or(u32::MAX), label, record_ids, bounds } +} + +fn shape_description_from_documents(shape_id: &ShapeId, before: &Document, after: &Document) -> String { + before + .shapes + .get(shape_id) + .or_else(|| after.shapes.get(shape_id)) + .map_or_else(|| shape_id.to_string(), shape_description) +} + +fn shape_description(shape: &crate::ShapeRecord) -> String { + if let Some(role) = shape.metadata.role.as_deref() { + format!("{} ({role})", shape.id) + } else if let Some(name) = shape.metadata.name.as_deref() { + format!("{} (‘{name}’)", shape.id) + } else { + shape.id.to_string() + } +} + fn validate_live_transaction(transaction: &TransactionDraft) -> Result<(), SessionError> { if transaction.origin != Origin::Agent { return Err(SessionError::AgentOriginRequired); @@ -985,6 +1309,15 @@ fn timestamp_after(duration: Duration) -> Timestamp { Timestamp(now.saturating_add(millis)) } +fn valid_bounds(bounds: Bounds) -> bool { + bounds.x.is_finite() + && bounds.y.is_finite() + && bounds.width.is_finite() + && bounds.height.is_finite() + && bounds.width >= 0.0 + && bounds.height >= 0.0 +} + fn timestamp_now() -> Timestamp { let millis = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -1026,6 +1359,24 @@ mod tests { static TEST_COUNTER: AtomicU64 = AtomicU64::new(0); + #[test] + fn proposal_operation_previews_use_human_labels_and_stable_positions() { + let document_id = DocumentId::from("document:operation-label"); + let document = blank_document(&document_id, Some("Overview")); + let page_id = document.page_ids[0].clone(); + let preview = operation_preview( + 2, + &Operation::RenamePage { page_id: page_id.clone(), name: "System".into(), expected_version: None }, + &document, + &document, + ); + + assert_eq!(preview.position, 2); + assert_eq!(preview.label, format!("Rename page {page_id}")); + assert_eq!(preview.record_ids, vec![RecordId::Page(page_id)]); + assert!(preview.bounds.is_empty()); + } + #[test] fn editor_context_tracks_page_selection_viewport_actor_and_heads() { let root = test_directory(); @@ -1039,12 +1390,24 @@ mod tests { let viewport = Bounds { x: -100.0, y: -50.0, width: 200.0, height: 100.0 }; service - .update_context(&opened.session_id, Some(page_id.clone()), Vec::new(), Some(viewport)) + .update_context( + &opened.session_id, + EditorContextUpdate { + page_id: Some(page_id.clone()), + active_layer_id: None, + selection_ids: Vec::new(), + viewport: Some(viewport), + camera: Some(CameraState { x: 0.0, y: 0.0, zoom: 2.0 }), + occluded_regions: vec![Bounds { x: -100.0, y: -50.0, width: 20.0, height: 100.0 }], + }, + ) .expect("update context"); let context = service.context(&opened.session_id).expect("read context"); assert_eq!(context.page_id, Some(page_id)); assert_eq!(context.viewport, Some(viewport)); + assert_eq!(context.camera, Some(CameraState { x: 0.0, y: 0.0, zoom: 2.0 })); + assert_eq!(context.occluded_regions.len(), 1); assert_eq!(context.actor_id, actor); assert_eq!(context.heads, opened.status.snapshot.heads); @@ -1237,8 +1600,9 @@ mod tests { let review = service .proposal_status(&opened.session_id, &proposal.id) .expect("retain accepted status"); - assert_eq!(review.state, ProposalReviewState::Accepted); + assert_eq!(review.state, ProposalReviewState::PartiallyAccepted); assert_eq!(review.heads, accepted.commit.heads); + assert_eq!(review.affected_ids, accepted.commit.affected_ids); assert_eq!(accepted.commit.patch.changed.len(), 1); let status = service @@ -1397,6 +1761,17 @@ mod tests { service.accept_proposal(&opened.session_id, &proposal.id, None), Err(SessionError::ProposalExpired(_)) )); + let renewed = service + .renew_proposal(&opened.session_id, &proposal.id) + .expect("renew expired proposal"); + assert_eq!(renewed.id, proposal.id); + assert_eq!( + service + .proposal_status(&opened.session_id, &proposal.id) + .expect("renewed status") + .state, + ProposalReviewState::Refreshed + ); service.close(&opened.session_id).expect("close session"); remove_test_directory(root); diff --git a/package.json b/package.json index f50b029..9e75bae 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dev:web": "pnpm --filter @inkfinite/web dev", "format": "pnpm -r --if-present format", "format:check": "pnpm -r --if-present format:check", + "lint": "pnpm -r --if-present lint", "check": "pnpm bindings:check && pnpm -r --if-present check && pnpm -r --if-present typecheck && pnpm -r --if-present lint", "test": "pnpm -r --if-present test", "performance:capture": "node scripts/capture-performance.mjs --output fixtures/native/performance/rendering-budget.json", diff --git a/packages/bindings/src/transaction.ts b/packages/bindings/src/transaction.ts index 64198ca..e0324b8 100644 --- a/packages/bindings/src/transaction.ts +++ b/packages/bindings/src/transaction.ts @@ -354,6 +354,23 @@ width: number, */ height: number, }; +/** + * Camera state reported by or requested from the desktop editor. + */ +export type CameraState = { +/** + * World-space horizontal coordinate at the viewport center. + */ +x: number, +/** + * World-space vertical coordinate at the viewport center. + */ +y: number, +/** + * Screen pixels per world-space unit. + */ +zoom: number, }; + /** * Region that a renderer should consider dirty after a transaction. */ @@ -526,30 +543,55 @@ heads: Array, }; */ export type ProposalId = string; +/** + * Review metadata for one operation in a proposal. + */ +export type ProposalOperationPreview = { +/** + * Zero-based position used by partial acceptance. + */ +position: number, +/** + * Stable human-readable operation label. + */ +label: string, +/** + * Records named directly by this operation. + */ +record_ids: Array, +/** + * Geometry attributable to this operation before or after the proposal. + */ +bounds: Array, }; + /** * Validated transaction held for explicit user review. */ -export type Proposal = { +export type Proposal = { /** * Stable proposal identifier. */ -id: ProposalId, +id: ProposalId, /** * Transaction validated against the listed heads. */ -transaction: TransactionDraft, +transaction: TransactionDraft, /** * Preview patch shown by the UI. */ -preview: DocumentPatch, +preview: DocumentPatch, /** * Document-coordinate regions affected by the proposed geometry. */ -affected_regions: Array, +affected_regions: Array, +/** + * Human-readable, independently selectable operation previews. + */ +operation_previews: Array, /** * Validation or repair warnings shown before acceptance. */ -warnings: Array, +warnings: Array, /** * Wall-clock expiry retained for clients and diagnostics. */ diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 255a2a6..59a42ca 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,46 +1,46 @@ import { - type Action, - Camera, - type CommandKind, - createId, - EditorState, - reorderShapes, - routeAction, - ShapeRecord, - type Store, - type Tool, -} from "@inkfinite/core"; + type Action, + Camera, + type CommandKind, + createId, + EditorState, + reorderShapes, + routeAction, + ShapeRecord, + type Store, + type Tool +} from '@inkfinite/core'; /** Grid settings consumed by the editor runtime. */ export type SnapSettings = { snapEnabled: boolean; gridEnabled: boolean; gridSize: number }; /** Select-tool behavior used for resize handles. */ export type SelectionTool = Tool & { - getHandleAtPoint(state: EditorState, world: { x: number; y: number }): string | null; - getActiveHandle?(): string | null; + getHandleAtPoint(state: EditorState, world: { x: number; y: number }): string | null; + getActiveHandle?(): string | null; }; /** One durable document change produced at an interaction boundary. */ export type RuntimeTransactionDraft = { - name: string; - kind: CommandKind; - before: EditorState; - after: EditorState; - action: Action; + name: string; + kind: CommandKind; + before: EditorState; + after: EditorState; + action: Action; }; /** Dependencies supplied by a UI adapter. */ export type EditorRuntimeOptions = { - store: Store; - tools: Map; - selectionTool: SelectionTool; - getSnapSettings: () => SnapSettings; - onTransactionDraft: (draft: RuntimeTransactionDraft) => void; - /** Opens the board browser from its dedicated Cmd/Ctrl+B shortcut. */ - onBrowseRequested?: () => void; - onHandleHover?: (handle: string | null) => void; - onInteractionChanged?: () => void; - onSnappedWorldChanged?: (world: { x: number; y: number }) => void; + store: Store; + tools: Map; + selectionTool: SelectionTool; + getSnapSettings: () => SnapSettings; + onTransactionDraft: (draft: RuntimeTransactionDraft) => void; + /** Opens the board browser from its dedicated Cmd/Ctrl+B shortcut. */ + onBrowseRequested?: () => void; + onHandleHover?: (handle: string | null) => void; + onInteractionChanged?: () => void; + onSnappedWorldChanged?: (world: { x: number; y: number }) => void; }; /** @@ -51,246 +51,249 @@ export type EditorRuntimeOptions = { * exactly one transaction draft through `onTransactionDraft`. */ export class EditorRuntime { - private readonly options: EditorRuntimeOptions; - private gestureStart: EditorState | null = null; - private pointerDown = false; - private spaceHeld = false; - private panning = false; - private lastPanScreen = { x: 0, y: 0 }; - - constructor(options: EditorRuntimeOptions) { - this.options = options; - } - - /** Returns the local interaction state needed by cursor and overlay adapters. */ - getInteractionState() { - return { pointerDown: this.pointerDown, spaceHeld: this.spaceHeld, panning: this.panning } as const; - } - - /** Routes one normalized action through camera, selection, and active-tool state. */ - handleAction(action: Action): void { - const { store, selectionTool } = this.options; - - if (action.type === "pointer-move" && !this.panning && !this.spaceHeld) { - this.options.onHandleHover?.(selectionTool.getHandleAtPoint(store.getState(), action.world)); - } - - if (action.type === "key-down" && action.key === " " && !action.repeat) { - this.spaceHeld = true; - this.interactionChanged(); - return; - } - - if (action.type === "key-up" && action.key === " ") { - this.spaceHeld = false; - this.panning = false; - this.interactionChanged(); - return; - } - - if (action.type === "pointer-down" && (action.button === 1 || (action.button === 0 && this.spaceHeld))) { - this.panning = true; - this.lastPanScreen = action.screen; - this.interactionChanged(); - return; - } - - if (action.type === "pointer-move" && this.panning) { - const delta = { x: action.screen.x - this.lastPanScreen.x, y: action.screen.y - this.lastPanScreen.y }; - this.lastPanScreen = action.screen; - store.setState((state) => ({ ...state, camera: Camera.pan(state.camera, delta) })); - return; - } - - if (action.type === "pointer-up" && this.panning) { - this.panning = false; - this.interactionChanged(); - return; - } - - if (this.panning || this.spaceHeld) return; - - const routedAction = snapAction(action, this.options.getSnapSettings()); - if ("world" in routedAction) this.options.onSnappedWorldChanged?.(routedAction.world); - - if (routedAction.type === "pointer-down" && routedAction.button === 0) { - this.pointerDown = true; - this.options.onHandleHover?.(null); - this.gestureStart = EditorState.clone(store.getState()); - this.interactionChanged(); - } - - const before = store.getState(); - const shortcut = applyKeyboardShortcut(before, routedAction, this.options.onBrowseRequested); - const after = shortcut ?? routeAction(before, routedAction, this.options.tools); - - if (!statesEqual(before, after)) { - const kind = commandKind(before, after); - if (!this.gestureStart && kind === "doc") { - this.emitDraft(before, after, routedAction); - } else { - store.setState(() => after); - this.interactionChanged(); - } - } - - if (routedAction.type === "pointer-up" && routedAction.button === 0) { - this.pointerDown = false; - const preview = store.getState(); - if (this.gestureStart && !statesEqual(this.gestureStart, preview)) { - this.emitDraft(this.gestureStart, preview, routedAction); - } - this.gestureStart = null; - this.interactionChanged(); - } - } - - /** Emits an explicit document draft, used by DOM editors and stencil insertion. */ - commit(before: EditorState, after: EditorState, name: string, action: Action): void { - if (statesEqual(before, after)) return; - this.options.onTransactionDraft({ - name, - kind: commandKind(before, after), - before: EditorState.clone(before), - after: EditorState.clone(after), - action, - }); - } - - private emitDraft(before: EditorState, after: EditorState, action: Action): void { - this.commit(before, after, describeAction(action, commandKind(before, after)), action); - } - - private interactionChanged(): void { - this.options.onInteractionChanged?.(); - } + private readonly options: EditorRuntimeOptions; + private gestureStart: EditorState | null = null; + private pointerDown = false; + private spaceHeld = false; + private panning = false; + private lastPanScreen = { x: 0, y: 0 }; + + constructor(options: EditorRuntimeOptions) { + this.options = options; + } + + /** Returns the local interaction state needed by cursor and overlay adapters. */ + getInteractionState() { + return { pointerDown: this.pointerDown, spaceHeld: this.spaceHeld, panning: this.panning } as const; + } + + /** Routes one normalized action through camera, selection, and active-tool state. */ + handleAction(action: Action): void { + const { store, selectionTool } = this.options; + + if (action.type === 'pointer-move' && !this.panning && !this.spaceHeld) { + this.options.onHandleHover?.(selectionTool.getHandleAtPoint(store.getState(), action.world)); + } + + if (action.type === 'key-down' && action.key === ' ' && !action.repeat) { + this.spaceHeld = true; + this.interactionChanged(); + return; + } + + if (action.type === 'key-up' && action.key === ' ') { + this.spaceHeld = false; + this.panning = false; + this.interactionChanged(); + return; + } + + if (action.type === 'pointer-down' && (action.button === 1 || (action.button === 0 && this.spaceHeld))) { + this.panning = true; + this.lastPanScreen = action.screen; + this.interactionChanged(); + return; + } + + if (action.type === 'pointer-move' && this.panning) { + const delta = { x: action.screen.x - this.lastPanScreen.x, y: action.screen.y - this.lastPanScreen.y }; + this.lastPanScreen = action.screen; + store.setState((state) => ({ ...state, camera: Camera.pan(state.camera, delta) })); + return; + } + + if (action.type === 'pointer-up' && this.panning) { + this.panning = false; + this.interactionChanged(); + return; + } + + if (this.panning || this.spaceHeld) return; + + const routedAction = snapAction(action, this.options.getSnapSettings()); + if ('world' in routedAction) this.options.onSnappedWorldChanged?.(routedAction.world); + + if (routedAction.type === 'pointer-down' && routedAction.button === 0) { + this.pointerDown = true; + this.options.onHandleHover?.(null); + this.gestureStart = EditorState.clone(store.getState()); + this.interactionChanged(); + } + + const before = store.getState(); + const shortcut = applyKeyboardShortcut(before, routedAction, this.options.onBrowseRequested); + const after = shortcut ?? routeAction(before, routedAction, this.options.tools); + + if (!statesEqual(before, after)) { + const kind = commandKind(before, after); + if (!this.gestureStart && kind === 'doc') { + this.emitDraft(before, after, routedAction); + } else { + store.setState(() => after); + this.interactionChanged(); + } + } + + if (routedAction.type === 'pointer-up' && routedAction.button === 0) { + this.pointerDown = false; + const preview = store.getState(); + if (this.gestureStart && !statesEqual(this.gestureStart, preview)) { + this.emitDraft(this.gestureStart, preview, routedAction); + } + this.gestureStart = null; + this.interactionChanged(); + } + } + + /** Emits an explicit document draft, used by DOM editors and stencil insertion. */ + commit(before: EditorState, after: EditorState, name: string, action: Action): void { + if (statesEqual(before, after)) return; + this.options.onTransactionDraft({ + name, + kind: commandKind(before, after), + before: EditorState.clone(before), + after: EditorState.clone(after), + action + }); + } + + private emitDraft(before: EditorState, after: EditorState, action: Action): void { + this.commit(before, after, describeAction(action, commandKind(before, after)), action); + } + + private interactionChanged(): void { + this.options.onInteractionChanged?.(); + } } /** Returns whether two editor states share the same immutable branches. */ export function statesEqual(a: EditorState, b: EditorState): boolean { - return a.doc === b.doc && a.camera === b.camera && a.ui === b.ui; + return a.doc === b.doc && a.camera === b.camera && a.ui === b.ui; } /** Classifies which editor state branch changed. */ export function commandKind(before: EditorState, after: EditorState): CommandKind { - if (before.doc !== after.doc) return "doc"; - if (before.camera !== after.camera) return "camera"; - return "ui"; + if (before.doc !== after.doc) return 'doc'; + if (before.camera !== after.camera) return 'camera'; + return 'ui'; } function snapAction(action: Action, snap: SnapSettings): Action { - if (!("world" in action) || !snap.snapEnabled || !snap.gridEnabled) return action; - const { gridSize } = snap; - return { - ...action, - world: { x: Math.round(action.world.x / gridSize) * gridSize, y: Math.round(action.world.y / gridSize) * gridSize }, - }; + if (!('world' in action) || !snap.snapEnabled || !snap.gridEnabled) return action; + const { gridSize } = snap; + return { + ...action, + world: { + x: Math.round(action.world.x / gridSize) * gridSize, + y: Math.round(action.world.y / gridSize) * gridSize + } + }; } function applyKeyboardShortcut( - state: EditorState, - action: Action, - onBrowseRequested: (() => void) | undefined, + state: EditorState, + action: Action, + onBrowseRequested: (() => void) | undefined ): EditorState | null { - if (action.type !== "key-down") return null; - const primary = action.modifiers.meta || action.modifiers.ctrl; - if (primary && (action.key === "b" || action.key === "B")) { - onBrowseRequested?.(); - return null; - } - if (state.ui.selectionIds.length === 0) return null; - - if (action.key.startsWith("Arrow")) { - const step = action.modifiers.shift ? 10 : 1; - const delta = arrowDelta(action.key, step); - if (delta) { - const shapes = { ...state.doc.shapes }; - let changed = false; - for (const id of state.ui.selectionIds) { - const shape = shapes[id]; - if (!shape) continue; - shapes[id] = { ...shape, x: shape.x + delta.x, y: shape.y + delta.y }; - changed = true; - } - if (changed) return { ...state, doc: { ...state.doc, shapes } }; - } - } - if (primary && ["d", "D"].includes(action.key)) return duplicateSelection(state); - if (primary && action.key === "]") return reorderSelection(state, "forward"); - if (primary && action.key === "[") return reorderSelection(state, "backward"); - if (primary && action.modifiers.shift && ["g", "G"].includes(action.key)) return ungroupSelection(state); - return null; + if (action.type !== 'key-down') return null; + const primary = action.modifiers.meta || action.modifiers.ctrl; + if (primary && (action.key === 'b' || action.key === 'B')) { + onBrowseRequested?.(); + return null; + } + if (state.ui.selectionIds.length === 0) return null; + + if (action.key.startsWith('Arrow')) { + const step = action.modifiers.shift ? 10 : 1; + const delta = arrowDelta(action.key, step); + if (delta) { + const shapes = { ...state.doc.shapes }; + let changed = false; + for (const id of state.ui.selectionIds) { + const shape = shapes[id]; + if (!shape) continue; + shapes[id] = { ...shape, x: shape.x + delta.x, y: shape.y + delta.y }; + changed = true; + } + if (changed) return { ...state, doc: { ...state.doc, shapes } }; + } + } + if (primary && ['d', 'D'].includes(action.key)) return duplicateSelection(state); + if (primary && action.key === ']') return reorderSelection(state, 'forward'); + if (primary && action.key === '[') return reorderSelection(state, 'backward'); + if (primary && action.modifiers.shift && ['g', 'G'].includes(action.key)) return ungroupSelection(state); + return null; } function arrowDelta(key: string, step: number): { x: number; y: number } | null { - switch (key) { - case "ArrowLeft": - return { x: -step, y: 0 }; - case "ArrowRight": - return { x: step, y: 0 }; - case "ArrowUp": - return { x: 0, y: -step }; - case "ArrowDown": - return { x: 0, y: step }; - default: - return null; - } + switch (key) { + case 'ArrowLeft': + return { x: -step, y: 0 }; + case 'ArrowRight': + return { x: step, y: 0 }; + case 'ArrowUp': + return { x: 0, y: -step }; + case 'ArrowDown': + return { x: 0, y: step }; + default: + return null; + } } function duplicateSelection(state: EditorState): EditorState | null { - const shapes = { ...state.doc.shapes }; - const pages = { ...state.doc.pages }; - const selectionIds: string[] = []; - for (const id of state.ui.selectionIds) { - const shape = shapes[id]; - if (!shape) continue; - const copy = ShapeRecord.clone(shape); - const newId = createId("shape"); - shapes[newId] = { ...copy, id: newId, x: copy.x + 12, y: copy.y + 12 }; - const page = pages[shape.pageId]; - if (!page) continue; - pages[shape.pageId] = { ...page, shapeIds: [...page.shapeIds, newId] }; - selectionIds.push(newId); - } - return selectionIds.length === 0 - ? null - : { ...state, doc: { ...state.doc, shapes, pages }, ui: { ...state.ui, selectionIds } }; + const shapes = { ...state.doc.shapes }; + const pages = { ...state.doc.pages }; + const selectionIds: string[] = []; + for (const id of state.ui.selectionIds) { + const shape = shapes[id]; + if (!shape) continue; + const copy = ShapeRecord.clone(shape); + const newId = createId('shape'); + shapes[newId] = { ...copy, id: newId, x: copy.x + 12, y: copy.y + 12 }; + const page = pages[shape.pageId]; + if (!page) continue; + pages[shape.pageId] = { ...page, shapeIds: [...page.shapeIds, newId] }; + selectionIds.push(newId); + } + return selectionIds.length === 0 + ? null + : { ...state, doc: { ...state.doc, shapes, pages }, ui: { ...state.ui, selectionIds } }; } -function reorderSelection(state: EditorState, direction: "forward" | "backward"): EditorState | null { - const next = reorderShapes(state, state.ui.selectionIds, direction); - return next === state ? null : next; +function reorderSelection(state: EditorState, direction: 'forward' | 'backward'): EditorState | null { + const next = reorderShapes(state, state.ui.selectionIds, direction); + return next === state ? null : next; } function ungroupSelection(state: EditorState): EditorState | null { - const groups = new Set( - state.ui.selectionIds.map((id) => state.doc.shapes[id]?.groupId).filter((id): id is string => Boolean(id)), - ); - if (groups.size === 0) return null; - const shapes = { ...state.doc.shapes }; - let changed = false; - for (const [id, shape] of Object.entries(shapes)) { - if (shape.groupId && groups.has(shape.groupId)) { - const copy = { ...shape }; - delete copy.groupId; - shapes[id] = copy; - changed = true; - } - } - return changed ? { ...state, doc: { ...state.doc, shapes } } : null; + const groups = new Set( + state.ui.selectionIds.map((id) => state.doc.shapes[id]?.groupId).filter((id): id is string => Boolean(id)) + ); + if (groups.size === 0) return null; + const shapes = { ...state.doc.shapes }; + let changed = false; + for (const [id, shape] of Object.entries(shapes)) { + if (shape.groupId && groups.has(shape.groupId)) { + const copy = { ...shape }; + delete copy.groupId; + shapes[id] = copy; + changed = true; + } + } + return changed ? { ...state, doc: { ...state.doc, shapes } } : null; } function describeAction(action: Action, kind: CommandKind): string { - if (action.type === "key-down") { - if (action.key.startsWith("Arrow")) return "Nudge"; - const primary = action.modifiers.meta || action.modifiers.ctrl; - if (primary && ["d", "D"].includes(action.key)) return "Duplicate"; - if (primary && action.key === "]") return "Bring Forward"; - if (primary && action.key === "[") return "Send Backward"; - } - if (action.type === "pointer-up") return "Pointer up"; - return kind === "doc" ? "Edit" : kind === "camera" ? "Camera change" : "UI change"; + if (action.type === 'key-down') { + if (action.key.startsWith('Arrow')) return 'Nudge'; + const primary = action.modifiers.meta || action.modifiers.ctrl; + if (primary && ['d', 'D'].includes(action.key)) return 'Duplicate'; + if (primary && action.key === ']') return 'Bring Forward'; + if (primary && action.key === '[') return 'Send Backward'; + } + if (action.type === 'pointer-up') return 'Pointer up'; + return kind === 'doc' ? 'Edit' : kind === 'camera' ? 'Camera change' : 'UI change'; } -export { Action, Camera, Modifiers, PointerButtons } from "@inkfinite/core"; -export type { Vec2, Viewport } from "@inkfinite/core"; +export { Action, Camera, Modifiers, PointerButtons } from '@inkfinite/core'; +export type { Vec2, Viewport } from '@inkfinite/core'; diff --git a/packages/ui/src/lib/editor/canvas/NavigationControls.svelte b/packages/ui/src/lib/editor/canvas/NavigationControls.svelte index 4c521c5..f6eafd7 100644 --- a/packages/ui/src/lib/editor/canvas/NavigationControls.svelte +++ b/packages/ui/src/lib/editor/canvas/NavigationControls.svelte @@ -55,7 +55,7 @@ } -