Combine source navigation and alias rename with canvas ownership guards
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user