Combine source navigation and alias rename with canvas ownership guards

This commit is contained in:
2026-09-12 19:49:26 -07:00
parent 95e0e12716
commit a17ca5d066
72 changed files with 21764 additions and 189 deletions
+6 -1
View File
@@ -372,6 +372,7 @@ impl App {
&& self.pending.is_none()
&& self.file_prompt.is_none()
&& !self.delete_pending
&& self.diagram_usages.is_none()
}
pub fn allocation_capability(&self) -> Capability<'_> {
Capability {
@@ -403,7 +404,11 @@ impl App {
| Message::Linted(..)
| Message::CanvasResized(_)
| Message::WindowResized(_)
| Message::SourceViewport
| Message::SourceViewport(_)
| Message::SourceSymbolsReady(_)
| Message::SourceRenameReady(_)
| Message::DiagramUsagesReady(_)
| Message::DiagramRevealViewport(..)
) {
self.allocation_drag.retire();
}
@@ -70,6 +70,7 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: element,
navigation_focus: None,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
@@ -225,6 +225,7 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: app.selected,
navigation_focus: None,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
@@ -300,6 +301,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: element,
navigation_focus: None,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
@@ -4,6 +4,8 @@ use crate::behavior::canvas_workspace::tests::{Mounted, key};
use iced::advanced::widget::{Operation, operation};
use iced::{Event as Input, Point, Rectangle, Size, Vector, keyboard, mouse};
mod navigation_union;
#[tokio::test]
async fn mounted_state_action_and_internal_rows_delete_relations_and_preserve_reused_work() {
use crate::behavior::state_canvas::visible_row_relation;
@@ -360,7 +362,13 @@ async fn mounted_oa_sa_delete_hides_exact_placement_and_round_trips_source_and_m
assert_eq!(f.app.document.bundle(), &before.bundle);
delete(&mut mounted, &mut f.app, true).await;
assert_eq!(
f.app.delete_target.as_ref().unwrap().snapshot.request.object,
f.app
.delete_target
.as_ref()
.unwrap()
.snapshot
.request
.object,
f.object
);
assert_eq!(f.app.document.bundle(), &before.bundle);
@@ -707,13 +715,15 @@ async fn mounted_inspector_can_take_focus_during_passive_save() {
}
mounted.perform(&mut f.app, task).await;
let geometry = mounted.geometry(&f.app);
assert!(geometry.focused.iter().any(|(_, bounds, focused)| {
*bounds == field && *focused
}));
assert!(
geometry
.focused
.iter()
.any(|(_, bounds, focused)| { *bounds == field && *focused })
);
assert!(!geometry.focused.iter().any(|(id, _, focused)| {
*focused
&& id.as_ref()
== Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
&& id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
}));
let before = f.app.document.bundle().clone();
let messages = mounted
@@ -764,7 +774,9 @@ async fn mounted_passive_save_retires_held_canvas_drag_but_allows_a_fresh_drag()
mounted
.batch(
&mut f.app,
&[Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))],
&[Input::Mouse(mouse::Event::ButtonReleased(
mouse::Button::Left,
))],
mouse::Cursor::Available(moved),
)
.await;
@@ -1049,11 +1061,15 @@ async fn mounted_state_delete_uses_state_placement_and_preserves_architecture_pl
assert!(crate::behavior::state_edit::unplaced(app.project(), diagram).is_empty());
let owner = app.state_context().unwrap();
let focused = |mounted: &mut Mounted, app: &App| {
mounted.geometry(app).focused.iter().any(|(id, _, focused)| {
*focused
&& id.as_ref()
== Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
})
mounted
.geometry(app)
.focused
.iter()
.any(|(id, _, focused)| {
*focused
&& id.as_ref()
== Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
})
};
assert!(focused(&mut mounted, &app));
let messages = delete(&mut mounted, &mut app, false).await;
@@ -0,0 +1,146 @@
//! Navigate first, then remove through the mounted root canvas and history.
use super::*;
use crate::diagram_usage::{self as usage, Target};
async fn navigate_then_remove(project: Project, subject: Id, owner: Id, row: bool) {
let mut app = App::with_project(project);
app.window_size = Size::new(2000., 1400.);
let before = app.document.snapshot();
let message = app.diagram_usage_message(subject);
let _ = app.update(message);
let panel = app.diagram_usages.as_ref().unwrap();
let token = panel.token;
let context = panel.context;
// Complete the same query independently without polling its worker Task.
let process = app.behavior.topology.usage_snapshot().into_query_state();
let completion = usage::Completion {
token,
context,
result: Ok(usage::collect(
app.project(),
subject,
&process,
&behavior::scenario::State::default(),
)),
};
let _ = app.update(Message::DiagramUsagesReady(completion));
let found = app
.diagram_usages
.as_ref()
.unwrap()
.usages
.iter()
.find(|usage| {
if row {
matches!(usage.target, Target::StateRow { relation, .. } if relation == subject)
} else {
matches!(usage.target, Target::Port { port, .. } if port == subject)
}
})
.unwrap()
.clone();
let _ = app.update(Message::DiagramUsage(usage::Event::Choose(
token,
found.clone(),
)));
assert_eq!(app.selected, subject);
// refresh_draft normalizes the owner's presentation placement; navigation
// must not leave it armed as a removal target.
assert!(app.selected_node.is_none());
assert!(app.behavior.diagram.placement.is_none());
assert_eq!(
app.diagram_focus(found.surface).unwrap().target,
found.target
);
let mut mounted = Mounted::new(&mut app).await;
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(app.pending_diagram_reveal.is_none());
for model in [false, true] {
mounted
.perform(
&mut app,
iced::widget::operation::focus(iced::advanced::widget::Id::from(
"diagram-keyboard-owner",
)),
)
.await;
let messages = delete(&mut mounted, &mut app, model).await;
let request = messages
.iter()
.find_map(|message| match message {
Message::DiagramRemove(intent)
| Message::StateDiagram(behavior::state_edit::Event::Remove(intent)) => {
Some(intent)
}
_ => None,
})
.expect("actual focused canvas emitted a removal intent");
assert_eq!(request.object, subject);
assert_eq!(request.placement, None);
assert_eq!(request.model, model);
assert_eq!(
app.delete_target.as_ref().unwrap().snapshot.request.object,
subject
);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
if !model {
let task = app.update(Message::CancelDelete);
mounted.perform(&mut app, task).await;
}
}
let token = app.delete_target.as_ref().unwrap().snapshot.token;
let task = app.update(Message::ConfirmDelete(token));
mounted.perform(&mut app, task).await;
let expected = execute_batch(&before.decoded.project, &[Command::Delete(subject)]).unwrap();
assert_eq!(app.project(), &expected);
assert!(app.project().contains(owner));
consistent(&app);
let task = app.update(Message::Undo);
mounted.perform(&mut app, task).await;
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
}
#[tokio::test]
async fn port_navigation_then_canvas_delete_preserves_owner_and_restores_exact_history() {
let mut project = Project::empty();
let owner = project.add(Kind::Function, "Compute", 1200., 1500.);
let port = Id::new_v4();
let element = oa_core::ports::make_port(
&project,
port,
owner,
"Input",
oa_core::PortContract::FunctionInput {
incoming_items: vec![],
},
)
.unwrap();
project = execute_batch(&project, &[Command::CreateElement(element)]).unwrap();
navigate_then_remove(project, port, owner, false).await;
}
#[tokio::test]
async fn state_row_navigation_then_canvas_delete_preserves_work_and_restores_exact_history() {
let mut project = Project::empty();
let machine = project
.add_element(Kind::StateMachine, Layer::Sa, "Modes", None)
.unwrap();
let region = project
.add_element(Kind::Region, Layer::Sa, "Main", Some(machine))
.unwrap();
let state = project
.add_element(Kind::State, Layer::Sa, "Running", Some(region))
.unwrap();
let work = project
.add_element(Kind::Function, Layer::Sa, "Monitor", None)
.unwrap();
let relation = project.relate(RelationKind::StateAction, "Entry", state, work);
let plan = crate::behavior::state_edit::create_view(&project, machine).unwrap();
project = execute_batch(&project, &[plan.command]).unwrap();
navigate_then_remove(project, relation, work, true).await;
}
+146
View File
@@ -0,0 +1,146 @@
//! Complete a navigation camera after the destination reports its real viewport.
use super::*;
use crate::diagram_usage::{Context, Surface};
#[cfg(test)]
mod tests;
impl App {
pub(super) fn is_diagram_viewport(message: &Message) -> bool {
matches!(
message,
Message::CanvasResized(_)
| Message::DiagramRevealViewport(..)
| Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(..))
| Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(..))
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
)
}
pub(crate) fn architecture_reveal_stamp(&self, diagram: Id) -> Option<(Context, u64)> {
self.pending_diagram_reveal
.as_ref()
.filter(|(_, u, _)| u.surface == Surface::Saved(diagram))
.map(|(context, _, epoch)| (*context, *epoch))
}
pub(super) fn diagram_reveal_viewport(&self, message: &Message) -> Option<Surface> {
let (context, usage, epoch) = self.pending_diagram_reveal.as_ref()?;
if context.document != self.canvas_document
|| context.revision != self.document.revision()
|| *epoch != self.canvas_epoch
{
return None;
}
let (surface, size, valid) = match message {
Message::DiagramRevealViewport(c, e, size) => (
usage.surface,
*size,
c == context
&& e == epoch
&& self.page == Page::Diagram
&& Some(usage.surface.id()) == self.active_diagram,
),
Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(c, size)) => (
Surface::Process(c.owner),
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch,
),
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(c, size)) => (
Surface::Scenario(c.scenario),
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch,
),
Message::StateDiagram(behavior::state_edit::Event::Viewport(c, size)) => (
Surface::Saved(c.diagram),
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch,
),
_ => return None,
};
(valid
&& surface == usage.surface
&& size.width.is_finite()
&& size.height.is_finite()
&& size.width > 0.
&& size.height > 0.)
.then_some(surface)
}
pub(super) fn reconcile_pending_diagram_reveal(&mut self, message: &Message) {
if !matches!(
message,
Message::CheckpointTick
| Message::CheckpointWritten(..)
| Message::Lint(_)
| Message::Linted(..)
| Message::SourceViewport(_)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::DiagramRevealViewport(..)
| Message::DiagramUsage(_)
| Message::DiagramUsagesReady(_)
| Message::SourceSymbolsReady(_)
| Message::SourceRenameReady(_)
| Message::WorkDrag(allocation_drag::Event::KeyboardBoundary)
| Message::WorkDrag(allocation_drag::Event::Unfocused)
| Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(..))
| Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(..))
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
) {
self.pending_diagram_reveal = None;
if !Self::is_navigation_camera(message) {
self.diagram_navigation_focus = None;
}
}
}
fn is_navigation_camera(message: &Message) -> bool {
if let Message::BehaviorControl(intent) = message {
return Self::is_navigation_camera(&intent.message);
}
matches!(
message,
Message::Pan(_)
| Message::Zoom(_)
| Message::Fit
| Message::TopologyCanvas(
behavior::topology::CanvasEvent::Camera(..)
| behavior::topology::CanvasEvent::Zoom(..)
| behavior::topology::CanvasEvent::Scrolled(..)
)
| Message::ScenarioCanvas(
behavior::scenario::CanvasEvent::Camera(..)
| behavior::scenario::CanvasEvent::Zoom(..)
| behavior::scenario::CanvasEvent::Scrolled(..)
)
| Message::StateDiagram(
behavior::state_edit::Event::Pan(..)
| behavior::state_edit::Event::Zoom(..)
| behavior::state_edit::Event::Fit(..)
)
)
}
pub(super) fn finish_diagram_reveal(&mut self, surface: Surface) {
let Some((context, usage, _)) = self.pending_diagram_reveal.take() else {
return;
};
if context.document == self.canvas_document
&& context.revision == self.document.revision()
&& usage.surface == surface
&& !self.draft_dirty()
&& !self.source_dirty()
&& !self.behavior.has_pending()
{
// The normal viewport handler has now stored the measured size.
// Target identity was validated for this revision before navigation.
self.reveal_diagram_usage(&usage);
}
self.pending_diagram_reveal = None;
}
}
@@ -0,0 +1,403 @@
//! Independent first-layout navigation oracles, through the real App boundary.
use super::*;
use crate::diagram_usage::{self as usage, Target, Usage};
use iced::{Size, Vector};
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug)]
enum Canvas {
Architecture,
Process,
Scenario,
State,
}
const CANVASES: [Canvas; 4] = [
Canvas::Architecture,
Canvas::Process,
Canvas::Scenario,
Canvas::State,
];
const WIDE: Size = Size::new(1200., 700.);
const ACTUAL: Size = Size::new(500., 300.);
fn send(app: &mut App, message: Message) {
let _ = app.update(message);
}
fn fixture(canvas: Canvas) -> (App, Usage) {
let mut p = Project::empty();
let work = p
.add_element(Kind::Activity, Layer::Oa, "Respond", None)
.unwrap();
let (surface, subject) = match canvas {
Canvas::Architecture => {
let diagram = p.ensure_diagram(Layer::Oa);
p.place(
diagram,
work,
Position {
x: 12_000.,
y: 9_000.,
},
);
(Surface::Saved(diagram), work)
}
Canvas::Process | Canvas::Scenario => {
let kind = if matches!(canvas, Canvas::Process) {
Kind::Process
} else {
Kind::Scenario
};
let owner = p.add_element(kind, Layer::Oa, "Response", None).unwrap();
let occurrence = p
.add_element(Kind::Occurrence, Layer::Oa, "Respond here", Some(owner))
.unwrap();
*p.elements
.get_mut(&occurrence)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Action {
action: Some(work),
lifeline: None,
},
precondition: String::new(),
postcondition: String::new(),
};
let steps = p.elements.get_mut(&owner).unwrap().steps_mut().unwrap();
if !steps.contains(&occurrence) {
steps.push(occurrence);
}
(
if matches!(canvas, Canvas::Process) {
Surface::Process(owner)
} else {
Surface::Scenario(owner)
},
occurrence,
)
}
Canvas::State => {
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Modes", None)
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Main", Some(machine))
.unwrap();
let state = p
.add_element(Kind::State, Layer::Oa, "Ready", Some(region))
.unwrap();
let diagram = Id::new_v4();
p.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: "ready_view".into(),
name: "Ready view".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::StateMachine { machine },
nodes: BTreeMap::new(),
routes: vec![],
view_options: Default::default(),
},
);
p.place(
diagram,
state,
Position {
x: 8_000.,
y: 11_000.,
},
);
(Surface::Saved(diagram), state)
}
};
let mut app = App::with_project(p);
let found = usage::collect(
app.project(),
subject,
&app.behavior.topology,
&app.behavior.scenario,
)
.into_iter()
.find(|u| u.surface == surface && u.target != Target::Whole)
.unwrap();
app.canvas_size = Some(WIDE);
app.behavior.diagram.viewport = Some(WIDE);
if let Surface::Process(owner) = surface {
app.behavior.topology.enter(app.canvas_document, owner);
app.behavior.topology.viewport(WIDE);
}
if let Surface::Scenario(owner) = surface {
app.behavior.scenario.enter(app.canvas_document, owner);
app.behavior.scenario.viewport(WIDE);
}
app.page = Page::Text;
(app, found)
}
fn measurement(
canvas: Canvas,
context: Context,
epoch: u64,
surface: Surface,
size: Size,
) -> Message {
match canvas {
Canvas::Architecture => Message::DiagramRevealViewport(context, epoch, size),
Canvas::Process => Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(
behavior::topology::Context {
document: context.document,
owner: surface.id(),
revision: context.revision,
epoch,
},
size,
)),
Canvas::Scenario => Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(
behavior::scenario::Context {
document: context.document,
scenario: surface.id(),
revision: context.revision,
epoch,
},
size,
)),
Canvas::State => Message::StateDiagram(behavior::state_edit::Event::Viewport(
behavior::state_edit::Context {
document: context.document,
diagram: surface.id(),
revision: context.revision,
epoch,
},
size,
)),
}
}
fn stamp(app: &App) -> (Context, u64) {
let (context, _, epoch) = app
.pending_diagram_reveal
.as_ref()
.expect("reveal awaits measured destination");
(*context, *epoch)
}
fn assert_centered(app: &App, canvas: Canvas, target: &Usage) {
// All independently authored fixture targets fit ACTUAL at the readable cap.
// This oracle does not call the production camera/reveal implementation.
let zoom = 1.25;
let pan = Vector::new(
250. - target.bounds.center_x() * zoom,
150. - target.bounds.center_y() * zoom,
);
match canvas {
Canvas::Architecture => {
assert_eq!(app.canvas_size, Some(ACTUAL));
assert_eq!(app.zoom, zoom);
assert_eq!(app.pan, pan);
assert!(!app.fit_to_view);
}
Canvas::State => {
assert_eq!(app.behavior.diagram.viewport, Some(ACTUAL));
assert_eq!(app.behavior.diagram.zoom, zoom);
assert_eq!(app.behavior.diagram.pan, pan);
assert!(!app.behavior.diagram.fit);
}
Canvas::Process => {
let mut expected = behavior::topology::State::default();
expected.enter(app.canvas_document, target.surface.id());
expected.viewport(ACTUAL);
expected.zoom(Some(zoom));
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
assert!(app.behavior.topology.camera_boundary() == expected.camera_boundary());
}
Canvas::Scenario => {
let mut expected = behavior::scenario::State::default();
expected.enter(app.canvas_document, target.surface.id());
expected.choose_tool(target.surface.id(), behavior::scenario::CanvasTool::Select);
expected.viewport(ACTUAL);
expected.zoom(Some(zoom));
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary());
}
}
}
#[test]
fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_authored_state() {
for canvas in CANVASES {
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
let drafts = app.source_workspace.bundle();
let revision = app.document.revision();
let history = (
app.document.can_undo(),
app.document.can_redo(),
app.document.dirty(),
);
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), drafts);
assert_eq!(app.document.revision(), revision);
assert_eq!(
(
app.document.can_undo(),
app.document.can_redo(),
app.document.dirty()
),
history
);
// The exact delivered callback is a replay after the one-shot is consumed.
let camera = app.camera_boundary();
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert!(app.camera_boundary() == camera);
assert_centered(&app, canvas, &target);
}
}
#[test]
fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
for canvas in CANVASES {
for invalid in 0..6 {
let (mut app, target) = fixture(canvas);
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
let camera = app.camera_boundary();
let mut stale = context;
let mut wrong_epoch = epoch;
let mut size = Size::new(111., 122.);
match invalid {
0 => stale.document = Id::new_v4(),
1 => stale.revision = stale.revision.wrapping_add(1),
2 => wrong_epoch = wrong_epoch.wrapping_add(1),
3 => size.width = f32::NAN,
4 => size.height = f32::INFINITY,
_ => size.width = 0.,
}
send(
&mut app,
measurement(canvas, stale, wrong_epoch, target.surface, size),
);
assert!(
app.pending_diagram_reveal.is_some(),
"{canvas:?} invalid case {invalid}"
);
assert_eq!(stamp(&app), (context, epoch));
assert_eq!(app.canvas_epoch, epoch);
assert!(
app.camera_boundary() == camera,
"{canvas:?} invalid case {invalid}"
);
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
}
}
}
#[test]
fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_context() {
for canvas in CANVASES {
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
assert!(app.reveal_diagram_usage(&target));
let (context, old_epoch) = stamp(&app);
send(&mut app, Message::WindowResized(Size::new(1100., 700.)));
let (current, epoch) = stamp(&app);
assert_eq!(current, context);
assert_eq!(epoch, app.canvas_epoch);
if matches!(canvas, Canvas::Process | Canvas::Scenario) {
assert_ne!(epoch, old_epoch);
let camera = app.camera_boundary();
send(
&mut app,
measurement(canvas, context, old_epoch, target.surface, ACTUAL),
);
assert_eq!(stamp(&app), (current, epoch));
assert!(app.camera_boundary() == camera);
}
send(
&mut app,
measurement(canvas, current, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
#[test]
fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping() {
for canvas in CANVASES {
for action in 0..3 {
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
let event = match action {
0 => match canvas {
Canvas::Architecture => Message::Pan(Vector::new(31., -17.)),
Canvas::State => Message::StateDiagram(behavior::state_edit::Event::Pan(
app.state_context().unwrap(),
Vector::new(31., -17.),
)),
Canvas::Process => {
Message::TopologyCanvas(behavior::topology::CanvasEvent::Camera(
app.topology_context().unwrap(),
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(Vector::new(31., -17.)),
))
}
Canvas::Scenario => {
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Camera(
app.scenario_context().unwrap(),
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(Vector::new(31., -17.)),
))
}
},
1 => Message::Field("/name".into(), "Pending title edit".into()),
_ => Message::Page(Page::Catalog),
};
send(&mut app, event);
assert!(
app.pending_diagram_reveal.is_none(),
"{canvas:?} action {action}"
);
let camera = app.camera_boundary();
let page = app.page;
// A stale layout callback at the already retained size cannot
// restore the old navigation camera after deliberate user input.
send(
&mut app,
measurement(canvas, context, epoch, target.surface, WIDE),
);
assert!(app.pending_diagram_reveal.is_none());
assert!(
app.camera_boundary() == camera,
"{canvas:?} action {action}"
);
assert_eq!(app.page, page);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
if action == 1 {
assert!(app.draft_dirty());
}
}
}
}
+446
View File
@@ -0,0 +1,446 @@
//! Navigation-only adapter: validates destinations before changing presentation.
use super::*;
use crate::diagram_usage::{self as usage, Context, Event, Panel, Surface, Target, Usage};
#[cfg(test)]
mod tests;
impl App {
pub(crate) fn diagram_usage_message(&self, subject: Id) -> Message {
Message::DiagramUsage(Event::Open(Context {
document: self.canvas_document,
revision: self.document.revision(),
subject,
}))
}
fn usage_navigation_available(&self) -> bool {
!self.busy
&& !self.source_rename.open()
&& self.pending.is_none()
&& self.file_prompt.is_none()
&& !self.delete_pending
&& self.workflow.is_none()
&& !self.hosted.visible
&& self.architecture.is_none()
&& !self.reference_input_active()
&& !self.behavior.has_pending()
&& !self.draft_dirty()
&& !self.source_dirty()
}
/// Admit at most one expensive lookup, including one accepted Project clone.
/// A replacement panel waits for the previous worker's exact completion.
fn start_diagram_lookup(&mut self) -> Task<Message> {
let Some(panel) = self.diagram_usages.as_ref() else {
return Task::none();
};
if !panel.loading || self.diagram_lookup_in_flight.is_some() {
return Task::none();
}
let (token, context) = (panel.token, panel.context);
if context.document != self.canvas_document
|| context.revision != self.document.revision()
|| !self.usage_navigation_available()
{
let panel = self.diagram_usages.as_mut().unwrap();
panel.loading = false;
panel.notice = Some(
"The model or pending edits changed. Reopen diagram navigation when ready.".into(),
);
return Task::none();
}
let project = self.project().clone();
let presentation = self.behavior.topology.usage_snapshot();
self.diagram_lookup_in_flight = Some(token);
Task::perform(
async move {
let result = tokio::task::spawn_blocking(move || {
let process = presentation.into_query_state();
let scenario = behavior::scenario::State::default();
usage::collect(&project, context.subject, &process, &scenario)
})
.await
.map_err(|error| format!("Diagram lookup failed: {error}. Close and try again."));
usage::Completion {
token,
context,
result,
}
},
Message::DiagramUsagesReady,
)
}
fn refresh_diagram_lookup(&mut self, notice: &str) -> Task<Message> {
let Some(panel) = self.diagram_usages.as_mut() else {
return Task::none();
};
panel.token = Id::new_v4();
panel.context.revision = self.document.revision();
panel.usages.clear();
panel.loading = true;
panel.page = 0;
panel.cursor = 0;
panel.destination = None;
panel.notice = Some(notice.into());
self.start_diagram_lookup()
}
/// Must run before modal/input dispatch. A closed or replaced panel still
/// owns its worker slot until this exact completion arrives.
pub(super) fn diagram_usages_ready(&mut self, completion: usage::Completion) -> Task<Message> {
if self.diagram_lookup_in_flight != Some(completion.token) {
return Task::none();
}
self.diagram_lookup_in_flight = None;
let Some(panel) = self.diagram_usages.as_ref() else {
return Task::none();
};
if panel.context.document != self.canvas_document {
self.diagram_usages = None;
return Task::none();
}
if panel.context.revision != self.document.revision() {
return self
.refresh_diagram_lookup("The model changed. Review the refreshed destinations.");
}
if panel.token != completion.token {
// A newer Open/refresh waited without cloning or spawning a second job.
return self.start_diagram_lookup();
}
let panel = self.diagram_usages.as_mut().unwrap();
if panel.context != completion.context || !panel.loading {
panel.loading = false;
panel.notice = Some(
"This lookup no longer matches the requested model. Close and try again.".into(),
);
return Task::none();
}
panel.loading = false;
match completion.result {
Ok(usages) => panel.usages = usages,
Err(error) => panel.notice = Some(error),
}
Task::none()
}
/// Selection validates one destination only; opening the chooser does the
/// whole-project scan on its worker, never on the GUI thread.
fn current_surface_usages(&self, subject: Id, surface: Surface) -> Vec<Usage> {
match surface {
Surface::Saved(diagram) => {
usage::saved_usages_for(self.project(), subject, Some(diagram))
}
Surface::Process(owner) => {
self.behavior
.topology
.diagram_usages(self.project(), owner, subject)
}
Surface::Scenario(owner) => {
self.behavior
.scenario
.diagram_usages(self.project(), owner, subject)
}
}
}
pub(super) fn diagram_usage_event(&mut self, event: Event) -> Task<Message> {
if let Event::Open(context) = event {
if context.document != self.canvas_document
|| context.revision != self.document.revision()
|| !self.project().contains(context.subject)
{
return Task::none();
}
if !self.usage_navigation_available() {
self.status = "Apply or revert pending edits before opening diagram navigation. Your inputs are retained.".into();
return Task::none();
}
let mut panel = Panel::new(context, Vec::new());
panel.loading = true;
let filter_id = format!("diagram-usage-filter-{}", panel.token);
self.diagram_usages = Some(panel);
self.model_menu = None;
// Only an accepted new chooser supersedes the previous reveal.
// Rejected/stale panel envelopes leave its first measurement intact.
self.pending_diagram_reveal = None;
self.diagram_navigation_focus = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
return Task::batch([
self.start_diagram_lookup(),
iced::widget::operation::focus(filter_id),
]);
}
let token = match &event {
Event::Open(_) => unreachable!(),
Event::Close(t)
| Event::Search(t, _)
| Event::Page(t, _)
| Event::Move(t, _)
| Event::Activate(t)
| Event::Destination(t, _)
| Event::Choose(t, _) => *t,
};
let Some(panel) = &self.diagram_usages else {
return Task::none();
};
if panel.token != token {
return Task::none();
}
if matches!(event, Event::Close(_)) {
self.diagram_usages = None;
if self.page == Page::Text {
self.restore_source_editor_focus();
}
return Task::none();
}
if panel.context.document != self.canvas_document {
self.diagram_usages = None;
return Task::none();
}
if panel.context.revision != self.document.revision() {
return self
.refresh_diagram_lookup("The model changed. Review the refreshed destinations.");
}
if panel.loading {
// Search and Close remain useful while loading; queued activation
// must not perform a synchronous fallback or choose an old row.
if let Event::Search(_, query) = event {
self.diagram_usages.as_mut().unwrap().search = query;
}
return Task::none();
}
match event {
Event::Move(_, direction) => {
let panel = self.diagram_usages.as_ref().unwrap();
let count = panel.choices(self.project()).len();
let panel = self.diagram_usages.as_mut().unwrap();
if count > 0 {
panel.cursor = if direction < 0 {
(panel.cursor + count - 1) % count
} else {
(panel.cursor + 1) % count
};
panel.page = panel.cursor / 6;
}
}
Event::Activate(_) => {
let panel = self.diagram_usages.as_ref().unwrap();
if let Some(event) = panel.choices(self.project()).get(panel.cursor).cloned() {
return self.diagram_usage_event(event);
}
}
Event::Search(_, query) => {
let panel = self.diagram_usages.as_mut().unwrap();
panel.search = query;
panel.page = 0;
panel.cursor = 0;
}
Event::Page(_, page) => {
let panel = self.diagram_usages.as_mut().unwrap();
panel.page = page;
panel.cursor = page.saturating_mul(6);
}
Event::Destination(_, surface) => {
let panel = self.diagram_usages.as_mut().unwrap();
if surface.is_none() || panel.usages.iter().any(|u| Some(u.surface) == surface) {
panel.destination = surface;
panel.page = 0;
panel.cursor = 0;
}
}
Event::Choose(_, requested) => {
if !self.usage_navigation_available() {
return Task::none();
}
let subject = self.diagram_usages.as_ref().unwrap().context.subject;
let fresh = self
.current_surface_usages(subject, requested.surface)
.into_iter()
.find(|u| u.subject == requested.subject && u.target == requested.target);
let Some(fresh) = fresh else {
return self.refresh_diagram_lookup(
"This representation changed. Review the refreshed destinations.",
);
};
if self.reveal_diagram_usage(&fresh) {
self.diagram_usages = None;
self.status = format!(
"{} · {} in {}",
self.project().named(subject),
fresh.role,
self.project().named(fresh.surface.id())
);
return Task::batch(["model-explorer", "model-browser", "inspector"].map(
|id| {
iced::widget::operation::snap_to(
id,
iced::widget::scrollable::RelativeOffset::START,
)
},
));
}
}
Event::Open(_) | Event::Close(_) => unreachable!(),
}
Task::none()
}
pub(super) fn reveal_diagram_usage(&mut self, usage: &Usage) -> bool {
if !self.usage_navigation_available() {
return false;
}
let previous_layer = self.layer;
let p = self.document.project();
let (selected, placement) = match &usage.target {
Target::Whole => (usage.surface.id(), None),
Target::Placement(id) => {
let Some(node) = p
.diagrams
.get(&usage.surface.id())
.and_then(|d| d.nodes.get(id))
else {
return false;
};
(node.element, Some(*id))
}
Target::Port { port, placement } => (*port, Some(*placement)),
Target::StateRow {
placement,
relation,
} => (*relation, Some(*placement)),
Target::Edge(id)
| Target::ProcessLink(id)
| Target::ScenarioObject(id)
| Target::AllocationGrouping { relation: id, .. }
| Target::ProcessPreviewLink { relation: id, .. } => (*id, None),
Target::ProcessOccurrence(endpoint) => (
endpoint
.references
.first()
.copied()
.unwrap_or(endpoint.occurrence),
None,
),
};
match usage.surface {
Surface::Saved(id) => {
let Some(diagram) = p.diagrams.get(&id) else {
return false;
};
self.layer = diagram.layer;
self.active_diagram = Some(id);
if let Some(machine) = diagram.projection.state_machine() {
let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new(
(self.window_size.width - 610.).max(280.),
(self.window_size.height - 320.).max(240.),
));
let (zoom, pan) = usage::camera_for(&usage.target, usage.bounds, viewport);
self.behavior.diagram = behavior::state_view::State {
diagram: Some(id),
placement,
viewport: Some(viewport),
zoom,
pan,
fit: false,
..Default::default()
};
self.behavior.active_owner = Some(machine);
self.page = Page::Behavior;
} else {
let viewport = self.canvas_size.unwrap_or(iced::Size::new(
(self.window_size.width - 550.).max(280.),
(self.window_size.height - 284.).max(240.),
));
(self.zoom, self.pan) =
usage::camera_for(&usage.target, usage.bounds, viewport);
self.fit_to_view = false;
self.page = Page::Diagram;
}
}
Surface::Process(owner) => {
if !self.behavior.topology.reveal_usage(
p,
self.canvas_document,
owner,
&usage.target,
) {
return false;
}
self.layer = p.elements[&owner].layer();
self.behavior.active_owner = Some(owner);
self.page = Page::Behavior;
}
Surface::Scenario(owner) => {
if !self.behavior.scenario.reveal_usage(
p,
self.canvas_document,
owner,
&usage.target,
) {
return false;
}
self.layer = p.elements[&owner].layer();
self.behavior.active_owner = Some(owner);
self.page = Page::Behavior;
}
}
self.selected = selected;
self.selected_node = placement;
self.model_menu = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.selected_bend = None;
self.connection = None;
self.last_workspace_page = self.page;
if previous_layer != self.layer {
self.data_state = Default::default();
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
self.explorer_focus = None;
self.explorer_group = None;
self.explorer_page = 0;
}
self.normalize_creation();
self.reveal_explorer_selection();
self.refresh_draft();
self.diagram_navigation_focus = Some(usage::Focus {
context: Context {
document: self.canvas_document,
revision: self.document.revision(),
subject: usage.subject,
},
usage: usage.clone(),
selected,
});
self.pending_diagram_reveal = Some((
Context {
document: self.canvas_document,
revision: self.document.revision(),
subject: usage.subject,
},
usage.clone(),
self.canvas_epoch,
));
true
}
/// Validated presentation identity. Edit selection and Delete capture remain
/// semantic; a port owner or allocation work box is only a visual marker.
pub(crate) fn diagram_focus(&self, surface: Surface) -> Option<&Usage> {
let focus = self.diagram_navigation_focus.as_ref()?;
let current_surface = match surface {
Surface::Saved(id) => {
self.active_diagram == Some(id)
&& matches!(self.page, Page::Diagram | Page::Behavior)
}
Surface::Process(owner) | Surface::Scenario(owner) => {
self.page == Page::Behavior && self.behavior.active_owner == Some(owner)
}
};
(current_surface
&& focus.usage.surface == surface
&& focus.context.document == self.canvas_document
&& focus.context.revision == self.document.revision()
&& focus.selected == self.selected)
.then_some(&focus.usage)
}
}
File diff suppressed because it is too large Load Diff
+16
View File
@@ -213,6 +213,14 @@ impl App {
self.pending = None;
return Task::none();
}
if self.path.is_some()
|| self.hosted.session.as_ref().is_none_or(|session| {
session.resource_id != remote.resource_id
|| session.connection.base() != request.connection.base()
})
{
self.source_workspace.reset_navigation_session();
}
self.hosted.session = Some(Session {
connection: request.connection.clone(),
resource_id: remote.resource_id,
@@ -345,6 +353,14 @@ impl App {
match self.document.apply_compiled((*remote.compiled).clone()) {
Ok(_) => {
self.document.mark_saved(remote.compiled.clone());
if self.path.is_some()
|| self.hosted.session.as_ref().is_none_or(|session| {
session.resource_id != remote.resource_id
|| session.connection.base() != connection.base()
})
{
self.source_workspace.reset_navigation_session();
}
self.hosted.session = Some(Session {
connection,
resource_id: remote.resource_id,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,219 @@
//! Keep Iced's single-line input from silently filtering pasted control bytes.
//! Only the alias field uses this adapter. The child retains editing and focus.
use iced::advanced::{
Clipboard, Layout, Shell, Widget, clipboard, layout, overlay, renderer, text, widget,
};
use iced::{Element, Event, Length, Rectangle, Size, Vector, keyboard, mouse};
pub(super) fn guard<'a, Message: Clone + 'a, Theme: 'a, R: text::Renderer + 'a>(
input: Element<'a, Message, Theme, R>,
rejected: Option<Message>,
) -> Element<'a, Message, Theme, R> {
Element::new(AliasInput { input, rejected })
}
struct AliasInput<'a, Message, Theme, R> {
input: Element<'a, Message, Theme, R>,
rejected: Option<Message>,
}
#[derive(Default)]
struct State {
modifiers: keyboard::Modifiers,
}
// Each fresh child clipboard read uses the checked snapshot. Iced may reuse its
// previously admitted paste while V is held, until KeyReleased clears that cache.
struct CapturedClipboard<'a> {
content: Option<String>,
original: &'a mut dyn Clipboard,
}
impl Clipboard for CapturedClipboard<'_> {
fn read(&self, kind: clipboard::Kind) -> Option<String> {
if kind == clipboard::Kind::Standard {
self.content.clone()
} else {
self.original.read(kind)
}
}
fn write(&mut self, kind: clipboard::Kind, contents: String) {
self.original.write(kind, contents);
}
}
impl<Message: Clone, Theme, R: text::Renderer> Widget<Message, Theme, R>
for AliasInput<'_, Message, Theme, R>
{
fn size(&self) -> Size<Length> {
self.input.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.input.as_widget().size_hint()
}
fn tag(&self) -> widget::tree::Tag {
widget::tree::Tag::of::<State>()
}
fn state(&self) -> widget::tree::State {
widget::tree::State::new(State::default())
}
fn children(&self) -> Vec<widget::Tree> {
vec![widget::Tree::new(self.input.as_widget())]
}
fn diff(&self, tree: &mut widget::Tree) {
tree.diff_children(std::slice::from_ref(&self.input));
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &R,
limits: &layout::Limits,
) -> layout::Node {
self.input
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.input.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &R,
operation: &mut dyn widget::Operation,
) {
self.input
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let focused = tree.children[0]
.state
.downcast_ref::<iced::widget::text_input::State<R::Paragraph>>()
.is_focused();
let state = tree.state.downcast_mut::<State>();
// Iced 0.14.2 uses stored ModifiersChanged state, not KeyPressed.modifiers.
// Mirror its Escape reset and its Latin/physical-key paste shortcut.
if let Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) = event {
state.modifiers = *modifiers;
}
let paste = matches!(event,
Event::Keyboard(keyboard::Event::KeyPressed { key, physical_key, .. })
if key.to_latin(*physical_key) == Some('v')
&& state.modifiers.command() && !state.modifiers.alt());
if focused
&& paste
&& let Some(rejected) = &self.rejected
{
let content = clipboard.read(clipboard::Kind::Standard);
if content
.as_ref()
.is_some_and(|value| value.chars().any(char::is_control))
{
// Do not invoke the child: even an empty replacement would erase
// its selection. Approval invalidation is an explicit App event.
shell.publish(rejected.clone());
shell.capture_event();
return;
}
self.input.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
&mut CapturedClipboard {
content,
original: clipboard,
},
shell,
viewport,
);
return;
}
self.input.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
// In Iced 0.14.2 the only KeyPressed branch that removes focus is its
// Escape branch, which also resets stored modifiers. Observe that branch
// after its text/shortcut early returns and effective-key conversion.
if focused
&& matches!(event, Event::Keyboard(keyboard::Event::KeyPressed { .. }))
&& !tree.children[0]
.state
.downcast_ref::<iced::widget::text_input::State<R::Paragraph>>()
.is_focused()
{
tree.state.downcast_mut::<State>().modifiers = keyboard::Modifiers::default();
}
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.input.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut widget::Tree,
layout: Layout<'a>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'a, Message, Theme, R>> {
self.input.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
#[cfg(all(test, debug_assertions))]
mod tests;
@@ -0,0 +1,220 @@
use super::*;
use crate::views::source_tests::FontLayout;
use iced::keyboard::{
Key, Modifiers,
key::{Code, Named, Physical},
};
use std::cell::Cell;
#[derive(Debug, Clone, PartialEq)]
enum Signal {
Input(String),
Rejected,
}
struct ClipboardText {
text: String,
reads: Cell<usize>,
}
impl Clipboard for ClipboardText {
fn read(&self, _: clipboard::Kind) -> Option<String> {
self.reads.set(self.reads.get() + 1);
Some(self.text.clone())
}
fn write(&mut self, _: clipboard::Kind, _: String) {}
}
type InputState = iced::widget::text_input::State<<FontLayout as text::Renderer>::Paragraph>;
struct Mounted {
input: Element<'static, Signal, iced::Theme, FontLayout>,
tree: widget::Tree,
node: layout::Node,
clipboard: ClipboardText,
messages: Vec<Signal>,
}
impl Mounted {
fn new(focused: bool, enabled: bool, modifiers: Modifiers) -> Self {
let mut child = iced::widget::text_input("Alias", "send_water");
if enabled {
child = child.on_input(Signal::Input);
}
let mut input = guard(child.into(), enabled.then_some(Signal::Rejected));
let mut tree = widget::Tree::new(input.as_widget());
let node = input.as_widget_mut().layout(
&mut tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, Size::new(500., 40.)),
);
if focused {
let state = tree.children[0].state.downcast_mut::<InputState>();
state.focus();
state.select_range(0, 10);
}
let mut mounted = Self {
input,
tree,
node,
clipboard: ClipboardText {
text: "foo\nbar".into(),
reads: Cell::new(0),
},
messages: vec![],
};
mounted.event(Event::Keyboard(keyboard::Event::ModifiersChanged(
modifiers,
)));
mounted
}
fn state(&self) -> &InputState {
self.tree.children[0].state.downcast_ref::<InputState>()
}
fn event(&mut self, event: Event) -> bool {
let mut shell = Shell::new(&mut self.messages);
self.input.as_widget_mut().update(
&mut self.tree,
&event,
Layout::new(&self.node),
mouse::Cursor::Unavailable,
&FontLayout,
&mut self.clipboard,
&mut shell,
&Rectangle::new(iced::Point::ORIGIN, Size::new(500., 40.)),
);
shell.is_event_captured()
}
}
fn key(key: Key, modified_key: Key, code: Code) -> Event {
Event::Keyboard(keyboard::Event::KeyPressed {
key,
modified_key,
physical_key: Physical::Code(code),
location: keyboard::Location::Standard,
modifiers: Modifiers::empty(),
text: None,
repeat: false,
})
}
fn paste() -> Event {
key(
Key::Character("v".into()),
Key::Character("v".into()),
Code::KeyV,
)
}
#[test]
fn paste_refusal_matches_child_focus_enabled_and_stored_modifier_protocol() {
for focused in [false, true] {
for enabled in [false, true] {
for modifiers in [
Modifiers::empty(),
Modifiers::SHIFT,
Modifiers::COMMAND,
Modifiers::COMMAND | Modifiers::ALT,
Modifiers::COMMAND | Modifiers::SHIFT,
] {
let mut mounted = Mounted::new(focused, enabled, modifiers);
let before = mounted.state().cursor();
let expected = focused && enabled && modifiers.command() && !modifiers.alt();
let captured = mounted.event(paste());
assert_eq!(
mounted.messages,
if expected {
vec![Signal::Rejected]
} else {
vec![]
}
);
assert_eq!(mounted.clipboard.reads.get(), usize::from(expected));
assert_eq!(mounted.state().cursor(), before);
if expected {
assert!(captured);
}
}
}
}
}
#[test]
fn effective_escape_reset_follows_child_after_early_returns() {
// Raw Escape mapped to Home must not clear only the guard's Command state.
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
mounted.event(key(
Key::Named(Named::Escape),
Key::Named(Named::Home),
Code::Escape,
));
assert!(mounted.state().is_focused());
assert!(mounted.event(paste()));
assert_eq!(mounted.messages, vec![Signal::Rejected]);
// Effective Escape clears both states; a later focus operation does not
// invent a new ModifiersChanged event or revive the previous Command state.
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
mounted.event(key(
Key::Named(Named::Home),
Key::Named(Named::Escape),
Code::Home,
));
assert!(!mounted.state().is_focused());
mounted.tree.children[0]
.state
.downcast_mut::<InputState>()
.focus();
mounted.event(paste());
assert!(mounted.messages.is_empty());
assert_eq!(mounted.clipboard.reads.get(), 0);
// Printable text returns before effective Escape and leaves modifiers live.
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
let mut event = key(
Key::Named(Named::Home),
Key::Named(Named::Escape),
Code::Home,
);
if let Event::Keyboard(keyboard::Event::KeyPressed { text, .. }) = &mut event {
*text = Some("x".into());
}
mounted.event(event);
assert!(mounted.state().is_focused());
assert_eq!(mounted.messages, vec![Signal::Input("x".into())]);
mounted.messages.clear();
mounted.event(paste());
assert_eq!(mounted.messages, vec![Signal::Rejected]);
}
#[test]
fn held_paste_cache_contains_only_previously_admitted_text() {
let mut mounted = Mounted::new(true, true, Modifiers::COMMAND);
mounted.clipboard.text = "water".into();
mounted.event(paste());
assert_eq!(mounted.messages, vec![Signal::Input("water".into())]);
mounted.messages.clear();
mounted.clipboard.text = "other".into();
mounted.event(paste());
assert_eq!(
mounted.messages,
vec![Signal::Input("waterwater".into())],
"Iced reuses its first admitted paste while the key is held"
);
mounted.messages.clear();
let before = mounted.state().cursor();
mounted.clipboard.text = "bad\tvalue".into();
mounted.event(paste());
assert_eq!(mounted.messages, vec![Signal::Rejected]);
assert_eq!(mounted.state().cursor(), before);
mounted.messages.clear();
mounted.event(Event::Keyboard(keyboard::Event::KeyReleased {
key: Key::Character("v".into()),
modified_key: Key::Character("v".into()),
physical_key: Physical::Code(Code::KeyV),
location: keyboard::Location::Standard,
modifiers: Modifiers::empty(),
}));
mounted.clipboard.text = "later".into();
mounted.event(paste());
assert_eq!(
mounted.messages,
vec![Signal::Input("waterwaterlater".into())]
);
}
@@ -0,0 +1,303 @@
//! Display-only reuse after a guarded alias rename. This is not model authority.
use iced::{advanced::text::Renderer, widget::text_editor};
use std::sync::Arc;
/// Replace exact, unchanged-line-count source in an existing editor.
///
/// The caller must first authorize/commit the model change and must reconstruct
/// from accepted source on false. Preflight failures do not mutate the editor;
/// a failed final readback can leave it changed. On changing success the cursor
/// is at 0:0 without selection; the caller restores its approved origin/reveal.
/// Direct Content actions deliberately bypass App draft edits and Undo history.
pub(super) fn replace<R: Renderer>(
editor: &mut text_editor::Content<R>,
before: &str,
after: &str,
) -> bool {
if editor.text() != before {
return false;
}
let Some(old_lines) = lines(before) else {
return false;
};
let Some(new_lines) = lines(after) else {
return false;
};
if old_lines.len() != new_lines.len() || old_lines.len() != editor.line_count() {
return false;
}
let mut patches = Vec::new();
for (line, ((old, old_ending), (new, new_ending))) in
old_lines.into_iter().zip(new_lines).enumerate()
{
let Some(actual) = editor.line(line) else {
return false;
};
// Cosmic also recognizes bare CR and LFCR. Comparing the actual lines
// catches its greedy LFCR interpretation of e.g. "x\n\r\ny" too.
if old_ending != new_ending || actual.text != old || actual.ending != old_ending {
return false;
}
if old == new {
continue;
}
let mut start = old
.bytes()
.zip(new.bytes())
.take_while(|(a, b)| a == b)
.count();
while !old.is_char_boundary(start) || !new.is_char_boundary(start) {
start -= 1;
}
let mut suffix = old[start..]
.bytes()
.rev()
.zip(new[start..].bytes().rev())
.take_while(|(a, b)| a == b)
.count();
while !old.is_char_boundary(old.len() - suffix) || !new.is_char_boundary(new.len() - suffix)
{
suffix -= 1;
}
// All positions and owned payloads are prepared before the first edit.
patches.push((
line,
start,
old.len() - suffix,
Arc::new(new[start..new.len() - suffix].to_owned()),
));
}
if patches.is_empty() {
return true;
}
let empty = Arc::new(String::new());
for (line, start, end, replacement) in patches.into_iter().rev() {
select(editor, line, start, end);
editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste(
replacement,
)));
}
// Iced overwrites its earliest-change marker on each Edit. An explicit
// zero-width selection + empty Paste changes no bytes, but marks line zero.
// This also refreshes previously unhighlighted lines before the first patch.
// It resets only line zero's text cache; other unchanged lines retain theirs.
select(editor, 0, 0, 0);
editor.perform(text_editor::Action::Edit(text_editor::Edit::Paste(empty)));
editor.text() == after
}
fn select<R: Renderer>(
editor: &mut text_editor::Content<R>,
line: usize,
start: usize,
end: usize,
) {
editor.move_to(text_editor::Cursor {
position: text_editor::Position {
line,
column: start,
},
// move_to(None) does not clear a previous selection in this Iced version.
selection: Some(text_editor::Position { line, column: end }),
});
}
fn lines(source: &str) -> Option<Vec<(&str, text_editor::LineEnding)>> {
use text_editor::LineEnding;
let mut result = Vec::new();
let mut parts = source.split('\n').peekable();
while let Some(part) = parts.next() {
let (text, ending) = if parts.peek().is_none() {
(part, LineEnding::None)
} else if let Some(text) = part.strip_suffix('\r') {
(text, LineEnding::CrLf)
} else {
(part, LineEnding::Lf)
};
if text.contains('\r')
|| (text.is_empty()
&& ending == LineEnding::CrLf
&& result
.last()
.is_some_and(|(_, previous)| *previous == LineEnding::Lf))
{
return None;
}
result.push((text, ending));
}
Some(result)
}
#[cfg(all(test, debug_assertions))]
mod tests {
use super::*;
use crate::views::source_tests::FontLayout;
use iced::advanced::{layout, text::Highlighter, widget::Tree};
use iced::{Element, Font, Length, Size};
use std::{cell::RefCell, ops::Range};
#[test]
fn exact_text_matrix_and_reverse_preserve_endings() {
for (before, after) in [
("", ""),
("", "alias"),
("alias", ""),
("same", "same"),
("a\n", "alias\n"),
("a\r\n", "alias\r\n"),
("\r\na\r\n\r\n", "\r\nlong_alias\r\n\r\n"),
("\na\n\n", "\nlong_alias\n\n"),
("a\r\nb\nc", "long_a\r\nlong_b\nlong_c"),
(
"雨🙂 e\u{301} @a @a // a",
"雨🙂 e\u{301} @a_long @a_long // a",
),
("@abc", "@abcdef"),
("@abcdef", "@abc"),
("@abc_suffix", "@def_suffix"),
("@none", "@identifier"),
// Shared prefix/suffix bytes inside distinct UTF-8 codepoints must
// not be used as String split positions, even outside alias scope.
("雨xé", "雪xê"),
("é", "©"),
(
"unchanged\n@a\nunchanged\n@a\n",
"unchanged\n@long\nunchanged\n@long\n",
),
] {
let mut editor = text_editor::Content::<FontLayout>::with_text(before);
assert!(
replace(&mut editor, before, after),
"{before:?} -> {after:?}"
);
assert_eq!(editor.text(), after);
let expected = text_editor::Content::<FontLayout>::with_text(after);
assert_eq!(editor.line_count(), expected.line_count());
for (actual, expected) in editor.lines().zip(expected.lines()) {
assert_eq!(actual.text, expected.text);
assert_eq!(actual.ending, expected.ending);
}
if before != after {
assert_eq!(
editor.cursor().position,
text_editor::Position { line: 0, column: 0 }
);
assert_eq!(editor.cursor().selection, None);
}
assert!(replace(&mut editor, after, before));
assert_eq!(editor.text(), before);
}
}
#[test]
fn all_preflight_refusals_leave_text_cursor_and_selection_unchanged() {
for (actual, before, after) in [
("live", "stale", "changed"),
("a\nb", "a\nb", "long\nb\nc"),
("a\nb", "a\nb", "long\r\nb"),
// Refusal on a later line must not apply the earlier planned edit.
("a\nb\r\n", "a\nb\r\n", "long\nb\n"),
("a\rb", "a\rb", "long\rb"),
("a\n\rb", "a\n\rb", "long\n\rb"),
("a\n\r\nb", "a\n\r\nb", "long\n\r\nb"),
// The replacement must not introduce a greedy LFCR boundary either.
("a\nx\r\n", "a\nx\r\n", "a\n\r\n"),
("a", "a", "long\r"),
] {
let mut editor = text_editor::Content::<FontLayout>::with_text(actual);
select(&mut editor, 0, 0, 1);
let cursor = editor.cursor();
let selection = editor.selection();
assert!(
!replace(&mut editor, before, after),
"{before:?} -> {after:?}"
);
assert_eq!(editor.text(), actual);
assert_eq!(editor.cursor(), cursor);
assert_eq!(editor.selection(), selection);
}
}
#[test]
fn unchanged_content_preserves_selection_and_next_direct_edit_works() {
let mut editor = text_editor::Content::<FontLayout>::with_text("@a");
select(&mut editor, 0, 1, 2);
let cursor = editor.cursor();
assert!(replace(&mut editor, "@a", "@a"));
assert_eq!(editor.cursor(), cursor);
assert!(replace(&mut editor, "@a", "@long"));
editor.move_to(text_editor::Cursor {
position: text_editor::Position { line: 0, column: 5 },
selection: None,
});
editor.perform(text_editor::Action::Edit(text_editor::Edit::Insert('!')));
assert_eq!(editor.text(), "@long!");
}
thread_local! {
static CHANGES: RefCell<Vec<usize>> = const { RefCell::new(Vec::new()) };
}
struct Tracked(crate::highlight::ArcadiaHighlighter);
impl Highlighter for Tracked {
type Settings = Arc<str>;
type Highlight = oa_core::language::TokenClass;
type Iterator<'a> = std::vec::IntoIter<(Range<usize>, Self::Highlight)>;
fn new(settings: &Self::Settings) -> Self {
Self(crate::highlight::ArcadiaHighlighter::new(settings))
}
fn update(&mut self, settings: &Self::Settings) {
self.0.update(settings);
}
fn change_line(&mut self, line: usize) {
CHANGES.with(|changes| changes.borrow_mut().push(line));
self.0.change_line(line);
}
fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> {
self.0.highlight_line(line)
}
fn current_line(&self) -> usize {
self.0.current_line()
}
}
fn view(
editor: &text_editor::Content<FontLayout>,
source: Arc<str>,
) -> Element<'_, (), iced::Theme, FontLayout> {
text_editor(editor)
.font(Font::MONOSPACE)
.size(14)
.height(Length::Fill)
.highlight_with::<Tracked>(source, crate::highlight::format)
.into()
}
#[test]
fn already_laid_out_editor_notifies_highlighter_from_zero_after_later_line_patch() {
let before = "// earlier\n// unmodified\n@a";
let after = "// earlier\n// unmodified\n@none";
let mut editor = text_editor::Content::<FontLayout>::with_text(before);
let limits = layout::Limits::new(Size::ZERO, Size::new(400., 60.));
let mut tree = {
let mut widget = view(&editor, Arc::from(before));
let mut tree = Tree::new(widget.as_widget());
let _ = widget
.as_widget_mut()
.layout(&mut tree, &FontLayout, &limits);
tree
};
CHANGES.with(|changes| changes.borrow_mut().clear());
assert!(replace(&mut editor, before, after));
{
let mut widget = view(&editor, Arc::from(after));
let _ = widget
.as_widget_mut()
.layout(&mut tree, &FontLayout, &limits);
}
// Font/metrics/bounds are already stable. This notification must come
// from the helper's empty edit, not a fresh renderer initialization.
CHANGES.with(|changes| assert_eq!(*changes.borrow(), [0]));
assert_eq!(editor.text(), after);
}
}
@@ -0,0 +1,450 @@
//! Combined App-boundary oracles: the workers are completed independently and
//! no returned Task is polled. Rendering measurements use the real reveal path.
use super::*;
use crate::diagram_usage::{self as usage, Surface, Target as UsageTarget, Usage};
use iced::{Size, Vector};
mod palette_union;
fn model_id(number: u8) -> Id {
Id::parse_str(&format!("00000000-0000-0000-0000-{number:012}")).unwrap()
}
fn navigation_fixture() -> App {
let mut source = bundle();
let entry = source.files.get_mut("project.arcadia").unwrap();
*entry = entry.replace(
"include \"uses.arcadia\";",
"include \"uses.arcadia\"; include \"views.arcadia\";",
);
source.files.insert(
"views.arcadia".into(),
concat!(
"// Keep @pump and the exact distant placement.\r\n",
"diagram overview \"Pump overview\" id \"00000000-0000-0000-0000-000000000008\" { layer Oa; }\r\n",
"node placed_pump id \"00000000-0000-0000-0000-000000000009\" { diagram @overview; element @pump; position { x 12000; y 9000; }; size { width 260; height 94; }; }\r\n",
).into(),
);
let mut app = fixture();
app.document = Document::from_bundle(source.clone(), LIMITS).unwrap();
assert_eq!(app.document.bundle(), &source);
assert!(
!validate(app.project())
.iter()
.any(|issue| issue.severity == validation::Severity::Error)
);
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.selected = app.project().id;
app.refresh_draft();
app.source_workspace.active = "uses.arcadia".into();
app.show_source_file(false);
seek_pump(&mut app);
app.canvas_size = Some(Size::new(1200., 700.));
app.zoom = 0.8;
app.pan = Vector::new(31., 47.);
app
}
fn seek_pump(app: &mut App) {
let source = app.source_workspace.text();
let offset = source.rfind("pump;").unwrap();
app.source_editor
.move_to(cursor(source, offset, offset + 4));
}
fn source_completion(app: &App, request: nav::Request) -> nav::Completion {
let snapshot = Arc::new(
app.with_source_input(|input| query::capture(input, query::Limits::default()).unwrap()),
);
let results = snapshot
.query(query::Query {
stamp: request.stamp,
path: &request.path,
byte_offset: request.offset,
action: request.action,
})
.unwrap();
nav::Completion {
request,
result: Ok((snapshot, Arc::new(results))),
}
}
fn diagram_completion(app: &App) -> usage::Completion {
let panel = app.diagram_usages.as_ref().unwrap();
let process = app.behavior.topology.usage_snapshot().into_query_state();
usage::Completion {
token: panel.token,
context: panel.context,
result: Ok(usage::collect(
app.project(),
panel.context.subject,
&process,
&behavior::scenario::State::default(),
)),
}
}
fn finish_lookup(app: &mut App) -> (Id, Usage) {
let completion = diagram_completion(app);
send(app, Message::DiagramUsagesReady(completion));
let panel = app.diagram_usages.as_ref().unwrap();
assert!(!panel.loading);
assert!(app.diagram_lookup_in_flight.is_none());
let found = panel
.usages
.iter()
.find(|u| {
u.surface == Surface::Saved(model_id(8))
&& u.target == UsageTarget::Placement(model_id(9))
})
.expect("the exact placed pump is represented")
.clone();
assert_eq!(found.subject, model_id(2));
assert_eq!((found.bounds.x, found.bounds.y), (12000., 9000.));
(panel.token, found)
}
fn navigate(app: &mut App) -> Usage {
let message = app.diagram_usage_message(model_id(2));
send(app, message);
let (token, found) = finish_lookup(app);
send(
app,
Message::DiagramUsage(usage::Event::Choose(token, found.clone())),
);
assert_eq!(app.page, Page::Diagram);
assert_eq!(app.active_diagram, Some(model_id(8)));
assert_eq!(app.selected, model_id(2));
assert_eq!(app.selected_node, Some(model_id(9)));
assert!(app.diagram_usages.is_none());
assert!(app.pending_diagram_reveal.is_some());
assert!(app.diagram_focus(found.surface).is_some());
found
}
#[derive(Debug, PartialEq)]
struct Navigation {
page: Page,
selected: Id,
placement: Option<Id>,
diagram: Option<Id>,
zoom: f32,
pan: Vector,
pending: Option<(usage::Context, Usage, u64)>,
focus: Option<(usage::Context, Usage, Id)>,
canvas_epoch: u64,
camera_epoch: u64,
source_interaction: Id,
source_reveal: Option<Id>,
}
fn navigation(app: &App) -> Navigation {
Navigation {
page: app.page,
selected: app.selected,
placement: app.selected_node,
diagram: app.active_diagram,
zoom: app.zoom,
pan: app.pan,
pending: app.pending_diagram_reveal.clone(),
focus: app
.diagram_navigation_focus
.as_ref()
.map(|f| (f.context, f.usage.clone(), f.selected)),
canvas_epoch: app.canvas_epoch,
camera_epoch: app.camera_epoch,
source_interaction: app.source_symbols.interaction,
source_reveal: app.source_reveal_ticket(),
}
}
fn first_measurement(app: &mut App, found: &Usage) {
let (context, _, epoch) = app.pending_diagram_reveal.as_ref().unwrap();
let (context, epoch) = (*context, *epoch);
let actual = Size::new(500., 300.);
send(app, Message::DiagramRevealViewport(context, epoch, actual));
assert!(app.pending_diagram_reveal.is_none());
assert_eq!(app.canvas_size, Some(actual));
assert!((found.bounds.center_x() * app.zoom + app.pan.x - 250.).abs() < 0.05);
assert!((found.bounds.center_y() * app.zoom + app.pan.y - 150.).abs() < 0.05);
assert_eq!(app.selected, model_id(2));
assert_eq!(app.selected_node, Some(model_id(9)));
assert!(app.diagram_focus(found.surface).is_some());
}
#[test]
fn rename_modal_rejects_queued_diagram_and_source_navigation_without_losing_approval() {
let mut app = navigation_fixture();
let original = evidence(&app);
let snapshot = app.document.snapshot();
let queued = app.diagram_usage_message(model_id(2));
begin(&mut app);
input(&mut app, "send_water");
let confirm = preview(&mut app);
let form = app.source_rename.form.as_ref().unwrap();
let approved = (
form.invocation,
form.generation,
form.approval.as_ref().unwrap().id,
);
send(&mut app, queued);
let source = app.source_symbol_message(nav::SymbolEvent::DiagramUsages);
send(&mut app, source);
let definition = app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition));
send(&mut app, definition);
assert!(app.diagram_usages.is_none());
assert!(app.diagram_lookup_in_flight.is_none());
assert!(app.source_symbols.in_flight.is_none());
assert!(!app.source_navigation_available());
let form = app.source_rename.form.as_ref().unwrap();
assert_eq!(
(
form.invocation,
form.generation,
form.approval.as_ref().unwrap().id
),
approved
);
assert_eq!(form.replacement, "send_water");
assert_eq!(evidence(&app), original);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
send(&mut app, Message::Escape);
send(&mut app, confirm);
assert!(!app.source_rename.open());
assert_eq!(evidence(&app), original);
}
#[test]
fn loading_and_ready_diagram_choosers_reject_a_previously_captured_rename_begin() {
for finish in [false, true] {
let mut app = navigation_fixture();
let before = evidence(&app);
let queued_rename = app.source_rename_message();
let open = app.diagram_usage_message(model_id(2));
send(&mut app, open);
if finish {
finish_lookup(&mut app);
}
let panel = app.diagram_usages.as_ref().unwrap();
let token = panel.token;
let loading = panel.loading;
assert!(app.rename_admission().is_err());
assert!(!app.source_navigation_available());
send(&mut app, queued_rename);
assert!(!app.source_rename.open());
assert!(app.source_rename.in_flight.is_none());
let panel = app.diagram_usages.as_ref().unwrap();
assert_eq!((panel.token, panel.loading), (token, loading));
assert_eq!(evidence(&app), before);
send(&mut app, Message::DiagramUsage(usage::Event::Close(token)));
let rename = app.source_rename_message();
send(&mut app, rename);
assert!(
app.source_rename.open(),
"the dismissed chooser no longer owns input"
);
}
}
#[test]
fn late_target_and_preview_results_release_only_their_slot_and_preserve_first_camera_measurement() {
for preview_pending in [false, true] {
let mut app = navigation_fixture();
let before = evidence(&app);
let completion = if preview_pending {
begin(&mut app);
input(&mut app, "send_water");
let job = start_preview(&mut app);
preview_completion(&app, job)
} else {
let rename = app.source_rename_message();
send(&mut app, rename);
target_completion(&app, current_job(&app))
};
let occupied = completion.job.id;
event(&mut app, Event::Close(completion.job.invocation));
assert_eq!(app.source_rename.in_flight, Some(occupied));
let found = navigate(&mut app);
let location = navigation(&app);
assert_eq!(evidence(&app), before);
let mut forged = completion.clone();
forged.job.id = Id::new_v4();
send(&mut app, Message::SourceRenameReady(forged));
assert_eq!(app.source_rename.in_flight, Some(occupied));
assert_eq!(navigation(&app), location);
send(&mut app, Message::SourceRenameReady(completion.clone()));
assert!(app.source_rename.in_flight.is_none());
assert!(!app.source_rename.open());
assert_eq!(navigation(&app), location);
assert_eq!(evidence(&app), before);
send(&mut app, Message::SourceRenameReady(completion));
assert_eq!(
navigation(&app),
location,
"duplicate completion is passive too"
);
first_measurement(&mut app, &found);
assert_eq!(evidence(&app), before);
}
}
#[test]
fn stale_begin_close_and_actual_confirm_leave_new_diagram_focus_and_pending_measurement_unchanged()
{
let mut app = navigation_fixture();
let before = evidence(&app);
let snapshot = app.document.snapshot();
let queued_begin = app.source_rename_message();
begin(&mut app);
input(&mut app, "send_water");
let confirm = preview(&mut app);
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
event(&mut app, Event::Close(invocation));
let found = navigate(&mut app);
let location = navigation(&app);
for stale in [
queued_begin,
Message::SourceRename(Event::Close(invocation)),
confirm,
] {
send(&mut app, stale);
assert_eq!(navigation(&app), location);
assert_eq!(evidence(&app), before);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
}
first_measurement(&mut app, &found);
}
#[test]
fn document_reset_retains_all_three_worker_slots_until_their_exact_results_arrive() {
let mut app = navigation_fixture();
let source = app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition));
send(&mut app, source);
let request = app.source_symbols.request.clone().unwrap();
let source_ready = source_completion(&app, request.clone());
let rename = app.source_rename_message();
send(&mut app, rename);
let rename_ready = target_completion(&app, current_job(&app));
let rename_job = rename_ready.job.id;
event(&mut app, Event::Close(rename_ready.job.invocation));
let open = app.diagram_usage_message(model_id(2));
send(&mut app, open);
let old_diagram = diagram_completion(&app);
let diagram_job = old_diagram.token;
let old_document = app.canvas_document;
let project = app.project().clone();
app.reset(project, None);
assert_ne!(app.canvas_document, old_document);
assert_eq!(app.source_symbols.in_flight, Some(request.job));
assert_eq!(app.source_rename.in_flight, Some(rename_job));
assert_eq!(app.diagram_lookup_in_flight, Some(diagram_job));
assert!(!app.source_rename.open());
assert!(app.diagram_usages.is_none());
let before = evidence(&app);
let snapshot = app.document.snapshot();
let new_open = app.diagram_usage_message(model_id(2));
send(&mut app, new_open);
let new_token = app.diagram_usages.as_ref().unwrap().token;
assert_ne!(new_token, diagram_job);
assert_eq!(
app.diagram_lookup_in_flight,
Some(diagram_job),
"replacement waits for old worker"
);
send(&mut app, Message::SourceRenameReady(rename_ready));
assert!(app.source_rename.in_flight.is_none());
assert_eq!(app.source_symbols.in_flight, Some(request.job));
assert_eq!(app.diagram_lookup_in_flight, Some(diagram_job));
send(&mut app, Message::SourceSymbolsReady(source_ready));
assert!(app.source_symbols.in_flight.is_none());
assert!(!app.source_symbols.open);
send(&mut app, Message::DiagramUsagesReady(old_diagram));
assert_eq!(app.diagram_lookup_in_flight, Some(new_token));
assert!(app.diagram_usages.as_ref().unwrap().loading);
finish_lookup(&mut app);
assert_eq!(evidence(&app), before);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert!(!app.source_rename.open());
}
#[test]
fn rename_then_exact_caret_diagram_navigation_preserves_complete_model_undo_redo_history() {
let mut app = navigation_fixture();
let baseline = app.document.snapshot();
let project = app.project().id;
app.document
.execute(Command::Rename(project, "Previously accepted name".into()))
.unwrap();
app.sync_source();
app.refresh_draft();
seek_pump(&mut app);
let before = app.document.snapshot();
let mut expected_project = app.project().clone();
expected_project
.elements
.get_mut(&model_id(2))
.unwrap()
.alias = "send_water".into();
let mut expected_source = app.document.bundle().clone();
let defs = expected_source.files.get_mut("defs.arcadia").unwrap();
*defs = defs.replace("oa_activity pump ", "oa_activity send_water ");
let uses = expected_source.files.get_mut("uses.arcadia").unwrap();
*uses = uses
.replace("source @pump;", "source @send_water;")
.replace("// keep @pump\r\n pump;", "// keep @pump\r\n send_water;");
let views = expected_source.files.get_mut("views.arcadia").unwrap();
*views = views.replace("element @pump;", "element @send_water;");
begin(&mut app);
input(&mut app, "send_water");
let confirm = preview(&mut app);
send(&mut app, confirm);
assert_eq!(app.project(), &expected_project);
assert_eq!(app.document.bundle(), &expected_source);
let renamed = app.document.snapshot();
let accepted = evidence(&app);
let queued_undo_rename = app
.source_rename_undo_message()
.expect("fresh rename offers its guarded undo");
let message = app.source_symbol_message(nav::SymbolEvent::DiagramUsages);
send(&mut app, message);
let request = app
.source_symbols
.request
.clone()
.expect("renamed exact caret remains queryable");
let ready = source_completion(&app, request);
send(&mut app, Message::SourceSymbolsReady(ready));
let (token, found) = finish_lookup(&mut app);
send(
&mut app,
Message::DiagramUsage(usage::Event::Choose(token, found.clone())),
);
assert_eq!(app.selected, model_id(2));
assert_eq!(app.selected_node, Some(model_id(9)));
let destination = navigation(&app);
send(&mut app, queued_undo_rename);
assert_eq!(navigation(&app), destination);
assert_eq!(evidence(&app), accepted);
first_measurement(&mut app, &found);
assert_eq!(evidence(&app), accepted);
assert!(Arc::ptr_eq(&renamed, &app.document.snapshot()));
for (message, expected) in [
(Message::Undo, before.clone()),
(Message::Redo, renamed.clone()),
(Message::Undo, before.clone()),
(Message::Undo, baseline.clone()),
(Message::Redo, before),
(Message::Redo, renamed),
] {
send(&mut app, message);
assert!(
Arc::ptr_eq(&expected, &app.document.snapshot()),
"exact history entry restored"
);
assert_eq!(app.project(), &expected.decoded.project);
assert_eq!(app.document.bundle(), &expected.bundle);
assert_eq!(app.source_workspace.bundle(), expected.bundle);
assert!(!app.source_dirty());
}
}
@@ -0,0 +1,72 @@
//! Interactions absent from the independently published navigation branch.
use super::*;
#[test]
fn allocation_keyboard_notification_preserves_navigation_and_first_measurement() {
let mut app = navigation_fixture();
let before = app.document.snapshot();
let notify = || Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary);
send(&mut app, notify());
let found = navigate(&mut app);
let destination = navigation(&app);
let allocation_epoch = app.allocation_drag.epoch;
send(&mut app, notify());
assert_eq!(navigation(&app), destination);
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
first_measurement(&mut app, &found);
send(&mut app, notify());
assert!(app.diagram_focus(found.surface).is_some());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), before.bundle);
}
#[test]
fn stale_rename_begin_cannot_retire_allocation_epoch_or_new_navigation() {
let mut app = navigation_fixture();
let captured = app.source_rename_begin();
let found = navigate(&mut app);
let destination = navigation(&app);
let allocation_epoch = app.allocation_drag.epoch;
let accepted = app.document.snapshot();
send(&mut app, Message::SourceRename(Event::Begin(captured)));
assert_eq!(navigation(&app), destination);
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
assert!(!app.source_rename.open());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
first_measurement(&mut app, &found);
}
#[test]
fn captured_behavior_toolbar_cannot_retire_a_new_documents_navigation() {
let mut project = Project::empty();
let scenario = project
.add_element(Kind::Scenario, Layer::Oa, "Prior scenario", None)
.unwrap();
let mut previous = App::with_project(project);
previous.page = Page::Behavior;
previous.behavior.active_owner = Some(scenario);
previous
.behavior
.scenario
.enter(previous.canvas_document, scenario);
let scope = behavior::canvas_workspace::Scope::input_owner(&previous).unwrap();
let captured = behavior::canvas_workspace::intent(
scope,
"Fit",
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Zoom(
previous.scenario_context().unwrap(),
None,
)),
);
let mut app = navigation_fixture();
let found = navigate(&mut app);
let destination = navigation(&app);
let allocation_epoch = app.allocation_drag.epoch;
let accepted = app.document.snapshot();
send(&mut app, captured);
assert_eq!(navigation(&app), destination);
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
first_measurement(&mut app, &found);
}
@@ -0,0 +1,449 @@
//! Real paragraph advances and clipping through the actual preview dialog.
//! Native screenshots additionally check raster ink and the platform renderer.
use super::*;
use crate::views::source_tests::FontLayout;
use iced::advanced::{Layout, layout, renderer, text as shaped, widget};
use iced::{Background, Color, Font, Pixels, Point, Rectangle, Size, Transformation, Vector};
use std::collections::{BTreeMap, BTreeSet};
struct Glyph {
text: String,
paragraph_y: u32,
line: usize,
bytes: std::ops::Range<usize>,
bounds: Rectangle,
clip: Rectangle,
}
struct GlyphRecorder {
wanted: BTreeSet<String>,
transforms: Vec<Transformation>,
clips: Vec<Rectangle>,
glyphs: Vec<Glyph>,
quads: Vec<Rectangle>,
}
impl GlyphRecorder {
fn new(wanted: BTreeSet<String>, viewport: Rectangle) -> Self {
Self {
wanted,
transforms: vec![Transformation::IDENTITY],
clips: vec![viewport],
glyphs: vec![],
quads: vec![],
}
}
fn transform(&self) -> Transformation {
*self.transforms.last().unwrap()
}
fn clip(&self, bounds: Rectangle) -> Rectangle {
self.clips
.last()
.unwrap()
.intersection(&(bounds * self.transform()))
.unwrap_or(Rectangle::with_size(Size::ZERO))
}
}
impl renderer::Renderer for GlyphRecorder {
fn start_layer(&mut self, bounds: Rectangle) {
self.clips.push(self.clip(bounds));
}
fn end_layer(&mut self) {
self.clips.pop().unwrap();
}
fn start_transformation(&mut self, transform: Transformation) {
self.transforms.push(self.transform() * transform);
}
fn end_transformation(&mut self) {
self.transforms.pop().unwrap();
}
fn reset(&mut self, bounds: Rectangle) {
self.transforms = vec![Transformation::IDENTITY];
self.clips = vec![bounds];
self.glyphs.clear();
self.quads.clear();
}
fn fill_quad(&mut self, quad: renderer::Quad, _: impl Into<Background>) {
self.quads.push(quad.bounds * self.transform());
}
fn allocate_image(
&mut self,
handle: &iced::advanced::image::Handle,
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
+ Send
+ 'static,
) {
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
}
}
impl shaped::Renderer for GlyphRecorder {
type Font = Font;
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
type Editor = <FontLayout as shaped::Renderer>::Editor;
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::ICED_LOGO;
fn default_font(&self) -> Font {
shaped::Renderer::default_font(&FontLayout)
}
fn default_size(&self) -> Pixels {
shaped::Renderer::default_size(&FontLayout)
}
fn fill_paragraph(
&mut self,
paragraph: &Self::Paragraph,
position: Point,
_: Color,
clip: Rectangle,
) {
let buffer = paragraph.buffer();
let text = buffer
.lines
.iter()
.map(|line| line.text())
.collect::<Vec<_>>()
.join("\n");
if !self.wanted.contains(&text) {
return;
}
let clip = self.clip(clip);
for run in buffer.layout_runs() {
for glyph in run.glyphs {
if run.text[glyph.start..glyph.end]
.chars()
.all(char::is_whitespace)
{
continue;
}
let bounds = Rectangle {
x: position.x + glyph.x + glyph.x_offset * glyph.font_size,
y: position.y + run.line_top,
width: glyph.w,
height: run.line_height,
} * self.transform();
self.glyphs.push(Glyph {
text: text.clone(),
paragraph_y: position.y.to_bits(),
line: run.line_i,
bytes: glyph.start..glyph.end,
bounds,
clip,
});
}
}
}
fn fill_editor(
&mut self,
editor: &Self::Editor,
position: Point,
color: Color,
clip: Rectangle,
) {
shaped::Renderer::fill_editor(&mut FontLayout, editor, position, color, clip);
}
fn fill_text(&mut self, text: shaped::Text, position: Point, color: Color, clip: Rectangle) {
shaped::Renderer::fill_text(&mut FontLayout, text, position, color, clip);
}
}
#[derive(Default)]
struct ScrollGeometry {
id: Option<widget::Id>,
bounds: Option<Rectangle>,
content: Option<Rectangle>,
offset: Vector,
}
impl widget::Operation for ScrollGeometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
content: Rectangle,
offset: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if id == self.id.as_ref() {
self.bounds = Some(bounds);
self.content = Some(content);
self.offset = offset;
}
}
}
fn normalized(text: &str) -> String {
text.replace("\r\n", "\n").replace('\r', "\n")
}
fn required_chars(text: &str) -> BTreeSet<(usize, usize)> {
text.split('\n')
.enumerate()
.flat_map(|(line, text)| {
text.char_indices()
.filter(|(_, ch)| !ch.is_whitespace())
.map(move |(byte, _)| (line, byte))
})
.collect()
}
fn declarations(long: bool) -> App {
let name = if long {
format!("Assess {} request", "界🚀".repeat(90))
} else {
"Assess 救援 request".into()
};
let source = SourceBundle { entry: "project.arcadia".into(), files: BTreeMap::from([
("project.arcadia".into(), "arcadia 7;\r\nproject navigation_trial \"Navigation — 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n include \"救援/assess.arcadia\";\r\n}\r\n".into()),
("救援/assess.arcadia".into(), format!("// Comment @assess must not be a lexical use.\r\noa_activity assess \"{name}\" id \"9e8f0000-0000-4000-8000-000000000002\" {{\r\n description \"Keep @assess as prose, not a use.\";\r\n}}\r\n")),
]) };
let mut app = fixture();
app.document = Document::from_bundle(source, LIMITS).unwrap();
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.source_workspace.active = "救援/assess.arcadia".into();
app.selected = app.project().id;
app.refresh_draft();
app.show_source_file(false);
let start = app
.source_workspace
.text()
.find("oa_activity assess")
.unwrap()
+ "oa_activity ".len();
app.source_editor.move_to(cursor(
app.source_workspace.text(),
start,
start + "assess".len(),
));
begin(&mut app);
let replacement = if long {
"a".repeat(255)
} else {
"assess_damage".into()
};
input(&mut app, &replacement);
let _ = preview(&mut app);
app
}
fn assert_page_glyphs_visible(app: &App, host: Size) {
let form = app.source_rename.form.as_ref().unwrap();
let ready = form.approval.as_ref().unwrap();
let file = &ready.preview.files()[form.file];
let mut expected = BTreeMap::<String, usize>::new();
for line in file.hunks()[form.hunk]
.lines()
.iter()
.skip(form.line)
.take(LINES_PER_PAGE)
{
for source in [
line.before_text(&ready.prepared.before().bundle.files[file.path()])
.unwrap(),
line.after_text(&ready.prepared.after().bundle.files[file.path()])
.unwrap(),
] {
if let Some(fragment) = preview::text_segment(source, form.segment) {
let text = normalized(fragment);
if !required_chars(&text).is_empty() {
*expected.entry(text).or_default() += 1;
}
}
}
}
assert!(!expected.is_empty());
let viewport = Rectangle::new(Point::ORIGIN, host);
let mut renderer = GlyphRecorder::new(expected.keys().cloned().collect(), viewport);
let editor = text_editor::Content::<GlyphRecorder>::new();
let mut view = dialog_content(app, &editor);
let mut tree = widget::Tree::new(view.as_widget());
let node =
view.as_widget_mut()
.layout(&mut tree, &renderer, &layout::Limits::new(Size::ZERO, host));
let mut geometry = ScrollGeometry {
id: Some(preview_scroll_id(form, ready.id)),
..ScrollGeometry::default()
};
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut geometry);
let bounds = geometry.bounds.unwrap();
let content = geometry.content.unwrap();
assert!(bounds.height >= 40.);
let maximum = (content.height - bounds.height).max(0.);
let steps = (maximum / (bounds.height * 0.5)).ceil() as usize;
let mut visible = BTreeMap::<(String, u32), BTreeSet<(usize, usize)>>::new();
let mut reserved_bar_gap = true;
for step in 0..=steps {
let offset = (step as f32 * bounds.height * 0.5).min(maximum);
view.as_widget_mut().operate(
&mut tree,
Layout::new(&node),
&renderer,
&mut widget::operation::scrollable::scroll_to(
preview_scroll_id(form, ready.id),
widget::operation::scrollable::AbsoluteOffset {
x: None,
y: Some(offset),
},
),
);
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &renderer, &mut geometry);
assert!(
(geometry.offset.y - offset).abs() <= 0.5,
"actual scroll operation reaches the requested source offset"
);
renderer::Renderer::reset(&mut renderer, viewport);
view.as_widget().draw(
&tree,
&mut renderer,
&iced::Theme::Light,
&renderer::Style::default(),
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&viewport,
);
// The vertical scrollbar's actual painted thumb fixes its excluded x
// region; the outer scroll clip alone cannot detect overpainting.
let scrollbar_left = renderer
.quads
.iter()
.filter(|quad| {
quad.width > 0.
&& quad.width <= 16.
&& quad.height > 0.
&& quad.x >= bounds.x + bounds.width - 18.
&& quad.x + quad.width <= bounds.x + bounds.width + 0.5
&& quad.y >= bounds.y - 0.5
&& quad.y + quad.height <= bounds.y + bounds.height + 0.5
})
.map(|quad| quad.x)
.min_by(f32::total_cmp);
if maximum > 0. {
let left = scrollbar_left.expect("overflowing actual preview must paint its scrollbar");
reserved_bar_gap &= content.x + content.width <= left - 7.5;
}
for glyph in &renderer.glyphs {
let right = glyph.bounds.x + glyph.bounds.width;
if let Some(left) = scrollbar_left {
assert!(
right <= left - 0.5,
"source glyph under scrollbar: text={:?} cluster={:?} glyph={:?} bar x={left}",
glyph.text,
glyph.bytes,
glyph.bounds
);
}
assert!(
glyph.bounds.x >= content.x - 0.5 && right <= content.x + content.width + 0.5,
"source glyph escaped reserved width: {:?} {:?}",
glyph.text,
glyph.bounds
);
assert!(
glyph.bounds.x >= glyph.clip.x - 0.5
&& right <= glyph.clip.x + glyph.clip.width + 0.5,
"source glyph horizontally clipped: {:?} {:?} {:?}",
glyph.text,
glyph.bounds,
glyph.clip
);
if glyph.bounds.y >= glyph.clip.y - 0.5
&& glyph.bounds.y + glyph.bounds.height <= glyph.clip.y + glyph.clip.height + 0.5
{
let logical_line = glyph.text.split('\n').nth(glyph.line).unwrap();
let chars = logical_line
.char_indices()
.filter(|(byte, ch)| glyph.bytes.contains(byte) && !ch.is_whitespace())
.map(|(byte, _)| (glyph.line, byte));
visible
.entry((glyph.text.clone(), glyph.paragraph_y))
.or_default()
.extend(chars);
}
}
}
assert!(
reserved_bar_gap,
"source content must reserve actual scrollbar width plus gap"
);
assert!(
bounds.width >= host.width - 30.,
"preview must use available dialog width: {bounds:?} / {host:?}"
);
for (text, count) in expected {
let required = required_chars(&text);
let instances: Vec<_> = visible
.iter()
.filter(|((value, _), _)| value == &text)
.collect();
assert_eq!(
instances.len(),
count,
"all before/after occurrences must be seen: {text:?}"
);
for (_, seen) in instances {
assert_eq!(
seen, &required,
"every non-whitespace source cluster must be fully visible at some vertical offset: {text:?}"
);
}
}
}
#[test]
fn actual_preview_source_glyphs_clear_scrollbar_and_all_long_fragments_are_reachable() {
for long in [false, true] {
let mut app = declarations(long);
let ready = app
.source_rename
.form
.as_ref()
.unwrap()
.approval
.as_ref()
.unwrap()
.clone();
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
let before = evidence(&app);
for (width, height) in [(1100., 700.), (580., 700.)] {
app.window_size = Size::new(width, height);
let host = Size::new((width - 84.).min(708.), height - 120.);
let total = segment_total(app.source_rename.form.as_ref().unwrap());
if long {
assert!(
total >= 3,
"include unbroken ASCII and wide Unicode across segments"
);
}
for segment in 0..total {
assert_page_glyphs_visible(&app, host);
if segment + 1 < total {
event(&mut app, Event::Segment(invocation, ready.id, true));
}
}
for _ in 1..total {
event(&mut app, Event::Segment(invocation, ready.id, false));
}
}
assert_eq!(
evidence(&app),
before,
"preview geometry/paging cannot alter transaction inputs"
);
assert_eq!(
app.source_rename
.form
.as_ref()
.unwrap()
.approval
.as_ref()
.unwrap()
.id,
ready.id
);
}
}
@@ -0,0 +1,385 @@
//! Execute the controller's scroll operations on a retained, scrolled dialog.
use super::*;
use crate::views::source_tests::FontLayout;
use iced::advanced::{Layout, layout, widget};
use iced::futures::StreamExt;
use iced::{Rectangle, Size, Vector};
const HOST: Size = Size::new(720., 660.);
fn paged_fixture() -> App {
let mut source = bundle();
let name = "Long engineering name 雨 ".repeat(18);
source.files.get_mut("defs.arcadia").unwrap().replace_range(
..,
&format!(
"oa_activity pump \"{name}\" id \"00000000-0000-0000-0000-000000000002\" {{}}\r\n\
oa_activity backup \"Backup\" id \"00000000-0000-0000-0000-000000000004\" {{}}\r\n"
),
);
let mut uses = String::new();
for index in 0..16 {
if index == 12 {
uses.push_str(&"// unchanged gap\r\n".repeat(10));
}
uses.push_str(&format!(
"interaction use_{index} \"{name}\" id \"00000000-0000-0000-0001-{index:012}\" {{ source @pump; target @backup; }}\r\n"
));
}
source.files.insert("uses.arcadia".into(), uses);
let mut app = fixture();
app.window_size = HOST;
app.document = Document::from_bundle(source, LIMITS).unwrap();
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.source_workspace.active = "uses.arcadia".into();
app.show_source_file(false);
let start = app.source_workspace.text().find("source @pump").unwrap() + "source @".len();
app.source_editor
.move_to(cursor(app.source_workspace.text(), start, start + 4));
begin(&mut app);
input(&mut app, "pump_long");
let _ = preview(&mut app);
app
}
fn scroll_id(app: &App) -> widget::Id {
let form = app.source_rename.form.as_ref().unwrap();
preview_scroll_id(form, form.approval.as_ref().unwrap().id)
}
struct Geometry {
id: widget::Id,
offset: Option<Vector>,
}
impl widget::Operation for Geometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
_: Rectangle,
_: Rectangle,
offset: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if id == Some(&self.id) {
self.offset = Some(offset);
}
}
}
struct Mounted {
editor: text_editor::Content<FontLayout>,
tree: widget::Tree,
}
impl Mounted {
fn new(app: &App) -> Self {
let editor = text_editor::Content::<FontLayout>::new();
let tree = widget::Tree::new(dialog_content(app, &editor).as_widget());
Self { editor, tree }
}
fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) {
let mut view = dialog_content(app, &self.editor);
// Preserve the actual scroll state across the new page's view diff.
self.tree.diff(view.as_widget());
let node = view.as_widget_mut().layout(
&mut self.tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, HOST),
);
view.as_widget_mut()
.operate(&mut self.tree, Layout::new(&node), &FontLayout, operation);
}
fn offset(&mut self, app: &App) -> f32 {
let mut geometry = Geometry {
id: scroll_id(app),
offset: None,
};
self.operate(app, &mut geometry);
geometry.offset.expect("mounted preview scrollable").y
}
fn scroll_down(&mut self, app: &App) -> f32 {
self.operate(
app,
&mut widget::operation::scrollable::scroll_to(
scroll_id(app),
widget::operation::scrollable::AbsoluteOffset {
x: None,
y: Some(10_000.),
},
),
);
let offset = self.offset(app);
assert!(offset > 10., "fixture must really be scrolled");
offset
}
async fn execute(&mut self, app: &App, task: Task<Message>) -> usize {
let mut count = 0;
if let Some(mut actions) = iced_runtime::task::into_stream(task) {
while let Some(action) = actions.next().await {
let iced_runtime::Action::Widget(mut operation) = action else {
panic!("preview paging must only schedule a widget operation");
};
self.operate(app, operation.as_mut());
count += 1;
}
}
count
}
}
#[tokio::test]
async fn explicit_navigation_resets_retained_scroll_but_id_change_alone_does_not() {
let mut app = paged_fixture();
let before = evidence(&app);
let form = app.source_rename.form.as_ref().unwrap();
let invocation = form.invocation;
let approval = form.approval.as_ref().unwrap().id;
let mut mounted = Mounted::new(&app);
mounted.scroll_down(&app);
let old_id = scroll_id(&app);
let task = app.update(Message::SourceRename(Event::File(
invocation, approval, true,
)));
assert_ne!(old_id, scroll_id(&app));
// Negative control: even after diff/layout with the new id and new file,
// Iced retains the previous offset until the returned operation executes.
assert!(mounted.offset(&app) > 10.);
assert_eq!(mounted.execute(&app, task).await, 1);
assert_eq!(mounted.offset(&app), 0.);
for navigation in [
Event::Line(invocation, approval, true),
Event::Segment(invocation, approval, true),
Event::Hunk(invocation, approval, true),
Event::File(invocation, approval, false),
Event::Search(invocation, "uses".into()),
Event::Search(invocation, "no-path-match".into()),
Event::Search(invocation, String::new()),
] {
mounted.scroll_down(&app);
let task = app.update(Message::SourceRename(navigation));
assert_eq!(mounted.execute(&app, task).await, 1);
assert_eq!(mounted.offset(&app), 0.);
assert_eq!(evidence(&app), before);
}
// Passive redraw and unchanged filter input preserve deliberate scrolling.
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.offset(&app), offset);
let task = app.update(Message::SourceRename(Event::Search(
invocation,
String::new(),
)));
assert_eq!(mounted.execute(&app, task).await, 0);
assert_eq!(mounted.offset(&app), offset);
let task = app.update(Message::SourceRename(Event::Input(
invocation,
"pump_long".into(),
)));
assert_eq!(mounted.execute(&app, task).await, 0);
assert_eq!(mounted.offset(&app), offset);
let task = app.update(Message::SourceRename(Event::File(
invocation, approval, false,
)));
assert_eq!(
mounted.execute(&app, task).await,
0,
"clamped first-page request is a no-op"
);
assert_eq!(mounted.offset(&app), offset);
assert_eq!(evidence(&app), before);
}
#[tokio::test]
async fn delayed_page_and_approval_operations_cannot_reset_a_new_destination() {
let mut app = paged_fixture();
let before = evidence(&app);
let form = app.source_rename.form.as_ref().unwrap();
let invocation = form.invocation;
let old_approval = form.approval.as_ref().unwrap().id;
let mut mounted = Mounted::new(&app);
let old_page_task = app.update(Message::SourceRename(Event::File(
invocation,
old_approval,
true,
)));
let next_page_task = app.update(Message::SourceRename(Event::Line(
invocation,
old_approval,
true,
)));
assert_eq!(mounted.execute(&app, next_page_task).await, 1);
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.execute(&app, old_page_task).await, 1);
assert_eq!(mounted.offset(&app), offset, "old page target is absent");
let old_approval_task = app.update(Message::SourceRename(Event::Search(
invocation,
"defs".into(),
)));
input(&mut app, "pump_new");
let _ = preview(&mut app); // Real new approval in the same invocation/page.
assert_ne!(
app.source_rename
.form
.as_ref()
.unwrap()
.approval
.as_ref()
.unwrap()
.id,
old_approval
);
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.execute(&app, old_approval_task).await, 1);
assert_eq!(
mounted.offset(&app),
offset,
"old approval target is absent"
);
let task = app.update(Message::SourceRename(Event::File(
invocation,
old_approval,
true,
)));
assert_eq!(
mounted.execute(&app, task).await,
0,
"stale envelope is rejected before scheduling"
);
assert_eq!(mounted.offset(&app), offset);
assert_eq!(evidence(&app), before);
}
#[tokio::test]
async fn repeated_page_visits_filters_and_fast_preview_completion_have_distinct_destinations() {
let mut app = paged_fixture();
let before = evidence(&app);
let form = app.source_rename.form.as_ref().unwrap();
let invocation = form.invocation;
let approval = form.approval.as_ref().unwrap().id;
let mut mounted = Mounted::new(&app);
let first_visit = app.update(Message::SourceRename(Event::File(
invocation, approval, true,
)));
let next_page = app.update(Message::SourceRename(Event::Line(
invocation, approval, true,
)));
assert_eq!(mounted.execute(&app, next_page).await, 1);
let return_visit = app.update(Message::SourceRename(Event::Line(
invocation, approval, false,
)));
assert_eq!(mounted.execute(&app, return_visit).await, 1);
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.execute(&app, first_visit).await, 1);
assert_eq!(
mounted.offset(&app),
offset,
"old visit to the identical page is stale"
);
let old_filter = app.update(Message::SourceRename(Event::Search(
invocation,
"uses".into(),
)));
let new_filter = app.update(Message::SourceRename(Event::Search(
invocation,
"us".into(),
)));
assert_eq!(mounted.execute(&app, new_filter).await, 1);
let offset = mounted.scroll_down(&app);
assert_eq!(mounted.execute(&app, old_filter).await, 1);
assert_eq!(
mounted.offset(&app),
offset,
"old filter on the same page is stale"
);
input(&mut app, "pump_new");
let job = start_preview(&mut app);
let completion = preview_completion(&app, job);
// No intermediate frame mounts the temporary no-approval view. The old
// Scrollable survives even though the new approval has a different id.
let ready = app.update(Message::SourceRenameReady(completion));
assert!(mounted.offset(&app) > 10.);
assert_eq!(mounted.execute(&app, ready).await, 1);
assert_eq!(mounted.offset(&app), 0.);
assert_eq!(evidence(&app), before);
}
#[tokio::test]
async fn details_round_trip_retires_held_reset_without_changing_page_or_repeated_visibility_scroll()
{
// Exercise both an actually mounted Details view and a fast round trip
// between frames, where Iced retains the same preview Scrollable state.
for mount_details in [true, false] {
let mut app = paged_fixture();
let before = evidence(&app);
let form = app.source_rename.form.as_ref().unwrap();
let invocation = form.invocation;
let approval = form.approval.as_ref().unwrap().id;
let mut mounted = Mounted::new(&app);
mounted.scroll_down(&app);
let held_reset = app.update(Message::SourceRename(Event::File(
invocation, approval, true,
)));
let old_preview = scroll_id(&app);
let form = app.source_rename.form.as_ref().unwrap();
let page = (form.file, form.hunk, form.line, form.segment);
let show_details = app.update(Message::SourceRename(Event::Details(invocation, true)));
assert_eq!(mounted.execute(&app, show_details).await, 0);
let details_visit = scroll_id(&app);
assert_ne!(details_visit, old_preview);
if mount_details {
let mut hidden = Geometry {
id: old_preview.clone(),
offset: None,
};
mounted.operate(&app, &mut hidden);
assert!(
hidden.offset.is_none(),
"Details actually replaced the preview"
);
}
let repeat_details = app.update(Message::SourceRename(Event::Details(invocation, true)));
assert_eq!(mounted.execute(&app, repeat_details).await, 0);
assert_eq!(
scroll_id(&app),
details_visit,
"unchanged visibility keeps its visit"
);
let show_preview = app.update(Message::SourceRename(Event::Details(invocation, false)));
assert_eq!(
mounted.execute(&app, show_preview).await,
0,
"returning does not force a scroll reset"
);
let returned_visit = scroll_id(&app);
assert_ne!(returned_visit, old_preview);
assert_ne!(returned_visit, details_visit);
let form = app.source_rename.form.as_ref().unwrap();
assert_eq!((form.file, form.hunk, form.line, form.segment), page);
assert_eq!(form.approval.as_ref().unwrap().id, approval);
let offset = mounted.scroll_down(&app);
let repeat_preview = app.update(Message::SourceRename(Event::Details(invocation, false)));
assert_eq!(mounted.execute(&app, repeat_preview).await, 0);
assert_eq!(scroll_id(&app), returned_visit);
assert_eq!(
mounted.offset(&app),
offset,
"repeated Preview preserves deliberate scroll"
);
assert_eq!(mounted.execute(&app, held_reset).await, 1);
assert_eq!(
mounted.offset(&app),
offset,
"the previous visit cannot reset the returned preview"
);
assert_eq!(evidence(&app), before);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,313 @@
//! Manual replacement + next-layout/highlight evaluator; no GPU or native paint.
//! Child of source_rename_timing_tests, preserving the separate real App test.
use super::*;
use crate::{highlight, views::source_tests::FontLayout};
use iced::advanced::widget::operation::Focusable;
use iced::advanced::{Layout, graphics, layout, renderer, text as shaped, widget::Tree};
use iced::{
Background, Color, Element, Font, Length, Pixels, Point, Rectangle, Size, Transformation,
};
// The same real Paragraph and Editor as FontLayout. During draw, retain only a
// Weak and fixed geometry; inspect glyphs after timing, never in fill_editor.
#[derive(Default)]
struct ObservedFontLayout {
drawn: Option<(graphics::text::editor::Weak, Point, Rectangle)>,
}
impl renderer::Renderer for ObservedFontLayout {
fn start_layer(&mut self, _: Rectangle) {}
fn end_layer(&mut self) {}
fn start_transformation(&mut self, _: Transformation) {}
fn end_transformation(&mut self) {}
fn reset(&mut self, _: Rectangle) {
self.drawn = None;
}
fn fill_quad(&mut self, _: renderer::Quad, _: impl Into<Background>) {}
fn allocate_image(
&mut self,
handle: &iced::advanced::image::Handle,
callback: impl FnOnce(Result<iced::advanced::image::Allocation, iced::advanced::image::Error>)
+ Send
+ 'static,
) {
renderer::Renderer::allocate_image(&mut FontLayout, handle, callback);
}
}
impl shaped::Renderer for ObservedFontLayout {
type Font = Font;
type Paragraph = <FontLayout as shaped::Renderer>::Paragraph;
type Editor = <FontLayout as shaped::Renderer>::Editor;
const ICON_FONT: Font = <FontLayout as shaped::Renderer>::ICON_FONT;
const CHECKMARK_ICON: char = <FontLayout as shaped::Renderer>::CHECKMARK_ICON;
const ARROW_DOWN_ICON: char = <FontLayout as shaped::Renderer>::ARROW_DOWN_ICON;
const SCROLL_UP_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_UP_ICON;
const SCROLL_DOWN_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_DOWN_ICON;
const SCROLL_LEFT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_LEFT_ICON;
const SCROLL_RIGHT_ICON: char = <FontLayout as shaped::Renderer>::SCROLL_RIGHT_ICON;
const ICED_LOGO: char = <FontLayout as shaped::Renderer>::ICED_LOGO;
fn default_font(&self) -> Font {
shaped::Renderer::default_font(&FontLayout)
}
fn default_size(&self) -> Pixels {
shaped::Renderer::default_size(&FontLayout)
}
fn fill_paragraph(&mut self, _: &Self::Paragraph, _: Point, _: Color, _: Rectangle) {}
fn fill_editor(&mut self, editor: &Self::Editor, position: Point, _: Color, clip: Rectangle) {
self.drawn = Some((editor.downgrade(), position, clip));
}
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
}
type Content = text_editor::Content<ObservedFontLayout>;
const HOST: Size = Size::new(640., 350.);
fn view(editor: &Content, source: Arc<str>) -> Element<'_, (), iced::Theme, ObservedFontLayout> {
text_editor(editor)
.on_action(|_| ())
.font(Font::MONOSPACE)
.size(14)
.padding(16)
.height(Length::Fill)
.highlight_with::<highlight::ArcadiaHighlighter>(source, highlight::format)
.into()
}
fn check_text(editor: &Content, expected: &str, cursor: text_editor::Cursor) {
assert_eq!(editor.text(), expected);
assert_eq!(editor.cursor(), cursor);
let lines: Vec<_> = expected.split('\n').collect();
assert_eq!(editor.line_count(), lines.len());
for (index, part) in lines.iter().enumerate() {
let (text, ending) = if index + 1 == lines.len() {
(*part, text_editor::LineEnding::None)
} else if let Some(text) = part.strip_suffix('\r') {
(text, text_editor::LineEnding::CrLf)
} else {
(*part, text_editor::LineEnding::Lf)
};
let actual = editor.line(index).unwrap();
assert_eq!(actual.text, text);
assert_eq!(actual.ending, ending);
}
}
fn check_draw(renderer: &ObservedFontLayout, cursor: text_editor::Cursor, alias: &str) {
let (weak, position, clip) = renderer.drawn.as_ref().expect("real editor draw");
let editor = weak.upgrade().expect("drawn editor is still live");
let buffer = editor.buffer();
assert_eq!(weak.bounds, Size::new(608., 318.));
assert_eq!(buffer.size(), (Some(608.), Some(318.)));
assert_eq!(buffer.metrics().font_size, 14.);
assert_eq!(
buffer.scroll().horizontal,
0.,
"fixture origin fits the viewport"
);
let start = cursor.position;
let end = cursor.selection.expect("approved alias selection");
assert_eq!(start.line, end.line);
assert_eq!(end.column - start.column, alias.len());
let line = &buffer.lines[start.line];
assert_eq!(&line.text()[start.column..end.column], alias);
let color = graphics::text::to_color(Color::from_rgb8(20, 93, 139));
let font = graphics::text::to_attributes(Font::MONOSPACE);
for byte in start.column..end.column {
let attrs = line.attrs_list().get_span(byte);
assert_eq!(
attrs.color_opt,
Some(color),
"accepted alias syntax attributes"
);
assert_eq!(attrs.family, font.family);
}
let mut covered = vec![false; alias.len()];
for run in buffer.layout_runs().filter(|run| run.line_i == start.line) {
for glyph in run.glyphs {
let from = glyph.start.max(start.column);
let to = glyph.end.min(end.column);
if from >= to {
continue;
}
assert_eq!(glyph.color_opt, Some(color), "drawn alias glyph color");
let x = position.x + glyph.x + glyph.x_offset * glyph.font_size;
let y = position.y + run.line_top;
assert!(x >= clip.x - 0.5 && x + glyph.w <= clip.x + clip.width + 0.5);
assert!(y + run.line_height > clip.y && y < clip.y + clip.height);
covered[from - start.column..to - start.column].fill(true);
}
}
assert!(
covered.into_iter().all(|seen| seen),
"entire alias has visible shaped glyphs"
);
// This temporary strong upgrade is dropped before the next editor mutation.
}
#[derive(Clone, Copy)]
enum Arm {
Constructor,
Reuse,
}
impl Arm {
fn name(self) -> &'static str {
match self {
Self::Constructor => "with_text",
Self::Reuse => "same_line_reuse",
}
}
fn phases(self) -> [&'static str; 4] {
match self {
Self::Constructor => [
"constructor_replace_origin",
"constructor_next_layout",
"constructor_highlight_draw",
"constructor_combined",
],
Self::Reuse => [
"reuse_replace_origin",
"reuse_next_layout",
"reuse_highlight_draw",
"reuse_combined",
],
}
}
}
fn trial(
fixture: &Fixture,
cursor: text_editor::Cursor,
arm: Arm,
trial: usize,
samples: &mut Samples,
) {
let before = &fixture.bundle.files[ORIGIN];
let after = &fixture.expected_bundle.files[ORIGIN];
let mut editor = Content::with_text(before);
editor.move_to(fixture.cursor);
let mut renderer = ObservedFontLayout::default();
let theme = iced::Theme::Light;
let defaults = renderer::Style::default();
let viewport = Rectangle::with_size(HOST);
let limits = layout::Limits::new(Size::ZERO, HOST);
let mut tree = {
let mut widget = view(&editor, Arc::from(before.as_str()));
let mut tree = Tree::new(widget.as_widget());
let node = widget.as_widget_mut().layout(&mut tree, &renderer, &limits);
tree.state
.downcast_mut::<text_editor::State<highlight::ArcadiaHighlighter>>()
.focus();
widget.as_widget().draw(
&tree,
&mut renderer,
&theme,
&defaults,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&viewport,
);
tree
};
check_text(&editor, before, fixture.cursor);
check_draw(&renderer, fixture.cursor, OLD);
// No recorder Weak/Strong from setup may perturb replacement ownership.
renderer.drawn = None;
let next_source: Arc<str> = Arc::from(after.as_str());
// Only normal operations and four timestamps occur between start and end.
// Source/model oracles, logging and recorder glyph inspection are outside.
let started = Instant::now();
let replaced = match arm {
Arm::Constructor => {
editor = Content::with_text(after);
true
}
Arm::Reuse => super::super::editor_patch::replace(&mut editor, before, after),
};
editor.move_to(cursor);
let replaced_at = Instant::now();
let mut widget = view(&editor, next_source);
let node = widget.as_widget_mut().layout(&mut tree, &renderer, &limits);
let laid_out_at = Instant::now();
widget.as_widget().draw(
&tree,
&mut renderer,
&theme,
&defaults,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&viewport,
);
let drawn_at = Instant::now();
drop(widget);
assert!(replaced, "reuse must not silently time a fallback/refusal");
check_text(&editor, after, cursor);
check_draw(&renderer, cursor, NEW);
renderer.drawn = None;
for (phase, duration) in arm.phases().into_iter().zip([
replaced_at.duration_since(started),
laid_out_at.duration_since(replaced_at),
drawn_at.duration_since(laid_out_at),
drawn_at.duration_since(started),
]) {
println!(
"{}",
serde_json::json!({
"record": "editor_layout_timing", "arm": arm.name(), "phase": phase,
"trial": trial, "warmup": trial == 0,
"elapsed_ns": duration.as_nanos().to_string(),
"elapsed_ms": duration.as_secs_f64() * 1000.0,
})
);
if trial != 0 {
samples.0.entry(phase).or_default().push(duration);
}
}
}
#[test]
#[ignore = "manual Windows dev/debug editor replacement + layout/highlight trial; run alone with --ignored --nocapture --test-threads=1"]
fn accepted_release1_constructor_vs_reuse_through_next_highlight() {
let fixture = Fixture::load();
// Resolve the exact production approval and verify full Project/source once,
// outside all intervals. The actual App Confirm evaluator remains separate.
let cursor = {
let app = fixture.app();
let target = resolve(&app, &app.rename_context());
let prepared = app
.document
.alias_rename_request(target.id, NEW.into())
.unwrap()
.prepare()
.unwrap();
fixture.check_candidate(&prepared);
prepare_approval(prepared, &target, ORIGIN).unwrap().cursor
};
println!(
"{}",
serde_json::json!({
"record": "editor_layout_fixture", "host_width": HOST.width, "host_height": HOST.height,
"font": "MONOSPACE", "font_size": 14, "padding": 16, "height": "Fill",
"active_file": ORIGIN, "before_bytes": fixture.bundle.files[ORIGIN].len(),
"after_bytes": fixture.expected_bundle.files[ORIGIN].len(), "warmups_per_arm": 1,
"measured_trials_per_arm": 5, "debug_assertions": cfg!(debug_assertions),
"scope": "real editor replacement, retained Tree layout and Widget draw/highlight; no GPU, native paint or App Confirm",
})
);
let mut samples = Samples::default();
for index in 0..=5 {
let arms = if index % 2 == 0 {
[Arm::Constructor, Arm::Reuse]
} else {
[Arm::Reuse, Arm::Constructor]
};
println!(
"{}",
serde_json::json!({"record": "editor_layout_order", "trial": index, "first": arms[0].name()})
);
for arm in arms {
trial(&fixture, cursor, arm, index, &mut samples);
}
}
samples.report();
}
@@ -0,0 +1,347 @@
//! Manual timing through the real Document and App paths. No disk writes,
//! executor polling, layout, GPU work or first-paint measurement occurs here.
//! Register as a test child of app::source_rename to use its actual worker helpers.
use super::*;
use crate::source_workspace::Workspace;
use oa_core::source_project::SourceBundle;
use std::{
collections::BTreeMap,
path::PathBuf,
time::{Duration, Instant},
};
#[cfg(debug_assertions)]
#[path = "source_rename_layout_timing_tests.rs"]
mod layout_timing_tests;
const OLD: &str = "oa_restore_work";
const NEW: &str = "oa_restore_pressure";
const DECLARATIONS: &str = "oa/activities/activity_oa_restore_work.arcadia";
const ORIGIN: &str = "traceability.arcadia";
struct Fixture {
bundle: SourceBundle,
project: Project,
expected_bundle: SourceBundle,
expected_project: Project,
target: Id,
cursor: text_editor::Cursor,
}
impl Fixture {
fn load() -> Self {
// The optional entry selects a parent-pinned copy of this exact fixture,
// not an arbitrary performance generator or a mutable GUI destination.
let entry = std::env::var_os("ARCADIA_RENAME_TIMING_ENTRY")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../examples/release1/project.arcadia")
});
let loaded = oa_core::source_files::load_project(&entry, LIMITS).unwrap();
let bundle = loaded.reachable_bundle();
assert_eq!(
bundle.files.len(),
30,
"use the preserved release1 walkthrough"
);
let document = Document::from_bundle(bundle.clone(), LIMITS).unwrap();
assert_eq!(
document.bundle(),
&bundle,
"fixture must need no stabilization/migration"
);
assert!(!document.dirty());
let project = document.project().clone();
let target = Id::parse_str("1b91f8a7-0bdb-514e-b1cd-955658e09927").unwrap();
assert_eq!(project.elements[&target].alias, OLD);
assert!(!project.identities().iter().any(|(_, alias)| *alias == NEW));
let mut expected_project = project.clone();
expected_project.elements.get_mut(&target).unwrap().alias = NEW.into();
// Independent literal source oracle, authored before running the rename.
// Do not replace the alias substring in filenames or the trace's alias.
let mut expected_bundle = bundle.clone();
let declarations = expected_bundle.files.get_mut(DECLARATIONS).unwrap();
assert_eq!(
declarations.matches("oa_activity oa_restore_work ").count(),
1
);
assert_eq!(declarations.matches("parent @oa_restore_work;").count(), 4);
*declarations = declarations
.replace(
"oa_activity oa_restore_work ",
"oa_activity oa_restore_pressure ",
)
.replace("parent @oa_restore_work;", "parent @oa_restore_pressure;");
let traces = expected_bundle.files.get_mut(ORIGIN).unwrap();
assert_eq!(traces.matches("oa @oa_restore_work;").count(), 1);
*traces = traces.replace("oa @oa_restore_work;", "oa @oa_restore_pressure;");
let source = &bundle.files[ORIGIN];
let start = source.rfind("@oa_restore_work;").unwrap() + 1;
let position = |offset| {
let prefix = &source[..offset];
text_editor::Position {
line: prefix.bytes().filter(|byte| *byte == b'\n').count(),
column: prefix
.rsplit_once('\n')
.map_or(prefix.len(), |(_, tail)| tail.len()),
}
};
let cursor = text_editor::Cursor {
position: position(start),
selection: Some(position(start + OLD.len())),
};
assert!(nav::exact_cursor(source, cursor));
println!(
"{}",
serde_json::json!({
"record": "fixture", "entry": entry, "reachable_files": bundle.files.len(),
"source_bytes": bundle.files.values().map(String::len).sum::<usize>(),
"identities": project.identities().len(), "active_file": ORIGIN,
"active_file_bytes": source.len(), "old": OLD, "new": NEW,
"warmups": 1, "measured_trials": 5, "debug_assertions": cfg!(debug_assertions),
"scope": "real worker helpers, guarded Document commit, App sync and Confirm update; no task polling or rendering"
})
);
Self {
bundle,
project,
expected_bundle,
expected_project,
target,
cursor,
}
}
fn app(&self) -> App {
let mut app = App::with_project(Project::empty());
app.document = Document::from_bundle(self.bundle.clone(), LIMITS).unwrap();
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.source_workspace.active = ORIGIN.into();
app.selected = app.project().id;
app.page = Page::Text;
app.sync();
app.source_editor.move_to(self.cursor);
assert_eq!(app.project(), &self.project);
assert_eq!(app.document.bundle(), &self.bundle);
assert_eq!(app.document.revision(), 0);
assert!(!app.document.dirty() && !app.document.can_undo() && !app.document.can_redo());
assert!(app.rename_admission().is_ok());
app
}
fn check_candidate(&self, prepared: &PreparedAliasRename) {
assert!(prepared.changed());
assert_eq!(prepared.before().bundle, self.bundle);
assert_eq!(prepared.before().decoded.project, self.project);
assert_eq!(prepared.after().bundle, self.expected_bundle);
assert_eq!(prepared.after().decoded.project, self.expected_project);
}
fn check_adopted(&self, app: &mut App, candidate_address: *const CompiledSourceProject) {
assert!(
std::ptr::eq(app.document.compiled(), candidate_address),
"adopt exact candidate allocation"
);
assert_eq!(app.project(), &self.expected_project);
assert_eq!(app.document.bundle(), &self.expected_bundle);
assert_eq!(app.source_workspace.bundle(), self.expected_bundle);
assert_eq!(app.source_editor.text(), self.expected_bundle.files[ORIGIN]);
assert_eq!(app.document.revision(), 1);
assert!(app.document.dirty() && app.document.can_undo() && !app.document.can_redo());
assert!(app.document.undo());
assert_eq!(app.project(), &self.project);
assert_eq!(app.document.bundle(), &self.bundle);
assert!(
!app.document.dirty(),
"live known saved baseline remains authoritative"
);
assert!(
!app.document.can_undo(),
"exactly one preceding model transaction"
);
assert!(app.document.redo());
assert_eq!(app.project(), &self.expected_project);
assert_eq!(app.document.bundle(), &self.expected_bundle);
assert!(std::ptr::eq(app.document.compiled(), candidate_address));
}
}
#[derive(Default)]
struct Samples(BTreeMap<&'static str, Vec<Duration>>);
impl Samples {
fn time<T>(&mut self, phase: &'static str, trial: usize, action: impl FnOnce() -> T) -> T {
let started = Instant::now();
let result = action();
let elapsed = started.elapsed();
// Printing, assertions and dropping the returned value are outside this
// interval. Full Confirm still includes production's own local drops.
println!(
"{}",
serde_json::json!({
"record": "timing", "phase": phase, "trial": trial,
"warmup": trial == 0, "elapsed_ns": elapsed.as_nanos().to_string(),
"elapsed_ms": elapsed.as_secs_f64() * 1000.0
})
);
if trial != 0 {
self.0.entry(phase).or_default().push(elapsed);
}
result
}
fn report(mut self) {
for (phase, values) in &mut self.0 {
assert_eq!(values.len(), 5);
values.sort();
println!(
"{}",
serde_json::json!({
"record": "summary", "phase": phase, "samples": values.len(),
"min_ms": values[0].as_secs_f64() * 1000.0,
"median_ms": values[2].as_secs_f64() * 1000.0,
"max_ms": values[4].as_secs_f64() * 1000.0
})
);
}
}
}
fn resolve(app: &App, context: &Context) -> Target {
let snapshot = app
.with_source_input(|input| query::admit_owned(input, query::Limits::default()))
.unwrap()
.capture()
.unwrap();
let offset = nav::cursor_offset(app.source_workspace.text(), context.begin.cursor).unwrap();
resolve_target(&snapshot, context, offset).unwrap()
}
fn active_job(app: &App) -> Job {
let form = app.source_rename.form.as_ref().unwrap();
let id = form.pending.unwrap();
assert_eq!(app.source_rename.in_flight, Some(id));
Job {
id,
invocation: form.invocation,
generation: form.generation,
context: form.context.clone(),
}
}
fn separate_phases(fixture: &Fixture, samples: &mut Samples, trial: usize) {
let mut app = fixture.app();
let target = resolve(&app, &app.rename_context());
assert_eq!(target.id, fixture.target);
let request = samples
.time("document_request_mint", trial, || {
app.document.alias_rename_request(target.id, NEW.into())
})
.unwrap();
let prepared = samples
.time("worker_document_prepare", trial, || request.prepare())
.unwrap();
fixture.check_candidate(&prepared);
let approval = samples
.time("worker_preview_and_origin", trial, || {
prepare_approval(prepared, &target, ORIGIN)
})
.unwrap();
assert_eq!(approval.preview.files().len(), 2);
assert_eq!(approval.preview.changed_line_count(), 6);
let candidate_address = approval.prepared.after() as *const CompiledSourceProject;
// Same-destination save completion is a legitimate live update after Preview.
app.document.mark_saved(app.document.snapshot());
let changed = samples
.time("document_guarded_commit", trial, || {
app.document.commit_alias_rename(&approval.prepared)
})
.unwrap();
assert!(changed);
samples.time("app_sync", trial, || app.sync());
fixture.check_adopted(&mut app, candidate_address);
}
fn whole_confirm(fixture: &Fixture, samples: &mut Samples, trial: usize) {
let mut app = fixture.app();
let begin = app.source_rename_message();
// These Tasks are deliberately not polled: deterministic worker helpers
// supply the same results through the real readiness messages below.
drop(app.update(begin));
let target_job = active_job(&app);
let target = Arc::new(resolve(&app, &target_job.context));
assert_eq!(target.id, fixture.target);
drop(app.update(Message::SourceRenameReady(Completion {
job: target_job,
payload: Payload::Target(Ok(target.clone())),
})));
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
drop(app.update(Message::SourceRename(Event::Input(invocation, NEW.into()))));
let generation = app.source_rename.form.as_ref().unwrap().generation;
drop(app.update(Message::SourceRename(Event::Preview(
invocation, generation,
))));
let preview_job = active_job(&app);
let prepared = app
.document
.alias_rename_request(target.id, NEW.into())
.unwrap()
.prepare()
.unwrap();
fixture.check_candidate(&prepared);
let approval = Arc::new(prepare_approval(prepared, &target, ORIGIN).unwrap());
drop(target);
let candidate_address = approval.prepared.after() as *const CompiledSourceProject;
let expected_cursor = approval.cursor;
let approval_id = approval.id;
drop(app.update(Message::SourceRenameReady(Completion {
job: preview_job,
payload: Payload::Preview(Ok(approval)),
})));
assert_eq!(
app.source_rename
.form
.as_ref()
.unwrap()
.approval
.as_ref()
.unwrap()
.id,
approval_id
);
app.document.mark_saved(app.document.snapshot());
let confirm = Message::SourceRename(Event::Confirm(invocation, generation, approval_id));
let task = samples.time("app_confirm_update", trial, || app.update(confirm));
samples.time("confirm_returned_task_drop_without_polling", trial, || {
drop(task)
});
assert!(!app.source_rename.open());
assert!(app.source_rename.in_flight.is_none());
assert!(app.source_rename_undo_message().is_some());
assert_eq!(app.source_editor.cursor(), expected_cursor);
fixture.check_adopted(&mut app, candidate_address);
}
#[test]
#[ignore = "manual Windows dev/debug rename latency trial; run alone with --ignored --nocapture --test-threads=1"]
fn accepted_release1_prepare_commit_sync_and_confirm_timing() {
let fixture = Fixture::load();
let mut samples = Samples::default();
for trial in 0..=5 {
println!(
"{}",
serde_json::json!({ "record": "order", "trial": trial,
"warmup": trial == 0, "separate_phases_first": trial % 2 == 0 })
);
if trial % 2 == 0 {
separate_phases(&fixture, &mut samples, trial);
whole_confirm(&fixture, &mut samples, trial);
} else {
whole_confirm(&fixture, &mut samples, trial);
separate_phases(&fixture, &mut samples, trial);
}
}
samples.report();
}
+508
View File
@@ -0,0 +1,508 @@
//! Current draft navigation, isolated from model application and disk discovery.
use super::*;
use crate::source_navigation::{self as nav, query};
use std::sync::atomic::Ordering;
fn admission_notice(error: query::AdmissionError) -> String {
use nav::symbols::{BuildError, InputProblem, Resource};
let (limit, resource) = match error {
query::AdmissionError::Index(BuildError::Limit { limit, resource, .. }) => (limit, resource),
query::AdmissionError::Outline { error, .. } => (error.limit, match error.resource {
language::OutlineResource::SourceBytes => Resource::SourceBytes,
language::OutlineResource::Tokens => Resource::Tokens,
language::OutlineResource::Declarations => Resource::Records,
}),
query::AdmissionError::Index(BuildError::Input { problem, .. }) => return match problem {
InputProblem::NonCanonicalPath | InputProblem::DuplicateOrCaseCollidingPath =>
"Navigation needs distinct relative source paths. Check file names and include paths.".into(),
_ => "Navigation could not verify the current source locations. Correct source errors and try again.".into(),
},
};
let unit = match resource {
Resource::Files => "loaded files",
Resource::SourceBytes => "source bytes",
Resource::PathBytes => "bytes in file paths",
Resource::Tokens => "source tokens",
Resource::Records => "declarations and references",
};
format!(
"Navigation reached its limit of {limit} {unit}. Reduce the loaded source size and try again."
)
}
impl App {
pub fn source_symbol_context(&self) -> nav::SymbolContext {
nav::SymbolContext {
session: self.source_workspace.session(),
path: self.source_workspace.active.clone(),
}
}
pub fn source_symbol_message(&self, event: nav::SymbolEvent) -> Message {
Message::SourceSymbols(self.source_symbol_context(), event)
}
pub fn source_find_close_message(&self) -> Message {
Message::SourceFindClose(self.source_symbol_context(), self.source_find_instance)
}
pub fn source_key(&self) -> query::SnapshotKey {
query::SnapshotKey {
workspace: self.source_workspace.session(),
revision: self.source_workspace.revision(),
}
}
pub(super) fn with_source_input<T>(&self, use_input: impl FnOnce(query::Input<'_>) -> T) -> T {
let files: Vec<_> = self
.source_workspace
.files
.iter()
.map(|(path, buffer)| query::DraftFile {
path,
text: &buffer.text,
})
.collect();
let accepted = self.document.bundle();
let validated: Vec<_> = accepted
.files
.iter()
.map(|(path, text)| query::DraftFile { path, text })
.collect();
use_input(query::Input {
key: self.source_key(),
entry: &self.source_workspace.entry,
files: &files,
applied: self.source_workspace.applied,
validation: self
.source_workspace
.applied
.then_some(query::ValidatedSources {
entry: &accepted.entry,
files: &validated,
}),
})
}
pub(super) fn source_snapshot_matches(&self, snapshot: &query::Snapshot) -> bool {
snapshot.key() == self.source_key()
&& self.with_source_input(|input| snapshot.matches(input))
}
pub fn source_navigation_available(&self) -> bool {
self.page == Page::Text
&& !self.source_rename.open()
&& !self.busy
&& self.pending.is_none()
&& self.file_prompt.is_none()
&& !self.delete_pending
&& self.workflow.is_none()
&& !self.hosted.visible
&& self.model_menu.is_none()
&& self.diagram_usages.is_none()
&& !self.reference_input_active()
&& self.architecture.is_none()
&& !self.behavior.has_pending()
}
/// Mounted widget IDs change when another interaction supersedes a reveal.
/// A queued focus operation therefore cannot find a subsequently reused editor.
pub fn source_editor_id(&self) -> iced::widget::Id {
iced::widget::Id::from(format!(
"source-editor-{}-{}-{}",
self.source_workspace.session(),
self.source_workspace.active,
self.source_symbols.interaction
))
}
pub fn source_reveal_ticket(&self) -> Option<Id> {
self.source_symbols
.reveal
.as_ref()
.map(|reveal| reveal.ticket)
}
pub(super) fn source_interaction(&mut self) {
self.source_symbols.interaction = Id::new_v4();
self.source_symbols.request = None;
self.source_symbols.reveal = None;
self.source_jump = None;
}
pub(super) fn queue_source_reveal(&mut self, cursor: text_editor::Cursor, select_line: bool) {
self.source_jump = Some(cursor);
self.source_select_line = select_line;
self.source_symbols.reveal = Some(nav::Reveal {
ticket: Id::new_v4(),
interaction: self.source_symbols.interaction,
raw_interaction: self.source_symbols.raw_interaction.load(Ordering::SeqCst),
snapshot: self.source_key(),
path: self.source_workspace.active.clone(),
});
}
pub(super) fn restore_source_editor_focus(&mut self) {
let cursor = self.source_editor.cursor();
if self.source_navigation_available()
&& nav::exact_cursor(self.source_workspace.text(), cursor)
{
self.queue_source_reveal(cursor, false);
}
}
pub(super) fn source_viewport(&mut self, ticket: Option<Id>) -> Task<Message> {
let Some(reveal) = &self.source_symbols.reveal else {
return Task::none();
};
if Some(reveal.ticket) != ticket
|| reveal.interaction != self.source_symbols.interaction
|| !nav::interaction::is_current(
&self.source_symbols.raw_interaction,
reveal.raw_interaction,
)
|| reveal.snapshot != self.source_key()
|| reveal.path != self.source_workspace.active
|| !self.source_navigation_available()
{
return Task::none();
}
let raw_interaction = reveal.raw_interaction;
self.source_symbols.reveal = None;
if let Some(cursor) = self.source_jump.take() {
if !nav::exact_cursor(self.source_workspace.text(), cursor) {
return Task::none();
}
self.source_editor.move_to(cursor);
if self.source_select_line && cursor.selection.is_none() {
self.source_editor.perform(text_editor::Action::SelectLine);
}
return nav::interaction::guarded_focus(
self.source_editor_id(),
self.source_symbols.raw_interaction.clone(),
raw_interaction,
);
}
Task::none()
}
pub(super) fn source_symbol_event(&mut self, event: nav::SymbolEvent) -> Task<Message> {
if !self.source_navigation_available() {
return Task::none();
}
match event {
nav::SymbolEvent::DiagramUsages => {
if self.source_dirty() || !self.source_workspace.applied {
self.source_symbols.open = true;
self.source_symbols.notice = Some("Apply source before finding diagram representations. Your current text is retained.".into());
return Task::none();
}
let task = self.start_source_query(query::Action::Definition);
self.source_diagram_job = self
.source_symbols
.request
.as_ref()
.map(|request| request.job);
return task;
}
nav::SymbolEvent::Close => {
self.source_symbols.open = false;
self.source_symbols.results = None;
self.source_symbols.notice = None;
}
nav::SymbolEvent::Back => {
self.source_symbol_back();
// A successful Back needs no empty results panel. Retain an
// already open panel, or reveal an actionable return notice.
self.source_symbols.open |= self.source_symbols.notice.is_some();
}
nav::SymbolEvent::Choose {
stamp,
reference,
index,
} => {
let Some(results) = self.source_symbols.results.clone() else {
return Task::none();
};
if results.stamp != stamp
|| stamp.snapshot != self.source_key()
|| self
.source_symbols
.cache
.as_ref()
.is_none_or(|cache| !self.source_snapshot_matches(cache))
{
self.source_symbols.notice =
Some("Source changed. Run navigation again for current locations.".into());
return Task::none();
}
let destination = if reference {
results
.references
.get(index)
.map(|row| (&row.path, row.position, row.end_position))
} else {
results
.definitions
.get(index)
.map(|row| (&row.path, row.position, row.end_position))
};
if let Some((path, start, end)) = destination {
self.source_symbol_jump(path, start, end);
}
}
nav::SymbolEvent::Query(action) => return self.start_source_query(action),
}
Task::none()
}
fn start_source_query(&mut self, action: query::Action) -> Task<Message> {
self.source_symbols.open = true;
if !nav::interaction::is_current(
&self.source_symbols.raw_interaction,
self.source_symbols.raw_interaction.load(Ordering::SeqCst),
) {
self.source_symbols.notice = Some("Reopen this workspace to reset navigation.".into());
return Task::none();
}
if self.source_symbols.in_flight.is_some() {
self.source_symbols.notice = Some("Navigation is finishing. Try again shortly.".into());
return Task::none();
}
let cursor = self.source_editor.cursor();
let Some(offset) = nav::cursor_offset(self.source_workspace.text(), cursor) else {
self.source_symbols.notice =
Some("Place the cursor on an alias or its @ reference.".into());
return Task::none();
};
self.source_symbols.serial = match self.source_symbols.serial.checked_add(1) {
Some(serial) => serial,
None => {
self.source_workspace.reset_navigation_session();
self.source_symbols.back.clear();
self.source_symbols.cache = None;
0
}
};
let request = nav::Request {
job: Id::new_v4(),
interaction: self.source_symbols.interaction,
raw_interaction: self.source_symbols.raw_interaction.load(Ordering::SeqCst),
stamp: query::QueryStamp {
snapshot: self.source_key(),
serial: self.source_symbols.serial,
},
path: self.source_workspace.active.clone(),
offset,
cursor,
action,
};
let cache = self
.source_symbols
.cache
.as_ref()
.filter(|cache| self.source_snapshot_matches(cache))
.cloned();
let input = if cache.is_none() {
match self
.with_source_input(|input| query::admit_owned(input, query::Limits::default()))
{
Ok(input) => Some(input),
Err(error) => {
self.source_symbols.notice = Some(admission_notice(error));
return Task::none();
}
}
} else {
None
};
self.source_symbols.in_flight = Some(request.job);
self.source_symbols.request = Some(request.clone());
self.source_symbols.results = None;
self.source_symbols.notice = None;
Task::perform(
async move {
let worker_request = request.clone();
let result = tokio::task::spawn_blocking(move || {
let snapshot = match cache {
Some(cache) => cache,
None => Arc::new(
input
.expect("admitted input for uncached request")
.capture()
.map_err(admission_notice)?,
),
};
let results = snapshot
.query(query::Query {
stamp: worker_request.stamp,
path: &worker_request.path,
byte_offset: worker_request.offset,
action: worker_request.action,
})
.map_err(|error| match error {
query::QueryError::ResultEnvelopeLimit => "These result details exceed the display limit. Try literal Find for this alias.".to_owned(),
_ => "Navigation could not verify this location. Place the cursor on an alias and try again.".to_owned(),
})?;
Ok((snapshot, Arc::new(results)))
})
.await
.unwrap_or_else(|error| Err(format!("Navigation task failed: {error}")));
nav::Completion { request, result }
},
Message::SourceSymbolsReady,
)
}
pub(super) fn source_symbols_ready(&mut self, completion: nav::Completion) -> Task<Message> {
let request = completion.request;
if self.source_symbols.in_flight != Some(request.job) {
return Task::none();
}
self.source_symbols.in_flight = None;
let diagram_query = self.source_diagram_job == Some(request.job);
if diagram_query {
self.source_diagram_job = None;
}
let wanted = self
.source_symbols
.request
.take()
.is_some_and(|current| current.job == request.job)
&& request.interaction == self.source_symbols.interaction
&& request.stamp.snapshot == self.source_key()
&& nav::interaction::is_current(
&self.source_symbols.raw_interaction,
request.raw_interaction,
)
&& request.path == self.source_workspace.active
&& request.cursor == self.source_editor.cursor()
&& self.source_navigation_available();
if !wanted {
return Task::none();
}
match completion.result {
Ok((snapshot, results)) if self.source_snapshot_matches(&snapshot) => {
self.source_symbols.cache = Some(snapshot);
self.source_symbols.results = Some(results.clone());
if diagram_query {
if results.disposition == query::Disposition::Direct
&& results.scope.validated
&& results.omitted_definitions == 0
&& let [row] = results.definitions.as_slice()
&& let query::Identity::Unique(identity) = row.declaration.identity
&& row.membership == query::Membership::Included
&& row.evidence == query::Evidence::Validated
&& self.project().contains(identity.id())
&& crate::diagram_usage::alias(self.project(), identity.id())
== row.declaration.alias
{
self.source_symbols.open = false;
return self.diagram_usage_event(crate::diagram_usage::Event::Open(
crate::diagram_usage::Context {
document: self.canvas_document,
revision: self.document.revision(),
subject: identity.id(),
},
));
}
self.source_symbols.notice = Some("Place the caret on a uniquely resolved alias in applied source to find diagram representations.".into());
return Task::none();
}
if results.disposition == query::Disposition::Direct
&& let Some(row) = results.definitions.first()
{
self.source_symbol_jump(&row.path, row.position, row.end_position);
}
}
Ok(_) => {
self.source_symbols.notice = Some("Source changed. Run navigation again.".into())
}
Err(error) => self.source_symbols.notice = Some(error),
}
Task::none()
}
fn source_symbol_jump(&mut self, path: &str, start: query::Position, end: query::Position) {
let cursor = text_editor::Cursor {
position: text_editor::Position {
line: start.line,
column: start.byte_column,
},
selection: Some(text_editor::Position {
line: end.line,
column: end.byte_column,
}),
};
if self
.source_workspace
.files
.get(path)
.is_none_or(|buffer| !nav::exact_cursor(&buffer.text, cursor))
{
self.source_symbols.notice =
Some("That source location is no longer available. Run navigation again.".into());
return;
}
if let Some(buffer) = self
.source_workspace
.files
.get(&self.source_workspace.active)
{
let origin = nav::HistoryEntry {
session: self.source_workspace.session(),
path: self.source_workspace.active.clone(),
generation: buffer.generation(),
cursor: self.source_editor.cursor(),
};
self.source_symbols
.back
.retain(|entry| entry.session == origin.session);
if self.source_symbols.back.len() == 64 {
self.source_symbols.back.remove(0);
}
self.source_symbols.back.push(origin);
}
self.source_workspace
.switch(path, self.source_editor.cursor());
self.show_source_file(false);
self.queue_source_reveal(cursor, false);
}
fn source_symbol_back(&mut self) {
while let Some(origin) = self.source_symbols.back.pop() {
if origin.session != self.source_workspace.session() {
continue;
}
let Some(buffer) = self.source_workspace.files.get(&origin.path) else {
self.source_symbols.notice = Some("The previous file is no longer loaded.".into());
return;
};
let unchanged = origin.generation == buffer.generation();
let cursor = if unchanged {
origin.cursor
} else if origin.path == self.source_workspace.active {
self.source_editor.cursor()
} else {
buffer.cursor
};
if !nav::exact_cursor(&buffer.text, cursor) {
self.source_symbols.notice = Some(
"The previous location changed. Open the file to choose a current position."
.into(),
);
return;
}
self.source_workspace
.switch(&origin.path, self.source_editor.cursor());
self.show_source_file(false);
self.queue_source_reveal(cursor, false);
self.source_symbols.notice =
(!unchanged).then(|| "This draft changed; its current cursor was restored.".into());
return;
}
self.source_symbols.notice = Some("No previous source location in this workspace.".into());
}
}
#[cfg(test)]
mod tests;
File diff suppressed because it is too large Load Diff