From 38789512626cb24f78f274998977fc8c6cf960d1 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Thu, 1 Oct 2026 20:54:07 -0700 Subject: [PATCH] experiment: isolate read-only viewport comparison runner --- crates/oa-desktop/src/focus_optical.rs | 744 +++++++++++++++++++ crates/oa-desktop/src/focus_optical/inert.rs | 167 +++++ crates/oa-desktop/src/focus_optical/tests.rs | 612 +++++++++++++++ crates/oa-desktop/src/main.rs | 4 + crates/oa-desktop/src/views.rs | 43 +- 5 files changed, 1563 insertions(+), 7 deletions(-) create mode 100644 crates/oa-desktop/src/focus_optical.rs create mode 100644 crates/oa-desktop/src/focus_optical/inert.rs create mode 100644 crates/oa-desktop/src/focus_optical/tests.rs diff --git a/crates/oa-desktop/src/focus_optical.rs b/crates/oa-desktop/src/focus_optical.rs new file mode 100644 index 00000000..4648aa20 --- /dev/null +++ b/crates/oa-desktop/src/focus_optical.rs @@ -0,0 +1,744 @@ +//! Private read-only optical comparison. Normal startup never constructs this runner. +mod inert; +#[cfg(test)] +mod tests; + +use crate::{app::App, diagram::DiagramCanvas, style}; +use iced::{ + Element, Fill, Rectangle, Renderer, Size, Task, Theme, Vector, mouse, + widget::{self, button, canvas, column, container, row, text}, +}; +use oa_core::{Id, Kind, Layer, Project, projection, routing}; +use serde_json::{Value, json}; +use std::{ + cell::RefCell, + fs, + io::Write, + path::{Path, PathBuf}, + rc::Rc, +}; + +type Result = std::result::Result; +type Mailbox = Rc>>; + +pub(crate) fn requested(args: &[String]) -> bool { + args.get(1).is_some_and(|arg| arg == "--focus-optical") +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Variant { + Baseline, + Focus, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct Camera { + zoom: f32, + pan: Vector, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct DrawStamp { + project: Id, + diagram: Id, + variant: Variant, + generation: u64, + viewport: Size, + camera: Camera, +} + +#[derive(Debug, Clone)] +enum Message { + Variant(Variant), + Fit, + Capture, + Measured(u64, Size), + Drawn(DrawStamp), + Header(u64, Size), + Opened(iced::window::Id, Size), + WindowSize(Size), + Scale(f32), + Close(iced::window::Id), + UnexpectedPassive(String), +} + +#[derive(Clone)] +struct Frozen { + diagram: Id, + project: Project, + projected: projection::ProjectedView, + routed: routing::RoutedView, + svg: String, + report: Value, +} + +impl Frozen { + fn new(project: Project, diagram: Id) -> Result { + let Some(saved) = project.diagrams.get(&diagram) else { + return Err("The named diagram does not exist.".into()); + }; + if !saved.projection.is_architecture() || saved.projection.scenario().is_some() { + return Err("The optical comparison accepts saved Architecture diagrams only.".into()); + } + let projected = projection::project_diagram(&project, saved); + let routed = routing::route_diagram(&project, saved, &projected); + let svg = crate::diagram::export_svg(&project, diagram).map_err(|e| e.to_string())?; + let report = scene_report(&project, diagram, &projected, &routed); + Ok(Self { + diagram, + project, + projected, + routed, + svg, + report, + }) + } + + fn check(&self, app: &App, diagram: Id) -> Result<()> { + if app.project() != &self.project { + return Err("Frozen complete Project changed.".into()); + } + let saved = &app.project().diagrams[&diagram]; + let projected = + projection::project_diagram_with_index(app.project(), saved, app.document.view_index()); + let routed = routing::route_diagram(app.project(), saved, &projected); + let svg = crate::diagram::export_svg(app.project(), diagram).map_err(|e| e.to_string())?; + if projected != self.projected || routed != self.routed || svg != self.svg { + return Err("Frozen projection, complete routed geometry or SVG changed.".into()); + } + if app.active_diagram != Some(diagram) + || app.layer != saved.layer + || app.page != crate::app::Page::Diagram + || app.selected != app.project().id + || app.diagram_selection.is_some() + || app.connection.is_some() + || app.architecture.is_some() + { + return Err("Optical presentation fixture is no longer neutral.".into()); + } + if app.document.dirty() + || app.document.can_undo() + || app.document.can_redo() + || app.draft_dirty() + || app.source_dirty() + || app.context_help + || app.busy + || app.pending.is_some() + || app.model_menu.is_some() + || app.file_prompt.is_some() + || app.diagram_navigation_focus.is_some() + || app.diagram_usages.is_some() + { + return Err( + "Optical fixture acquired draft, history, modal or navigation state.".into(), + ); + } + Ok(()) + } +} + +fn scene_report( + project: &Project, + diagram: Id, + projected: &projection::ProjectedView, + routed: &routing::RoutedView, +) -> Value { + let label = |l: &routing::EdgeLabelGeometry| { + json!({ + "text": l.text, "fraction": l.fraction, "anchor": l.anchor, "center": l.center, + "bounds": {"position": l.bounds.position, "size": l.bounds.size} + }) + }; + let edges: Vec<_> = routed + .edges + .values() + .map(|edge| { + let ends = project + .relations + .get(&edge.id) + .map(|relation| { + routing::association_end_geometry(relation, edge).iter().map(|end| json!({ + "source_end": end.source_end, "label": label(&end.label), "tip": end.tip, + "inward": end.inward, "navigable": end.navigable + })).collect::>() + }) + .unwrap_or_default(); + json!({"id": edge.id, "points": edge.points, "label": label(&edge.label), + "pins": edge.pins, "pin_fractions": edge.pin_fractions, + "warnings": edge.warnings.iter().map(|w| format!("{w:?}")).collect::>(), + "custom_suspended": edge.custom_suspended, "association_ends": ends}) + }) + .collect(); + json!({"diagram": diagram, "projected": projected, + "routed": {"bounds": {"position": routed.bounds.position, "size": routed.bounds.size}, "edges": edges}, + "complete_routed_debug": format!("{routed:#?}"), + "debug_scope": "Includes private derived routing fields; complete RoutedView PartialEq is also checked on every capture."}) +} + +fn fit(bounds: routing::RoutingBounds, empty: bool, viewport: Size) -> Camera { + if empty { + return Camera { + zoom: 1., + pan: Vector::new(30., 30.), + }; + } + let width = (viewport.width - 48.).max(1.); + let height = (viewport.height - 48.).max(1.); + // Preserve production f32 operation order, including origin addition/subtraction. + let left = bounds.position.x; + let top = bounds.position.y; + let right = left + bounds.size.width; + let bottom = top + bounds.size.height; + let zoom = (width / (right - left).max(1.)) + .min(height / (bottom - top).max(1.)) + .clamp(0.0001, 1.5); + Camera { + zoom, + pan: Vector::new( + 24. + (width - (right - left) * zoom) / 2. - left * zoom, + 24. + (height - (bottom - top) * zoom) / 2. - top * zoom, + ), + } +} + +fn valid_size(size: Size) -> bool { + size.width.is_finite() && size.height.is_finite() && size.width > 0. && size.height > 0. +} + +struct LayoutState { + variant: Variant, + generation: u64, + expected: Option, + acknowledged: Option, +} +impl Default for LayoutState { + fn default() -> Self { + Self { + variant: Variant::Baseline, + generation: 0, + expected: None, + acknowledged: None, + } + } +} +impl LayoutState { + fn invalidate(&mut self) { + self.generation = self.generation.wrapping_add(1); + self.expected = None; + self.acknowledged = None; + } + fn measured(&mut self, generation: u64, viewport: Size, frozen: &Frozen) -> bool { + if generation != self.generation + || !valid_size(viewport) + || self + .expected + .is_some_and(|stamp| stamp.viewport == viewport) + { + return false; + } + // A genuine canvas resize can happen without a native WindowResized. + if self.expected.is_some() { + self.invalidate(); + } + self.expected = Some(DrawStamp { + project: frozen.project.id, + diagram: frozen.diagram, + variant: self.variant, + generation: self.generation, + viewport, + camera: fit( + frozen.routed.bounds, + frozen.projected.nodes.is_empty(), + viewport, + ), + }); + self.acknowledged = None; + true + } + fn drawn(&mut self, stamp: DrawStamp) -> bool { + if Some(stamp) != self.expected || self.acknowledged == Some(stamp) { + return false; + } + self.acknowledged = Some(stamp); + true + } + fn ready(&self) -> bool { + self.expected.is_some() && self.expected == self.acknowledged + } +} + +/// No update override: no interactive canvas state can ever be mutated here. +struct OpticalCanvas<'a> { + program: DiagramCanvas<'a>, + stamp: Option, + drawn: Mailbox, +} +impl canvas::Program for OpticalCanvas<'_> { + type State = crate::diagram::State; + fn draw( + &self, + state: &Self::State, + renderer: &Renderer, + theme: &Theme, + bounds: Rectangle, + _: mouse::Cursor, + ) -> Vec { + let geometry = as canvas::Program>::draw( + &self.program, + state, + renderer, + theme, + bounds, + mouse::Cursor::Unavailable, + ); + if let Some(stamp) = self.stamp.filter(|s| { + s.viewport == bounds.size() + && s.camera.zoom == self.program.zoom + && s.camera.pan == self.program.pan + }) { + *self.drawn.borrow_mut() = Some(stamp); + } + geometry + } +} + +struct Runner { + fixture: App, + frozen: Frozen, + diagram: Id, + input_path: PathBuf, + input: Vec, + output: PathBuf, + layout: LayoutState, + drawn: Mailbox, + header: Option<(u64, Size)>, + scale_factor: Option, + capture: u32, + error: Option, +} + +fn fixture(project: Project, diagram: Id) -> Result { + // Preflight the same conversion used by with_project so invalid input is a visible error. + let document = + crate::document::Document::from_project(project.clone(), crate::source_workspace::LIMITS) + .map_err(|e| e.to_string())?; + if document.project() != &project { + return Err("Document admission changed the complete Project.".into()); + } + let layer = project.diagrams[&diagram].layer; + let mut app = App::with_project(project); + app.active_diagram = Some(diagram); + app.layer = layer; + app.page = crate::app::Page::Diagram; + app.explorer_index = crate::explorer::Index::new(app.project(), layer); + app.create_kind = if layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }; + app.architecture = None; + app.architecture_palette_expanded = false; + app.connection = None; + app.selected = app.project().id; + app.selected_node = None; + app.diagram_selection = None; + app.selected_attachment = None; + app.selected_bend = None; + app.draft = crate::forms::Draft::from_project(app.project(), app.selected); + app.diagram_navigation_focus = None; + app.diagram_usages = None; + app.status = + "READ-ONLY OPTICAL EXPERIMENT · Waiting for measured canvas and draw acknowledgment".into(); + Ok(app) +} + +impl Runner { + fn invalidate(&mut self) { + self.layout.invalidate(); + self.header = None; + *self.drawn.borrow_mut() = None; + self.fixture.status = + "READ-ONLY · Waiting for measured canvas and draw acknowledgment".into(); + } + fn update(&mut self, message: Message) -> Task { + // A fault is terminal for this evidence session. Queued redraw/capture + // messages cannot restore readiness or write another successful capture. + if self.error.is_some() && !matches!(&message, Message::Close(_)) { + return Task::none(); + } + match message { + Message::Variant(variant) if variant != self.layout.variant => { + self.layout.variant = variant; + self.invalidate(); + } + Message::Fit => self.invalidate(), + Message::Measured(generation, size) => { + if self.layout.measured(generation, size, &self.frozen) { + let stamp = self.layout.expected.unwrap(); + self.fixture.zoom = stamp.camera.zoom; + self.fixture.pan = stamp.camera.pan; + *self.drawn.borrow_mut() = None; + self.fixture.status = + "READ-ONLY · Fit measured; waiting for matching canvas draw".into(); + } + } + Message::Drawn(stamp) => { + if self.layout.drawn(stamp) { + self.fixture.status = format!( + "READ-ONLY · {:?} ready · {:.1} × {:.1} · Fit {:.5} · Capture records draw acknowledgment, not GPU completion", + stamp.variant, + stamp.viewport.width, + stamp.viewport.height, + stamp.camera.zoom + ); + } + } + Message::Header(generation, size) + if generation == self.layout.generation && valid_size(size) => + { + self.header = Some((generation, size)); + } + Message::WindowSize(size) if valid_size(size) && size != self.fixture.window_size => { + self.fixture.window_size = size; + self.invalidate(); + } + Message::Opened(id, size) => { + if valid_size(size) && size != self.fixture.window_size { + self.fixture.window_size = size; + self.invalidate(); + } + return iced::window::scale_factor(id).map(Message::Scale); + } + Message::Scale(scale) + if scale.is_finite() && scale > 0. && self.scale_factor != Some(scale) => + { + self.scale_factor = Some(scale); + self.invalidate(); + } + Message::Capture => { + if !self.layout.ready() + || *self.drawn.borrow() != self.layout.expected + || self.header.is_none_or(|(g, _)| g != self.layout.generation) + || self.scale_factor.is_none() + { + self.fixture.status = + "READ-ONLY · Capture refused: waiting for matching fitted draw".into(); + } else if let Err(error) = self.capture() { + self.fail(error); + } + } + Message::UnexpectedPassive(reason) => self.fail(reason), + Message::Close(id) => { + let conservation = self.check_frozen(); + if let Err(error) = &conservation { + self.fail(error.clone()); + } + let succeeded = conservation.is_ok() && self.error.is_none(); + let result = write_json( + &self.output.join("finished.json"), + &json!({ + "status": if succeeded { "PASS" } else { "FAIL" }, + "complete_project_scene_svg_and_input_unchanged": conservation.is_ok(), + "captures": self.capture, "error": self.error, + "scope": "Optical runner close; failed conservation or an earlier fault cannot produce PASS. No readability acceptance." + }), + ); + if let Err(error) = result { + self.fail(error); + } + // A changed fixture or occupied receipt must never trap the + // user. Existing evidence is preserved; failures remain in stderr. + return iced::window::close(id); + } + _ => {} + } + Task::none() + } + fn fail(&mut self, error: String) { + eprintln!("Focus optical error: {error}"); + self.error = Some(error); + self.layout.acknowledged = None; + } + fn check_frozen(&self) -> Result<()> { + self.frozen.check(&self.fixture, self.diagram)?; + if fs::read(&self.input_path).map_err(|e| e.to_string())? != self.input { + return Err("The original fixture bytes changed during the comparison.".into()); + } + Ok(()) + } + fn capture(&mut self) -> Result<()> { + if let Some(error) = &self.error { + return Err(format!( + "This optical evidence session has stopped: {error}" + )); + } + if !self.layout.ready() + || *self.drawn.borrow() != self.layout.expected + || self.header.is_none_or(|(g, _)| g != self.layout.generation) + || self.scale_factor.is_none() + { + return Err( + "Capture is pending a matching measured viewport, header, scale factor and draw." + .into(), + ); + } + self.check_frozen()?; + let stamp = self.layout.expected.unwrap(); + let next = self.capture + 1; + // One fresh directory makes partial failures visible without overwriting any capture. + let directory = self.output.join(format!("capture-{next:03}")); + fs::create_dir(&directory).map_err(|e| e.to_string())?; + write_json( + &directory.join("measurement.json"), + &json!({ + "variant": format!("{:?}", stamp.variant), "generation": stamp.generation, + "viewport": {"width": stamp.viewport.width, "height": stamp.viewport.height}, + "zoom": stamp.camera.zoom, "pan": {"x": stamp.camera.pan.x, "y": stamp.camera.pan.y}, + "logical_client": {"width": self.fixture.window_size.width, "height": self.fixture.window_size.height}, + "scale_factor": self.scale_factor, "scale_factor_source": "Iced window scale_factor task / Rescaled event", + "measured_header_height": self.header.unwrap().1.height, + "baseline_deviation": "Only shared header action group is replaced; original logo/project typography/padding and body chrome retained.", + "draw_acknowledged": true, "gpu_completion_claimed": false, + "screenshot": "Separate native capture required to establish displayed output.", + "selection": "project (no projected object)", "hover": false, + "palette_expanded": false, "diagram": self.diagram, + "complete_project_projection_routed_svg_equal": true, + "scope": "Fit-only read-only optical measurement; no reading or editing acceptance." + }), + )?; + write_json( + &directory.join("project.json"), + &serde_json::to_value(&self.frozen.project).map_err(|e| e.to_string())?, + )?; + write_json(&directory.join("scene.json"), &self.frozen.report)?; + write_new(&directory.join("diagram.svg"), self.frozen.svg.as_bytes())?; + self.capture = next; + self.fixture.status = format!( + "READ-ONLY · Captured {next:03} · {:?} · native screenshot still required", + stamp.variant + ); + Ok(()) + } + fn view(&self) -> Element<'_, Message> { + let generation = self.layout.generation; + let ready = self.layout.ready() + && self.header.is_some_and(|(g, _)| g == generation) + && self.scale_factor.is_some() + && self.error.is_none(); + let controls = row![ + text("READ ONLY · EXPERIMENT").size(11), + button(text("Baseline").size(12)) + .padding([7, 9]) + .on_press(Message::Variant(Variant::Baseline)), + button(text("Focus").size(12)) + .padding([7, 9]) + .on_press(Message::Variant(Variant::Focus)), + button(text("Fit").size(12)) + .padding([7, 9]) + .on_press(Message::Fit), + button(text("Capture").size(12)) + .padding([7, 9]) + .on_press_maybe(ready.then_some(Message::Capture)), + ] + .spacing(6) + .align_y(iced::Center); + let header: Element<'_, Message> = if self.layout.variant == Variant::Baseline { + crate::views::header(&self.fixture, controls.into()) + } else { + container( + row![ + text(crate::views::short( + &self.frozen.project.diagrams[&self.diagram].name, + 38 + )) + .size(14), + widget::Space::new().width(Fill), + controls + ] + .spacing(10) + .align_y(iced::Center), + ) + .padding([6, 12]) + .height(48) + .style(style::panel) + .into() + }; + let header = widget::sensor(header) + .key(generation) + .on_show(move |size| Message::Header(generation, size)) + .on_resize(move |size| Message::Header(generation, size)); + if let Some(error) = &self.error { + return column![header, container(column![text("Optical comparison stopped").size(22), text(error).size(16), + text("No further capture is accepted. Inspect logs and preserve this failed session.").size(14)] + .spacing(16)).padding(24)].into(); + } + let program = DiagramCanvas { + selection: None, + allocation: None, + project: self.fixture.project(), + view_index: Some(self.fixture.document.view_index()), + diagram: self.diagram, + selected: self.fixture.project().id, + navigation_focus: None, + zoom: self.fixture.zoom, + pan: self.fixture.pan, + context: self.fixture.route_context().unwrap(), + selected_bend: None, + selected_attachment: None, + editable: false, + placing: false, + connection: None, + }; + let canvas: Element<'_, crate::app::Message> = widget::canvas(OpticalCanvas { + program, + stamp: self.layout.expected, + drawn: self.drawn.clone(), + }) + .width(Fill) + .height(Fill) + .into(); + let body = if self.layout.variant == Variant::Baseline { + crate::views::optical_baseline(&self.fixture, canvas) + } else { + widget::sensor(canvas) + .key(generation) + .on_show(crate::app::Message::CanvasResized) + .on_resize(crate::app::Message::CanvasResized) + .into() + }; + let body = inert::wrap(body, generation, self.layout.expected, self.drawn.clone()); + if self.layout.variant == Variant::Baseline { + column![header, body].into() + } else { + column![ + header, + body, + container( + text(&self.fixture.status) + .size(11) + .wrapping(widget::text::Wrapping::None) + ) + .padding([6, 12]) + .height(28) + .style(style::panel) + ] + .into() + } + } +} + +fn write_new(path: &Path, data: &[u8]) -> Result<()> { + fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .and_then(|mut file| file.write_all(data)) + .map_err(|e| format!("{}: {e}", path.display())) +} +fn write_json(path: &Path, value: &Value) -> Result<()> { + let mut bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?; + bytes.push(b'\n'); + write_new(path, &bytes) +} + +pub(crate) fn run(args: &[String]) -> iced::Result { + let prepare = || -> Result { + if args.len() != 3 { + return Err( + "usage: arcadia-studio --focus-optical PROJECT.json DIAGRAM_UUID FRESH_OUTPUT_DIR" + .into(), + ); + } + let input_path = fs::canonicalize(&args[0]).map_err(|e| e.to_string())?; + let input = fs::read(&input_path).map_err(|e| e.to_string())?; + let raw: Value = serde_json::from_slice(&input).map_err(|e| e.to_string())?; + let decoded = + oa_core::persistence::decode_with_report(&input).map_err(|e| e.to_string())?; + let diagram: Id = args[1] + .parse() + .map_err(|e| format!("Invalid diagram UUID: {e}"))?; + let frozen = Frozen::new(decoded.project, diagram)?; + let fixture = fixture(frozen.project.clone(), diagram)?; + frozen.check(&fixture, diagram)?; + let output = PathBuf::from(&args[2]); + fs::create_dir(&output).map_err(|e| format!("Fresh output directory required: {e}"))?; + write_new(&output.join("input.json"), &input)?; + write_json( + &output.join("admission.json"), + &json!({ + "input_path": input_path, "input_bytes": input.len(), "raw_schema": raw.get("schema_version"), + "admitted_schema": frozen.project.schema_version, "migration": decoded.migration, + "diagnostics": oa_core::validation::validate(&frozen.project), + "diagram": diagram, "projection": frozen.project.diagrams[&diagram].projection, + "layer": frozen.project.diagrams[&diagram].layer, + "document_equals_admitted_complete_project": true, + "hash_identity": "Parent pins input/executable hashes externally; original input bytes retained.", + "scope": "Existing schema migration occurs once at admission; both variants use the same frozen admitted Project." + }), + )?; + write_json( + &output.join("project.json"), + &serde_json::to_value(&frozen.project).map_err(|e| e.to_string())?, + )?; + write_json(&output.join("scene.json"), &frozen.report)?; + write_new(&output.join("diagram.svg"), frozen.svg.as_bytes())?; + Ok(Runner { + fixture, + frozen, + diagram, + input_path, + input, + output, + layout: LayoutState::default(), + drawn: Rc::new(RefCell::new(None)), + header: None, + scale_factor: None, + capture: 0, + error: None, + }) + }; + let runner = match prepare() { + Ok(runner) => runner, + Err(error) => { + eprintln!("Focus optical admission failed: {error}"); + std::process::exit(2); + } + }; + let boot = RefCell::new(Some(runner)); + iced::application( + move || { + ( + boot.borrow_mut().take().expect("Optical runner boots once"), + iced::window::oldest().and_then(|id| { + iced::window::size(id).map(move |size| Message::Opened(id, size)) + }), + ) + }, + Runner::update, + Runner::view, + ) + .title("ARCADIA · private read-only optical comparison") + .theme(|_: &Runner| style::theme()) + .subscription(|_: &Runner| { + iced::event::listen_with(|event, _, id| match event { + iced::Event::Window(iced::window::Event::Opened { size, .. }) => { + Some(Message::Opened(id, size)) + } + iced::Event::Window(iced::window::Event::Resized(size)) => { + Some(Message::WindowSize(size)) + } + iced::Event::Window(iced::window::Event::Rescaled(scale)) => { + Some(Message::Scale(scale)) + } + iced::Event::Window(iced::window::Event::CloseRequested) => Some(Message::Close(id)), + _ => None, + }) + }) + .window(iced::window::Settings { + size: Size::new(1440., 900.), + min_size: Some(Size::new(1100., 700.)), + exit_on_close_request: false, + ..Default::default() + }) + .run() +} diff --git a/crates/oa-desktop/src/focus_optical/inert.rs b/crates/oa-desktop/src/focus_optical/inert.rs new file mode 100644 index 00000000..30ba5f5c --- /dev/null +++ b/crates/oa-desktop/src/focus_optical/inert.rs @@ -0,0 +1,167 @@ +//! Pre-dispatch boundary: frozen chrome never receives interactive events/operations. +use super::{DrawStamp, Mailbox, Message}; +use crate::app::Message as AppMessage; +use iced::advanced::{ + Clipboard, Layout, Shell, Widget, layout, renderer, + widget::{Tree, tree}, +}; +use iced::{Element, Event, Length, Rectangle, Size, Theme, mouse}; + +pub(super) fn wrap<'a>( + content: Element<'a, AppMessage>, + generation: u64, + expected: Option, + drawn: Mailbox, +) -> Element<'a, Message> { + Element::new(Inert { + content, + generation, + expected, + drawn, + }) +} + +pub(super) struct Inert<'a, R: renderer::Renderer> { + pub(super) content: Element<'a, AppMessage, Theme, R>, + pub(super) generation: u64, + pub(super) expected: Option, + pub(super) drawn: Mailbox, +} + +#[derive(Default)] +struct State { + generation: u64, + acknowledged: Option, +} + +impl Widget for Inert<'_, R> { + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + fn state(&self) -> tree::State { + tree::State::new(State { + generation: self.generation, + acknowledged: None, + }) + } + fn children(&self) -> Vec { + vec![Tree::new(self.content.as_widget())] + } + fn diff(&self, tree: &mut Tree) { + let state = tree.state.downcast_mut::(); + if state.generation != self.generation { + // This rig owns no editable widget state. Remount so an unchanged-size + // Baseline sensor still reports after explicit Fit/generation change. + *state = State { + generation: self.generation, + acknowledged: None, + }; + tree.children = self.children(); + } else { + tree.diff_children(std::slice::from_ref(&self.content)); + } + } + fn size(&self) -> Size { + self.content.as_widget().size() + } + fn size_hint(&self) -> Size { + self.content.as_widget().size_hint() + } + fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node { + self.content + .as_widget_mut() + .layout(&mut tree.children[0], renderer, limits) + } + // Default operate/overlay/mouse_interaction do not delegate into hidden controls. + fn update( + &mut self, + tree: &mut Tree, + event: &Event, + layout: Layout<'_>, + _: mouse::Cursor, + renderer: &R, + clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + if matches!( + event, + Event::Window(iced::window::Event::Resized(_) | iced::window::Event::Rescaled(_)) + ) { + *self.drawn.borrow_mut() = None; + } + if !matches!( + event, + Event::Window(iced::window::Event::RedrawRequested(_)) + ) { + return; + } + let mut messages = Vec::new(); + let mut passive = Shell::new(&mut messages); + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout, + mouse::Cursor::Unavailable, + renderer, + clipboard, + &mut passive, + viewport, + ); + shell.request_redraw_at(passive.redraw_request()); + if passive.is_layout_invalid() { + shell.invalidate_layout(); + } + if passive.are_widgets_invalid() { + shell.invalidate_widgets(); + } + // Do not dispatch any production command, including an unexpected passive one. + for message in messages { + match message { + AppMessage::CanvasResized(size) => { + if self.expected.is_some_and(|stamp| stamp.viewport != size) { + *self.drawn.borrow_mut() = None; + } + shell.publish(Message::Measured(self.generation, size)); + } + other => shell.publish(Message::UnexpectedPassive(format!( + "Frozen chrome emitted unexpected passive message {:?}", + std::mem::discriminant(&other) + ))), + } + } + let state = tree.state.downcast_mut::(); + if let Some(stamp) = *self.drawn.borrow() + && Some(stamp) == self.expected + && state.acknowledged != Some(stamp) + { + state.acknowledged = Some(stamp); + shell.publish(Message::Drawn(stamp)); + } + // draw runs after this update. One further redraw lets us acknowledge its + // exact receipt. Stop requesting once that receipt was delivered. + if self.expected.is_some() && state.acknowledged != self.expected { + shell.request_redraw(); + } + } + fn draw( + &self, + tree: &Tree, + renderer: &mut R, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + _: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout, + mouse::Cursor::Unavailable, + viewport, + ); + } +} diff --git a/crates/oa-desktop/src/focus_optical/tests.rs b/crates/oa-desktop/src/focus_optical/tests.rs new file mode 100644 index 00000000..895b760f --- /dev/null +++ b/crates/oa-desktop/src/focus_optical/tests.rs @@ -0,0 +1,612 @@ +use super::*; +use iced::advanced::{ + Clipboard, Layout, Shell, Widget, layout, renderer, + widget::{Operation, Tree}, +}; +use iced::{Event, Length, Point}; +use std::cell::Cell; + +fn sample() -> Frozen { + let project = Project::demo(); + let id = project + .diagrams + .values() + .find(|d| d.projection.is_architecture()) + .unwrap() + .id; + Frozen::new(project, id).unwrap() +} + +#[test] +fn fit_uses_both_axes_offsets_margin_clamp_and_empty_scene() { + let bounds = routing::RoutingBounds { + position: oa_core::Position { x: 54., y: 18. }, + size: oa_core::Size { + width: 1506., + height: 814., + }, + }; + let width = fit(bounds, false, Size::new(826., 534.)); + assert_eq!(width.zoom, 778. / 1506.); + let height = fit(bounds, false, Size::new(1406., 534.)); + assert_eq!(height.zoom, 486. / 814.); + assert!((width.pan.x + 54. * width.zoom - 24.).abs() < 0.0001); + assert!((height.pan.y + 18. * height.zoom - 24.).abs() < 0.0001); + assert_eq!(fit(bounds, false, Size::new(10000., 10000.)).zoom, 1.5); + assert_eq!( + fit(bounds, true, Size::new(1., 1.)), + Camera { + zoom: 1., + pan: Vector::new(30., 30.) + } + ); + // Large nonzero origin makes algebraically simplified size differ in f32. + let offset = routing::RoutingBounds { + position: oa_core::Position { + x: 1_000_000., + y: 500_000., + }, + size: oa_core::Size { + width: 100.03, + height: 80.02, + }, + }; + let camera = fit(offset, false, Size::new(100., 200.)); + assert_eq!( + camera.zoom, + 52. / ((offset.position.x + offset.size.width) - offset.position.x) + ); +} + +#[test] +fn measurements_and_draw_acknowledgments_reject_stale_invalid_repeat_and_wrong_camera() { + let frozen = sample(); + let mut layout = LayoutState::default(); + for size in [ + Size::ZERO, + Size::new(f32::NAN, 10.), + Size::new(10., f32::INFINITY), + Size::new(-1., 20.), + ] { + assert!(!layout.measured(0, size, &frozen)); + } + assert!(layout.measured(0, Size::new(800., 500.), &frozen)); + assert!( + !layout.ready(), + "Sensor receipt is not a draw acknowledgment" + ); + let first = layout.expected.unwrap(); + assert!(!layout.measured(0, first.viewport, &frozen)); + assert!(!layout.drawn(DrawStamp { + camera: Camera { + zoom: first.camera.zoom * 1.1, + ..first.camera + }, + ..first + })); + assert!(layout.drawn(first)); + assert!(layout.ready()); + assert!(!layout.drawn(first)); + assert!(layout.measured(0, Size::new(900., 600.), &frozen)); + assert_eq!( + layout.generation, 1, + "Same-variant real resize invalidates old authority" + ); + assert!(!layout.drawn(first)); + assert!(!layout.measured(0, Size::new(800., 500.), &frozen)); + assert!(!layout.ready()); + layout.variant = Variant::Focus; + layout.invalidate(); + assert!(!layout.drawn(first)); + assert!(!layout.measured(1, Size::new(1400., 800.), &frozen)); + assert!(layout.measured(2, Size::new(1400., 800.), &frozen)); + assert!(layout.drawn(layout.expected.unwrap())); +} + +#[test] +fn admission_rejects_missing_state_and_scenario_views_without_project_mutation() { + let original = sample(); + assert!(Frozen::new(original.project.clone(), Id::new_v4()).is_err()); + for projection in [ + oa_core::ProjectionKind::StateMachine { + machine: Id::new_v4(), + }, + oa_core::ProjectionKind::Scenario { + scenario: Id::new_v4(), + }, + ] { + let mut candidate = original.project.clone(); + candidate + .diagrams + .get_mut(&original.diagram) + .unwrap() + .projection = projection; + assert!(Frozen::new(candidate, original.diagram).is_err()); + } + let app = fixture(original.project.clone(), original.diagram).unwrap(); + original.check(&app, original.diagram).unwrap(); + assert!(!app.document.dirty() && !app.document.can_undo() && !app.document.can_redo()); +} + +#[test] +fn fixed_schema_seven_and_migrated_schema_three_fixtures_admit_coherent_oa_sa_chrome() { + let cases: &[(&[u8], &str)] = &[ + ( + include_bytes!( + "../../../../harness/experiments/focus-optical-20261001/eight-card.json" + ), + "00000000-0038-4000-8000-000000000001", + ), + ( + include_bytes!("../../../../examples/routing-workshop.json"), + "945a7c88-a352-5028-b808-73d852e5abe6", + ), + ( + include_bytes!("../../../../examples/routing-workshop.json"), + "cb21862d-79e1-5a9d-bdf0-ee344c2fb219", + ), + ( + include_bytes!("../../../../examples/coastal-rescue.json"), + "487c064d-df33-45f2-b4b7-6dd68d7b601f", + ), + ]; + for (bytes, diagram) in cases { + let decoded = oa_core::persistence::decode_with_report(bytes).unwrap(); + let raw: Value = serde_json::from_slice(bytes).unwrap(); + assert_eq!(decoded.migration.is_some(), raw["schema_version"] == 3); + let frozen = Frozen::new(decoded.project, diagram.parse().unwrap()).unwrap(); + let app = fixture(frozen.project.clone(), frozen.diagram).unwrap(); + frozen.check(&app, frozen.diagram).unwrap(); + assert_eq!( + app.create_kind, + if app.layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + } + ); + assert_eq!(app.draft.as_ref().unwrap().original, app.project().id); + assert_eq!( + app.draft.as_ref().unwrap().kind, + crate::forms::RecordKind::Project + ); + assert!( + app.architecture.is_none() + && app.connection.is_none() + && app.diagram_navigation_focus.is_none() + ); + let expected_index = crate::explorer::Index::new(app.project(), app.layer); + for group in crate::explorer::Group::ALL { + assert_eq!( + app.explorer_index.rows(group, "", &app.explorer_collapsed), + expected_index.rows(group, "", &app.explorer_collapsed) + ); + } + } +} + +#[test] +fn captures_require_a_matching_draw_and_preserve_complete_model_scene_svg() { + let frozen = sample(); + let dir = tempfile::tempdir().unwrap(); + let input = serde_json::to_vec(&frozen.project).unwrap(); + let input_path = dir.path().join("fixture.json"); + fs::write(&input_path, &input).unwrap(); + let mut runner = Runner { + fixture: fixture(frozen.project.clone(), frozen.diagram).unwrap(), + diagram: frozen.diagram, + frozen, + input_path, + input, + output: dir.path().to_owned(), + layout: LayoutState::default(), + drawn: Rc::new(RefCell::new(None)), + header: None, + scale_factor: Some(1.), + capture: 0, + error: None, + }; + let original_bundle = runner.fixture.document.bundle().clone(); + assert!(runner.capture().is_err()); + for (index, variant) in [Variant::Baseline, Variant::Focus, Variant::Baseline] + .into_iter() + .enumerate() + { + runner.layout.variant = variant; + runner.invalidate(); + let generation = runner.layout.generation; + let size = if variant == Variant::Focus { + Size::new(1400., 800.) + } else { + Size::new(826., 534.) + }; + let _ = runner.update(Message::Measured(generation, size)); + runner.header = Some((generation, Size::new(1440., 67.4))); + assert!(runner.capture().is_err()); + let stamp = runner.layout.expected.unwrap(); + *runner.drawn.borrow_mut() = Some(stamp); + let _ = runner.update(Message::Drawn(stamp)); + runner.capture().unwrap(); + let captured = dir.path().join(format!("capture-{:03}", index + 1)); + assert_eq!( + fs::read(captured.join("diagram.svg")).unwrap(), + runner.frozen.svg.as_bytes() + ); + let scene: Value = + serde_json::from_slice(&fs::read(captured.join("scene.json")).unwrap()).unwrap(); + assert_eq!(scene, runner.frozen.report); + let project: Project = + serde_json::from_slice(&fs::read(captured.join("project.json")).unwrap()).unwrap(); + assert_eq!(project, runner.frozen.project); + assert_eq!(runner.fixture.document.bundle(), &original_bundle); + assert!(!runner.fixture.document.can_undo() && !runner.fixture.document.can_redo()); + } + let mut altered = runner.frozen.project.clone(); + altered.name.push_str(" changed"); + let altered_app = fixture(altered, runner.diagram).unwrap(); + assert!(runner.frozen.check(&altered_app, runner.diagram).is_err()); + fs::write(&runner.input_path, b"changed").unwrap(); + assert!(runner.capture().is_err()); +} + +#[test] +fn private_flag_is_exact_and_other_launches_remain_opted_out() { + for arguments in [ + vec!["app"], + vec!["app", "--write-example", "out"], + vec!["app", "--focus"], + vec!["app", "--focus-optical-extra"], + ] { + assert!(!requested( + &arguments.into_iter().map(String::from).collect::>() + )); + } + assert!(requested(&["app".into(), "--focus-optical".into()])); +} + +#[test] +fn terminal_fault_rejects_queued_draw_capture_and_close_never_overwrites_or_traps() { + let frozen = sample(); + let dir = tempfile::tempdir().unwrap(); + let input = serde_json::to_vec(&frozen.project).unwrap(); + let input_path = dir.path().join("fixture.json"); + fs::write(&input_path, &input).unwrap(); + let mut runner = Runner { + fixture: fixture(frozen.project.clone(), frozen.diagram).unwrap(), + diagram: frozen.diagram, + frozen, + input_path, + input, + output: dir.path().to_owned(), + layout: LayoutState::default(), + drawn: Rc::new(RefCell::new(None)), + header: None, + scale_factor: Some(1.), + capture: 0, + error: None, + }; + let _ = runner.update(Message::Measured(0, Size::new(800., 500.))); + let stamp = runner.layout.expected.unwrap(); + runner.header = Some((0, Size::new(1440., 67.4))); + *runner.drawn.borrow_mut() = Some(stamp); + let _ = runner.update(Message::Drawn(stamp)); + assert!(runner.layout.ready()); + runner.fail("Injected harness fault".into()); + let _ = runner.update(Message::Drawn(stamp)); + let _ = runner.update(Message::Capture); + assert!(!runner.layout.ready()); + assert!(runner.capture().is_err()); + assert_eq!(runner.capture, 0); + assert!(!dir.path().join("capture-001").exists()); + fs::write(&runner.input_path, b"changed after admission").unwrap(); + let close = runner.update(Message::Close(iced::window::Id::unique())); + assert!( + iced_runtime::task::into_stream(close).is_some(), + "Failure still issues the exit task" + ); + let receipt = dir.path().join("finished.json"); + let original = fs::read(&receipt).unwrap(); + let recorded: Value = serde_json::from_slice(&original).unwrap(); + assert_eq!(recorded["status"], "FAIL"); + assert_eq!( + recorded["complete_project_scene_svg_and_input_unchanged"], + false + ); + let close_again = runner.update(Message::Close(iced::window::Id::unique())); + assert!( + iced_runtime::task::into_stream(close_again).is_some(), + "Existing receipt cannot trap Close" + ); + assert_eq!( + fs::read(receipt).unwrap(), + original, + "Never overwrite the first close receipt" + ); +} + +#[derive(Default)] +struct SpyCounts { + updates: Cell, + operations: Cell, + overlays: Cell, + draws: Cell, +} +struct Spy { + counts: Rc, + unexpected: bool, +} +impl Widget for Spy { + fn size(&self) -> Size { + Size::new(Fill, Fill) + } + fn layout(&mut self, _: &mut Tree, _: &(), _: &layout::Limits) -> layout::Node { + layout::Node::new(Size::new(500., 400.)) + } + fn update( + &mut self, + _: &mut Tree, + event: &Event, + _: Layout<'_>, + cursor: mouse::Cursor, + _: &(), + _: &mut dyn Clipboard, + shell: &mut Shell<'_, crate::app::Message>, + _: &Rectangle, + ) { + self.counts.updates.set(self.counts.updates.get() + 1); + assert!(matches!( + event, + Event::Window(iced::window::Event::RedrawRequested(_)) + )); + assert_eq!(cursor, mouse::Cursor::Unavailable); + shell.publish(if self.unexpected { + crate::app::Message::Fit + } else { + crate::app::Message::CanvasResized(Size::new(500., 400.)) + }); + } + fn operate(&mut self, _: &mut Tree, _: Layout<'_>, _: &(), _: &mut dyn Operation) { + self.counts.operations.set(self.counts.operations.get() + 1); + } + fn overlay<'a>( + &'a mut self, + _: &'a mut Tree, + _: Layout<'a>, + _: &(), + _: &Rectangle, + _: Vector, + ) -> Option> { + self.counts.overlays.set(self.counts.overlays.get() + 1); + None + } + fn draw( + &self, + _: &Tree, + _: &mut (), + _: &Theme, + _: &renderer::Style, + _: Layout<'_>, + cursor: mouse::Cursor, + _: &Rectangle, + ) { + assert_eq!(cursor, mouse::Cursor::Unavailable); + self.counts.draws.set(self.counts.draws.get() + 1); + } +} +struct NoClipboard; +impl Clipboard for NoClipboard { + fn read(&self, _: iced::advanced::clipboard::Kind) -> Option { + panic!("Unexpected clipboard read") + } + fn write(&mut self, _: iced::advanced::clipboard::Kind, _: String) { + panic!("Unexpected clipboard write") + } +} +struct Traverse; +impl Operation for Traverse { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) { + visit(self); + } +} + +#[test] +fn inert_widget_blocks_all_input_operations_overlays_and_allows_only_redraw_measurement() { + let counts = Rc::new(SpyCounts::default()); + let mut wrapper = inert::Inert { + content: Element::new(Spy { + counts: counts.clone(), + unexpected: false, + }), + generation: 9, + expected: None, + drawn: Rc::new(RefCell::new(None)), + }; + let mut tree = Tree::new(&wrapper as &dyn Widget); + let node = wrapper.layout( + &mut tree, + &(), + &layout::Limits::new(Size::ZERO, Size::new(500., 400.)), + ); + let layout = Layout::new(&node); + let bounds = Rectangle::with_size(Size::new(500., 400.)); + let cursor = mouse::Cursor::Available(Point::new(40., 40.)); + let key = crate::behavior::canvas_workspace::tests::key; + let mut events = vec![ + Event::Mouse(mouse::Event::CursorMoved { + position: Point::new(50., 50.), + }), + Event::Mouse(mouse::Event::WheelScrolled { + delta: mouse::ScrollDelta::Lines { x: 0., y: 1. }, + }), + key(iced::keyboard::key::Named::Delete), + key(iced::keyboard::key::Named::Insert), + key(iced::keyboard::key::Named::Tab), + Event::Keyboard(iced::keyboard::Event::ModifiersChanged( + iced::keyboard::Modifiers::SHIFT, + )), + Event::InputMethod(iced::advanced::input_method::Event::Preedit( + "字".into(), + None, + )), + Event::InputMethod(iced::advanced::input_method::Event::Commit("字".into())), + Event::Touch(iced::touch::Event::FingerPressed { + id: iced::touch::Finger(1), + position: Point::ORIGIN, + }), + Event::Window(iced::window::Event::Resized(Size::new(600., 500.))), + Event::Window(iced::window::Event::Rescaled(1.25)), + Event::Window(iced::window::Event::Unfocused), + Event::Window(iced::window::Event::FileDropped(PathBuf::from( + "ignored.json", + ))), + ]; + for button in [ + mouse::Button::Left, + mouse::Button::Middle, + mouse::Button::Right, + ] { + events.push(Event::Mouse(mouse::Event::ButtonPressed(button))); + events.push(Event::Mouse(mouse::Event::ButtonReleased(button))); + } + let mut messages = vec![]; + for event in &events { + wrapper.update( + &mut tree, + event, + layout, + cursor, + &(), + &mut NoClipboard, + &mut Shell::new(&mut messages), + &bounds, + ); + } + assert_eq!(counts.updates.get(), 0); + assert!(messages.is_empty()); + wrapper.operate(&mut tree, layout, &(), &mut Traverse); + assert!( + wrapper + .overlay(&mut tree, layout, &(), &bounds, Vector::ZERO) + .is_none() + ); + assert_eq!( + wrapper.mouse_interaction(&tree, layout, cursor, &bounds, &()), + mouse::Interaction::None + ); + assert_eq!(counts.operations.get() + counts.overlays.get(), 0); + wrapper.draw( + &tree, + &mut (), + &Theme::Light, + &renderer::Style::default(), + layout, + cursor, + &bounds, + ); + assert_eq!(counts.draws.get(), 1); + wrapper.update( + &mut tree, + &Event::Window(iced::window::Event::RedrawRequested( + iced::time::Instant::now(), + )), + layout, + cursor, + &(), + &mut NoClipboard, + &mut Shell::new(&mut messages), + &bounds, + ); + assert!( + matches!(messages.as_slice(), [Message::Measured(9, size)] if *size == Size::new(500., 400.)) + ); + assert_eq!(counts.updates.get(), 1); +} + +#[test] +fn unexpected_passive_message_is_a_fault_and_draw_only_adapter_never_emits_actions() { + let counts = Rc::new(SpyCounts::default()); + let mut wrapper = inert::Inert { + content: Element::new(Spy { + counts, + unexpected: true, + }), + generation: 0, + expected: None, + drawn: Rc::new(RefCell::new(None)), + }; + let mut tree = Tree::new(&wrapper as &dyn Widget); + let node = wrapper.layout( + &mut tree, + &(), + &layout::Limits::new(Size::ZERO, Size::new(500., 400.)), + ); + let mut messages = vec![]; + wrapper.update( + &mut tree, + &Event::Window(iced::window::Event::RedrawRequested( + iced::time::Instant::now(), + )), + Layout::new(&node), + mouse::Cursor::Unavailable, + &(), + &mut NoClipboard, + &mut Shell::new(&mut messages), + &Rectangle::with_size(Size::new(500., 400.)), + ); + assert!(matches!( + messages.as_slice(), + [Message::UnexpectedPassive(_)] + )); + let frozen = sample(); + let app = fixture(frozen.project.clone(), frozen.diagram).unwrap(); + let program = OpticalCanvas { + program: DiagramCanvas { + selection: None, + allocation: None, + project: app.project(), + view_index: Some(app.document.view_index()), + diagram: frozen.diagram, + selected: app.project().id, + navigation_focus: None, + zoom: 1., + pan: Vector::ZERO, + context: app.route_context().unwrap(), + selected_bend: None, + selected_attachment: None, + editable: false, + placing: false, + connection: None, + }, + stamp: None, + drawn: Rc::new(RefCell::new(None)), + }; + let mut state = crate::diagram::State::default(); + use canvas::Program; + for event in [ + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Event::Mouse(mouse::Event::CursorMoved { + position: Point::new(300., 200.), + }), + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + ] { + assert!( + program + .update( + &mut state, + &event, + Rectangle::with_size(Size::new(500., 400.)), + mouse::Cursor::Available(Point::new(200., 100.)) + ) + .is_none() + ); + } + assert_eq!( + program.mouse_interaction( + &state, + Rectangle::with_size(Size::new(500., 400.)), + mouse::Cursor::Available(Point::new(200., 100.)) + ), + mouse::Interaction::None + ); + frozen.check(&app, frozen.diagram).unwrap(); +} diff --git a/crates/oa-desktop/src/main.rs b/crates/oa-desktop/src/main.rs index 188a21f2..2971267f 100644 --- a/crates/oa-desktop/src/main.rs +++ b/crates/oa-desktop/src/main.rs @@ -15,6 +15,7 @@ mod diagram_usage; mod document; mod explorer; mod explorer_view; +mod focus_optical; mod forms; mod guide; mod highlight; @@ -44,6 +45,9 @@ mod workflow; fn main() -> iced::Result { // Small headless entry points support reproducible examples and CI validation. let args: Vec<_> = std::env::args().collect(); + if focus_optical::requested(&args) { + return focus_optical::run(&args[2..]); + } if args.get(1).is_some_and(|s| s == "--write-example") { let path = args .get(2) diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index a1ceaf75..57ea751b 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -65,8 +65,18 @@ pub fn heading<'a>(title: &str, subtitle: &str) -> View<'a, Message> { } pub fn view(app: &App) -> View<'_, Message> { + workspace(app, None) +} + +/// Private optical rig: preserve actual Baseline chrome below the shared header. +pub(crate) fn optical_baseline<'a>(app: &'a App, canvas: View<'a, Message>) -> View<'a, Message> { + workspace(app, Some(canvas)) +} + +/// Same logo, project typography, padding and intrinsic height in both runners. +pub(crate) fn header<'a, M: 'a>(app: &App, actions: View<'a, M>) -> View<'a, M> { let p = app.project(); - let header = container( + container( row![ row![ text("◈").size(28).color(style::TEAL), @@ -81,6 +91,22 @@ pub fn view(app: &App) -> View<'_, Message> { column![text(short(&p.name, 46)).size(17), label(app.hosted_label())] .spacing(3) .width(Fill), + actions + ] + .spacing(8) + .align_y(iced::Center), + ) + .padding([14, 18]) + .style(style::panel) + .into() +} + +fn workspace<'a>(app: &'a App, optical_canvas: Option>) -> View<'a, Message> { + let p = app.project(); + let optical = optical_canvas.is_some(); + let header = header( + app, + row![ action("New", Message::Request(Pending::New)), crate::hosted::view::header_action(), action("Open", Message::Request(Pending::Open)), @@ -90,10 +116,9 @@ pub fn view(app: &App) -> View<'_, Message> { .on_press(Message::Save(false)) ] .spacing(8) - .align_y(iced::Center), - ) - .padding([14, 18]) - .style(style::panel); + .align_y(iced::Center) + .into(), + ); let mut nav = column![ label("ENGINEERING PERSPECTIVE"), row![ @@ -240,7 +265,7 @@ pub fn view(app: &App) -> View<'_, Message> { &app.recovery, ) .map(Message::Home), - Page::Diagram => diagram(app), + Page::Diagram => diagram(app, optical_canvas), Page::Catalog => crate::explorer_view::catalog(app), Page::Data => data_catalog(app), Page::Behavior => crate::behavior::view(app), @@ -295,6 +320,9 @@ pub fn view(app: &App) -> View<'_, Message> { ) .padding([4, 16]) .style(style::panel); + if optical { + return column![task_nav, body, footer].into(); + } let root = crate::allocation_input::root(app, column![header, task_nav, body, footer]); let root = crate::guide::protect_workspace(app.context_help, root); if app.context_help { @@ -439,7 +467,7 @@ fn modal_width<'a>( ] .into() } -fn diagram(app: &App) -> View<'_, Message> { +fn diagram<'a>(app: &'a App, optical_canvas: Option>) -> View<'a, Message> { if app .active_diagram .and_then(|id| app.project().diagrams.get(&id)) @@ -569,6 +597,7 @@ fn diagram(app: &App) -> View<'_, Message> { if let Some(editor) = crate::app::architecture_authoring::inline_editor(app) { scene = scene.push(editor); } + let scene: View<'_, Message> = optical_canvas.unwrap_or_else(|| scene.into()); let reveal = app.architecture_reveal_stamp(id); row![ widget::sensor(